using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Replay.Core; using StellaOps.Scanner.ProofSpine; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Serialization; namespace StellaOps.Scanner.WebService.Endpoints; internal static class ProofSpineEndpoints { public static void MapProofSpineEndpoints(this RouteGroupBuilder apiGroup, string spinesSegment, string scansSegment) { ArgumentNullException.ThrowIfNull(apiGroup); var spines = apiGroup.MapGroup(NormalizeSegment(spinesSegment)); spines.MapGet("/{spineId}", HandleGetSpineAsync) .WithName("scanner.spines.get") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK, contentType: CborNegotiation.ContentType) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); var scans = apiGroup.MapGroup(NormalizeSegment(scansSegment)); scans.MapGet("/{scanId}/spines", HandleListSpinesAsync) .WithName("scanner.spines.list-by-scan") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK, contentType: CborNegotiation.ContentType) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleGetSpineAsync( HttpRequest request, string spineId, IProofSpineRepository repository, ProofSpineVerifier verifier, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(verifier); if (string.IsNullOrWhiteSpace(spineId)) { return Results.NotFound(); } var spine = await repository.GetByIdAsync(spineId, cancellationToken).ConfigureAwait(false); if (spine is null) { return Results.NotFound(); } var segments = await repository.GetSegmentsAsync(spineId, cancellationToken).ConfigureAwait(false); var full = spine with { Segments = segments }; var verification = await verifier.VerifyAsync(full, cancellationToken).ConfigureAwait(false); var verificationBySegment = verification.Segments.ToDictionary(s => s.SegmentId, s => s, StringComparer.Ordinal); var dto = new ProofSpineResponseDto { SpineId = full.SpineId, ArtifactId = full.ArtifactId, VulnerabilityId = full.VulnerabilityId, PolicyProfileId = full.PolicyProfileId, Verdict = full.Verdict, VerdictReason = full.VerdictReason, RootHash = full.RootHash, ScanRunId = full.ScanRunId, CreatedAt = full.CreatedAt, SupersededBySpineId = full.SupersededBySpineId, Segments = full.Segments.Select(segment => { verificationBySegment.TryGetValue(segment.SegmentId, out var segmentVerification); var status = segmentVerification?.Status ?? segment.Status; return new ProofSegmentDto { SegmentId = segment.SegmentId, SegmentType = ToWireSegmentType(segment.SegmentType), Index = segment.Index, InputHash = segment.InputHash, ResultHash = segment.ResultHash, PrevSegmentHash = segment.PrevSegmentHash, Envelope = MapEnvelope(segment.Envelope), ToolId = segment.ToolId, ToolVersion = segment.ToolVersion, Status = ToWireStatus(status), CreatedAt = segment.CreatedAt, VerificationErrors = segmentVerification?.Errors.Count > 0 ? segmentVerification.Errors : null }; }).ToArray(), Verification = new ProofSpineVerificationDto { IsValid = verification.IsValid, Errors = verification.Errors.ToArray() } }; if (CborNegotiation.AcceptsCbor(request)) { return Results.Bytes( DeterministicCborSerializer.Serialize(dto), contentType: CborNegotiation.ContentType); } return Results.Ok(dto); } private static async Task HandleListSpinesAsync( HttpRequest request, string scanId, IProofSpineRepository repository, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(repository); if (string.IsNullOrWhiteSpace(scanId)) { var empty = new ProofSpineListResponseDto { Items = Array.Empty(), Total = 0 }; if (CborNegotiation.AcceptsCbor(request)) { return Results.Bytes( DeterministicCborSerializer.Serialize(empty), contentType: CborNegotiation.ContentType); } return Results.Ok(empty); } var summaries = await repository.GetSummariesByScanRunAsync(scanId, cancellationToken).ConfigureAwait(false); var items = summaries.Select(summary => new ProofSpineSummaryDto { SpineId = summary.SpineId, ArtifactId = summary.ArtifactId, VulnerabilityId = summary.VulnerabilityId, Verdict = summary.Verdict, SegmentCount = summary.SegmentCount, CreatedAt = summary.CreatedAt }).ToArray(); var response = new ProofSpineListResponseDto { Items = items, Total = items.Length }; if (CborNegotiation.AcceptsCbor(request)) { return Results.Bytes( DeterministicCborSerializer.Serialize(response), contentType: CborNegotiation.ContentType); } return Results.Ok(response); } private static DsseEnvelopeDto MapEnvelope(DsseEnvelope envelope) => new() { PayloadType = envelope.PayloadType, Payload = envelope.Payload, Signatures = envelope.Signatures.Select(signature => new DsseSignatureDto { KeyId = signature.KeyId, Sig = signature.Sig }).ToArray() }; private static string ToWireSegmentType(ProofSegmentType type) => type switch { ProofSegmentType.SbomSlice => "SBOM_SLICE", ProofSegmentType.Match => "MATCH", ProofSegmentType.Reachability => "REACHABILITY", ProofSegmentType.GuardAnalysis => "GUARD_ANALYSIS", ProofSegmentType.RuntimeObservation => "RUNTIME_OBSERVATION", ProofSegmentType.PolicyEval => "POLICY_EVAL", _ => type.ToString() }; private static string ToWireStatus(ProofSegmentStatus status) => status.ToString().ToLowerInvariant(); private static string NormalizeSegment(string segment) { if (string.IsNullOrWhiteSpace(segment)) { return "/"; } var trimmed = segment.Trim('/'); return "/" + trimmed; } }