387 lines
12 KiB
C#
387 lines
12 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.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
|