339 lines
13 KiB
C#
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();
|
|
}
|
|
}
|