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

175 lines
6.1 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; version=1.7",
"application/vnd.cyclonedx+json; version=1.6",
"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; version=1.7' (or 1.6) 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);
}
}