using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; 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; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using static StellaOps.Localization.T; namespace StellaOps.Scanner.WebService.Endpoints; /// /// Endpoints for per-layer SBOM access and composition recipes. /// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api /// internal static class LayerSbomEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; public static void MapLayerSbomEndpoints(this RouteGroupBuilder scansGroup) { ArgumentNullException.ThrowIfNull(scansGroup); // GET /scans/{scanId}/layers - List layers with SBOM info scansGroup.MapGet("/{scanId}/layers", HandleListLayersAsync) .WithName("scanner.scans.layers.list") .WithTags("Scans", "Layers") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /scans/{scanId}/layers/{layerDigest}/sbom - Get per-layer SBOM scansGroup.MapGet("/{scanId}/layers/{layerDigest}/sbom", HandleGetLayerSbomAsync) .WithName("scanner.scans.layers.sbom") .WithTags("Scans", "Layers", "SBOM") .Produces(StatusCodes.Status200OK, contentType: "application/json") .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /scans/{scanId}/composition-recipe - Get composition recipe scansGroup.MapGet("/{scanId}/composition-recipe", HandleGetCompositionRecipeAsync) .WithName("scanner.scans.composition-recipe") .WithTags("Scans", "SBOM") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // POST /scans/{scanId}/composition-recipe/verify - Verify composition recipe scansGroup.MapPost("/{scanId}/composition-recipe/verify", HandleVerifyCompositionRecipeAsync) .WithName("scanner.scans.composition-recipe.verify") .WithTags("Scans", "SBOM") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleListLayersAsync( string scanId, IScanCoordinator coordinator, ILayerSbomService layerSbomService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(layerSbomService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, _t("scanner.scan.invalid_identifier"), StatusCodes.Status400BadRequest, detail: _t("scanner.scan.identifier_required")); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, _t("scanner.scan.not_found"), StatusCodes.Status404NotFound, detail: _t("scanner.scan.not_found_detail")); } var layers = await layerSbomService.GetLayerSummariesAsync(parsed, cancellationToken).ConfigureAwait(false); var response = new LayerListResponseDto { ScanId = scanId, ImageDigest = snapshot.Target.Digest ?? string.Empty, Layers = layers.Select(l => new LayerSummaryDto { Digest = l.LayerDigest, Order = l.Order, HasSbom = l.HasSbom, ComponentCount = l.ComponentCount, }).ToList(), }; return Json(response, StatusCodes.Status200OK); } private static async Task HandleGetLayerSbomAsync( string scanId, string layerDigest, string? format, IScanCoordinator coordinator, ILayerSbomService layerSbomService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(layerSbomService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, _t("scanner.scan.invalid_identifier"), StatusCodes.Status400BadRequest, detail: _t("scanner.scan.identifier_required")); } if (string.IsNullOrWhiteSpace(layerDigest)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, _t("scanner.layer_sbom.invalid_layer_digest"), StatusCodes.Status400BadRequest, detail: _t("scanner.layer_sbom.layer_digest_required")); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, _t("scanner.scan.not_found"), StatusCodes.Status404NotFound, detail: _t("scanner.scan.not_found_detail")); } // Normalize layer digest (URL decode if needed) var normalizedDigest = Uri.UnescapeDataString(layerDigest); // Determine format: cdx (default) or spdx var sbomFormat = string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase) ? "spdx" : "cdx"; var sbomBytes = await layerSbomService.GetLayerSbomAsync( parsed, normalizedDigest, sbomFormat, cancellationToken).ConfigureAwait(false); if (sbomBytes is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, _t("scanner.layer_sbom.not_found"), StatusCodes.Status404NotFound, detail: _tn("scanner.layer_sbom.not_found_detail", ("layerDigest", normalizedDigest))); } var contentType = sbomFormat == "spdx" ? "application/spdx+json; version=3.0.1" : "application/vnd.cyclonedx+json; version=1.7"; var contentDigest = ComputeSha256(sbomBytes); context.Response.Headers.ETag = $"\"{contentDigest}\""; context.Response.Headers["X-StellaOps-Layer-Digest"] = normalizedDigest; context.Response.Headers["X-StellaOps-Format"] = sbomFormat == "spdx" ? "spdx-3.0.1" : "cyclonedx-1.7"; context.Response.Headers.CacheControl = "public, max-age=31536000, immutable"; return Results.Bytes(sbomBytes, contentType); } private static async Task HandleGetCompositionRecipeAsync( string scanId, IScanCoordinator coordinator, ILayerSbomService layerSbomService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(layerSbomService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, _t("scanner.scan.invalid_identifier"), StatusCodes.Status400BadRequest, detail: _t("scanner.scan.identifier_required")); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, _t("scanner.scan.not_found"), StatusCodes.Status404NotFound, detail: _t("scanner.scan.not_found_detail")); } var recipe = await layerSbomService.GetCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false); if (recipe is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, _t("scanner.layer_sbom.recipe_not_found"), StatusCodes.Status404NotFound, detail: _t("scanner.layer_sbom.recipe_not_found_detail")); } var response = new CompositionRecipeResponseDto { ScanId = scanId, ImageDigest = snapshot.Target.Digest ?? string.Empty, CreatedAt = recipe.CreatedAt, Recipe = new CompositionRecipeDto { Version = recipe.Recipe.Version, GeneratorName = recipe.Recipe.GeneratorName, GeneratorVersion = recipe.Recipe.GeneratorVersion, Layers = recipe.Recipe.Layers.Select(l => new CompositionRecipeLayerDto { Digest = l.Digest, Order = l.Order, FragmentDigest = l.FragmentDigest, SbomDigests = new LayerSbomDigestsDto { CycloneDx = l.SbomDigests.CycloneDx, Spdx = l.SbomDigests.Spdx, }, ComponentCount = l.ComponentCount, }).ToList(), MerkleRoot = recipe.Recipe.MerkleRoot, AggregatedSbomDigests = new AggregatedSbomDigestsDto { CycloneDx = recipe.Recipe.AggregatedSbomDigests.CycloneDx, Spdx = recipe.Recipe.AggregatedSbomDigests.Spdx, }, }, }; return Json(response, StatusCodes.Status200OK); } private static async Task HandleVerifyCompositionRecipeAsync( string scanId, IScanCoordinator coordinator, ILayerSbomService layerSbomService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(layerSbomService); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, _t("scanner.scan.invalid_identifier"), StatusCodes.Status400BadRequest, detail: _t("scanner.scan.identifier_required")); } var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, _t("scanner.scan.not_found"), StatusCodes.Status404NotFound, detail: _t("scanner.scan.not_found_detail")); } var verificationResult = await layerSbomService.VerifyCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false); if (verificationResult is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, _t("scanner.layer_sbom.recipe_not_found"), StatusCodes.Status404NotFound, detail: _t("scanner.layer_sbom.recipe_not_found_for_verification")); } var response = new CompositionRecipeVerificationResponseDto { Valid = verificationResult.Valid, MerkleRootMatch = verificationResult.MerkleRootMatch, LayerDigestsMatch = verificationResult.LayerDigestsMatch, Errors = verificationResult.Errors.IsDefaultOrEmpty ? null : verificationResult.Errors.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", Encoding.UTF8, statusCode); } private static string ComputeSha256(byte[] bytes) { var hash = System.Security.Cryptography.SHA256.HashData(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } }