feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration

- 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.
This commit is contained in:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

@@ -0,0 +1,261 @@
// -----------------------------------------------------------------------------
// ScoreReplayEndpoints.cs
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
// Task: SCORE-REPLAY-010 - Implement POST /score/replay endpoint
// Description: Endpoints for score replay and proof bundle verification
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class ScoreReplayEndpoints
{
public static void MapScoreReplayEndpoints(this RouteGroupBuilder apiGroup)
{
var score = apiGroup.MapGroup("/score");
score.MapPost("/{scanId}/replay", HandleReplayAsync)
.WithName("scanner.score.replay")
.Produces<ScoreReplayResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
.WithDescription("Replay scoring for a previous scan using frozen inputs");
score.MapGet("/{scanId}/bundle", HandleGetBundleAsync)
.WithName("scanner.score.bundle")
.Produces<ScoreBundleResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.WithDescription("Get the proof bundle for a scan");
score.MapPost("/{scanId}/verify", HandleVerifyAsync)
.WithName("scanner.score.verify")
.Produces<ScoreVerifyResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)
.WithDescription("Verify a proof bundle against expected root hash");
}
/// <summary>
/// POST /score/{scanId}/replay
/// Recompute scores for a previous scan without rescanning.
/// Uses frozen manifest inputs to produce deterministic results.
/// </summary>
private static async Task<IResult> HandleReplayAsync(
string scanId,
ScoreReplayRequest? request,
IScoreReplayService replayService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(scanId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid scan ID",
Detail = "Scan ID is required",
Status = StatusCodes.Status400BadRequest
});
}
try
{
var result = await replayService.ReplayScoreAsync(
scanId,
request?.ManifestHash,
request?.FreezeTimestamp,
cancellationToken);
if (result is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Scan not found",
Detail = $"No scan found with ID: {scanId}",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ScoreReplayResponse(
Score: result.Score,
RootHash: result.RootHash,
BundleUri: result.BundleUri,
ManifestHash: result.ManifestHash,
ReplayedAtUtc: result.ReplayedAt,
Deterministic: result.Deterministic));
}
catch (InvalidOperationException ex)
{
return Results.UnprocessableEntity(new ProblemDetails
{
Title = "Replay failed",
Detail = ex.Message,
Status = StatusCodes.Status422UnprocessableEntity
});
}
}
/// <summary>
/// GET /score/{scanId}/bundle
/// Get the proof bundle for a scan.
/// </summary>
private static async Task<IResult> HandleGetBundleAsync(
string scanId,
[FromQuery] string? rootHash,
IScoreReplayService replayService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(scanId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid scan ID",
Detail = "Scan ID is required",
Status = StatusCodes.Status400BadRequest
});
}
var bundle = await replayService.GetBundleAsync(scanId, rootHash, cancellationToken);
if (bundle is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Bundle not found",
Detail = $"No proof bundle found for scan: {scanId}",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new ScoreBundleResponse(
ScanId: bundle.ScanId,
RootHash: bundle.RootHash,
BundleUri: bundle.BundleUri,
CreatedAtUtc: bundle.CreatedAtUtc));
}
/// <summary>
/// POST /score/{scanId}/verify
/// Verify a proof bundle against expected root hash.
/// </summary>
private static async Task<IResult> HandleVerifyAsync(
string scanId,
ScoreVerifyRequest request,
IScoreReplayService replayService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(scanId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid scan ID",
Detail = "Scan ID is required",
Status = StatusCodes.Status400BadRequest
});
}
if (string.IsNullOrWhiteSpace(request.ExpectedRootHash))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Missing expected root hash",
Detail = "Expected root hash is required for verification",
Status = StatusCodes.Status400BadRequest
});
}
try
{
var result = await replayService.VerifyBundleAsync(
scanId,
request.ExpectedRootHash,
request.BundleUri,
cancellationToken);
return Results.Ok(new ScoreVerifyResponse(
Valid: result.Valid,
ComputedRootHash: result.ComputedRootHash,
ExpectedRootHash: request.ExpectedRootHash,
ManifestValid: result.ManifestValid,
LedgerValid: result.LedgerValid,
VerifiedAtUtc: result.VerifiedAt,
ErrorMessage: result.ErrorMessage));
}
catch (FileNotFoundException ex)
{
return Results.NotFound(new ProblemDetails
{
Title = "Bundle not found",
Detail = ex.Message,
Status = StatusCodes.Status404NotFound
});
}
}
}
/// <summary>
/// Request for score replay.
/// </summary>
/// <param name="ManifestHash">Optional: specific manifest hash to replay against.</param>
/// <param name="FreezeTimestamp">Optional: freeze timestamp for deterministic replay.</param>
public sealed record ScoreReplayRequest(
string? ManifestHash = null,
DateTimeOffset? FreezeTimestamp = null);
/// <summary>
/// Response from score replay.
/// </summary>
/// <param name="Score">The computed score (0.0 - 1.0).</param>
/// <param name="RootHash">Root hash of the proof ledger.</param>
/// <param name="BundleUri">URI to the proof bundle.</param>
/// <param name="ManifestHash">Hash of the manifest used.</param>
/// <param name="ReplayedAtUtc">When the replay was performed.</param>
/// <param name="Deterministic">Whether the replay was deterministic.</param>
public sealed record ScoreReplayResponse(
double Score,
string RootHash,
string BundleUri,
string ManifestHash,
DateTimeOffset ReplayedAtUtc,
bool Deterministic);
/// <summary>
/// Response for bundle retrieval.
/// </summary>
public sealed record ScoreBundleResponse(
string ScanId,
string RootHash,
string BundleUri,
DateTimeOffset CreatedAtUtc);
/// <summary>
/// Request for bundle verification.
/// </summary>
/// <param name="ExpectedRootHash">The expected root hash to verify against.</param>
/// <param name="BundleUri">Optional: specific bundle URI to verify.</param>
public sealed record ScoreVerifyRequest(
string ExpectedRootHash,
string? BundleUri = null);
/// <summary>
/// Response from bundle verification.
/// </summary>
/// <param name="Valid">Whether the bundle is valid.</param>
/// <param name="ComputedRootHash">The computed root hash.</param>
/// <param name="ExpectedRootHash">The expected root hash.</param>
/// <param name="ManifestValid">Whether the manifest signature is valid.</param>
/// <param name="LedgerValid">Whether the ledger integrity is valid.</param>
/// <param name="VerifiedAtUtc">When verification was performed.</param>
/// <param name="ErrorMessage">Error message if verification failed.</param>
public sealed record ScoreVerifyResponse(
bool Valid,
string ComputedRootHash,
string ExpectedRootHash,
bool ManifestValid,
bool LedgerValid,
DateTimeOffset VerifiedAtUtc,
string? ErrorMessage = null);

