Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/SbomEndpoints.cs
master 4391f35d8a Refactor SurfaceCacheValidator to simplify oldest entry calculation
Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
2025-12-16 10:44:00 +02:00

170 lines
5.9 KiB
C#

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;
internal static class SbomEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static void MapSbomEndpoints(this RouteGroupBuilder scansGroup)
{
ArgumentNullException.ThrowIfNull(scansGroup);
// POST /scans/{scanId}/sbom
scansGroup.MapPost("/{scanId}/sbom", HandleSubmitSbomAsync)
.WithName("scanner.scans.sbom.submit")
.WithTags("Scans")
.Accepts<JsonDocument>("application/vnd.cyclonedx+json", "application/spdx+json", "application/json")
.Produces<SbomAcceptedResponseDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansWrite);
}
private static async Task<IResult> HandleSubmitSbomAsync(
string scanId,
IScanCoordinator coordinator,
ISbomIngestionService ingestionService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(ingestionService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
// Verify scan exists
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.");
}
// Parse JSON body
JsonDocument sbomDocument;
try
{
sbomDocument = await JsonDocument.ParseAsync(
context.Request.Body,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid JSON",
StatusCodes.Status400BadRequest,
detail: $"Failed to parse SBOM JSON: {ex.Message}");
}
// Detect format from Content-Type or document structure
var contentType = context.Request.ContentType ?? "application/json";
var format = DetectSbomFormat(contentType, sbomDocument, ingestionService);
if (format is null)
{
sbomDocument.Dispose();
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unknown SBOM format",
StatusCodes.Status400BadRequest,
detail: "Could not detect SBOM format. Use Content-Type 'application/vnd.cyclonedx+json' or 'application/spdx+json'.");
}
// Validate the SBOM
var validationResult = ingestionService.Validate(sbomDocument, format);
if (!validationResult.IsValid)
{
sbomDocument.Dispose();
var extensions = new Dictionary<string, object?>
{
["errors"] = validationResult.Errors
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid SBOM",
StatusCodes.Status400BadRequest,
detail: "SBOM validation failed.",
extensions: extensions);
}
// Optional Content-Digest for idempotency
var contentDigest = context.Request.Headers["Content-Digest"].FirstOrDefault();
// Ingest the SBOM
var result = await ingestionService.IngestAsync(
parsed,
sbomDocument,
format,
contentDigest,
cancellationToken).ConfigureAwait(false);
sbomDocument.Dispose();
var response = new SbomAcceptedResponseDto(
SbomId: result.SbomId,
Format: result.Format,
ComponentCount: result.ComponentCount,
Digest: result.Digest);
context.Response.Headers.Location = $"/api/scans/{scanId}/sbom/{result.SbomId}";
return Json(response, StatusCodes.Status202Accepted);
}
private static string? DetectSbomFormat(
string contentType,
JsonDocument document,
ISbomIngestionService ingestionService)
{
// Check Content-Type first
if (contentType.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormats.CycloneDx;
}
if (contentType.Contains("spdx", StringComparison.OrdinalIgnoreCase))
{
return SbomFormats.Spdx;
}
// Fall back to document structure detection
return ingestionService.DetectFormat(document);
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
}
}