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
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

@@ -0,0 +1,244 @@
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 CallGraphEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
private static readonly HashSet<string> SupportedLanguages = new(StringComparer.OrdinalIgnoreCase)
{
"dotnet", "java", "node", "python", "go", "rust", "binary", "ruby", "php"
};
public static void MapCallGraphEndpoints(this RouteGroupBuilder scansGroup)
{
ArgumentNullException.ThrowIfNull(scansGroup);
// POST /scans/{scanId}/callgraphs
scansGroup.MapPost("/{scanId}/callgraphs", HandleSubmitCallGraphAsync)
.WithName("scanner.scans.callgraphs.submit")
.WithTags("CallGraphs")
.Produces<CallGraphAcceptedResponseDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status413PayloadTooLarge)
.RequireAuthorization(ScannerPolicies.CallGraphIngest);
}
private static async Task<IResult> HandleSubmitCallGraphAsync(
string scanId,
CallGraphV1Dto request,
IScanCoordinator coordinator,
ICallGraphIngestionService ingestionService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(ingestionService);
ArgumentNullException.ThrowIfNull(request);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
// Validate Content-Digest header for idempotency
var contentDigest = context.Request.Headers["Content-Digest"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(contentDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Missing Content-Digest header",
StatusCodes.Status400BadRequest,
detail: "Content-Digest header is required for idempotent call graph submission.");
}
// 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.");
}
// Validate call graph schema
var validationResult = ValidateCallGraph(request);
if (!validationResult.IsValid)
{
var extensions = new Dictionary<string, object?>
{
["errors"] = validationResult.Errors
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid call graph",
StatusCodes.Status400BadRequest,
detail: "Call graph validation failed.",
extensions: extensions);
}
// Check for duplicate submission (idempotency)
var existing = await ingestionService.FindByDigestAsync(parsed, contentDigest, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
var conflictExtensions = new Dictionary<string, object?>
{
["callgraphId"] = existing.Id,
["digest"] = existing.Digest
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Conflict,
"Duplicate call graph",
StatusCodes.Status409Conflict,
detail: "Call graph with this Content-Digest already submitted.",
extensions: conflictExtensions);
}
// Ingest the call graph
var result = await ingestionService.IngestAsync(parsed, request, contentDigest, cancellationToken)
.ConfigureAwait(false);
var response = new CallGraphAcceptedResponseDto(
CallgraphId: result.CallgraphId,
NodeCount: result.NodeCount,
EdgeCount: result.EdgeCount,
Digest: result.Digest);
context.Response.Headers.Location = $"/api/scans/{scanId}/callgraphs/{result.CallgraphId}";
return Json(response, StatusCodes.Status202Accepted);
}
private static CallGraphValidationResult ValidateCallGraph(CallGraphV1Dto callGraph)
{
var errors = new List<string>();
// Validate schema version
if (string.IsNullOrWhiteSpace(callGraph.Schema))
{
errors.Add("Schema version is required.");
}
else if (!string.Equals(callGraph.Schema, "stella.callgraph.v1", StringComparison.Ordinal))
{
errors.Add($"Unsupported schema '{callGraph.Schema}'. Expected 'stella.callgraph.v1'.");
}
// Validate scan key
if (string.IsNullOrWhiteSpace(callGraph.ScanKey))
{
errors.Add("ScanKey is required.");
}
// Validate language
if (string.IsNullOrWhiteSpace(callGraph.Language))
{
errors.Add("Language is required.");
}
else if (!SupportedLanguages.Contains(callGraph.Language))
{
errors.Add($"Unsupported language '{callGraph.Language}'. Supported: {string.Join(", ", SupportedLanguages)}.");
}
// Validate nodes
if (callGraph.Nodes is null || callGraph.Nodes.Count == 0)
{
errors.Add("At least one node is required.");
}
else
{
var nodeIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < callGraph.Nodes.Count; i++)
{
var node = callGraph.Nodes[i];
if (string.IsNullOrWhiteSpace(node.NodeId))
{
errors.Add($"nodes[{i}].nodeId is required.");
}
else if (!nodeIds.Add(node.NodeId))
{
errors.Add($"Duplicate nodeId '{node.NodeId}'.");
}
if (string.IsNullOrWhiteSpace(node.SymbolKey))
{
errors.Add($"nodes[{i}].symbolKey is required.");
}
}
}
// Validate edges
if (callGraph.Edges is null || callGraph.Edges.Count == 0)
{
errors.Add("At least one edge is required.");
}
else
{
var nodeIds = callGraph.Nodes?
.Where(n => !string.IsNullOrWhiteSpace(n.NodeId))
.Select(n => n.NodeId)
.ToHashSet(StringComparer.Ordinal) ?? new HashSet<string>();
for (var i = 0; i < callGraph.Edges.Count; i++)
{
var edge = callGraph.Edges[i];
if (string.IsNullOrWhiteSpace(edge.From))
{
errors.Add($"edges[{i}].from is required.");
}
else if (nodeIds.Count > 0 && !nodeIds.Contains(edge.From))
{
errors.Add($"edges[{i}].from references unknown node '{edge.From}'.");
}
if (string.IsNullOrWhiteSpace(edge.To))
{
errors.Add($"edges[{i}].to is required.");
}
else if (nodeIds.Count > 0 && !nodeIds.Contains(edge.To))
{
errors.Add($"edges[{i}].to references unknown node '{edge.To}'.");
}
}
}
return errors.Count > 0
? CallGraphValidationResult.Failure(errors.ToArray())
: CallGraphValidationResult.Success();
}
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);
}
}

