334 lines
12 KiB
C#
334 lines
12 KiB
C#
// SPDX-License-Identifier: BUSL-1.1
|
|
// Copyright (c) StellaOps
|
|
// Sprint: EVID-001-002 - Reachability Evidence Endpoints
|
|
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using StellaOps.Scanner.Reachability.Jobs;
|
|
using StellaOps.Scanner.Reachability.Services;
|
|
using StellaOps.Scanner.Reachability.Vex;
|
|
using StellaOps.Scanner.WebService.Security;
|
|
using static StellaOps.Localization.T;
|
|
|
|
namespace StellaOps.Scanner.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Minimal API endpoints for reachability evidence operations.
|
|
/// </summary>
|
|
public static class ReachabilityEvidenceEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps reachability evidence endpoints.
|
|
/// </summary>
|
|
public static IEndpointRouteBuilder MapReachabilityEvidenceEndpoints(
|
|
this IEndpointRouteBuilder routes)
|
|
{
|
|
var group = routes.MapGroup("/api/reachability")
|
|
.WithTags("Reachability Evidence")
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
|
|
// Analyze reachability for a CVE
|
|
group.MapPost("/analyze", AnalyzeAsync)
|
|
.WithName("AnalyzeReachability")
|
|
.WithSummary("Analyze reachability of a CVE in an image")
|
|
.Produces<ReachabilityAnalyzeResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
|
|
|
// Get job result
|
|
group.MapGet("/result/{jobId}", GetResultAsync)
|
|
.WithName("GetReachabilityResult")
|
|
.WithSummary("Get result of a reachability analysis job")
|
|
.Produces<ReachabilityResultResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
|
|
|
// Check if CVE has sink mappings
|
|
group.MapGet("/mapping/{cveId}", GetCveMappingAsync)
|
|
.WithName("GetCveMapping")
|
|
.WithSummary("Get CVE-to-symbol mappings for a vulnerability")
|
|
.Produces<CveMappingResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
|
|
|
// Get VEX statement from reachability
|
|
group.MapPost("/vex", GenerateVexAsync)
|
|
.WithName("GenerateVexFromReachability")
|
|
.WithSummary("Generate VEX statement from reachability analysis")
|
|
.Produces<VexStatementResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
|
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
|
|
|
return routes;
|
|
}
|
|
|
|
private static async Task<IResult> AnalyzeAsync(
|
|
[FromBody] ReachabilityAnalyzeRequest request,
|
|
[FromServices] IReachabilityEvidenceJobExecutor executor,
|
|
[FromServices] ICveSymbolMappingService mappingService,
|
|
[FromServices] TimeProvider timeProvider,
|
|
CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.ImageDigest) ||
|
|
string.IsNullOrWhiteSpace(request.CveId) ||
|
|
string.IsNullOrWhiteSpace(request.Purl))
|
|
{
|
|
return Results.Problem(
|
|
detail: _t("scanner.reachability_evidence.required_fields"),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// Check if we have mappings for this CVE
|
|
var hasMappings = await mappingService.HasMappingAsync(request.CveId, ct);
|
|
if (!hasMappings)
|
|
{
|
|
return Results.Problem(
|
|
detail: _tn("scanner.reachability_evidence.no_sink_mappings", ("cveId", request.CveId)),
|
|
statusCode: StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
// Create and execute job
|
|
var jobId = ReachabilityEvidenceJob.ComputeJobId(
|
|
request.ImageDigest, request.CveId, request.Purl);
|
|
|
|
var job = new ReachabilityEvidenceJob
|
|
{
|
|
JobId = jobId,
|
|
ImageDigest = request.ImageDigest,
|
|
CveId = request.CveId,
|
|
Purl = request.Purl,
|
|
SourceCommit = request.SourceCommit,
|
|
Options = new ReachabilityJobOptions
|
|
{
|
|
IncludeL2 = request.IncludeBinaryAnalysis,
|
|
IncludeL3 = request.IncludeRuntimeAnalysis,
|
|
MaxPathsPerSink = request.MaxPaths ?? 5,
|
|
MaxDepth = request.MaxDepth ?? 256
|
|
},
|
|
QueuedAt = timeProvider.GetUtcNow()
|
|
};
|
|
|
|
var result = await executor.ExecuteAsync(job, ct);
|
|
|
|
return Results.Ok(new ReachabilityAnalyzeResponse
|
|
{
|
|
JobId = result.JobId,
|
|
Status = result.Status.ToString(),
|
|
Verdict = result.Stack?.Verdict.ToString(),
|
|
EvidenceUri = result.EvidenceUri,
|
|
DurationMs = result.DurationMs,
|
|
Error = result.Error
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> GetResultAsync(
|
|
[FromRoute] string jobId,
|
|
[FromServices] IEvidenceResultStore resultStore,
|
|
CancellationToken ct)
|
|
{
|
|
var result = await resultStore.GetResultAsync(jobId, ct);
|
|
|
|
if (result is null)
|
|
{
|
|
return Results.Problem(
|
|
detail: _tn("scanner.reachability_evidence.job_result_not_found", ("jobId", jobId)),
|
|
statusCode: StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
return Results.Ok(new ReachabilityResultResponse
|
|
{
|
|
JobId = result.JobId,
|
|
Status = result.Status.ToString(),
|
|
Verdict = result.Stack?.Verdict.ToString(),
|
|
VerdictExplanation = result.Stack?.Explanation,
|
|
IsReachable = result.Stack?.StaticCallGraph.IsReachable,
|
|
PathCount = result.Stack?.StaticCallGraph.Paths.Length ?? 0,
|
|
EntrypointCount = result.Stack?.StaticCallGraph.ReachingEntrypoints.Length ?? 0,
|
|
EvidenceBundleId = result.EvidenceBundleId,
|
|
EvidenceUri = result.EvidenceUri,
|
|
CompletedAt = result.CompletedAt,
|
|
DurationMs = result.DurationMs,
|
|
Error = result.Error
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> GetCveMappingAsync(
|
|
[FromRoute] string cveId,
|
|
[FromQuery] string? purl,
|
|
[FromServices] ICveSymbolMappingService mappingService,
|
|
CancellationToken ct)
|
|
{
|
|
var mappings = string.IsNullOrWhiteSpace(purl)
|
|
? await mappingService.GetAllMappingsForCveAsync(cveId, ct)
|
|
: await mappingService.GetSinksForCveAsync(cveId, purl, ct);
|
|
|
|
if (mappings.Count == 0)
|
|
{
|
|
return Results.Problem(
|
|
detail: _tn("scanner.reachability_evidence.cve_mappings_not_found", ("cveId", cveId)),
|
|
statusCode: StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
return Results.Ok(new CveMappingResponse
|
|
{
|
|
CveId = cveId,
|
|
MappingCount = mappings.Count,
|
|
Mappings = mappings.Select(m => new CveMappingItem
|
|
{
|
|
SymbolName = m.SymbolName,
|
|
CanonicalId = m.CanonicalId,
|
|
Purl = m.Purl,
|
|
FilePath = m.FilePath,
|
|
VulnType = m.VulnType.ToString(),
|
|
Confidence = m.Confidence,
|
|
Source = m.Source.ToString()
|
|
}).ToList()
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> GenerateVexAsync(
|
|
[FromBody] GenerateVexRequest request,
|
|
[FromServices] IEvidenceResultStore resultStore,
|
|
[FromServices] IVexStatusDeterminer vexDeterminer,
|
|
CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.JobId) ||
|
|
string.IsNullOrWhiteSpace(request.ProductId))
|
|
{
|
|
return Results.Problem(
|
|
detail: _t("scanner.reachability_evidence.vex_required_fields"),
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
var result = await resultStore.GetResultAsync(request.JobId, ct);
|
|
|
|
if (result?.Stack is null)
|
|
{
|
|
return Results.Problem(
|
|
detail: _tn("scanner.reachability_evidence.vex_result_not_found", ("jobId", request.JobId)),
|
|
statusCode: StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
var evidenceUris = result.EvidenceUri is not null
|
|
? new[] { result.EvidenceUri }
|
|
: Array.Empty<string>();
|
|
|
|
var statement = vexDeterminer.CreateStatement(
|
|
result.Stack,
|
|
request.ProductId,
|
|
evidenceUris);
|
|
|
|
return Results.Ok(new VexStatementResponse
|
|
{
|
|
StatementId = statement.StatementId,
|
|
VulnerabilityId = statement.VulnerabilityId,
|
|
ProductId = statement.ProductId,
|
|
Status = statement.Status.ToString(),
|
|
Justification = statement.Justification is not null ? new VexJustificationResponse
|
|
{
|
|
Category = statement.Justification.Category.ToString(),
|
|
Detail = statement.Justification.Detail,
|
|
Confidence = statement.Justification.Confidence,
|
|
EvidenceReferences = statement.Justification.EvidenceReferences.ToList()
|
|
} : null,
|
|
ActionStatement = statement.ActionStatement,
|
|
ImpactStatement = statement.ImpactStatement,
|
|
Timestamp = statement.Timestamp
|
|
});
|
|
}
|
|
}
|
|
|
|
// Request/Response DTOs
|
|
|
|
public sealed record ReachabilityAnalyzeRequest
|
|
{
|
|
public string ImageDigest { get; init; } = string.Empty;
|
|
public string CveId { get; init; } = string.Empty;
|
|
public string Purl { get; init; } = string.Empty;
|
|
public string? SourceCommit { get; init; }
|
|
public bool IncludeBinaryAnalysis { get; init; } = false;
|
|
public bool IncludeRuntimeAnalysis { get; init; } = false;
|
|
public int? MaxPaths { get; init; }
|
|
public int? MaxDepth { get; init; }
|
|
}
|
|
|
|
public sealed record ReachabilityAnalyzeResponse
|
|
{
|
|
public string JobId { get; init; } = string.Empty;
|
|
public string Status { get; init; } = string.Empty;
|
|
public string? Verdict { get; init; }
|
|
public string? EvidenceUri { get; init; }
|
|
public long? DurationMs { get; init; }
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
public sealed record ReachabilityResultResponse
|
|
{
|
|
public string JobId { get; init; } = string.Empty;
|
|
public string Status { get; init; } = string.Empty;
|
|
public string? Verdict { get; init; }
|
|
public string? VerdictExplanation { get; init; }
|
|
public bool? IsReachable { get; init; }
|
|
public int PathCount { get; init; }
|
|
public int EntrypointCount { get; init; }
|
|
public string? EvidenceBundleId { get; init; }
|
|
public string? EvidenceUri { get; init; }
|
|
public DateTimeOffset? CompletedAt { get; init; }
|
|
public long? DurationMs { get; init; }
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
public sealed record CveMappingResponse
|
|
{
|
|
public string CveId { get; init; } = string.Empty;
|
|
public int MappingCount { get; init; }
|
|
public List<CveMappingItem> Mappings { get; init; } = [];
|
|
}
|
|
|
|
public sealed record CveMappingItem
|
|
{
|
|
public string SymbolName { get; init; } = string.Empty;
|
|
public string? CanonicalId { get; init; }
|
|
public string Purl { get; init; } = string.Empty;
|
|
public string? FilePath { get; init; }
|
|
public string VulnType { get; init; } = string.Empty;
|
|
public decimal Confidence { get; init; }
|
|
public string Source { get; init; } = string.Empty;
|
|
}
|
|
|
|
public sealed record GenerateVexRequest
|
|
{
|
|
public string JobId { get; init; } = string.Empty;
|
|
public string ProductId { get; init; } = string.Empty;
|
|
}
|
|
|
|
public sealed record VexStatementResponse
|
|
{
|
|
public string StatementId { get; init; } = string.Empty;
|
|
public string VulnerabilityId { get; init; } = string.Empty;
|
|
public string ProductId { get; init; } = string.Empty;
|
|
public string Status { get; init; } = string.Empty;
|
|
public VexJustificationResponse? Justification { get; init; }
|
|
public string? ActionStatement { get; init; }
|
|
public string? ImpactStatement { get; init; }
|
|
public DateTimeOffset Timestamp { get; init; }
|
|
}
|
|
|
|
public sealed record VexJustificationResponse
|
|
{
|
|
public string Category { get; init; } = string.Empty;
|
|
public string Detail { get; init; } = string.Empty;
|
|
public decimal Confidence { get; init; }
|
|
public List<string> EvidenceReferences { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store for evidence job results.
|
|
/// </summary>
|
|
public interface IEvidenceResultStore
|
|
{
|
|
Task<ReachabilityEvidenceJobResult?> GetResultAsync(string jobId, CancellationToken ct);
|
|
Task StoreResultAsync(ReachabilityEvidenceJobResult result, CancellationToken ct);
|
|
}
|