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; /// /// Endpoints for slice query and replay operations. /// 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(StatusCodes.Status200OK) .Produces(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(StatusCodes.Status200OK, "application/json") .Produces(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(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(StatusCodes.Status200OK) .RequireAuthorization(ScannerPolicies.Admin); } private static async Task 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 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 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 /// /// Request to query reachability and generate a slice. /// public sealed class SliceQueryRequestDto { /// /// The scan ID to query against. /// [JsonPropertyName("scanId")] public string? ScanId { get; set; } /// /// Optional CVE ID to query reachability for. /// [JsonPropertyName("cveId")] public string? CveId { get; set; } /// /// Target symbols to check reachability for. /// [JsonPropertyName("symbols")] public List? Symbols { get; set; } /// /// Entrypoint symbols to start reachability analysis from. /// [JsonPropertyName("entrypoints")] public List? Entrypoints { get; set; } /// /// Optional policy hash to include in the slice. /// [JsonPropertyName("policyHash")] public string? PolicyHash { get; set; } } /// /// Response from slice query. /// public sealed class SliceQueryResponseDto { /// /// Content-addressed digest of the generated slice. /// [JsonPropertyName("sliceDigest")] public required string SliceDigest { get; set; } /// /// Reachability verdict (reachable, unreachable, unknown, gated). /// [JsonPropertyName("verdict")] public required string Verdict { get; set; } /// /// Confidence score [0.0, 1.0]. /// [JsonPropertyName("confidence")] public double Confidence { get; set; } /// /// Example paths demonstrating reachability (if reachable). /// [JsonPropertyName("pathWitnesses")] public IReadOnlyList? PathWitnesses { get; set; } /// /// Whether result was served from cache. /// [JsonPropertyName("cacheHit")] public bool CacheHit { get; set; } /// /// Job ID for async generation (if slice is large). /// [JsonPropertyName("jobId")] public string? JobId { get; set; } } /// /// Request to replay/verify a slice. /// public sealed class SliceReplayRequestDto { /// /// Digest of the slice to replay. /// [JsonPropertyName("sliceDigest")] public string? SliceDigest { get; set; } } /// /// Response from slice replay verification. /// public sealed class SliceReplayResponseDto { /// /// Whether the recomputed slice matches the original. /// [JsonPropertyName("match")] public bool Match { get; set; } /// /// Digest of the original slice. /// [JsonPropertyName("originalDigest")] public required string OriginalDigest { get; set; } /// /// Digest of the recomputed slice. /// [JsonPropertyName("recomputedDigest")] public required string RecomputedDigest { get; set; } /// /// Detailed diff if slices don't match. /// [JsonPropertyName("diff")] public SliceDiffDto? Diff { get; set; } } /// /// Diff between two slices. /// public sealed class SliceDiffDto { [JsonPropertyName("missingNodes")] public IReadOnlyList? MissingNodes { get; set; } [JsonPropertyName("extraNodes")] public IReadOnlyList? ExtraNodes { get; set; } [JsonPropertyName("missingEdges")] public IReadOnlyList? MissingEdges { get; set; } [JsonPropertyName("extraEdges")] public IReadOnlyList? ExtraEdges { get; set; } [JsonPropertyName("verdictDiff")] public string? VerdictDiff { get; set; } } /// /// Slice cache statistics. /// 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