View File

@@ -0,0 +1,188 @@
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.Domain;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class ExportEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
WriteIndented = true
};
public static void MapExportEndpoints(this RouteGroupBuilder scansGroup)
{
ArgumentNullException.ThrowIfNull(scansGroup);
// GET /scans/{scanId}/exports/sarif
scansGroup.MapGet("/{scanId}/exports/sarif", HandleExportSarifAsync)
.WithName("scanner.scans.exports.sarif")
.WithTags("Exports")
.Produces(StatusCodes.Status200OK, contentType: "application/sarif+json")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/exports/cdxr
scansGroup.MapGet("/{scanId}/exports/cdxr", HandleExportCycloneDxRAsync)
.WithName("scanner.scans.exports.cdxr")
.WithTags("Exports")
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.cyclonedx+json")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/exports/openvex
scansGroup.MapGet("/{scanId}/exports/openvex", HandleExportOpenVexAsync)
.WithName("scanner.scans.exports.openvex")
.WithTags("Exports")
.Produces(StatusCodes.Status200OK, contentType: "application/json")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleExportSarifAsync(
string scanId,
IScanCoordinator coordinator,
ISarifExportService exportService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(exportService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
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.");
}
var sarifDocument = await exportService.ExportAsync(parsed, cancellationToken).ConfigureAwait(false);
if (sarifDocument is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"No findings available",
StatusCodes.Status404NotFound,
detail: "No findings available for SARIF export.");
}
var json = JsonSerializer.Serialize(sarifDocument, SerializerOptions);
return Results.Content(json, "application/sarif+json", System.Text.Encoding.UTF8, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleExportCycloneDxRAsync(
string scanId,
IScanCoordinator coordinator,
ICycloneDxExportService exportService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(exportService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
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.");
}
var cdxDocument = await exportService.ExportWithReachabilityAsync(parsed, cancellationToken).ConfigureAwait(false);
if (cdxDocument is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"No findings available",
StatusCodes.Status404NotFound,
detail: "No findings available for CycloneDX export.");
}
var json = JsonSerializer.Serialize(cdxDocument, SerializerOptions);
return Results.Content(json, "application/vnd.cyclonedx+json", System.Text.Encoding.UTF8, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleExportOpenVexAsync(
string scanId,
IScanCoordinator coordinator,
IOpenVexExportService exportService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(exportService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
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.");
}
var vexDocument = await exportService.ExportAsync(parsed, cancellationToken).ConfigureAwait(false);
if (vexDocument is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"No VEX data available",
StatusCodes.Status404NotFound,
detail: "No VEX data available for export.");
}
var json = JsonSerializer.Serialize(vexDocument, SerializerOptions);
return Results.Content(json, "application/json", System.Text.Encoding.UTF8, StatusCodes.Status200OK);
}
}

View File

@@ -544,7 +544,7 @@ internal static class PolicyEndpoints
Rekor = rekor,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null,
Metadata = decision.Metadata is { Count: > 0 }
? new ReadOnlyDictionary<string, string>(decision.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal))
? new ReadOnlyDictionary<string, string>(decision.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value?.ToString() ?? string.Empty, StringComparer.Ordinal))
: null
};
}

