// ----------------------------------------------------------------------------- // 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.Mvc; 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; /// /// Endpoints for triage inbox - grouped exploit paths. /// internal static class TriageInboxEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; /// /// Maps triage inbox endpoints. /// 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(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.TriageRead); } private static async Task HandleGetInboxAsync( [FromQuery] string artifactDigest, [FromQuery] string? filter, [FromServices] IExploitPathGroupingService groupingService, [FromServices] IFindingQueryService findingService, 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 ApplyFilter( IReadOnlyList 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 }; } } /// /// Response for triage inbox endpoint. /// 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 Paths { get; init; } public required DateTimeOffset GeneratedAt { get; init; } } public interface IFindingQueryService { Task> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct); }