470 lines
18 KiB
C#
470 lines
18 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
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);
|
|
|
|
// Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence
|
|
// GET /scans/{scanId}/reachability/traces/export - Trace export with runtime evidence
|
|
scansGroup.MapGet("/{scanId}/reachability/traces/export", HandleTraceExportAsync)
|
|
.WithName("scanner.scans.reachability.traces.export")
|
|
.WithTags("Reachability")
|
|
.Produces<ReachabilityTraceExportDto>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
}
|
|
|
|
private static async Task<IResult> HandleComputeReachabilityAsync(
|
|
string scanId,
|
|
ComputeReachabilityRequestDto? request,
|
|
IScanCoordinator coordinator,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(coordinator);
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
|
|
var computeService = context.RequestServices.GetRequiredService<IReachabilityComputeService>();
|
|
|
|
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);
|
|
}
|
|
|
|
// Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence (SCAN-RT-003)
|
|
private static async Task<IResult> HandleTraceExportAsync(
|
|
string scanId,
|
|
string? format,
|
|
bool? includeRuntimeEvidence,
|
|
double? minReachabilityScore,
|
|
bool? runtimeConfirmedOnly,
|
|
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.");
|
|
}
|
|
|
|
// Determine export format (default to json-lines for determinism)
|
|
var exportFormat = (format?.ToLowerInvariant()) switch
|
|
{
|
|
"graphson" => "graphson",
|
|
"ndjson" or "json-lines" => "json-lines",
|
|
_ => "json-lines"
|
|
};
|
|
|
|
var options = new TraceExportOptions
|
|
{
|
|
Format = exportFormat,
|
|
IncludeRuntimeEvidence = includeRuntimeEvidence ?? true,
|
|
MinReachabilityScore = minReachabilityScore,
|
|
RuntimeConfirmedOnly = runtimeConfirmedOnly ?? false
|
|
};
|
|
|
|
var export = await queryService.ExportTracesAsync(parsed, options, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (export is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"No reachability data",
|
|
StatusCodes.Status404NotFound,
|
|
detail: "No reachability data found for this scan.");
|
|
}
|
|
|
|
var response = new ReachabilityTraceExportDto(
|
|
Format: export.Format,
|
|
CanonicalizationMethod: "StellaOps.Canonical.Json",
|
|
ContentDigest: export.ContentDigest,
|
|
Timestamp: export.Timestamp,
|
|
NodeCount: export.Nodes.Count,
|
|
EdgeCount: export.Edges.Count,
|
|
RuntimeCoverage: export.RuntimeCoverage,
|
|
AverageReachabilityScore: export.AverageReachabilityScore,
|
|
Nodes: export.Nodes.Select(n => new TraceNodeDto(
|
|
Id: n.Id,
|
|
SymbolId: n.SymbolId,
|
|
ReachabilityScore: n.ReachabilityScore,
|
|
RuntimeConfirmed: n.RuntimeConfirmed,
|
|
RuntimeObservationCount: n.RuntimeObservationCount,
|
|
Evidence: n.Evidence)).ToList(),
|
|
Edges: export.Edges.Select(e => new TraceEdgeDto(
|
|
From: e.From,
|
|
To: e.To,
|
|
Kind: e.Kind,
|
|
Confidence: e.Confidence,
|
|
RuntimeConfirmed: e.RuntimeConfirmed,
|
|
RuntimeObservationCount: e.RuntimeObservationCount,
|
|
Evidence: e.Evidence)).ToList());
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Sprint: SPRINT_20260112_004_SCANNER_reachability_trace_runtime_evidence
|
|
// Trace export DTOs
|
|
|
|
/// <summary>Options for trace export.</summary>
|
|
public sealed record TraceExportOptions
|
|
{
|
|
public string Format { get; init; } = "json-lines";
|
|
public bool IncludeRuntimeEvidence { get; init; } = true;
|
|
public double? MinReachabilityScore { get; init; }
|
|
public bool RuntimeConfirmedOnly { get; init; }
|
|
}
|
|
|
|
/// <summary>Trace export response.</summary>
|
|
public sealed record ReachabilityTraceExportDto(
|
|
string Format,
|
|
string CanonicalizationMethod,
|
|
string ContentDigest,
|
|
DateTimeOffset Timestamp,
|
|
int NodeCount,
|
|
int EdgeCount,
|
|
double RuntimeCoverage,
|
|
double? AverageReachabilityScore,
|
|
IReadOnlyList<TraceNodeDto> Nodes,
|
|
IReadOnlyList<TraceEdgeDto> Edges);
|
|
|
|
/// <summary>Node in trace export.</summary>
|
|
public sealed record TraceNodeDto(
|
|
string Id,
|
|
string SymbolId,
|
|
double? ReachabilityScore,
|
|
bool? RuntimeConfirmed,
|
|
ulong? RuntimeObservationCount,
|
|
IReadOnlyList<string>? Evidence);
|
|
|
|
/// <summary>Edge in trace export.</summary>
|
|
public sealed record TraceEdgeDto(
|
|
string From,
|
|
string To,
|
|
string Kind,
|
|
double Confidence,
|
|
bool? RuntimeConfirmed,
|
|
ulong? RuntimeObservationCount,
|
|
IReadOnlyList<string>? Evidence);
|