View File

@@ -0,0 +1,320 @@
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 ReachabilityEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static void MapReachabilityEndpoints(this RouteGroupBuilder scansGroup)
{
ArgumentNullException.ThrowIfNull(scansGroup);
// POST /scans/{scanId}/compute-reachability
scansGroup.MapPost("/{scanId}/compute-reachability", HandleComputeReachabilityAsync)
.WithName("scanner.scans.compute-reachability")
.WithTags("Reachability")
.Produces<ComputeReachabilityResponseDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.ScansWrite);
// GET /scans/{scanId}/reachability/components
scansGroup.MapGet("/{scanId}/reachability/components", HandleGetComponentsAsync)
.WithName("scanner.scans.reachability.components")
.WithTags("Reachability")
.Produces<ComponentReachabilityListDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/reachability/findings
scansGroup.MapGet("/{scanId}/reachability/findings", HandleGetFindingsAsync)
.WithName("scanner.scans.reachability.findings")
.WithTags("Reachability")
.Produces<ReachabilityFindingListDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// GET /scans/{scanId}/reachability/explain
scansGroup.MapGet("/{scanId}/reachability/explain", HandleExplainAsync)
.WithName("scanner.scans.reachability.explain")
.WithTags("Reachability")
.Produces<ReachabilityExplanationDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleComputeReachabilityAsync(
string scanId,
ComputeReachabilityRequestDto? request,
IScanCoordinator coordinator,
IReachabilityComputeService computeService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(computeService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
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.");
}
var jobResult = await computeService.TriggerComputeAsync(
parsed,
request?.ForceRecompute ?? false,
request?.Entrypoints,
request?.Targets,
cancellationToken).ConfigureAwait(false);
if (jobResult.AlreadyInProgress)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Conflict,
"Computation already in progress",
StatusCodes.Status409Conflict,
detail: $"Reachability computation already running for scan {scanId}.");
}
var response = new ComputeReachabilityResponseDto(
JobId: jobResult.JobId,
Status: jobResult.Status,
EstimatedDuration: jobResult.EstimatedDuration);
return Json(response, StatusCodes.Status202Accepted);
}
private static async Task<IResult> HandleGetComponentsAsync(
string scanId,
string? purl,
string? status,
IScanCoordinator coordinator,
IReachabilityQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(queryService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
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.");
}
var components = await queryService.GetComponentsAsync(
parsed,
purl,
status,
cancellationToken).ConfigureAwait(false);
var items = components
.Select(c => new ComponentReachabilityDto(
c.Purl,
c.Status,
c.Confidence,
c.LatticeState,
c.Why))
.ToList();
var response = new ComponentReachabilityListDto(items, items.Count);
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleGetFindingsAsync(
string scanId,
string? cve,
string? status,
IScanCoordinator coordinator,
IReachabilityQueryService queryService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(queryService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
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.");
}
var findings = await queryService.GetFindingsAsync(
parsed,
cve,
status,
cancellationToken).ConfigureAwait(false);
var items = findings
.Select(f => new ReachabilityFindingDto(
f.CveId,
f.Purl,
f.Status,
f.Confidence,
f.LatticeState,
f.Severity,
f.AffectedVersions))
.ToList();
var response = new ReachabilityFindingListDto(items, items.Count);
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleExplainAsync(
string scanId,
string? cve,
string? purl,
IScanCoordinator coordinator,
IReachabilityExplainService explainService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(explainService);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
if (string.IsNullOrWhiteSpace(cve) || string.IsNullOrWhiteSpace(purl))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Missing required parameters",
StatusCodes.Status400BadRequest,
detail: "Both 'cve' and 'purl' query parameters are required.");
}
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.");
}
var explanation = await explainService.ExplainAsync(
parsed,
cve.Trim(),
purl.Trim(),
cancellationToken).ConfigureAwait(false);
if (explanation is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Explanation not found",
StatusCodes.Status404NotFound,
detail: $"No reachability data for CVE {cve} and PURL {purl}.");
}
var response = new ReachabilityExplanationDto(
CveId: explanation.CveId,
Purl: explanation.Purl,
Status: explanation.Status,
Confidence: explanation.Confidence,
LatticeState: explanation.LatticeState,
PathWitness: explanation.PathWitness,
Why: explanation.Why?
.Select(r => new ExplanationReasonDto(r.Code, r.Description, r.Impact))
.ToList(),
Evidence: explanation.Evidence is null ? null : new EvidenceChainDto(
StaticAnalysis: explanation.Evidence.StaticAnalysis is null ? null :
new StaticAnalysisEvidenceDto(
explanation.Evidence.StaticAnalysis.CallgraphDigest,
explanation.Evidence.StaticAnalysis.PathLength,
explanation.Evidence.StaticAnalysis.EdgeTypes),
RuntimeEvidence: explanation.Evidence.RuntimeEvidence is null ? null :
new RuntimeEvidenceDto(
explanation.Evidence.RuntimeEvidence.Observed,
explanation.Evidence.RuntimeEvidence.HitCount,
explanation.Evidence.RuntimeEvidence.LastObserved),
PolicyEvaluation: explanation.Evidence.PolicyEvaluation is null ? null :
new PolicyEvaluationEvidenceDto(
explanation.Evidence.PolicyEvaluation.PolicyDigest,
explanation.Evidence.PolicyEvaluation.Verdict,
explanation.Evidence.PolicyEvaluation.VerdictReason)),
SpineId: explanation.SpineId);
return Json(response, StatusCodes.Status200OK);
}
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);
}
}

View File

@@ -0,0 +1,169 @@
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);
}
}

View File

@@ -78,6 +78,12 @@ internal static class ScanEndpoints
.Produces<BunPackagesResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// Register additional scan-related endpoints
scans.MapCallGraphEndpoints();
scans.MapSbomEndpoints();
scans.MapReachabilityEndpoints();
scans.MapExportEndpoints();
}
private static async Task<IResult> HandleSubmitAsync(
@@ -125,7 +131,7 @@ internal static class ScanEndpoints
}
var target = new ScanTarget(reference, digest).Normalize();
var metadata = NormalizeMetadata(request.Metadata);
var metadata = NormalizeMetadataAsDictionary(request.Metadata);
var determinism = options.Value?.Determinism ?? new ScannerWebServiceOptions.DeterminismOptions();
if (!string.IsNullOrWhiteSpace(determinism.FeedSnapshotId) && !metadata.ContainsKey("determinism.feed"))
@@ -562,10 +568,13 @@ internal static class ScanEndpoints
}
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
=> NormalizeMetadataAsDictionary(metadata);
private static Dictionary<string, string> NormalizeMetadataAsDictionary(IDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new Dictionary<string, string>();
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);