308 lines
11 KiB
C#
308 lines
11 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Endpoints for scan manifest and proof bundle operations.
|
|
/// </summary>
|
|
internal static class ManifestEndpoints
|
|
{
|
|
private const string DsseContentType = "application/dsse+json";
|
|
private const string JsonContentType = "application/json";
|
|
|
|
/// <summary>
|
|
/// Register manifest and proof bundle endpoints on a scans group.
|
|
/// </summary>
|
|
public static void MapManifestEndpoints(this RouteGroupBuilder scansGroup)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scansGroup);
|
|
|
|
// GET /scans/{scanId}/manifest
|
|
scansGroup.MapGet("/{scanId}/manifest", HandleGetManifestAsync)
|
|
.WithName("scanner.scans.manifest")
|
|
.Produces<ScanManifestResponse>(StatusCodes.Status200OK)
|
|
.Produces<SignedScanManifestResponse>(StatusCodes.Status200OK, contentType: DsseContentType)
|
|
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
|
.Produces<ProblemDetails>(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<ProofBundleListResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(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<ProofBundleResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
|
.WithDescription("Get a specific proof bundle by root hash")
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /scans/{scanId}/manifest
|
|
/// Returns the scan manifest with input hashes for reproducibility.
|
|
/// Supports content negotiation for DSSE-signed response.
|
|
/// </summary>
|
|
private static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /scans/{scanId}/proofs
|
|
/// Lists all proof bundles for a scan.
|
|
/// </summary>
|
|
private static async Task<IResult> 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
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /scans/{scanId}/proofs/{rootHash}
|
|
/// Gets a specific proof bundle by root hash.
|
|
/// </summary>
|
|
private static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compute RFC 9530 Content-Digest header value.
|
|
/// </summary>
|
|
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}:";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Map DSSE envelope to DTO.
|
|
/// </summary>
|
|
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()
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify the DSSE signature of a proof bundle.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|