- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
512 lines
18 KiB
C#
512 lines
18 KiB
C#
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.AdvisoryAI.Caching;
|
|
using StellaOps.AdvisoryAI.Diagnostics;
|
|
using StellaOps.AdvisoryAI.Explanation;
|
|
using StellaOps.AdvisoryAI.Hosting;
|
|
using StellaOps.AdvisoryAI.Metrics;
|
|
using StellaOps.AdvisoryAI.Outputs;
|
|
using StellaOps.AdvisoryAI.Orchestration;
|
|
using StellaOps.AdvisoryAI.Queue;
|
|
using StellaOps.AdvisoryAI.Remediation;
|
|
using StellaOps.AdvisoryAI.WebService.Contracts;
|
|
using StellaOps.Router.AspNet;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration
|
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
|
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
|
|
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
|
|
|
|
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddOpenApi();
|
|
builder.Services.AddProblemDetails();
|
|
|
|
// Stella Router integration
|
|
var routerOptions = builder.Configuration.GetSection("AdvisoryAI:Router").Get<StellaRouterOptionsBase>();
|
|
builder.Services.TryAddStellaRouter(
|
|
serviceName: "advisoryai",
|
|
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
|
routerOptions: routerOptions);
|
|
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.AddPolicy("advisory-ai", context =>
|
|
{
|
|
var clientId = context.Request.Headers.TryGetValue("X-StellaOps-Client", out var value)
|
|
? value.ToString()
|
|
: "anonymous";
|
|
|
|
return RateLimitPartition.GetTokenBucketLimiter(
|
|
clientId,
|
|
_ => new TokenBucketRateLimiterOptions
|
|
{
|
|
TokenLimit = 30,
|
|
TokensPerPeriod = 30,
|
|
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true
|
|
});
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseExceptionHandler(static options => options.Run(async context =>
|
|
{
|
|
var problem = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
|
|
await problem.ExecuteAsync(context);
|
|
}));
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
}
|
|
|
|
app.UseRateLimiter();
|
|
app.TryUseStellaRouter(routerOptions);
|
|
|
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
|
|
|
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Explanation endpoints (SPRINT_20251226_015_AI_zastava_companion)
|
|
app.MapPost("/v1/advisory-ai/explain", HandleExplain)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
|
|
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Refresh Router endpoint cache
|
|
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
|
|
|
app.Run();
|
|
|
|
static async Task<IResult> HandleSinglePlan(
|
|
HttpContext httpContext,
|
|
string taskType,
|
|
PipelinePlanRequest request,
|
|
IAdvisoryPipelineOrchestrator orchestrator,
|
|
IAdvisoryPlanCache planCache,
|
|
IAdvisoryTaskQueue taskQueue,
|
|
AdvisoryAiMetrics requestMetrics,
|
|
AdvisoryPipelineMetrics pipelineMetrics,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_request", ActivityKind.Server);
|
|
activity?.SetTag("advisory.task_type", taskType);
|
|
activity?.SetTag("advisory.advisory_key", request.AdvisoryKey);
|
|
|
|
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
|
|
{
|
|
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
|
|
}
|
|
|
|
if (!EnsureAuthorized(httpContext, parsedType))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.AdvisoryKey))
|
|
{
|
|
return Results.BadRequest(new { error = "AdvisoryKey is required." });
|
|
}
|
|
|
|
var normalizedRequest = request with { TaskType = parsedType };
|
|
var taskRequest = normalizedRequest.ToTaskRequest();
|
|
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
|
|
activity?.SetTag("advisory.plan_cache_key", plan.CacheKey);
|
|
|
|
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
|
|
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
|
|
|
|
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
|
|
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
|
|
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
|
|
|
|
var response = AdvisoryPipelinePlanResponse.FromPlan(plan);
|
|
return Results.Ok(response);
|
|
}
|
|
|
|
static async Task<IResult> HandleBatchPlans(
|
|
HttpContext httpContext,
|
|
BatchPipelinePlanRequest batchRequest,
|
|
IAdvisoryPipelineOrchestrator orchestrator,
|
|
IAdvisoryPlanCache planCache,
|
|
IAdvisoryTaskQueue taskQueue,
|
|
AdvisoryAiMetrics requestMetrics,
|
|
AdvisoryPipelineMetrics pipelineMetrics,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_batch", ActivityKind.Server);
|
|
activity?.SetTag("advisory.batch_size", batchRequest.Requests.Count);
|
|
|
|
if (batchRequest.Requests.Count == 0)
|
|
{
|
|
return Results.BadRequest(new { error = "At least one request must be supplied." });
|
|
}
|
|
|
|
var results = new List<AdvisoryPipelinePlanResponse>(batchRequest.Requests.Count);
|
|
|
|
foreach (var item in batchRequest.Requests)
|
|
{
|
|
var taskType = item.TaskType?.ToString() ?? "summary";
|
|
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
|
|
{
|
|
return Results.BadRequest(new { error = $"Unknown task type '{taskType}' in batch item." });
|
|
}
|
|
|
|
if (!EnsureAuthorized(httpContext, parsedType))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(item.AdvisoryKey))
|
|
{
|
|
return Results.BadRequest(new { error = "AdvisoryKey is required for every batch item." });
|
|
}
|
|
|
|
var normalizedRequest = item with { TaskType = parsedType };
|
|
var taskRequest = normalizedRequest.ToTaskRequest();
|
|
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
|
|
activity?.AddEvent(new ActivityEvent("advisory.plan.created", tags: new ActivityTagsCollection
|
|
{
|
|
{ "advisory.task_type", plan.Request.TaskType.ToString() },
|
|
{ "advisory.advisory_key", plan.Request.AdvisoryKey },
|
|
{ "advisory.plan_cache_key", plan.CacheKey }
|
|
}));
|
|
|
|
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
|
|
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
|
|
|
|
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
|
|
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
|
|
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
|
|
|
|
results.Add(AdvisoryPipelinePlanResponse.FromPlan(plan));
|
|
}
|
|
|
|
return Results.Ok(results);
|
|
}
|
|
|
|
static async Task<IResult> HandleGetOutput(
|
|
HttpContext httpContext,
|
|
string cacheKey,
|
|
string taskType,
|
|
string? profile,
|
|
IAdvisoryOutputStore outputStore,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(outputStore);
|
|
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedTaskType))
|
|
{
|
|
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
|
|
}
|
|
|
|
if (!EnsureAuthorized(httpContext, parsedTaskType))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile!.Trim();
|
|
var output = await outputStore.TryGetAsync(cacheKey, parsedTaskType, resolvedProfile, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (output is null)
|
|
{
|
|
return Results.NotFound(new { error = "Output not found." });
|
|
}
|
|
|
|
return Results.Ok(AdvisoryOutputResponse.FromDomain(output));
|
|
}
|
|
|
|
static bool EnsureAuthorized(HttpContext context, AdvisoryTaskType taskType)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
if (allowed.Contains("advisory:run"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return allowed.Contains($"advisory:{taskType.ToString().ToLowerInvariant()}");
|
|
}
|
|
|
|
static bool EnsureExplainAuthorized(HttpContext context)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return allowed.Contains("advisory:run") || allowed.Contains("advisory:explain");
|
|
}
|
|
|
|
// ZASTAVA-13: POST /v1/advisory-ai/explain
|
|
static async Task<IResult> HandleExplain(
|
|
HttpContext httpContext,
|
|
ExplainRequest request,
|
|
IExplanationGenerator explanationGenerator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain", ActivityKind.Server);
|
|
activity?.SetTag("advisory.finding_id", request.FindingId);
|
|
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
|
activity?.SetTag("advisory.explanation_type", request.ExplanationType);
|
|
|
|
if (!EnsureExplainAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var domainRequest = request.ToDomain();
|
|
var result = await explanationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.explanation_id", result.ExplanationId);
|
|
activity?.SetTag("advisory.authority", result.Authority.ToString());
|
|
activity?.SetTag("advisory.citation_rate", result.CitationRate);
|
|
|
|
return Results.Ok(ExplainResponse.FromDomain(result));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// ZASTAVA-14: GET /v1/advisory-ai/explain/{explanationId}/replay
|
|
static async Task<IResult> HandleExplanationReplay(
|
|
HttpContext httpContext,
|
|
string explanationId,
|
|
IExplanationGenerator explanationGenerator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain_replay", ActivityKind.Server);
|
|
activity?.SetTag("advisory.explanation_id", explanationId);
|
|
|
|
if (!EnsureExplainAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await explanationGenerator.ReplayAsync(explanationId, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.replayed_explanation_id", result.ExplanationId);
|
|
activity?.SetTag("advisory.authority", result.Authority.ToString());
|
|
|
|
return Results.Ok(ExplainResponse.FromDomain(result));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
static bool EnsureRemediationAuthorized(HttpContext context)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return allowed.Contains("advisory:run") || allowed.Contains("advisory:remediate");
|
|
}
|
|
|
|
// REMEDY-19: POST /v1/advisory-ai/remediation/plan
|
|
static async Task<IResult> HandleRemediationPlan(
|
|
HttpContext httpContext,
|
|
RemediationPlanApiRequest request,
|
|
IRemediationPlanner remediationPlanner,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_plan", ActivityKind.Server);
|
|
activity?.SetTag("advisory.finding_id", request.FindingId);
|
|
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
|
activity?.SetTag("advisory.remediation_type", request.RemediationType);
|
|
|
|
if (!EnsureRemediationAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var domainRequest = request.ToDomain();
|
|
var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.plan_id", plan.PlanId);
|
|
activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString());
|
|
activity?.SetTag("advisory.pr_ready", plan.PrReady);
|
|
|
|
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// REMEDY-20: POST /v1/advisory-ai/remediation/apply
|
|
static async Task<IResult> HandleApplyRemediation(
|
|
HttpContext httpContext,
|
|
ApplyRemediationRequest request,
|
|
IRemediationPlanner remediationPlanner,
|
|
IEnumerable<IPullRequestGenerator> prGenerators,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.apply_remediation", ActivityKind.Server);
|
|
activity?.SetTag("advisory.plan_id", request.PlanId);
|
|
activity?.SetTag("advisory.scm_type", request.ScmType);
|
|
|
|
if (!EnsureRemediationAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var plan = await remediationPlanner.GetPlanAsync(request.PlanId, cancellationToken).ConfigureAwait(false);
|
|
if (plan is null)
|
|
{
|
|
return Results.NotFound(new { error = $"Plan {request.PlanId} not found" });
|
|
}
|
|
|
|
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(request.ScmType, StringComparison.OrdinalIgnoreCase));
|
|
if (generator is null)
|
|
{
|
|
return Results.BadRequest(new { error = $"SCM type '{request.ScmType}' not supported" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var prResult = await generator.CreatePullRequestAsync(plan, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.pr_id", prResult.PrId);
|
|
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
|
|
|
|
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// REMEDY-21: GET /v1/advisory-ai/remediation/status/{prId}
|
|
static async Task<IResult> HandleRemediationStatus(
|
|
HttpContext httpContext,
|
|
string prId,
|
|
string? scmType,
|
|
IEnumerable<IPullRequestGenerator> prGenerators,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_status", ActivityKind.Server);
|
|
activity?.SetTag("advisory.pr_id", prId);
|
|
|
|
if (!EnsureRemediationAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var resolvedScmType = scmType ?? "github";
|
|
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(resolvedScmType, StringComparison.OrdinalIgnoreCase));
|
|
if (generator is null)
|
|
{
|
|
return Results.BadRequest(new { error = $"SCM type '{resolvedScmType}' not supported" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var prResult = await generator.GetStatusAsync(prId, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
|
|
|
|
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
internal sealed record PipelinePlanRequest(
|
|
AdvisoryTaskType? TaskType,
|
|
string AdvisoryKey,
|
|
string? ArtifactId,
|
|
string? ArtifactPurl,
|
|
string? PolicyVersion,
|
|
string Profile = "default",
|
|
IReadOnlyCollection<string>? PreferredSections = null,
|
|
bool ForceRefresh = false)
|
|
{
|
|
public AdvisoryTaskRequest ToTaskRequest()
|
|
{
|
|
if (TaskType is null)
|
|
{
|
|
throw new InvalidOperationException("Task type must be specified.");
|
|
}
|
|
|
|
return new AdvisoryTaskRequest(
|
|
TaskType.Value,
|
|
AdvisoryKey,
|
|
ArtifactId,
|
|
ArtifactPurl,
|
|
PolicyVersion,
|
|
Profile,
|
|
PreferredSections,
|
|
ForceRefresh);
|
|
}
|
|
}
|
|
|
|
internal sealed record BatchPipelinePlanRequest
|
|
{
|
|
public IReadOnlyList<PipelinePlanRequest> Requests { get; init; } = Array.Empty<PipelinePlanRequest>();
|
|
}
|