Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -102,13 +102,13 @@ internal static class EvidenceEndpoints
|
||||
}
|
||||
|
||||
// Add warning header if evidence is stale or near expiry
|
||||
if (evidence.IsStale)
|
||||
if (evidence.Freshness.IsStale)
|
||||
{
|
||||
context.Response.Headers["X-Evidence-Warning"] = "stale";
|
||||
}
|
||||
else if (evidence.ExpiresAt.HasValue)
|
||||
else if (evidence.Freshness.ExpiresAt.HasValue)
|
||||
{
|
||||
var timeUntilExpiry = evidence.ExpiresAt.Value - DateTimeOffset.UtcNow;
|
||||
var timeUntilExpiry = evidence.Freshness.ExpiresAt.Value - DateTimeOffset.UtcNow;
|
||||
if (timeUntilExpiry <= TimeSpan.FromDays(1))
|
||||
{
|
||||
context.Response.Headers["X-Evidence-Warning"] = "near-expiry";
|
||||
|
||||
@@ -35,7 +35,7 @@ internal static class ExportEndpoints
|
||||
scansGroup.MapGet("/{scanId}/exports/cdxr", HandleExportCycloneDxRAsync)
|
||||
.WithName("scanner.scans.exports.cdxr")
|
||||
.WithTags("Exports")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.cyclonedx+json")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.cyclonedx+json; version=1.7")
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
@@ -137,7 +137,7 @@ internal static class ExportEndpoints
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(cdxDocument, SerializerOptions);
|
||||
return Results.Content(json, "application/vnd.cyclonedx+json", System.Text.Encoding.UTF8, StatusCodes.Status200OK);
|
||||
return Results.Content(json, "application/vnd.cyclonedx+json; version=1.7", System.Text.Encoding.UTF8, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExportOpenVexAsync(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Scanner.Orchestration.Fidelity;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
public static class FidelityEndpoints
|
||||
{
|
||||
public static void MapFidelityEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/scan")
|
||||
.WithTags("Fidelity")
|
||||
.RequireAuthorization();
|
||||
|
||||
// POST /api/v1/scan/analyze?fidelity={level}
|
||||
group.MapPost("/analyze", async (
|
||||
[FromBody] AnalysisRequest request,
|
||||
[FromQuery] FidelityLevel fidelity = FidelityLevel.Standard,
|
||||
IFidelityAwareAnalyzer analyzer,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await analyzer.AnalyzeAsync(request, fidelity, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("AnalyzeWithFidelity")
|
||||
.WithDescription("Analyze with specified fidelity level")
|
||||
.Produces<FidelityAnalysisResult>(200);
|
||||
|
||||
// POST /api/v1/scan/findings/{findingId}/upgrade
|
||||
group.MapPost("/findings/{findingId:guid}/upgrade", async (
|
||||
Guid findingId,
|
||||
[FromQuery] FidelityLevel target = FidelityLevel.Deep,
|
||||
IFidelityAwareAnalyzer analyzer,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await analyzer.UpgradeFidelityAsync(findingId, target, ct);
|
||||
return result.Success
|
||||
? Results.Ok(result)
|
||||
: Results.BadRequest(result);
|
||||
})
|
||||
.WithName("UpgradeFidelity")
|
||||
.WithDescription("Upgrade analysis fidelity for a finding")
|
||||
.Produces<FidelityUpgradeResult>(200)
|
||||
.Produces<FidelityUpgradeResult>(400);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,12 @@ internal static class SbomEndpoints
|
||||
scansGroup.MapPost("/{scanId}/sbom", HandleSubmitSbomAsync)
|
||||
.WithName("scanner.scans.sbom.submit")
|
||||
.WithTags("Scans")
|
||||
.Accepts<JsonDocument>("application/vnd.cyclonedx+json", "application/spdx+json", "application/json")
|
||||
.Accepts<JsonDocument>(
|
||||
"application/vnd.cyclonedx+json; version=1.7",
|
||||
"application/vnd.cyclonedx+json; version=1.6",
|
||||
"application/vnd.cyclonedx+json",
|
||||
"application/spdx+json",
|
||||
"application/json")
|
||||
.Produces<SbomAcceptedResponseDto>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
@@ -96,7 +101,7 @@ internal static class SbomEndpoints
|
||||
ProblemTypes.Validation,
|
||||
"Unknown SBOM format",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Could not detect SBOM format. Use Content-Type 'application/vnd.cyclonedx+json' or 'application/spdx+json'.");
|
||||
detail: "Could not detect SBOM format. Use Content-Type 'application/vnd.cyclonedx+json; version=1.7' (or 1.6) or 'application/spdx+json'.");
|
||||
}
|
||||
|
||||
// Validate the SBOM
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class SbomUploadEndpoints
|
||||
{
|
||||
public static void MapSbomUploadEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var sbomGroup = apiGroup.MapGroup("/sbom");
|
||||
|
||||
sbomGroup.MapPost("/upload", HandleUploadAsync)
|
||||
.WithName("scanner.sbom.upload")
|
||||
.WithTags("SBOM")
|
||||
.Produces<SbomUploadResponseDto>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
sbomGroup.MapGet("/uploads/{sbomId}", HandleGetUploadAsync)
|
||||
.WithName("scanner.sbom.uploads.get")
|
||||
.WithTags("SBOM")
|
||||
.Produces<SbomUploadRecordDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleUploadAsync(
|
||||
SbomUploadRequestDto request,
|
||||
ISbomByosUploadService uploadService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(uploadService);
|
||||
|
||||
var (response, validation) = await uploadService.UploadAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!validation.Valid)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["errors"] = validation.Errors,
|
||||
["warnings"] = validation.Warnings
|
||||
};
|
||||
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid SBOM",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "SBOM validation failed.",
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
return Results.Accepted($"/api/v1/sbom/uploads/{response.SbomId}", response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetUploadAsync(
|
||||
string sbomId,
|
||||
ISbomByosUploadService uploadService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uploadService);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sbomId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid SBOM identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "SBOM identifier is required.");
|
||||
}
|
||||
|
||||
var record = await uploadService.GetRecordAsync(sbomId.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"SBOM upload not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested SBOM upload could not be located.");
|
||||
}
|
||||
|
||||
return Results.Ok(record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
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.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for slice query and replay operations.
|
||||
/// </summary>
|
||||
internal static class SliceEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public static void MapSliceEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var slicesGroup = endpoints.MapGroup("/api/slices")
|
||||
.WithTags("Slices");
|
||||
|
||||
// POST /api/slices/query - Generate reachability slice on demand
|
||||
slicesGroup.MapPost("/query", HandleQueryAsync)
|
||||
.WithName("scanner.slices.query")
|
||||
.WithDescription("Query reachability for CVE/symbols and generate an attested slice")
|
||||
.Produces<SliceQueryResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<SliceQueryResponseDto>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /api/slices/{digest} - Retrieve attested slice by digest
|
||||
slicesGroup.MapGet("/{digest}", HandleGetSliceAsync)
|
||||
.WithName("scanner.slices.get")
|
||||
.WithDescription("Retrieve an attested reachability slice by its content digest")
|
||||
.Produces<object>(StatusCodes.Status200OK, "application/json")
|
||||
.Produces<object>(StatusCodes.Status200OK, "application/dsse+json")
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// POST /api/slices/replay - Verify slice reproducibility
|
||||
slicesGroup.MapPost("/replay", HandleReplayAsync)
|
||||
.WithName("scanner.slices.replay")
|
||||
.WithDescription("Recompute a slice and verify byte-for-byte match with the original")
|
||||
.Produces<SliceReplayResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /api/slices/cache/stats - Cache statistics (admin only)
|
||||
slicesGroup.MapGet("/cache/stats", HandleCacheStatsAsync)
|
||||
.WithName("scanner.slices.cache.stats")
|
||||
.WithDescription("Get slice cache statistics")
|
||||
.Produces<SliceCacheStatsDto>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.Admin);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleQueryAsync(
|
||||
[FromBody] SliceQueryRequestDto request,
|
||||
[FromServices] ISliceQueryService sliceService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ScanId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "scanId is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.CveId) &&
|
||||
(request.Symbols == null || request.Symbols.Count == 0))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Either cveId or symbols must be specified" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var serviceRequest = new SliceQueryRequest
|
||||
{
|
||||
ScanId = request.ScanId,
|
||||
CveId = request.CveId,
|
||||
Symbols = request.Symbols,
|
||||
Entrypoints = request.Entrypoints,
|
||||
PolicyHash = request.PolicyHash
|
||||
};
|
||||
|
||||
var response = await sliceService.QueryAsync(serviceRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var dto = new SliceQueryResponseDto
|
||||
{
|
||||
SliceDigest = response.SliceDigest,
|
||||
Verdict = response.Verdict,
|
||||
Confidence = response.Confidence,
|
||||
PathWitnesses = response.PathWitnesses,
|
||||
CacheHit = response.CacheHit,
|
||||
JobId = response.JobId
|
||||
};
|
||||
|
||||
// Return 202 Accepted if async generation (jobId present)
|
||||
if (!string.IsNullOrEmpty(response.JobId))
|
||||
{
|
||||
return Results.Accepted($"/api/slices/jobs/{response.JobId}", dto);
|
||||
}
|
||||
|
||||
return Results.Ok(dto);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetSliceAsync(
|
||||
[FromRoute] string digest,
|
||||
[FromHeader(Name = "Accept")] string? accept,
|
||||
[FromServices] ISliceQueryService sliceService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return Results.BadRequest(new { error = "digest is required" });
|
||||
}
|
||||
|
||||
var wantsDsse = accept?.Contains("dsse", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
try
|
||||
{
|
||||
if (wantsDsse)
|
||||
{
|
||||
var dsse = await sliceService.GetSliceDsseAsync(digest, cancellationToken).ConfigureAwait(false);
|
||||
if (dsse == null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Slice {digest} not found" });
|
||||
}
|
||||
return Results.Json(dsse, SerializerOptions, "application/dsse+json");
|
||||
}
|
||||
else
|
||||
{
|
||||
var slice = await sliceService.GetSliceAsync(digest, cancellationToken).ConfigureAwait(false);
|
||||
if (slice == null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Slice {digest} not found" });
|
||||
}
|
||||
return Results.Json(slice, SerializerOptions, "application/json");
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleReplayAsync(
|
||||
[FromBody] SliceReplayRequestDto request,
|
||||
[FromServices] ISliceQueryService sliceService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SliceDigest))
|
||||
{
|
||||
return Results.BadRequest(new { error = "sliceDigest is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var serviceRequest = new SliceReplayRequest
|
||||
{
|
||||
SliceDigest = request.SliceDigest
|
||||
};
|
||||
|
||||
var response = await sliceService.ReplayAsync(serviceRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var dto = new SliceReplayResponseDto
|
||||
{
|
||||
Match = response.Match,
|
||||
OriginalDigest = response.OriginalDigest,
|
||||
RecomputedDigest = response.RecomputedDigest,
|
||||
Diff = response.Diff == null ? null : new SliceDiffDto
|
||||
{
|
||||
MissingNodes = response.Diff.MissingNodes,
|
||||
ExtraNodes = response.Diff.ExtraNodes,
|
||||
MissingEdges = response.Diff.MissingEdges,
|
||||
ExtraEdges = response.Diff.ExtraEdges,
|
||||
VerdictDiff = response.Diff.VerdictDiff
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Ok(dto);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult HandleCacheStatsAsync(
|
||||
[FromServices] Reachability.Slices.ISliceCache cache)
|
||||
{
|
||||
var stats = cache.GetStatistics();
|
||||
return Results.Ok(new SliceCacheStatsDto
|
||||
{
|
||||
ItemCount = (int)stats.EntryCount,
|
||||
HitCount = stats.HitCount,
|
||||
MissCount = stats.MissCount,
|
||||
HitRate = stats.HitRate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request to query reachability and generate a slice.
|
||||
/// </summary>
|
||||
public sealed class SliceQueryRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan ID to query against.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional CVE ID to query reachability for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cveId")]
|
||||
public string? CveId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target symbols to check reachability for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbols")]
|
||||
public List<string>? Symbols { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint symbols to start reachability analysis from.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoints")]
|
||||
public List<string>? Entrypoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional policy hash to include in the slice.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyHash")]
|
||||
public string? PolicyHash { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from slice query.
|
||||
/// </summary>
|
||||
public sealed class SliceQueryResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the generated slice.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sliceDigest")]
|
||||
public required string SliceDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability verdict (reachable, unreachable, unknown, gated).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdict")]
|
||||
public required string Verdict { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score [0.0, 1.0].
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Example paths demonstrating reachability (if reachable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("pathWitnesses")]
|
||||
public IReadOnlyList<string>? PathWitnesses { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether result was served from cache.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cacheHit")]
|
||||
public bool CacheHit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Job ID for async generation (if slice is large).
|
||||
/// </summary>
|
||||
[JsonPropertyName("jobId")]
|
||||
public string? JobId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to replay/verify a slice.
|
||||
/// </summary>
|
||||
public sealed class SliceReplayRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the slice to replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sliceDigest")]
|
||||
public string? SliceDigest { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from slice replay verification.
|
||||
/// </summary>
|
||||
public sealed class SliceReplayResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the recomputed slice matches the original.
|
||||
/// </summary>
|
||||
[JsonPropertyName("match")]
|
||||
public bool Match { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the original slice.
|
||||
/// </summary>
|
||||
[JsonPropertyName("originalDigest")]
|
||||
public required string OriginalDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the recomputed slice.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recomputedDigest")]
|
||||
public required string RecomputedDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed diff if slices don't match.
|
||||
/// </summary>
|
||||
[JsonPropertyName("diff")]
|
||||
public SliceDiffDto? Diff { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between two slices.
|
||||
/// </summary>
|
||||
public sealed class SliceDiffDto
|
||||
{
|
||||
[JsonPropertyName("missingNodes")]
|
||||
public IReadOnlyList<string>? MissingNodes { get; set; }
|
||||
|
||||
[JsonPropertyName("extraNodes")]
|
||||
public IReadOnlyList<string>? ExtraNodes { get; set; }
|
||||
|
||||
[JsonPropertyName("missingEdges")]
|
||||
public IReadOnlyList<string>? MissingEdges { get; set; }
|
||||
|
||||
[JsonPropertyName("extraEdges")]
|
||||
public IReadOnlyList<string>? ExtraEdges { get; set; }
|
||||
|
||||
[JsonPropertyName("verdictDiff")]
|
||||
public string? VerdictDiff { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice cache statistics.
|
||||
/// </summary>
|
||||
public sealed class SliceCacheStatsDto
|
||||
{
|
||||
[JsonPropertyName("itemCount")]
|
||||
public int ItemCount { get; set; }
|
||||
|
||||
[JsonPropertyName("hitCount")]
|
||||
public long HitCount { get; set; }
|
||||
|
||||
[JsonPropertyName("missCount")]
|
||||
public long MissCount { get; set; }
|
||||
|
||||
[JsonPropertyName("hitRate")]
|
||||
public double HitRate { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,163 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProofBundleEndpoints.cs
|
||||
// Sprint: SPRINT_3900_0003_0001_exploit_path_inbox_proof_bundles
|
||||
// Description: HTTP endpoints for proof bundle generation (attestations + evidence).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for proof bundle generation - attested evidence packages.
|
||||
/// </summary>
|
||||
internal static class ProofBundleEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps proof bundle endpoints.
|
||||
/// </summary>
|
||||
public static void MapProofBundleEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var triageGroup = apiGroup.MapGroup("/triage")
|
||||
.WithTags("Triage");
|
||||
|
||||
// POST /v1/triage/proof-bundle
|
||||
triageGroup.MapPost("/proof-bundle", HandleGenerateProofBundleAsync)
|
||||
.WithName("scanner.triage.proof-bundle")
|
||||
.WithDescription("Generates an attested proof bundle for an exploit path.")
|
||||
.Produces<ProofBundleResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.TriageWrite);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGenerateProofBundleAsync(
|
||||
ProofBundleRequest request,
|
||||
IProofBundleGenerator bundleGenerator,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleGenerator);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PathId))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid path ID",
|
||||
detail = "Path ID is required."
|
||||
});
|
||||
}
|
||||
|
||||
var bundle = await bundleGenerator.GenerateBundleAsync(
|
||||
request.PathId,
|
||||
request.IncludeReachGraph,
|
||||
request.IncludeCallTrace,
|
||||
request.IncludeVexStatements,
|
||||
request.AttestationKeyId,
|
||||
cancellationToken);
|
||||
|
||||
var response = new ProofBundleResponse
|
||||
{
|
||||
PathId = request.PathId,
|
||||
Bundle = bundle,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for proof bundle generation.
|
||||
/// </summary>
|
||||
public sealed record ProofBundleRequest
|
||||
{
|
||||
public required string PathId { get; init; }
|
||||
public bool IncludeReachGraph { get; init; } = true;
|
||||
public bool IncludeCallTrace { get; init; } = true;
|
||||
public bool IncludeVexStatements { get; init; } = true;
|
||||
public string? AttestationKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing proof bundle.
|
||||
/// </summary>
|
||||
public sealed record ProofBundleResponse
|
||||
{
|
||||
public required string PathId { get; init; }
|
||||
public required ProofBundle Bundle { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proof bundle containing attestations and evidence.
|
||||
/// </summary>
|
||||
public sealed record ProofBundle
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public required string PathId { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required ExploitPathSummary Path { get; init; }
|
||||
public required IReadOnlyList<EvidenceAttestation> Attestations { get; init; }
|
||||
public ReachGraphEvidence? ReachGraph { get; init; }
|
||||
public CallTraceEvidence? CallTrace { get; init; }
|
||||
public IReadOnlyList<VexStatement>? VexStatements { get; init; }
|
||||
public required BundleSignature Signature { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ExploitPathSummary(
|
||||
string PathId,
|
||||
string PackagePurl,
|
||||
string VulnerableSymbol,
|
||||
string EntryPoint,
|
||||
IReadOnlyList<string> CveIds,
|
||||
string ReachabilityStatus);
|
||||
|
||||
public sealed record EvidenceAttestation(
|
||||
string Type,
|
||||
string Predicate,
|
||||
string Subject,
|
||||
string DsseEnvelope);
|
||||
|
||||
public sealed record ReachGraphEvidence(
|
||||
IReadOnlyList<GraphNode> Nodes,
|
||||
IReadOnlyList<GraphEdge> Edges);
|
||||
|
||||
public sealed record GraphNode(string Id, string Label, string Type);
|
||||
public sealed record GraphEdge(string From, string To, string Label);
|
||||
|
||||
public sealed record CallTraceEvidence(
|
||||
IReadOnlyList<CallFrame> Frames);
|
||||
|
||||
public sealed record CallFrame(string Function, string File, int Line);
|
||||
|
||||
public sealed record VexStatement(
|
||||
string CveId,
|
||||
string Status,
|
||||
string Justification,
|
||||
DateTimeOffset IssuedAt);
|
||||
|
||||
public sealed record BundleSignature(
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
string Signature,
|
||||
DateTimeOffset SignedAt);
|
||||
|
||||
public interface IProofBundleGenerator
|
||||
{
|
||||
Task<ProofBundle> GenerateBundleAsync(
|
||||
string pathId,
|
||||
bool includeReachGraph,
|
||||
bool includeCallTrace,
|
||||
bool includeVexStatements,
|
||||
string? attestationKeyId,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TriageInboxEndpoints.cs
|
||||
// Sprint: SPRINT_3900_0003_0001_exploit_path_inbox_proof_bundles
|
||||
// Description: HTTP endpoints for triage inbox with grouped exploit paths.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Triage.Models;
|
||||
using StellaOps.Scanner.Triage.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints.Triage;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for triage inbox - grouped exploit paths.
|
||||
/// </summary>
|
||||
internal static class TriageInboxEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps triage inbox endpoints.
|
||||
/// </summary>
|
||||
public static void MapTriageInboxEndpoints(this RouteGroupBuilder apiGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var triageGroup = apiGroup.MapGroup("/triage")
|
||||
.WithTags("Triage");
|
||||
|
||||
// GET /v1/triage/inbox?artifactDigest={digest}&filter={filter}
|
||||
triageGroup.MapGet("/inbox", HandleGetInboxAsync)
|
||||
.WithName("scanner.triage.inbox")
|
||||
.WithDescription("Retrieves triage inbox with grouped exploit paths for an artifact.")
|
||||
.Produces<TriageInboxResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.TriageRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetInboxAsync(
|
||||
string artifactDigest,
|
||||
string? filter,
|
||||
IExploitPathGroupingService groupingService,
|
||||
IFindingQueryService findingService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(groupingService);
|
||||
ArgumentNullException.ThrowIfNull(findingService);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
type = "validation-error",
|
||||
title = "Invalid artifact digest",
|
||||
detail = "Artifact digest is required."
|
||||
});
|
||||
}
|
||||
|
||||
var findings = await findingService.GetFindingsForArtifactAsync(artifactDigest, cancellationToken);
|
||||
var paths = await groupingService.GroupFindingsAsync(artifactDigest, findings, cancellationToken);
|
||||
|
||||
var filteredPaths = ApplyFilter(paths, filter);
|
||||
|
||||
var response = new TriageInboxResponse
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
TotalPaths = paths.Count,
|
||||
FilteredPaths = filteredPaths.Count,
|
||||
Filter = filter,
|
||||
Paths = filteredPaths,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExploitPath> ApplyFilter(
|
||||
IReadOnlyList<ExploitPath> paths,
|
||||
string? filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
return paths;
|
||||
|
||||
return filter.ToLowerInvariant() switch
|
||||
{
|
||||
"actionable" => paths.Where(p => !p.IsQuiet && p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToList(),
|
||||
"noisy" => paths.Where(p => p.IsQuiet).ToList(),
|
||||
"reachable" => paths.Where(p => p.Reachability is ReachabilityStatus.StaticallyReachable or ReachabilityStatus.RuntimeConfirmed).ToList(),
|
||||
"runtime" => paths.Where(p => p.Reachability == ReachabilityStatus.RuntimeConfirmed).ToList(),
|
||||
"critical" => paths.Where(p => p.RiskScore.CriticalCount > 0).ToList(),
|
||||
"high" => paths.Where(p => p.RiskScore.HighCount > 0).ToList(),
|
||||
_ => paths
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for triage inbox endpoint.
|
||||
/// </summary>
|
||||
public sealed record TriageInboxResponse
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required int TotalPaths { get; init; }
|
||||
public required int FilteredPaths { get; init; }
|
||||
public string? Filter { get; init; }
|
||||
public required IReadOnlyList<ExploitPath> Paths { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
public interface IFindingQueryService
|
||||
{
|
||||
Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct);
|
||||
}
|
||||
Reference in New Issue
Block a user