// ----------------------------------------------------------------------------- // UnknownsEndpoints.cs // Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment // Task: UNK-RANK-007, UNK-RANK-008 - Implement GET /unknowns API with sorting/pagination // Description: REST API for querying and filtering unknowns // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Unknowns.Core.Models; using StellaOps.Unknowns.Core.Repositories; using StellaOps.Unknowns.Core.Services; namespace StellaOps.Scanner.WebService.Endpoints; internal static class UnknownsEndpoints { public static void MapUnknownsEndpoints(this RouteGroupBuilder apiGroup) { var unknowns = apiGroup.MapGroup("/unknowns"); unknowns.MapGet("/", HandleListAsync) .WithName("scanner.unknowns.list") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .WithDescription("List unknowns with optional sorting and filtering"); unknowns.MapGet("/{id}", HandleGetByIdAsync) .WithName("scanner.unknowns.get") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .WithDescription("Get details of a specific unknown"); unknowns.MapGet("/{id}/proof", HandleGetProofAsync) .WithName("scanner.unknowns.proof") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .WithDescription("Get the proof trail for an unknown ranking"); } /// /// GET /unknowns?sort=score&order=desc&artifact=sha256:...&reason=missing_vex&page=1&limit=50 /// private static async Task HandleListAsync( [FromQuery] string? sort, [FromQuery] string? order, [FromQuery] string? artifact, [FromQuery] string? reason, [FromQuery] string? kind, [FromQuery] string? severity, [FromQuery] double? minScore, [FromQuery] double? maxScore, [FromQuery] int? page, [FromQuery] int? limit, IUnknownRepository repository, IUnknownRanker ranker, CancellationToken cancellationToken) { // Validate and default pagination var pageNum = Math.Max(1, page ?? 1); var pageSize = Math.Clamp(limit ?? 50, 1, 200); // Parse sort field var sortField = (sort?.ToLowerInvariant()) switch { "score" => UnknownSortField.Score, "created" => UnknownSortField.Created, "updated" => UnknownSortField.Updated, "severity" => UnknownSortField.Severity, "popularity" => UnknownSortField.Popularity, _ => UnknownSortField.Score // Default to score }; var sortOrder = (order?.ToLowerInvariant()) switch { "asc" => SortOrder.Ascending, _ => SortOrder.Descending // Default to descending (highest first) }; // Parse filters UnknownKind? kindFilter = kind != null && Enum.TryParse(kind, true, out var k) ? k : null; UnknownSeverity? severityFilter = severity != null && Enum.TryParse(severity, true, out var s) ? s : null; var query = new UnknownListQuery( ArtifactDigest: artifact, Reason: reason, Kind: kindFilter, Severity: severityFilter, MinScore: minScore, MaxScore: maxScore, SortField: sortField, SortOrder: sortOrder, Page: pageNum, PageSize: pageSize); var result = await repository.ListUnknownsAsync(query, cancellationToken); return Results.Ok(new UnknownsListResponse( Items: result.Items.Select(UnknownItemResponse.FromUnknownItem).ToList(), TotalCount: result.TotalCount, Page: pageNum, PageSize: pageSize, TotalPages: (int)Math.Ceiling((double)result.TotalCount / pageSize), HasNextPage: pageNum * pageSize < result.TotalCount, HasPreviousPage: pageNum > 1)); } /// /// GET /unknowns/{id} /// private static async Task HandleGetByIdAsync( Guid id, IUnknownRepository repository, CancellationToken cancellationToken) { var unknown = await repository.GetByIdAsync(id, cancellationToken); if (unknown is null) { return Results.NotFound(new ProblemDetails { Title = "Unknown not found", Detail = $"No unknown found with ID: {id}", Status = StatusCodes.Status404NotFound }); } return Results.Ok(UnknownDetailResponse.FromUnknown(unknown)); } /// /// GET /unknowns/{id}/proof /// private static async Task HandleGetProofAsync( Guid id, IUnknownRepository repository, CancellationToken cancellationToken) { var unknown = await repository.GetByIdAsync(id, cancellationToken); if (unknown is null) { return Results.NotFound(new ProblemDetails { Title = "Unknown not found", Detail = $"No unknown found with ID: {id}", Status = StatusCodes.Status404NotFound }); } var proofRef = unknown.ProofRef; if (string.IsNullOrEmpty(proofRef)) { return Results.NotFound(new ProblemDetails { Title = "Proof not available", Detail = $"No proof trail available for unknown: {id}", Status = StatusCodes.Status404NotFound }); } // In a real implementation, read proof from storage return Results.Ok(new UnknownProofResponse( UnknownId: id, ProofRef: proofRef, CreatedAt: unknown.SysFrom)); } } /// /// Response model for unknowns list. /// public sealed record UnknownsListResponse( IReadOnlyList Items, int TotalCount, int Page, int PageSize, int TotalPages, bool HasNextPage, bool HasPreviousPage); /// /// Compact unknown item for list response. /// public sealed record UnknownItemResponse( Guid Id, string SubjectRef, string Kind, string? Severity, double Score, string TriageBand, string Priority, BlastRadiusResponse? BlastRadius, ContainmentResponse? Containment, DateTimeOffset CreatedAt) { public static UnknownItemResponse FromUnknownItem(UnknownItem item) => new( Id: Guid.TryParse(item.Id, out var id) ? id : Guid.Empty, SubjectRef: item.ArtifactPurl ?? item.ArtifactDigest, Kind: string.Join(",", item.Reasons), Severity: null, // Would come from full Unknown Score: item.Score, TriageBand: item.Score.ToTriageBand().ToString(), Priority: item.Score.ToPriorityLabel(), BlastRadius: item.BlastRadius != null ? new BlastRadiusResponse(item.BlastRadius.Dependents, item.BlastRadius.NetFacing, item.BlastRadius.Privilege) : null, Containment: item.Containment != null ? new ContainmentResponse(item.Containment.Seccomp, item.Containment.Fs) : null, CreatedAt: DateTimeOffset.UtcNow); // Would come from Unknown.SysFrom } /// /// Blast radius in API response. /// public sealed record BlastRadiusResponse(int Dependents, bool NetFacing, string Privilege); /// /// Containment signals in API response. /// public sealed record ContainmentResponse(string Seccomp, string Fs); /// /// Detailed unknown response. /// public sealed record UnknownDetailResponse( Guid Id, string TenantId, string SubjectHash, string SubjectType, string SubjectRef, string Kind, string? Severity, double Score, string TriageBand, double PopularityScore, int DeploymentCount, double UncertaintyScore, BlastRadiusResponse? BlastRadius, ContainmentResponse? Containment, string? ProofRef, DateTimeOffset ValidFrom, DateTimeOffset? ValidTo, DateTimeOffset SysFrom, DateTimeOffset? ResolvedAt, string? ResolutionType, string? ResolutionRef) { public static UnknownDetailResponse FromUnknown(Unknown u) => new( Id: u.Id, TenantId: u.TenantId, SubjectHash: u.SubjectHash, SubjectType: u.SubjectType.ToString(), SubjectRef: u.SubjectRef, Kind: u.Kind.ToString(), Severity: u.Severity?.ToString(), Score: u.TriageScore, TriageBand: u.TriageScore.ToTriageBand().ToString(), PopularityScore: u.PopularityScore, DeploymentCount: u.DeploymentCount, UncertaintyScore: u.UncertaintyScore, BlastRadius: u.BlastDependents.HasValue ? new BlastRadiusResponse(u.BlastDependents.Value, u.BlastNetFacing ?? false, u.BlastPrivilege ?? "user") : null, Containment: !string.IsNullOrEmpty(u.ContainmentSeccomp) || !string.IsNullOrEmpty(u.ContainmentFs) ? new ContainmentResponse(u.ContainmentSeccomp ?? "unknown", u.ContainmentFs ?? "unknown") : null, ProofRef: u.ProofRef, ValidFrom: u.ValidFrom, ValidTo: u.ValidTo, SysFrom: u.SysFrom, ResolvedAt: u.ResolvedAt, ResolutionType: u.ResolutionType?.ToString(), ResolutionRef: u.ResolutionRef); } /// /// Proof trail response. /// public sealed record UnknownProofResponse( Guid UnknownId, string ProofRef, DateTimeOffset CreatedAt); /// /// Sort fields for unknowns query. /// public enum UnknownSortField { Score, Created, Updated, Severity, Popularity } /// /// Sort order. /// public enum SortOrder { Ascending, Descending } /// /// Query parameters for listing unknowns. /// public sealed record UnknownListQuery( string? ArtifactDigest, string? Reason, UnknownKind? Kind, UnknownSeverity? Severity, double? MinScore, double? MaxScore, UnknownSortField SortField, SortOrder SortOrder, int Page, int PageSize);