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
170 lines
5.9 KiB
C#
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);
|
|
}
|
|
}
|