- Add RateLimitConfig for configuration management with YAML binding support. - Introduce RateLimitDecision to encapsulate the result of rate limit checks. - Implement RateLimitMetrics for OpenTelemetry metrics tracking. - Create RateLimitMiddleware for enforcing rate limits on incoming requests. - Develop RateLimitService to orchestrate instance and environment rate limit checks. - Add RateLimitServiceCollectionExtensions for dependency injection registration.
322 lines
10 KiB
C#
322 lines
10 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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<UnknownsListResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
|
.WithDescription("List unknowns with optional sorting and filtering");
|
|
|
|
unknowns.MapGet("/{id}", HandleGetByIdAsync)
|
|
.WithName("scanner.unknowns.get")
|
|
.Produces<UnknownDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
|
.WithDescription("Get details of a specific unknown");
|
|
|
|
unknowns.MapGet("/{id}/proof", HandleGetProofAsync)
|
|
.WithName("scanner.unknowns.proof")
|
|
.Produces<UnknownProofResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
|
.WithDescription("Get the proof trail for an unknown ranking");
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /unknowns?sort=score&order=desc&artifact=sha256:...&reason=missing_vex&page=1&limit=50
|
|
/// </summary>
|
|
private static async Task<IResult> 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<UnknownKind>(kind, true, out var k) ? k : null;
|
|
UnknownSeverity? severityFilter = severity != null && Enum.TryParse<UnknownSeverity>(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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /unknowns/{id}
|
|
/// </summary>
|
|
private static async Task<IResult> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /unknowns/{id}/proof
|
|
/// </summary>
|
|
private static async Task<IResult> 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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response model for unknowns list.
|
|
/// </summary>
|
|
public sealed record UnknownsListResponse(
|
|
IReadOnlyList<UnknownItemResponse> Items,
|
|
int TotalCount,
|
|
int Page,
|
|
int PageSize,
|
|
int TotalPages,
|
|
bool HasNextPage,
|
|
bool HasPreviousPage);
|
|
|
|
/// <summary>
|
|
/// Compact unknown item for list response.
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Blast radius in API response.
|
|
/// </summary>
|
|
public sealed record BlastRadiusResponse(int Dependents, bool NetFacing, string Privilege);
|
|
|
|
/// <summary>
|
|
/// Containment signals in API response.
|
|
/// </summary>
|
|
public sealed record ContainmentResponse(string Seccomp, string Fs);
|
|
|
|
/// <summary>
|
|
/// Detailed unknown response.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proof trail response.
|
|
/// </summary>
|
|
public sealed record UnknownProofResponse(
|
|
Guid UnknownId,
|
|
string ProofRef,
|
|
DateTimeOffset CreatedAt);
|
|
|
|
/// <summary>
|
|
/// Sort fields for unknowns query.
|
|
/// </summary>
|
|
public enum UnknownSortField
|
|
{
|
|
Score,
|
|
Created,
|
|
Updated,
|
|
Severity,
|
|
Popularity
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sort order.
|
|
/// </summary>
|
|
public enum SortOrder
|
|
{
|
|
Ascending,
|
|
Descending
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query parameters for listing unknowns.
|
|
/// </summary>
|
|
public sealed record UnknownListQuery(
|
|
string? ArtifactDigest,
|
|
string? Reason,
|
|
UnknownKind? Kind,
|
|
UnknownSeverity? Severity,
|
|
double? MinScore,
|
|
double? MaxScore,
|
|
UnknownSortField SortField,
|
|
SortOrder SortOrder,
|
|
int Page,
|
|
int PageSize);
|