461 lines
15 KiB
C#
461 lines
15 KiB
C#
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<UnknownsListResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.WithDescription("Lists unknown entries with tenant-scoped filtering.");
|
|
|
|
unknowns.MapGet("/stats", HandleGetStatsAsync)
|
|
.WithName("scanner.unknowns.stats")
|
|
.Produces<UnknownsStatsResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.WithDescription("Returns tenant-scoped unknown summary statistics.");
|
|
|
|
unknowns.MapGet("/bands", HandleGetBandsAsync)
|
|
.WithName("scanner.unknowns.bands")
|
|
.Produces<UnknownsBandsResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.WithDescription("Returns tenant-scoped unknown distribution by triage band.");
|
|
|
|
unknowns.MapGet("/{id}/evidence", HandleGetEvidenceAsync)
|
|
.WithName("scanner.unknowns.evidence")
|
|
.Produces<UnknownEvidenceResponse>(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<UnknownHistoryResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.WithDescription("Returns tenant-scoped unknown history.");
|
|
|
|
unknowns.MapGet("/{id}", HandleGetByIdAsync)
|
|
.WithName("scanner.unknowns.get")
|
|
.Produces<UnknownDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.WithDescription("Returns tenant-scoped unknown detail.");
|
|
}
|
|
|
|
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<UnknownsListItemResponse> 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<UnknownHistoryEntryResponse> 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<string, long> Bands { get; init; }
|
|
}
|