View File

@@ -1,7 +1,9 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.SmartDiff.Output;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.WebService.Security;
@@ -10,6 +12,7 @@ namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Smart-Diff API endpoints for material risk changes and VEX candidates.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// Task SDIFF-BIN-029 - API endpoint `GET /scans/{id}/sarif`
/// </summary>
internal static class SmartDiffEndpoints
{
@@ -27,6 +30,14 @@ internal static class SmartDiffEndpoints
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// SARIF output endpoint (Task SDIFF-BIN-029)
group.MapGet("/scans/{scanId}/sarif", HandleGetScanSarifAsync)
.WithName("scanner.smartdiff.sarif")
.WithTags("SmartDiff", "SARIF")
.Produces(StatusCodes.Status200OK, contentType: "application/sarif+json")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// VEX candidate endpoints
group.MapGet("/images/{digest}/candidates", HandleGetCandidatesAsync)
.WithName("scanner.smartdiff.candidates")
@@ -51,6 +62,81 @@ internal static class SmartDiffEndpoints
.RequireAuthorization(ScannerPolicies.ScansWrite);
}
/// <summary>
/// GET /smart-diff/scans/{scanId}/sarif - Get Smart-Diff results as SARIF 2.1.0.
/// Task: SDIFF-BIN-029
/// </summary>
private static async Task<IResult> HandleGetScanSarifAsync(
string scanId,
IMaterialRiskChangeRepository changeRepo,
IVexCandidateStore candidateStore,
IScanMetadataRepository? metadataRepo = null,
bool? pretty = null,
CancellationToken ct = default)
{
// Gather all data for the scan
var changes = await changeRepo.GetChangesForScanAsync(scanId, ct);
// Get scan metadata if available
string? baseDigest = null;
string? targetDigest = null;
DateTimeOffset scanTime = DateTimeOffset.UtcNow;
if (metadataRepo is not null)
{
var metadata = await metadataRepo.GetScanMetadataAsync(scanId, ct);
if (metadata is not null)
{
baseDigest = metadata.BaseDigest;
targetDigest = metadata.TargetDigest;
scanTime = metadata.ScanTime;
}
}
// Convert to SARIF input format
var sarifInput = new SmartDiffSarifInput(
ScannerVersion: GetScannerVersion(),
ScanTime: scanTime,
BaseDigest: baseDigest,
TargetDigest: targetDigest,
MaterialChanges: changes.Select(c => new MaterialRiskChange(
VulnId: c.VulnId,
ComponentPurl: c.ComponentPurl,
Direction: c.IsRiskIncrease ? RiskDirection.Increased : RiskDirection.Decreased,
Reason: c.ChangeReason,
FilePath: c.FilePath
)).ToList(),
HardeningRegressions: [],
VexCandidates: [],
ReachabilityChanges: []);
// Generate SARIF
var options = new SarifOutputOptions
{
IndentedJson = pretty == true,
IncludeVexCandidates = true,
IncludeHardeningRegressions = true,
IncludeReachabilityChanges = true
};
var generator = new SarifOutputGenerator();
var sarifJson = generator.Generate(sarifInput, options);
// Return as SARIF content type with proper filename
var fileName = $"smartdiff-{scanId}.sarif";
return Results.Text(
sarifJson,
contentType: "application/sarif+json",
statusCode: StatusCodes.Status200OK);
}
private static string GetScannerVersion()
{
var assembly = typeof(SmartDiffEndpoints).Assembly;
var version = assembly.GetName().Version;
return version?.ToString() ?? "1.0.0";
}
/// <summary>
/// GET /smart-diff/scans/{scanId}/changes - Get material risk changes for a scan.
/// </summary>

View File

@@ -0,0 +1,321 @@
// -----------------------------------------------------------------------------
// 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&amp;order=desc&amp;artifact=sha256:...&amp;reason=missing_vex&amp;page=1&amp;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);