Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityEndpoints.cs

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);