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( "application/vnd.cyclonedx+json; version=1.7", "application/vnd.cyclonedx+json; version=1.6", "application/vnd.cyclonedx+json", "application/spdx+json", "application/json") .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansWrite); } private static async Task 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 { ["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 value, int statusCode) { var payload = JsonSerializer.Serialize(value, SerializerOptions); return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode); } }