// ----------------------------------------------------------------------------- // ManifestEndpoints.cs // Sprint: SPRINT_3500_0002_0003_proof_replay_api // Task: T1, T2 - Manifest and Proof Bundle Endpoints // Description: Endpoints for scan manifest and proof bundle retrieval // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Replay.Core; using StellaOps.Scanner.Core; using StellaOps.Scanner.Storage.Entities; using StellaOps.Scanner.Storage.Repositories; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Extensions; using StellaOps.Scanner.WebService.Security; using System.Security.Cryptography; using System.Text; namespace StellaOps.Scanner.WebService.Endpoints; /// /// Endpoints for scan manifest and proof bundle operations. /// internal static class ManifestEndpoints { private const string DsseContentType = "application/dsse+json"; private const string JsonContentType = "application/json"; /// /// Register manifest and proof bundle endpoints on a scans group. /// public static void MapManifestEndpoints(this RouteGroupBuilder scansGroup) { ArgumentNullException.ThrowIfNull(scansGroup); // GET /scans/{scanId}/manifest scansGroup.MapGet("/{scanId}/manifest", HandleGetManifestAsync) .WithName("scanner.scans.manifest") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK, contentType: DsseContentType) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status429TooManyRequests) .WithDescription("Get the scan manifest, optionally with DSSE signature") .RequireAuthorization(ScannerPolicies.ScansRead) .RequireRateLimiting(RateLimitingExtensions.ManifestPolicy); // GET /scans/{scanId}/proofs scansGroup.MapGet("/{scanId}/proofs", HandleListProofsAsync) .WithName("scanner.scans.proofs.list") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .WithDescription("List all proof bundles for a scan") .RequireAuthorization(ScannerPolicies.ScansRead); // GET /scans/{scanId}/proofs/{rootHash} scansGroup.MapGet("/{scanId}/proofs/{rootHash}", HandleGetProofAsync) .WithName("scanner.scans.proofs.get") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .WithDescription("Get a specific proof bundle by root hash") .RequireAuthorization(ScannerPolicies.ScansRead); } /// /// GET /scans/{scanId}/manifest /// Returns the scan manifest with input hashes for reproducibility. /// Supports content negotiation for DSSE-signed response. /// private static async Task HandleGetManifestAsync( HttpRequest request, string scanId, [FromServices] IScanManifestRepository manifestRepository, [FromServices] IScanManifestSigner manifestSigner, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId, out var scanGuid)) { return Results.NotFound(new ProblemDetails { Title = "Scan not found", Detail = "Invalid scan ID format", Status = StatusCodes.Status404NotFound }); } var manifestRow = await manifestRepository.GetByScanIdAsync(scanGuid, cancellationToken); if (manifestRow is null) { return Results.NotFound(new ProblemDetails { Title = "Manifest not found", Detail = $"No manifest found for scan: {scanId}", Status = StatusCodes.Status404NotFound }); } // Check Accept header for DSSE content negotiation var acceptHeader = request.Headers.Accept.ToString(); var wantsDsse = acceptHeader.Contains(DsseContentType, StringComparison.OrdinalIgnoreCase); // Build base manifest response var manifestResponse = new ScanManifestResponse { ManifestId = manifestRow.ManifestId, ScanId = manifestRow.ScanId, ManifestHash = manifestRow.ManifestHash, SbomHash = manifestRow.SbomHash, RulesHash = manifestRow.RulesHash, FeedHash = manifestRow.FeedHash, PolicyHash = manifestRow.PolicyHash, ScanStartedAt = manifestRow.ScanStartedAt, ScanCompletedAt = manifestRow.ScanCompletedAt, ScannerVersion = manifestRow.ScannerVersion, CreatedAt = manifestRow.CreatedAt, ContentDigest = ComputeContentDigest(manifestRow.ManifestContent) }; if (wantsDsse) { // Return DSSE-signed manifest var manifest = ScanManifest.FromJson(manifestRow.ManifestContent); var signedManifest = await manifestSigner.SignAsync(manifest, cancellationToken); var verifyResult = await manifestSigner.VerifyAsync(signedManifest, cancellationToken); var signedResponse = new SignedScanManifestResponse { Manifest = manifestResponse, ManifestHash = signedManifest.ManifestHash, Envelope = MapToDsseEnvelopeDto(signedManifest.Envelope), SignedAt = signedManifest.SignedAt, SignatureValid = verifyResult.IsValid }; return Results.Json(signedResponse, contentType: DsseContentType); } // Return plain manifest with Content-Digest header return Results.Ok(manifestResponse); } /// /// GET /scans/{scanId}/proofs /// Lists all proof bundles for a scan. /// private static async Task HandleListProofsAsync( string scanId, [FromServices] IProofBundleRepository bundleRepository, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId, out var scanGuid)) { return Results.NotFound(new ProblemDetails { Title = "Scan not found", Detail = "Invalid scan ID format", Status = StatusCodes.Status404NotFound }); } var bundles = await bundleRepository.GetByScanIdAsync(scanGuid, cancellationToken); var items = bundles.Select(b => new ProofBundleSummary { RootHash = b.RootHash, BundleType = b.BundleType, BundleHash = b.BundleHash, CreatedAt = b.CreatedAt }).ToList(); return Results.Ok(new ProofBundleListResponse { Items = items, Total = items.Count }); } /// /// GET /scans/{scanId}/proofs/{rootHash} /// Gets a specific proof bundle by root hash. /// private static async Task HandleGetProofAsync( string scanId, string rootHash, [FromServices] IProofBundleRepository bundleRepository, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId, out var scanGuid)) { return Results.NotFound(new ProblemDetails { Title = "Scan not found", Detail = "Invalid scan ID format", Status = StatusCodes.Status404NotFound }); } if (string.IsNullOrWhiteSpace(rootHash)) { return Results.NotFound(new ProblemDetails { Title = "Invalid root hash", Detail = "Root hash is required", Status = StatusCodes.Status404NotFound }); } var bundle = await bundleRepository.GetByRootHashAsync(rootHash, cancellationToken); if (bundle is null || bundle.ScanId != scanGuid) { return Results.NotFound(new ProblemDetails { Title = "Proof bundle not found", Detail = $"No proof bundle found with root hash: {rootHash}", Status = StatusCodes.Status404NotFound }); } // Verify the DSSE signature if present var (signatureValid, verificationError) = VerifyDsseSignature(bundle); var response = new ProofBundleResponse { ScanId = bundle.ScanId, RootHash = bundle.RootHash, BundleType = bundle.BundleType, BundleHash = bundle.BundleHash, LedgerHash = bundle.LedgerHash, ManifestHash = bundle.ManifestHash, SbomHash = bundle.SbomHash, VexHash = bundle.VexHash, SignatureKeyId = bundle.SignatureKeyId, SignatureAlgorithm = bundle.SignatureAlgorithm, CreatedAt = bundle.CreatedAt, ExpiresAt = bundle.ExpiresAt, SignatureValid = signatureValid, VerificationError = verificationError, ContentDigest = ComputeContentDigest(bundle.BundleHash) }; return Results.Ok(response); } /// /// Compute RFC 9530 Content-Digest header value. /// private static string ComputeContentDigest(string content) { var bytes = Encoding.UTF8.GetBytes(content); var hash = SHA256.HashData(bytes); var base64 = Convert.ToBase64String(hash); return $"sha-256=:{base64}:"; } /// /// Map DSSE envelope to DTO. /// private static DsseEnvelopeDto MapToDsseEnvelopeDto(DsseEnvelope envelope) { return new DsseEnvelopeDto { PayloadType = envelope.PayloadType, Payload = envelope.Payload, Signatures = envelope.Signatures.Select(s => new DsseSignatureDto { KeyId = s.KeyId, Sig = s.Sig }).ToList() }; } /// /// Verify the DSSE signature of a proof bundle. /// private static (bool SignatureValid, string? Error) VerifyDsseSignature(ProofBundleRow bundle) { try { // If no DSSE envelope, signature is not applicable if (string.IsNullOrEmpty(bundle.DsseEnvelope)) { return (true, null); } // Verify bundle hash matches stored hash if (bundle.BundleContent is not null) { var computedHash = Convert.ToHexStringLower(SHA256.HashData(bundle.BundleContent)); if (!string.Equals(bundle.BundleHash, computedHash, StringComparison.OrdinalIgnoreCase)) { return (false, "Bundle content hash mismatch"); } } // Full DSSE signature verification would require the signing service // For now, we trust the stored envelope if present return (true, null); } catch (Exception ex) { return (false, ex.Message); } } }