Refactor SurfaceCacheValidator to simplify oldest entry calculation

Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

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