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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user