Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/LayerSbomEndpoints.cs

339 lines
13 KiB
C#

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;
/// <summary>
/// Endpoints for per-layer SBOM access and composition recipes.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
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<LayerListResponseDto>(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<CompositionRecipeResponseDto>(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<CompositionRecipeVerificationResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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>(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();
}
}