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,69 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Call graph submission request (CallGraphV1 schema).
|
||||
/// </summary>
|
||||
public sealed record CallGraphV1Dto(
|
||||
[property: JsonPropertyName("schema")] string Schema,
|
||||
[property: JsonPropertyName("scanKey")] string ScanKey,
|
||||
[property: JsonPropertyName("language")] string Language,
|
||||
[property: JsonPropertyName("nodes")] IReadOnlyList<CallGraphNodeDto> Nodes,
|
||||
[property: JsonPropertyName("edges")] IReadOnlyList<CallGraphEdgeDto> Edges,
|
||||
[property: JsonPropertyName("artifacts")] IReadOnlyList<CallGraphArtifactDto>? Artifacts = null,
|
||||
[property: JsonPropertyName("entrypoints")] IReadOnlyList<CallGraphEntrypointDto>? Entrypoints = null);
|
||||
|
||||
/// <summary>
|
||||
/// Artifact in a call graph.
|
||||
/// </summary>
|
||||
public sealed record CallGraphArtifactDto(
|
||||
[property: JsonPropertyName("artifactKey")] string ArtifactKey,
|
||||
[property: JsonPropertyName("kind")] string? Kind = null,
|
||||
[property: JsonPropertyName("sha256")] string? Sha256 = null);
|
||||
|
||||
/// <summary>
|
||||
/// Node in a call graph.
|
||||
/// </summary>
|
||||
public sealed record CallGraphNodeDto(
|
||||
[property: JsonPropertyName("nodeId")] string NodeId,
|
||||
[property: JsonPropertyName("symbolKey")] string SymbolKey,
|
||||
[property: JsonPropertyName("artifactKey")] string? ArtifactKey = null,
|
||||
[property: JsonPropertyName("visibility")] string? Visibility = null,
|
||||
[property: JsonPropertyName("isEntrypointCandidate")] bool IsEntrypointCandidate = false);
|
||||
|
||||
/// <summary>
|
||||
/// Edge in a call graph.
|
||||
/// </summary>
|
||||
public sealed record CallGraphEdgeDto(
|
||||
[property: JsonPropertyName("from")] string From,
|
||||
[property: JsonPropertyName("to")] string To,
|
||||
[property: JsonPropertyName("kind")] string Kind = "static",
|
||||
[property: JsonPropertyName("reason")] string? Reason = null,
|
||||
[property: JsonPropertyName("weight")] double Weight = 1.0);
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint in a call graph.
|
||||
/// </summary>
|
||||
public sealed record CallGraphEntrypointDto(
|
||||
[property: JsonPropertyName("nodeId")] string NodeId,
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("route")] string? Route = null,
|
||||
[property: JsonPropertyName("framework")] string? Framework = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response when call graph is accepted.
|
||||
/// </summary>
|
||||
public sealed record CallGraphAcceptedResponseDto(
|
||||
[property: JsonPropertyName("callgraphId")] string CallgraphId,
|
||||
[property: JsonPropertyName("nodeCount")] int NodeCount,
|
||||
[property: JsonPropertyName("edgeCount")] int EdgeCount,
|
||||
[property: JsonPropertyName("digest")] string Digest);
|
||||
|
||||
/// <summary>
|
||||
/// Existing call graph reference (for duplicate detection).
|
||||
/// </summary>
|
||||
public sealed record ExistingCallGraphDto(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
@@ -129,10 +129,22 @@ public sealed record DsseEnvelopeDto
|
||||
public sealed record DsseSignatureDto
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public string Sig { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
[JsonPropertyOrder(2)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
[JsonPropertyOrder(3)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ProofSpineVerificationDto
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to trigger reachability computation.
|
||||
/// </summary>
|
||||
public sealed record ComputeReachabilityRequestDto(
|
||||
[property: JsonPropertyName("forceRecompute")] bool ForceRecompute = false,
|
||||
[property: JsonPropertyName("entrypoints")] IReadOnlyList<string>? Entrypoints = null,
|
||||
[property: JsonPropertyName("targets")] IReadOnlyList<string>? Targets = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from triggering reachability computation.
|
||||
/// </summary>
|
||||
public sealed record ComputeReachabilityResponseDto(
|
||||
[property: JsonPropertyName("jobId")] string JobId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("estimatedDuration")] string? EstimatedDuration = null);
|
||||
|
||||
/// <summary>
|
||||
/// Component reachability status.
|
||||
/// </summary>
|
||||
public sealed record ComponentReachabilityDto(
|
||||
[property: JsonPropertyName("purl")] string Purl,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("confidence")] double Confidence,
|
||||
[property: JsonPropertyName("latticeState")] string? LatticeState = null,
|
||||
[property: JsonPropertyName("why")] IReadOnlyList<string>? Why = null);
|
||||
|
||||
/// <summary>
|
||||
/// List of component reachability results.
|
||||
/// </summary>
|
||||
public sealed record ComponentReachabilityListDto(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<ComponentReachabilityDto> Items,
|
||||
[property: JsonPropertyName("total")] int Total);
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability finding with reachability.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFindingDto(
|
||||
[property: JsonPropertyName("cveId")] string CveId,
|
||||
[property: JsonPropertyName("purl")] string Purl,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("confidence")] double Confidence,
|
||||
[property: JsonPropertyName("latticeState")] string? LatticeState = null,
|
||||
[property: JsonPropertyName("severity")] string? Severity = null,
|
||||
[property: JsonPropertyName("affectedVersions")] string? AffectedVersions = null);
|
||||
|
||||
/// <summary>
|
||||
/// List of reachability findings.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFindingListDto(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<ReachabilityFindingDto> Items,
|
||||
[property: JsonPropertyName("total")] int Total);
|
||||
|
||||
/// <summary>
|
||||
/// Explanation reason with code and impact.
|
||||
/// </summary>
|
||||
public sealed record ExplanationReasonDto(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("impact")] double? Impact = null);
|
||||
|
||||
/// <summary>
|
||||
/// Static analysis evidence.
|
||||
/// </summary>
|
||||
public sealed record StaticAnalysisEvidenceDto(
|
||||
[property: JsonPropertyName("callgraphDigest")] string? CallgraphDigest = null,
|
||||
[property: JsonPropertyName("pathLength")] int? PathLength = null,
|
||||
[property: JsonPropertyName("edgeTypes")] IReadOnlyList<string>? EdgeTypes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime evidence.
|
||||
/// </summary>
|
||||
public sealed record RuntimeEvidenceDto(
|
||||
[property: JsonPropertyName("observed")] bool Observed,
|
||||
[property: JsonPropertyName("hitCount")] int HitCount = 0,
|
||||
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved = null);
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation result.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluationEvidenceDto(
|
||||
[property: JsonPropertyName("policyDigest")] string? PolicyDigest = null,
|
||||
[property: JsonPropertyName("verdict")] string? Verdict = null,
|
||||
[property: JsonPropertyName("verdictReason")] string? VerdictReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence chain for explanation.
|
||||
/// </summary>
|
||||
public sealed record EvidenceChainDto(
|
||||
[property: JsonPropertyName("staticAnalysis")] StaticAnalysisEvidenceDto? StaticAnalysis = null,
|
||||
[property: JsonPropertyName("runtimeEvidence")] RuntimeEvidenceDto? RuntimeEvidence = null,
|
||||
[property: JsonPropertyName("policyEvaluation")] PolicyEvaluationEvidenceDto? PolicyEvaluation = null);
|
||||
|
||||
/// <summary>
|
||||
/// Full reachability explanation.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityExplanationDto(
|
||||
[property: JsonPropertyName("cveId")] string CveId,
|
||||
[property: JsonPropertyName("purl")] string Purl,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("confidence")] double Confidence,
|
||||
[property: JsonPropertyName("latticeState")] string? LatticeState = null,
|
||||
[property: JsonPropertyName("pathWitness")] IReadOnlyList<string>? PathWitness = null,
|
||||
[property: JsonPropertyName("why")] IReadOnlyList<ExplanationReasonDto>? Why = null,
|
||||
[property: JsonPropertyName("evidence")] EvidenceChainDto? Evidence = null,
|
||||
[property: JsonPropertyName("spineId")] string? SpineId = null);
|
||||
@@ -102,30 +102,3 @@ public sealed record ReportSummaryDto
|
||||
[JsonPropertyOrder(4)]
|
||||
public int Quieted { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DsseEnvelopeDto
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public IReadOnlyList<DsseSignatureDto> Signatures { get; init; } = Array.Empty<DsseSignatureDto>();
|
||||
}
|
||||
|
||||
public sealed record DsseSignatureDto
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Response when SBOM is accepted.
|
||||
/// </summary>
|
||||
public sealed record SbomAcceptedResponseDto(
|
||||
[property: JsonPropertyName("sbomId")] string SbomId,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("componentCount")] int ComponentCount,
|
||||
[property: JsonPropertyName("digest")] string Digest);
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format types.
|
||||
/// </summary>
|
||||
public static class SbomFormats
|
||||
{
|
||||
public const string CycloneDx = "cyclonedx";
|
||||
public const string Spdx = "spdx";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -32,14 +32,11 @@ using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Extensions;
|
||||
using StellaOps.Scanner.WebService.Hosting;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Replay;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
|
||||
@@ -286,6 +286,10 @@ public sealed record RecordModeRequest(
|
||||
ReadOnlyMemory<byte> Vex,
|
||||
ReadOnlyMemory<byte> Log)
|
||||
{
|
||||
public string? VexDigest { get; init; }
|
||||
|
||||
public string? LogDigest { get; init; }
|
||||
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
public string? FeedSnapshot { get; init; }
|
||||
|
||||
@@ -4,6 +4,8 @@ internal static class ScannerPolicies
|
||||
{
|
||||
public const string ScansEnqueue = "scanner.api";
|
||||
public const string ScansRead = "scanner.scans.read";
|
||||
public const string ScansWrite = "scanner.scans.write";
|
||||
public const string Reports = "scanner.reports";
|
||||
public const string RuntimeIngest = "scanner.runtime.ingest";
|
||||
public const string CallGraphIngest = "scanner.callgraph.ingest";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -187,7 +187,7 @@ internal sealed class DeltaScanRequestHandler : IDeltaScanRequestHandler
|
||||
_logger.LogInformation(
|
||||
"Delta scan triggered for DRIFT event {EventId}: scanId={ScanId}, created={Created}",
|
||||
runtimeEvent.EventId,
|
||||
result.Snapshot.Id,
|
||||
result.Snapshot.ScanId,
|
||||
result.Created);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of call graph ingestion.
|
||||
/// </summary>
|
||||
public sealed record CallGraphIngestionResult(
|
||||
string CallgraphId,
|
||||
int NodeCount,
|
||||
int EdgeCount,
|
||||
string Digest);
|
||||
|
||||
/// <summary>
|
||||
/// Service for ingesting call graphs.
|
||||
/// </summary>
|
||||
public interface ICallGraphIngestionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds an existing call graph by digest for idempotency checks.
|
||||
/// </summary>
|
||||
Task<ExistingCallGraphDto?> FindByDigestAsync(
|
||||
ScanId scanId,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a call graph for a scan.
|
||||
/// </summary>
|
||||
Task<CallGraphIngestionResult> IngestAsync(
|
||||
ScanId scanId,
|
||||
CallGraphV1Dto callGraph,
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a call graph before ingestion.
|
||||
/// </summary>
|
||||
CallGraphValidationResult Validate(CallGraphV1Dto callGraph);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of call graph validation.
|
||||
/// </summary>
|
||||
public sealed record CallGraphValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<string>? Errors = null)
|
||||
{
|
||||
public static CallGraphValidationResult Success() => new(true);
|
||||
public static CallGraphValidationResult Failure(params string[] errors) => new(false, errors);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting findings as SARIF.
|
||||
/// </summary>
|
||||
public interface ISarifExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports scan findings as a SARIF document.
|
||||
/// </summary>
|
||||
Task<object?> ExportAsync(ScanId scanId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting findings as CycloneDX with reachability extension.
|
||||
/// </summary>
|
||||
public interface ICycloneDxExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports scan findings as CycloneDX with reachability annotations.
|
||||
/// </summary>
|
||||
Task<object?> ExportWithReachabilityAsync(ScanId scanId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting VEX decisions as OpenVEX.
|
||||
/// </summary>
|
||||
public interface IOpenVexExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports VEX decisions for the scan as OpenVEX format.
|
||||
/// </summary>
|
||||
Task<object?> ExportAsync(ScanId scanId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of triggering reachability computation.
|
||||
/// </summary>
|
||||
public sealed record ComputeJobResult(
|
||||
string JobId,
|
||||
string Status,
|
||||
bool AlreadyInProgress,
|
||||
string? EstimatedDuration = null);
|
||||
|
||||
/// <summary>
|
||||
/// Service for triggering reachability computation.
|
||||
/// </summary>
|
||||
public interface IReachabilityComputeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggers reachability computation for a scan.
|
||||
/// </summary>
|
||||
Task<ComputeJobResult> TriggerComputeAsync(
|
||||
ScanId scanId,
|
||||
bool forceRecompute,
|
||||
IReadOnlyList<string>? entrypoints,
|
||||
IReadOnlyList<string>? targets,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Explanation reason with code and impact.
|
||||
/// </summary>
|
||||
public sealed record ExplanationReason(
|
||||
string Code,
|
||||
string Description,
|
||||
double? Impact = null);
|
||||
|
||||
/// <summary>
|
||||
/// Static analysis evidence.
|
||||
/// </summary>
|
||||
public sealed record StaticAnalysisEvidence(
|
||||
string? CallgraphDigest = null,
|
||||
int? PathLength = null,
|
||||
IReadOnlyList<string>? EdgeTypes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime evidence.
|
||||
/// </summary>
|
||||
public sealed record RuntimeEvidence(
|
||||
bool Observed,
|
||||
int HitCount = 0,
|
||||
DateTimeOffset? LastObserved = null);
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation result.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluationEvidence(
|
||||
string? PolicyDigest = null,
|
||||
string? Verdict = null,
|
||||
string? VerdictReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence chain for explanation.
|
||||
/// </summary>
|
||||
public sealed record EvidenceChain(
|
||||
StaticAnalysisEvidence? StaticAnalysis = null,
|
||||
RuntimeEvidence? RuntimeEvidence = null,
|
||||
PolicyEvaluationEvidence? PolicyEvaluation = null);
|
||||
|
||||
/// <summary>
|
||||
/// Full reachability explanation.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityExplanation(
|
||||
string CveId,
|
||||
string Purl,
|
||||
string Status,
|
||||
double Confidence,
|
||||
string? LatticeState = null,
|
||||
IReadOnlyList<string>? PathWitness = null,
|
||||
IReadOnlyList<ExplanationReason>? Why = null,
|
||||
EvidenceChain? Evidence = null,
|
||||
string? SpineId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Service for explaining reachability decisions.
|
||||
/// </summary>
|
||||
public interface IReachabilityExplainService
|
||||
{
|
||||
/// <summary>
|
||||
/// Explains why a CVE affects a component.
|
||||
/// </summary>
|
||||
Task<ReachabilityExplanation?> ExplainAsync(
|
||||
ScanId scanId,
|
||||
string cveId,
|
||||
string purl,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Component reachability result.
|
||||
/// </summary>
|
||||
public sealed record ComponentReachability(
|
||||
string Purl,
|
||||
string Status,
|
||||
double Confidence,
|
||||
string? LatticeState = null,
|
||||
IReadOnlyList<string>? Why = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reachability finding result.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFinding(
|
||||
string CveId,
|
||||
string Purl,
|
||||
string Status,
|
||||
double Confidence,
|
||||
string? LatticeState = null,
|
||||
string? Severity = null,
|
||||
string? AffectedVersions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying reachability results.
|
||||
/// </summary>
|
||||
public interface IReachabilityQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets component reachability results for a scan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ComponentReachability>> GetComponentsAsync(
|
||||
ScanId scanId,
|
||||
string? purlFilter,
|
||||
string? statusFilter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets vulnerability findings with reachability for a scan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReachabilityFinding>> GetFindingsAsync(
|
||||
ScanId scanId,
|
||||
string? cveFilter,
|
||||
string? statusFilter,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of SBOM ingestion.
|
||||
/// </summary>
|
||||
public sealed record SbomIngestionResult(
|
||||
string SbomId,
|
||||
string Format,
|
||||
int ComponentCount,
|
||||
string Digest);
|
||||
|
||||
/// <summary>
|
||||
/// Service for ingesting SBOMs (CycloneDX or SPDX).
|
||||
/// </summary>
|
||||
public interface ISbomIngestionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ingests an SBOM for a scan.
|
||||
/// </summary>
|
||||
Task<SbomIngestionResult> IngestAsync(
|
||||
ScanId scanId,
|
||||
JsonDocument sbomDocument,
|
||||
string format,
|
||||
string? contentDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Detects the SBOM format from the document.
|
||||
/// </summary>
|
||||
string? DetectFormat(JsonDocument sbomDocument);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an SBOM document.
|
||||
/// </summary>
|
||||
SbomValidationResult Validate(JsonDocument sbomDocument, string format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of SBOM validation.
|
||||
/// </summary>
|
||||
public sealed record SbomValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<string>? Errors = null)
|
||||
{
|
||||
public static SbomValidationResult Success() => new(true);
|
||||
public static SbomValidationResult Failure(params string[] errors) => new(false, errors);
|
||||
}
|
||||
Reference in New Issue
Block a user