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