175 lines
6.1 KiB
C#
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);
|
|
}
|
|
}
|