using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using StellaOps.Scanner.WebService.Constants; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; using StellaOps.Scanner.WebService.Infrastructure; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Endpoints; internal static class ReachabilityEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; public static void MapReachabilityEndpoints(this RouteGroupBuilder scansGroup) { ArgumentNullException.ThrowIfNull(scansGroup); // POST /scans/{scanId}/compute-reachability scansGroup.MapPost("/{scanId}/compute-reachability", HandleComputeReachabilityAsync) .WithName("scanner.scans.compute-reachability") .WithTags("Reachability") .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .RequireAuthorization(ScannerPolicies.ScansWrite); // GET /scans/{scanId}/reachability/components scansGroup.MapGet("/{scanId}/reachability/components", HandleGetComponentsAsync) .WithName("scanner.scans.reachability.components") .WithTags("Reachability") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /scans/{scanId}/reachability/findings scansGroup.MapGet("/{scanId}/reachability/findings", HandleGetFindingsAsync) .WithName("scanner.scans.reachability.findings") .WithTags("Reachability") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /scans/{scanId}/reachability/explain scansGroup.MapGet("/{scanId}/reachability/explain", HandleExplainAsync) .WithName("scanner.scans.reachability.explain") .WithTags("Reachability") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence // GET /scans/{scanId}/reachability/traces/export - Trace export with runtime evidence scansGroup.MapGet("/{scanId}/reachability/traces/export", HandleTraceExportAsync) .WithName("scanner.scans.reachability.traces.export") .WithTags("Reachability") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleComputeReachabilityAsync( string scanId, ComputeReachabilityRequestDto? request, IScanCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(context); var computeService = context.RequestServices.GetRequiredService(); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var jobResult = await computeService.TriggerComputeAsync( parsed, request?.ForceRecompute ?? false, request?.Entrypoints, request?.Targets, cancellationToken).ConfigureAwait(false); if (jobResult.AlreadyInProgress) { return ProblemResultFactory.Create( context, ProblemTypes.Conflict, "Computation already in progress", StatusCodes.Status409Conflict, detail: $"Reachability computation already running for scan {scanId}."); } var response = new ComputeReachabilityResponseDto( JobId: jobResult.JobId, Status: jobResult.Status, EstimatedDuration: jobResult.EstimatedDuration); return Json(response, StatusCodes.Status202Accepted); } private static async Task HandleGetComponentsAsync( string scanId, string? purl, string? status, IScanCoordinator coordinator, IReachabilityQueryService queryService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(queryService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var components = await queryService.GetComponentsAsync( parsed, purl, status, cancellationToken).ConfigureAwait(false); var items = components .Select(c => new ComponentReachabilityDto( c.Purl, c.Status, c.Confidence, c.LatticeState, c.Why)) .ToList(); var response = new ComponentReachabilityListDto(items, items.Count); return Json(response, StatusCodes.Status200OK); } private static async Task HandleGetFindingsAsync( string scanId, string? cve, string? status, IScanCoordinator coordinator, IReachabilityQueryService queryService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(queryService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var findings = await queryService.GetFindingsAsync( parsed, cve, status, cancellationToken).ConfigureAwait(false); var items = findings .Select(f => new ReachabilityFindingDto( f.CveId, f.Purl, f.Status, f.Confidence, f.LatticeState, f.Severity, f.AffectedVersions)) .ToList(); var response = new ReachabilityFindingListDto(items, items.Count); return Json(response, StatusCodes.Status200OK); } private static async Task HandleExplainAsync( string scanId, string? cve, string? purl, IScanCoordinator coordinator, IReachabilityExplainService explainService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(explainService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } if (string.IsNullOrWhiteSpace(cve) || string.IsNullOrWhiteSpace(purl)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Missing required parameters", StatusCodes.Status400BadRequest, detail: "Both 'cve' and 'purl' query parameters are required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var explanation = await explainService.ExplainAsync( parsed, cve.Trim(), purl.Trim(), cancellationToken).ConfigureAwait(false); if (explanation is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Explanation not found", StatusCodes.Status404NotFound, detail: $"No reachability data for CVE {cve} and PURL {purl}."); } var response = new ReachabilityExplanationDto( CveId: explanation.CveId, Purl: explanation.Purl, Status: explanation.Status, Confidence: explanation.Confidence, LatticeState: explanation.LatticeState, PathWitness: explanation.PathWitness, Why: explanation.Why? .Select(r => new ExplanationReasonDto(r.Code, r.Description, r.Impact)) .ToList(), Evidence: explanation.Evidence is null ? null : new EvidenceChainDto( StaticAnalysis: explanation.Evidence.StaticAnalysis is null ? null : new StaticAnalysisEvidenceDto( explanation.Evidence.StaticAnalysis.CallgraphDigest, explanation.Evidence.StaticAnalysis.PathLength, explanation.Evidence.StaticAnalysis.EdgeTypes), RuntimeEvidence: explanation.Evidence.RuntimeEvidence is null ? null : new RuntimeEvidenceDto( explanation.Evidence.RuntimeEvidence.Observed, explanation.Evidence.RuntimeEvidence.HitCount, explanation.Evidence.RuntimeEvidence.LastObserved), PolicyEvaluation: explanation.Evidence.PolicyEvaluation is null ? null : new PolicyEvaluationEvidenceDto( explanation.Evidence.PolicyEvaluation.PolicyDigest, explanation.Evidence.PolicyEvaluation.Verdict, explanation.Evidence.PolicyEvaluation.VerdictReason)), SpineId: explanation.SpineId); return Json(response, StatusCodes.Status200OK); } // Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence (SCAN-RT-003) private static async Task HandleTraceExportAsync( string scanId, string? format, bool? includeRuntimeEvidence, double? minReachabilityScore, bool? runtimeConfirmedOnly, IScanCoordinator coordinator, IReachabilityQueryService queryService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(queryService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } // Determine export format (default to json-lines for determinism) var exportFormat = (format?.ToLowerInvariant()) switch { "graphson" => "graphson", "ndjson" or "json-lines" => "json-lines", _ => "json-lines" }; var options = new TraceExportOptions { Format = exportFormat, IncludeRuntimeEvidence = includeRuntimeEvidence ?? true, MinReachabilityScore = minReachabilityScore, RuntimeConfirmedOnly = runtimeConfirmedOnly ?? false }; var export = await queryService.ExportTracesAsync(parsed, options, cancellationToken).ConfigureAwait(false); if (export is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "No reachability data", StatusCodes.Status404NotFound, detail: "No reachability data found for this scan."); } var response = new ReachabilityTraceExportDto( Format: export.Format, CanonicalizationMethod: "StellaOps.Canonical.Json", ContentDigest: export.ContentDigest, Timestamp: export.Timestamp, NodeCount: export.Nodes.Count, EdgeCount: export.Edges.Count, RuntimeCoverage: export.RuntimeCoverage, AverageReachabilityScore: export.AverageReachabilityScore, Nodes: export.Nodes.Select(n => new TraceNodeDto( Id: n.Id, SymbolId: n.SymbolId, ReachabilityScore: n.ReachabilityScore, RuntimeConfirmed: n.RuntimeConfirmed, RuntimeObservationCount: n.RuntimeObservationCount, Evidence: n.Evidence)).ToList(), Edges: export.Edges.Select(e => new TraceEdgeDto( From: e.From, To: e.To, Kind: e.Kind, Confidence: e.Confidence, RuntimeConfirmed: e.RuntimeConfirmed, RuntimeObservationCount: e.RuntimeObservationCount, Evidence: e.Evidence)).ToList()); return Json(response, StatusCodes.Status200OK); } private static IResult Json(T value, int statusCode) { var payload = JsonSerializer.Serialize(value, SerializerOptions); return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode); } } // Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence // Trace export DTOs /// Options for trace export. public sealed record TraceExportOptions { public string Format { get; init; } = "json-lines"; public bool IncludeRuntimeEvidence { get; init; } = true; public double? MinReachabilityScore { get; init; } public bool RuntimeConfirmedOnly { get; init; } } /// Trace export response. public sealed record ReachabilityTraceExportDto( string Format, string CanonicalizationMethod, string ContentDigest, DateTimeOffset Timestamp, int NodeCount, int EdgeCount, double RuntimeCoverage, double? AverageReachabilityScore, IReadOnlyList Nodes, IReadOnlyList Edges); /// Node in trace export. public sealed record TraceNodeDto( string Id, string SymbolId, double? ReachabilityScore, bool? RuntimeConfirmed, ulong? RuntimeObservationCount, IReadOnlyList? Evidence); /// Edge in trace export. public sealed record TraceEdgeDto( string From, string To, string Kind, double Confidence, bool? RuntimeConfirmed, ulong? RuntimeObservationCount, IReadOnlyList? Evidence);