// SPDX-License-Identifier: BUSL-1.1 // Copyright (c) StellaOps // Sprint: EVID-001-002 - Reachability Evidence Endpoints using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.Reachability.Jobs; using StellaOps.Scanner.Reachability.Services; using StellaOps.Scanner.Reachability.Vex; using StellaOps.Scanner.WebService.Security; namespace StellaOps.Scanner.WebService.Endpoints; /// /// Minimal API endpoints for reachability evidence operations. /// public static class ReachabilityEvidenceEndpoints { /// /// Maps reachability evidence endpoints. /// public static IEndpointRouteBuilder MapReachabilityEvidenceEndpoints( this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/api/reachability") .WithTags("Reachability Evidence") .RequireAuthorization(ScannerPolicies.ScansRead); // Analyze reachability for a CVE group.MapPost("/analyze", AnalyzeAsync) .WithName("AnalyzeReachability") .WithSummary("Analyze reachability of a CVE in an image") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansWrite); // Get job result group.MapGet("/result/{jobId}", GetResultAsync) .WithName("GetReachabilityResult") .WithSummary("Get result of a reachability analysis job") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // Check if CVE has sink mappings group.MapGet("/mapping/{cveId}", GetCveMappingAsync) .WithName("GetCveMapping") .WithSummary("Get CVE-to-symbol mappings for a vulnerability") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // Get VEX statement from reachability group.MapPost("/vex", GenerateVexAsync) .WithName("GenerateVexFromReachability") .WithSummary("Generate VEX statement from reachability analysis") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.ScansWrite); return routes; } private static async Task AnalyzeAsync( [FromBody] ReachabilityAnalyzeRequest request, [FromServices] IReachabilityEvidenceJobExecutor executor, [FromServices] ICveSymbolMappingService mappingService, [FromServices] TimeProvider timeProvider, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.ImageDigest) || string.IsNullOrWhiteSpace(request.CveId) || string.IsNullOrWhiteSpace(request.Purl)) { return Results.Problem( detail: "imageDigest, cveId, and purl are required", statusCode: StatusCodes.Status400BadRequest); } // Check if we have mappings for this CVE var hasMappings = await mappingService.HasMappingAsync(request.CveId, ct); if (!hasMappings) { return Results.Problem( detail: $"No sink mappings found for CVE {request.CveId}", statusCode: StatusCodes.Status404NotFound); } // Create and execute job var jobId = ReachabilityEvidenceJob.ComputeJobId( request.ImageDigest, request.CveId, request.Purl); var job = new ReachabilityEvidenceJob { JobId = jobId, ImageDigest = request.ImageDigest, CveId = request.CveId, Purl = request.Purl, SourceCommit = request.SourceCommit, Options = new ReachabilityJobOptions { IncludeL2 = request.IncludeBinaryAnalysis, IncludeL3 = request.IncludeRuntimeAnalysis, MaxPathsPerSink = request.MaxPaths ?? 5, MaxDepth = request.MaxDepth ?? 256 }, QueuedAt = timeProvider.GetUtcNow() }; var result = await executor.ExecuteAsync(job, ct); return Results.Ok(new ReachabilityAnalyzeResponse { JobId = result.JobId, Status = result.Status.ToString(), Verdict = result.Stack?.Verdict.ToString(), EvidenceUri = result.EvidenceUri, DurationMs = result.DurationMs, Error = result.Error }); } private static async Task GetResultAsync( [FromRoute] string jobId, [FromServices] IEvidenceResultStore resultStore, CancellationToken ct) { var result = await resultStore.GetResultAsync(jobId, ct); if (result is null) { return Results.Problem( detail: $"No result found for job {jobId}", statusCode: StatusCodes.Status404NotFound); } return Results.Ok(new ReachabilityResultResponse { JobId = result.JobId, Status = result.Status.ToString(), Verdict = result.Stack?.Verdict.ToString(), VerdictExplanation = result.Stack?.Explanation, IsReachable = result.Stack?.StaticCallGraph.IsReachable, PathCount = result.Stack?.StaticCallGraph.Paths.Length ?? 0, EntrypointCount = result.Stack?.StaticCallGraph.ReachingEntrypoints.Length ?? 0, EvidenceBundleId = result.EvidenceBundleId, EvidenceUri = result.EvidenceUri, CompletedAt = result.CompletedAt, DurationMs = result.DurationMs, Error = result.Error }); } private static async Task GetCveMappingAsync( [FromRoute] string cveId, [FromQuery] string? purl, [FromServices] ICveSymbolMappingService mappingService, CancellationToken ct) { var mappings = string.IsNullOrWhiteSpace(purl) ? await mappingService.GetAllMappingsForCveAsync(cveId, ct) : await mappingService.GetSinksForCveAsync(cveId, purl, ct); if (mappings.Count == 0) { return Results.Problem( detail: $"No mappings found for CVE {cveId}", statusCode: StatusCodes.Status404NotFound); } return Results.Ok(new CveMappingResponse { CveId = cveId, MappingCount = mappings.Count, Mappings = mappings.Select(m => new CveMappingItem { SymbolName = m.SymbolName, CanonicalId = m.CanonicalId, Purl = m.Purl, FilePath = m.FilePath, VulnType = m.VulnType.ToString(), Confidence = m.Confidence, Source = m.Source.ToString() }).ToList() }); } private static async Task GenerateVexAsync( [FromBody] GenerateVexRequest request, [FromServices] IEvidenceResultStore resultStore, [FromServices] IVexStatusDeterminer vexDeterminer, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.JobId) || string.IsNullOrWhiteSpace(request.ProductId)) { return Results.Problem( detail: "jobId and productId are required", statusCode: StatusCodes.Status400BadRequest); } var result = await resultStore.GetResultAsync(request.JobId, ct); if (result?.Stack is null) { return Results.Problem( detail: $"No reachability result found for job {request.JobId}", statusCode: StatusCodes.Status404NotFound); } var evidenceUris = result.EvidenceUri is not null ? new[] { result.EvidenceUri } : Array.Empty(); var statement = vexDeterminer.CreateStatement( result.Stack, request.ProductId, evidenceUris); return Results.Ok(new VexStatementResponse { StatementId = statement.StatementId, VulnerabilityId = statement.VulnerabilityId, ProductId = statement.ProductId, Status = statement.Status.ToString(), Justification = statement.Justification is not null ? new VexJustificationResponse { Category = statement.Justification.Category.ToString(), Detail = statement.Justification.Detail, Confidence = statement.Justification.Confidence, EvidenceReferences = statement.Justification.EvidenceReferences.ToList() } : null, ActionStatement = statement.ActionStatement, ImpactStatement = statement.ImpactStatement, Timestamp = statement.Timestamp }); } } // Request/Response DTOs public sealed record ReachabilityAnalyzeRequest { public string ImageDigest { get; init; } = string.Empty; public string CveId { get; init; } = string.Empty; public string Purl { get; init; } = string.Empty; public string? SourceCommit { get; init; } public bool IncludeBinaryAnalysis { get; init; } = false; public bool IncludeRuntimeAnalysis { get; init; } = false; public int? MaxPaths { get; init; } public int? MaxDepth { get; init; } } public sealed record ReachabilityAnalyzeResponse { public string JobId { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; public string? Verdict { get; init; } public string? EvidenceUri { get; init; } public long? DurationMs { get; init; } public string? Error { get; init; } } public sealed record ReachabilityResultResponse { public string JobId { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; public string? Verdict { get; init; } public string? VerdictExplanation { get; init; } public bool? IsReachable { get; init; } public int PathCount { get; init; } public int EntrypointCount { get; init; } public string? EvidenceBundleId { get; init; } public string? EvidenceUri { get; init; } public DateTimeOffset? CompletedAt { get; init; } public long? DurationMs { get; init; } public string? Error { get; init; } } public sealed record CveMappingResponse { public string CveId { get; init; } = string.Empty; public int MappingCount { get; init; } public List Mappings { get; init; } = []; } public sealed record CveMappingItem { public string SymbolName { get; init; } = string.Empty; public string? CanonicalId { get; init; } public string Purl { get; init; } = string.Empty; public string? FilePath { get; init; } public string VulnType { get; init; } = string.Empty; public decimal Confidence { get; init; } public string Source { get; init; } = string.Empty; } public sealed record GenerateVexRequest { public string JobId { get; init; } = string.Empty; public string ProductId { get; init; } = string.Empty; } public sealed record VexStatementResponse { public string StatementId { get; init; } = string.Empty; public string VulnerabilityId { get; init; } = string.Empty; public string ProductId { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; public VexJustificationResponse? Justification { get; init; } public string? ActionStatement { get; init; } public string? ImpactStatement { get; init; } public DateTimeOffset Timestamp { get; init; } } public sealed record VexJustificationResponse { public string Category { get; init; } = string.Empty; public string Detail { get; init; } = string.Empty; public decimal Confidence { get; init; } public List EvidenceReferences { get; init; } = []; } /// /// Store for evidence job results. /// public interface IEvidenceResultStore { Task GetResultAsync(string jobId, CancellationToken ct); Task StoreResultAsync(ReachabilityEvidenceJobResult result, CancellationToken ct); }