sprints and audit work
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
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;
|
||||
|
||||
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,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
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,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(layerDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid layer digest",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Layer digest is required.");
|
||||
}
|
||||
|
||||
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
// 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,
|
||||
"Layer SBOM not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"SBOM for layer {normalizedDigest} could not be found.");
|
||||
}
|
||||
|
||||
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,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
var recipe = await layerSbomService.GetCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (recipe is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Composition recipe not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Composition recipe for this scan is not available.");
|
||||
}
|
||||
|
||||
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,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
var verificationResult = await layerSbomService.VerifyCompositionRecipeAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verificationResult is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Composition recipe not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Composition recipe for this scan is not available 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user