using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Services; using StellaOps.Scanner.WebService.Tenancy; namespace StellaOps.Scanner.WebService.Endpoints; internal static class UnknownsEndpoints { private const double HotBandThreshold = 0.70; private const double WarmBandThreshold = 0.40; private const string ExternalUnknownIdPrefix = "unk-"; public static void MapUnknownsEndpoints(this RouteGroupBuilder apiGroup) { ArgumentNullException.ThrowIfNull(apiGroup); var unknowns = apiGroup.MapGroup("/unknowns") .WithTags("Unknowns") .RequireAuthorization(ScannerPolicies.ScansRead); unknowns.MapGet("/", HandleListAsync) .WithName("scanner.unknowns.list") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .WithDescription("Lists unknown entries with tenant-scoped filtering."); unknowns.MapGet("/stats", HandleGetStatsAsync) .WithName("scanner.unknowns.stats") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .WithDescription("Returns tenant-scoped unknown summary statistics."); unknowns.MapGet("/bands", HandleGetBandsAsync) .WithName("scanner.unknowns.bands") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .WithDescription("Returns tenant-scoped unknown distribution by triage band."); unknowns.MapGet("/{id}/evidence", HandleGetEvidenceAsync) .WithName("scanner.unknowns.evidence") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest) .WithDescription("Returns tenant-scoped unknown evidence metadata."); unknowns.MapGet("/{id}/history", HandleGetHistoryAsync) .WithName("scanner.unknowns.history") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest) .WithDescription("Returns tenant-scoped unknown history."); unknowns.MapGet("/{id}", HandleGetByIdAsync) .WithName("scanner.unknowns.get") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest) .WithDescription("Returns tenant-scoped unknown detail."); } private static async Task HandleListAsync( [FromQuery] string? artifactDigest, [FromQuery] string? vulnId, [FromQuery] string? band, [FromQuery] string? sortBy, [FromQuery] string? sortOrder, [FromQuery] int? limit, [FromQuery] int? offset, IUnknownsQueryService queryService, HttpContext context, CancellationToken cancellationToken) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } if (!TryMapBand(band, out var mappedBand)) { return Results.BadRequest(new { type = "validation-error", title = "Invalid band", detail = "Band must be one of HOT, WARM, or COLD." }); } var query = new UnknownsListQuery { ArtifactDigest = string.IsNullOrWhiteSpace(artifactDigest) ? null : artifactDigest.Trim(), VulnerabilityId = string.IsNullOrWhiteSpace(vulnId) ? null : vulnId.Trim(), Band = mappedBand, SortBy = MapSortField(sortBy), SortOrder = MapSortOrder(sortOrder), Limit = Math.Clamp(limit ?? 50, 1, 500), Offset = Math.Max(offset ?? 0, 0) }; var result = await queryService.ListAsync(tenantId, query, cancellationToken).ConfigureAwait(false); return Results.Ok(new UnknownsListResponse { Items = result.Items .Select(MapItem) .ToArray(), TotalCount = result.TotalCount, Limit = query.Limit, Offset = query.Offset }); } private static async Task HandleGetByIdAsync( string id, IUnknownsQueryService queryService, HttpContext context, CancellationToken cancellationToken) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } if (!TryParseUnknownId(id, out var unknownId)) { return Results.NotFound(); } var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false); if (detail is null) { return Results.NotFound(); } return Results.Ok(MapDetail(detail)); } private static async Task HandleGetEvidenceAsync( string id, IUnknownsQueryService queryService, HttpContext context, CancellationToken cancellationToken) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } if (!TryParseUnknownId(id, out var unknownId)) { return Results.NotFound(); } var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false); if (detail is null) { return Results.NotFound(); } return Results.Ok(new UnknownEvidenceResponse { Id = ToExternalUnknownId(detail.UnknownId), ProofRef = detail.ProofRef, LastUpdatedAtUtc = detail.UpdatedAtUtc }); } private static async Task HandleGetHistoryAsync( string id, IUnknownsQueryService queryService, HttpContext context, CancellationToken cancellationToken) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } if (!TryParseUnknownId(id, out var unknownId)) { return Results.NotFound(); } var detail = await queryService.GetByIdAsync(tenantId, unknownId, cancellationToken).ConfigureAwait(false); if (detail is null) { return Results.NotFound(); } return Results.Ok(new UnknownHistoryResponse { Id = ToExternalUnknownId(detail.UnknownId), History = new[] { new UnknownHistoryEntryResponse { CapturedAtUtc = detail.UpdatedAtUtc, Score = detail.Score, Band = DetermineBand(detail.Score) } } }); } private static async Task HandleGetStatsAsync( IUnknownsQueryService queryService, HttpContext context, CancellationToken cancellationToken) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } var stats = await queryService.GetStatsAsync(tenantId, cancellationToken).ConfigureAwait(false); return Results.Ok(new UnknownsStatsResponse { Total = stats.Total, Hot = stats.Hot, Warm = stats.Warm, Cold = stats.Cold }); } private static async Task HandleGetBandsAsync( IUnknownsQueryService queryService, HttpContext context, CancellationToken cancellationToken) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } var distribution = await queryService.GetBandDistributionAsync(tenantId, cancellationToken).ConfigureAwait(false); return Results.Ok(new UnknownsBandsResponse { Bands = distribution }); } private static bool TryResolveTenant(HttpContext context, out string tenantId, out IResult? failure) { tenantId = string.Empty; failure = null; if (ScannerRequestContextResolver.TryResolveTenant( context, out tenantId, out var tenantError, allowDefaultTenant: true)) { return true; } failure = Results.BadRequest(new { type = "validation-error", title = "Invalid tenant context", detail = tenantError ?? "tenant_conflict" }); return false; } private static UnknownsListItemResponse MapItem(UnknownsListItem item) { return new UnknownsListItemResponse { Id = ToExternalUnknownId(item.UnknownId), ArtifactDigest = item.ArtifactDigest, VulnerabilityId = item.VulnerabilityId, PackagePurl = item.PackagePurl, Score = item.Score, Band = DetermineBand(item.Score), CreatedAtUtc = item.CreatedAtUtc, UpdatedAtUtc = item.UpdatedAtUtc }; } private static UnknownDetailResponse MapDetail(UnknownsDetail detail) { return new UnknownDetailResponse { Id = ToExternalUnknownId(detail.UnknownId), ArtifactDigest = detail.ArtifactDigest, VulnerabilityId = detail.VulnerabilityId, PackagePurl = detail.PackagePurl, Score = detail.Score, Band = DetermineBand(detail.Score), ProofRef = detail.ProofRef, CreatedAtUtc = detail.CreatedAtUtc, UpdatedAtUtc = detail.UpdatedAtUtc }; } private static UnknownsSortField MapSortField(string? rawValue) { if (string.IsNullOrWhiteSpace(rawValue)) { return UnknownsSortField.Score; } return rawValue.Trim().ToLowerInvariant() switch { "score" => UnknownsSortField.Score, "created" => UnknownsSortField.CreatedAt, "createdat" => UnknownsSortField.CreatedAt, "updated" => UnknownsSortField.UpdatedAt, "updatedat" => UnknownsSortField.UpdatedAt, "lastseen" => UnknownsSortField.UpdatedAt, _ => UnknownsSortField.Score }; } private static UnknownsSortOrder MapSortOrder(string? rawValue) { if (string.IsNullOrWhiteSpace(rawValue)) { return UnknownsSortOrder.Descending; } return rawValue.Trim().Equals("asc", StringComparison.OrdinalIgnoreCase) ? UnknownsSortOrder.Ascending : UnknownsSortOrder.Descending; } private static bool TryMapBand(string? rawValue, out UnknownsBand? band) { band = null; if (string.IsNullOrWhiteSpace(rawValue)) { return true; } switch (rawValue.Trim().ToUpperInvariant()) { case "HOT": band = UnknownsBand.Hot; return true; case "WARM": band = UnknownsBand.Warm; return true; case "COLD": band = UnknownsBand.Cold; return true; default: return false; } } private static string DetermineBand(double score) { if (score >= HotBandThreshold) { return "HOT"; } if (score >= WarmBandThreshold) { return "WARM"; } return "COLD"; } private static string ToExternalUnknownId(Guid unknownId) => $"{ExternalUnknownIdPrefix}{unknownId:N}"; private static bool TryParseUnknownId(string rawValue, out Guid unknownId) { unknownId = Guid.Empty; if (string.IsNullOrWhiteSpace(rawValue)) { return false; } var trimmed = rawValue.Trim(); if (Guid.TryParse(trimmed, out unknownId)) { return true; } if (!trimmed.StartsWith(ExternalUnknownIdPrefix, StringComparison.OrdinalIgnoreCase)) { return false; } var guidPart = trimmed[ExternalUnknownIdPrefix.Length..]; return Guid.TryParseExact(guidPart, "N", out unknownId) || Guid.TryParse(guidPart, out unknownId); } } public sealed record UnknownsListResponse { public required IReadOnlyList Items { get; init; } public required int TotalCount { get; init; } public required int Limit { get; init; } public required int Offset { get; init; } } public sealed record UnknownsListItemResponse { public required string Id { get; init; } public required string ArtifactDigest { get; init; } public required string VulnerabilityId { get; init; } public required string PackagePurl { get; init; } public required double Score { get; init; } public required string Band { get; init; } public required DateTimeOffset CreatedAtUtc { get; init; } public required DateTimeOffset UpdatedAtUtc { get; init; } } public sealed record UnknownDetailResponse { public required string Id { get; init; } public required string ArtifactDigest { get; init; } public required string VulnerabilityId { get; init; } public required string PackagePurl { get; init; } public required double Score { get; init; } public required string Band { get; init; } public string? ProofRef { get; init; } public required DateTimeOffset CreatedAtUtc { get; init; } public required DateTimeOffset UpdatedAtUtc { get; init; } } public sealed record UnknownEvidenceResponse { public required string Id { get; init; } public string? ProofRef { get; init; } public required DateTimeOffset LastUpdatedAtUtc { get; init; } } public sealed record UnknownHistoryResponse { public required string Id { get; init; } public required IReadOnlyList History { get; init; } } public sealed record UnknownHistoryEntryResponse { public required DateTimeOffset CapturedAtUtc { get; init; } public required double Score { get; init; } public required string Band { get; init; } } public sealed record UnknownsStatsResponse { public required long Total { get; init; } public required long Hot { get; init; } public required long Warm { get; init; } public required long Cold { get; init; } } public sealed record UnknownsBandsResponse { public required IReadOnlyDictionary Bands { get; init; } }