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:
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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&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);
|
||||
@@ -0,0 +1,362 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FeedChangeRescoreJob.cs
|
||||
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
|
||||
// Task: SCORE-REPLAY-011 - Add scheduled job to rescore when feed snapshots change
|
||||
// Description: Background job that detects feed changes and triggers rescoring
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the feed change rescore job.
|
||||
/// </summary>
|
||||
public sealed class FeedChangeRescoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the job is enabled. Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Interval between feed change checks. Default: 15 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum scans to rescore per cycle. Default: 100.
|
||||
/// </summary>
|
||||
public int MaxScansPerCycle { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Time window for considering scans for rescoring. Default: 7 days.
|
||||
/// </summary>
|
||||
public TimeSpan ScanAgeLimit { get; set; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency limit for rescoring operations. Default: 4.
|
||||
/// </summary>
|
||||
public int RescoreConcurrency { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background job that monitors feed snapshot changes and triggers rescoring for affected scans.
|
||||
/// Per Sprint 3401.0002.0001 - Score Replay & Proof Bundle.
|
||||
/// </summary>
|
||||
public sealed class FeedChangeRescoreJob : BackgroundService
|
||||
{
|
||||
private readonly IFeedSnapshotTracker _feedTracker;
|
||||
private readonly IScanManifestRepository _manifestRepository;
|
||||
private readonly IScoreReplayService _replayService;
|
||||
private readonly IOptions<FeedChangeRescoreOptions> _options;
|
||||
private readonly ILogger<FeedChangeRescoreJob> _logger;
|
||||
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.FeedChangeRescore");
|
||||
|
||||
private string? _lastConcelierSnapshot;
|
||||
private string? _lastExcititorSnapshot;
|
||||
private string? _lastPolicySnapshot;
|
||||
|
||||
public FeedChangeRescoreJob(
|
||||
IFeedSnapshotTracker feedTracker,
|
||||
IScanManifestRepository manifestRepository,
|
||||
IScoreReplayService replayService,
|
||||
IOptions<FeedChangeRescoreOptions> options,
|
||||
ILogger<FeedChangeRescoreJob> logger)
|
||||
{
|
||||
_feedTracker = feedTracker ?? throw new ArgumentNullException(nameof(feedTracker));
|
||||
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
|
||||
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Feed change rescore job started");
|
||||
|
||||
// Initial delay to let the system stabilize
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
|
||||
// Initialize snapshot tracking
|
||||
await InitializeSnapshotsAsync(stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Feed change rescore job is disabled");
|
||||
await Task.Delay(opts.CheckInterval, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var activity = _activitySource.StartActivity("feedchange.rescore.cycle", ActivityKind.Internal);
|
||||
|
||||
try
|
||||
{
|
||||
await CheckAndRescoreAsync(opts, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Feed change rescore cycle failed");
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
FeedChangeRescoreMetrics.RecordError("cycle_failed");
|
||||
}
|
||||
|
||||
await Task.Delay(opts.CheckInterval, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Feed change rescore job stopped");
|
||||
}
|
||||
|
||||
private async Task InitializeSnapshotsAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshots = await _feedTracker.GetCurrentSnapshotsAsync(ct);
|
||||
_lastConcelierSnapshot = snapshots.ConcelierHash;
|
||||
_lastExcititorSnapshot = snapshots.ExcititorHash;
|
||||
_lastPolicySnapshot = snapshots.PolicyHash;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Initialized feed snapshots: Concelier={ConcelierHash}, Excititor={ExcititorHash}, Policy={PolicyHash}",
|
||||
_lastConcelierSnapshot?[..12] ?? "null",
|
||||
_lastExcititorSnapshot?[..12] ?? "null",
|
||||
_lastPolicySnapshot?[..12] ?? "null");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to initialize feed snapshots, will retry on next cycle");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckAndRescoreAsync(FeedChangeRescoreOptions opts, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Get current feed snapshots
|
||||
var currentSnapshots = await _feedTracker.GetCurrentSnapshotsAsync(ct);
|
||||
|
||||
// Check for changes
|
||||
var changes = DetectChanges(currentSnapshots);
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No feed changes detected");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Feed changes detected: {Changes}", string.Join(", ", changes));
|
||||
FeedChangeRescoreMetrics.RecordFeedChange(changes);
|
||||
|
||||
// Find scans affected by the changes
|
||||
var affectedScans = await FindAffectedScansAsync(changes, opts, ct);
|
||||
if (affectedScans.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No affected scans found");
|
||||
UpdateSnapshots(currentSnapshots);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} scans to rescore", affectedScans.Count);
|
||||
|
||||
// Rescore affected scans with concurrency limit
|
||||
var rescored = 0;
|
||||
var semaphore = new SemaphoreSlim(opts.RescoreConcurrency);
|
||||
|
||||
var tasks = affectedScans.Select(async scanId =>
|
||||
{
|
||||
await semaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await RescoreScanAsync(scanId, ct);
|
||||
Interlocked.Increment(ref rescored);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Update tracked snapshots
|
||||
UpdateSnapshots(currentSnapshots);
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation(
|
||||
"Feed change rescore cycle completed in {ElapsedMs}ms: {Rescored}/{Total} scans rescored",
|
||||
sw.ElapsedMilliseconds, rescored, affectedScans.Count);
|
||||
|
||||
FeedChangeRescoreMetrics.RecordCycle(sw.Elapsed.TotalMilliseconds, rescored);
|
||||
}
|
||||
|
||||
private List<string> DetectChanges(FeedSnapshots current)
|
||||
{
|
||||
var changes = new List<string>();
|
||||
|
||||
if (_lastConcelierSnapshot != current.ConcelierHash)
|
||||
changes.Add("concelier");
|
||||
|
||||
if (_lastExcititorSnapshot != current.ExcititorHash)
|
||||
changes.Add("excititor");
|
||||
|
||||
if (_lastPolicySnapshot != current.PolicyHash)
|
||||
changes.Add("policy");
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private async Task<List<string>> FindAffectedScansAsync(
|
||||
List<string> changes,
|
||||
FeedChangeRescoreOptions opts,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - opts.ScanAgeLimit;
|
||||
|
||||
// Find scans using the old snapshot hashes
|
||||
var query = new AffectedScansQuery
|
||||
{
|
||||
ChangedFeeds = changes,
|
||||
OldConcelierHash = changes.Contains("concelier") ? _lastConcelierSnapshot : null,
|
||||
OldExcititorHash = changes.Contains("excititor") ? _lastExcititorSnapshot : null,
|
||||
OldPolicyHash = changes.Contains("policy") ? _lastPolicySnapshot : null,
|
||||
MinCreatedAt = cutoff,
|
||||
Limit = opts.MaxScansPerCycle
|
||||
};
|
||||
|
||||
return await _manifestRepository.FindAffectedScansAsync(query, ct);
|
||||
}
|
||||
|
||||
private async Task RescoreScanAsync(string scanId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Rescoring scan {ScanId}", scanId);
|
||||
|
||||
var result = await _replayService.ReplayScoreAsync(scanId, cancellationToken: ct);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Rescored scan {ScanId}: Score={Score}, RootHash={RootHash}",
|
||||
scanId, result.Score, result.RootHash[..12]);
|
||||
|
||||
FeedChangeRescoreMetrics.RecordRescore(result.Deterministic);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to rescore scan {ScanId}: manifest not found", scanId);
|
||||
FeedChangeRescoreMetrics.RecordError("manifest_not_found");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rescore scan {ScanId}", scanId);
|
||||
FeedChangeRescoreMetrics.RecordError("rescore_failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSnapshots(FeedSnapshots current)
|
||||
{
|
||||
_lastConcelierSnapshot = current.ConcelierHash;
|
||||
_lastExcititorSnapshot = current.ExcititorHash;
|
||||
_lastPolicySnapshot = current.PolicyHash;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current feed snapshot hashes.
|
||||
/// </summary>
|
||||
public sealed record FeedSnapshots(
|
||||
string ConcelierHash,
|
||||
string ExcititorHash,
|
||||
string PolicyHash);
|
||||
|
||||
/// <summary>
|
||||
/// Query for finding affected scans.
|
||||
/// </summary>
|
||||
public sealed record AffectedScansQuery
|
||||
{
|
||||
public required List<string> ChangedFeeds { get; init; }
|
||||
public string? OldConcelierHash { get; init; }
|
||||
public string? OldExcititorHash { get; init; }
|
||||
public string? OldPolicyHash { get; init; }
|
||||
public DateTimeOffset MinCreatedAt { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for tracking feed snapshots.
|
||||
/// </summary>
|
||||
public interface IFeedSnapshotTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Get current feed snapshot hashes.
|
||||
/// </summary>
|
||||
Task<FeedSnapshots> GetCurrentSnapshotsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for scan manifest repository operations.
|
||||
/// </summary>
|
||||
public interface IScanManifestRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Find scans affected by feed changes.
|
||||
/// </summary>
|
||||
Task<List<string>> FindAffectedScansAsync(AffectedScansQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for feed change rescore operations.
|
||||
/// </summary>
|
||||
public static class FeedChangeRescoreMetrics
|
||||
{
|
||||
private static readonly System.Diagnostics.Metrics.Meter Meter =
|
||||
new("StellaOps.Scanner.FeedChangeRescore", "1.0.0");
|
||||
|
||||
private static readonly System.Diagnostics.Metrics.Counter<int> FeedChanges =
|
||||
Meter.CreateCounter<int>("stellaops.scanner.feed_changes", description: "Number of feed changes detected");
|
||||
|
||||
private static readonly System.Diagnostics.Metrics.Counter<int> Rescores =
|
||||
Meter.CreateCounter<int>("stellaops.scanner.rescores", description: "Number of scans rescored");
|
||||
|
||||
private static readonly System.Diagnostics.Metrics.Counter<int> Errors =
|
||||
Meter.CreateCounter<int>("stellaops.scanner.rescore_errors", description: "Number of rescore errors");
|
||||
|
||||
private static readonly System.Diagnostics.Metrics.Histogram<double> CycleDuration =
|
||||
Meter.CreateHistogram<double>("stellaops.scanner.rescore_cycle_duration_ms", description: "Duration of rescore cycle in ms");
|
||||
|
||||
public static void RecordFeedChange(List<string> changes)
|
||||
{
|
||||
foreach (var change in changes)
|
||||
{
|
||||
FeedChanges.Add(1, new System.Diagnostics.TagList { { "feed", change } });
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecordRescore(bool deterministic)
|
||||
{
|
||||
Rescores.Add(1, new System.Diagnostics.TagList { { "deterministic", deterministic.ToString().ToLowerInvariant() } });
|
||||
}
|
||||
|
||||
public static void RecordError(string context)
|
||||
{
|
||||
Errors.Add(1, new System.Diagnostics.TagList { { "context", context } });
|
||||
}
|
||||
|
||||
public static void RecordCycle(double durationMs, int rescored)
|
||||
{
|
||||
CycleDuration.Record(durationMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IScoreReplayService.cs
|
||||
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
|
||||
// Task: SCORE-REPLAY-010 - Implement score replay service
|
||||
// Description: Service interface for score replay operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Core;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for replaying scores and managing proof bundles.
|
||||
/// </summary>
|
||||
public interface IScoreReplayService
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay scoring for a previous scan using frozen inputs.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID to replay.</param>
|
||||
/// <param name="manifestHash">Optional specific manifest hash to use.</param>
|
||||
/// <param name="freezeTimestamp">Optional freeze timestamp for deterministic replay.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Replay result or null if scan not found.</returns>
|
||||
Task<ScoreReplayResult?> ReplayScoreAsync(
|
||||
string scanId,
|
||||
string? manifestHash = null,
|
||||
DateTimeOffset? freezeTimestamp = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a proof bundle for a scan.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID.</param>
|
||||
/// <param name="rootHash">Optional specific root hash to retrieve.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The proof bundle or null if not found.</returns>
|
||||
Task<ProofBundle?> GetBundleAsync(
|
||||
string scanId,
|
||||
string? rootHash = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a proof bundle against expected root hash.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan ID.</param>
|
||||
/// <param name="expectedRootHash">The expected root hash.</param>
|
||||
/// <param name="bundleUri">Optional specific bundle URI to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<BundleVerifyResult> VerifyBundleAsync(
|
||||
string scanId,
|
||||
string expectedRootHash,
|
||||
string? bundleUri = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a score replay operation.
|
||||
/// </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="ReplayedAt">When the replay was performed.</param>
|
||||
/// <param name="Deterministic">Whether the replay was deterministic.</param>
|
||||
public sealed record ScoreReplayResult(
|
||||
double Score,
|
||||
string RootHash,
|
||||
string BundleUri,
|
||||
string ManifestHash,
|
||||
DateTimeOffset ReplayedAt,
|
||||
bool Deterministic);
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
/// <param name="Valid">Whether the bundle is valid.</param>
|
||||
/// <param name="ComputedRootHash">The computed 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="VerifiedAt">When verification was performed.</param>
|
||||
/// <param name="ErrorMessage">Error message if verification failed.</param>
|
||||
public sealed record BundleVerifyResult(
|
||||
bool Valid,
|
||||
string ComputedRootHash,
|
||||
bool ManifestValid,
|
||||
bool LedgerValid,
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static BundleVerifyResult Success(string computedRootHash) =>
|
||||
new(true, computedRootHash, true, true, DateTimeOffset.UtcNow);
|
||||
|
||||
public static BundleVerifyResult Failure(string error, string computedRootHash = "") =>
|
||||
new(false, computedRootHash, false, false, DateTimeOffset.UtcNow, error);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScoreReplayService.cs
|
||||
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
|
||||
// Task: SCORE-REPLAY-010 - Implement score replay service
|
||||
// Description: Service implementation for score replay operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Scanner.Core;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IScoreReplayService.
|
||||
/// </summary>
|
||||
public sealed class ScoreReplayService : IScoreReplayService
|
||||
{
|
||||
private readonly IScanManifestRepository _manifestRepository;
|
||||
private readonly IProofBundleRepository _bundleRepository;
|
||||
private readonly IProofBundleWriter _bundleWriter;
|
||||
private readonly IScanManifestSigner _manifestSigner;
|
||||
private readonly IScoringService _scoringService;
|
||||
private readonly ILogger<ScoreReplayService> _logger;
|
||||
|
||||
public ScoreReplayService(
|
||||
IScanManifestRepository manifestRepository,
|
||||
IProofBundleRepository bundleRepository,
|
||||
IProofBundleWriter bundleWriter,
|
||||
IScanManifestSigner manifestSigner,
|
||||
IScoringService scoringService,
|
||||
ILogger<ScoreReplayService> logger)
|
||||
{
|
||||
_manifestRepository = manifestRepository ?? throw new ArgumentNullException(nameof(manifestRepository));
|
||||
_bundleRepository = bundleRepository ?? throw new ArgumentNullException(nameof(bundleRepository));
|
||||
_bundleWriter = bundleWriter ?? throw new ArgumentNullException(nameof(bundleWriter));
|
||||
_manifestSigner = manifestSigner ?? throw new ArgumentNullException(nameof(manifestSigner));
|
||||
_scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScoreReplayResult?> ReplayScoreAsync(
|
||||
string scanId,
|
||||
string? manifestHash = null,
|
||||
DateTimeOffset? freezeTimestamp = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Starting score replay for scan {ScanId}", scanId);
|
||||
|
||||
// Get the manifest
|
||||
var signedManifest = await _manifestRepository.GetManifestAsync(scanId, manifestHash, cancellationToken);
|
||||
if (signedManifest is null)
|
||||
{
|
||||
_logger.LogWarning("Manifest not found for scan {ScanId}", scanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify manifest signature
|
||||
var verifyResult = await _manifestSigner.VerifyAsync(signedManifest, cancellationToken);
|
||||
if (!verifyResult.IsValid)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest signature verification failed: {verifyResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var manifest = signedManifest.Manifest;
|
||||
|
||||
// Replay scoring with frozen inputs
|
||||
var ledger = new ProofLedger();
|
||||
var score = await _scoringService.ReplayScoreAsync(
|
||||
manifest.ScanId,
|
||||
manifest.ConcelierSnapshotHash,
|
||||
manifest.ExcititorSnapshotHash,
|
||||
manifest.LatticePolicyHash,
|
||||
manifest.Seed,
|
||||
freezeTimestamp ?? manifest.CreatedAtUtc,
|
||||
ledger,
|
||||
cancellationToken);
|
||||
|
||||
// Create proof bundle
|
||||
var bundle = await _bundleWriter.CreateBundleAsync(signedManifest, ledger, cancellationToken);
|
||||
|
||||
// Store bundle reference
|
||||
await _bundleRepository.SaveBundleAsync(bundle, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Score replay complete for scan {ScanId}: score={Score}, rootHash={RootHash}",
|
||||
scanId, score, bundle.RootHash);
|
||||
|
||||
return new ScoreReplayResult(
|
||||
Score: score,
|
||||
RootHash: bundle.RootHash,
|
||||
BundleUri: bundle.BundleUri,
|
||||
ManifestHash: manifest.ComputeHash(),
|
||||
ReplayedAt: DateTimeOffset.UtcNow,
|
||||
Deterministic: manifest.Deterministic);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProofBundle?> GetBundleAsync(
|
||||
string scanId,
|
||||
string? rootHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _bundleRepository.GetBundleAsync(scanId, rootHash, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleVerifyResult> VerifyBundleAsync(
|
||||
string scanId,
|
||||
string expectedRootHash,
|
||||
string? bundleUri = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying bundle for scan {ScanId}, expected hash {ExpectedHash}", scanId, expectedRootHash);
|
||||
|
||||
try
|
||||
{
|
||||
// Get bundle URI if not provided
|
||||
if (string.IsNullOrEmpty(bundleUri))
|
||||
{
|
||||
var bundle = await _bundleRepository.GetBundleAsync(scanId, expectedRootHash, cancellationToken);
|
||||
if (bundle is null)
|
||||
{
|
||||
return BundleVerifyResult.Failure($"Bundle not found for scan {scanId}");
|
||||
}
|
||||
bundleUri = bundle.BundleUri;
|
||||
}
|
||||
|
||||
// Read and verify bundle
|
||||
var contents = await _bundleWriter.ReadBundleAsync(bundleUri, cancellationToken);
|
||||
|
||||
// Verify manifest signature
|
||||
var manifestVerify = await _manifestSigner.VerifyAsync(contents.SignedManifest, cancellationToken);
|
||||
|
||||
// Verify ledger integrity
|
||||
var ledgerValid = contents.ProofLedger.VerifyIntegrity();
|
||||
|
||||
// Compute and compare root hash
|
||||
var computedRootHash = contents.ProofLedger.RootHash();
|
||||
var hashMatch = computedRootHash.Equals(expectedRootHash, StringComparison.Ordinal);
|
||||
|
||||
if (!manifestVerify.IsValid || !ledgerValid || !hashMatch)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (!manifestVerify.IsValid) errors.Add($"Manifest: {manifestVerify.ErrorMessage}");
|
||||
if (!ledgerValid) errors.Add("Ledger integrity check failed");
|
||||
if (!hashMatch) errors.Add($"Root hash mismatch: expected {expectedRootHash}, got {computedRootHash}");
|
||||
|
||||
return new BundleVerifyResult(
|
||||
Valid: false,
|
||||
ComputedRootHash: computedRootHash,
|
||||
ManifestValid: manifestVerify.IsValid,
|
||||
LedgerValid: ledgerValid,
|
||||
VerifiedAt: DateTimeOffset.UtcNow,
|
||||
ErrorMessage: string.Join("; ", errors));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Bundle verification successful for scan {ScanId}", scanId);
|
||||
return BundleVerifyResult.Success(computedRootHash);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Bundle verification failed for scan {ScanId}", scanId);
|
||||
return BundleVerifyResult.Failure(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for scan manifests.
|
||||
/// </summary>
|
||||
public interface IScanManifestRepository
|
||||
{
|
||||
Task<SignedScanManifest?> GetManifestAsync(string scanId, string? manifestHash = null, CancellationToken cancellationToken = default);
|
||||
Task SaveManifestAsync(SignedScanManifest manifest, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for proof bundles.
|
||||
/// </summary>
|
||||
public interface IProofBundleRepository
|
||||
{
|
||||
Task<ProofBundle?> GetBundleAsync(string scanId, string? rootHash = null, CancellationToken cancellationToken = default);
|
||||
Task SaveBundleAsync(ProofBundle bundle, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoring service interface for replay.
|
||||
/// </summary>
|
||||
public interface IScoringService
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay scoring with frozen inputs.
|
||||
/// </summary>
|
||||
Task<double> ReplayScoreAsync(
|
||||
string scanId,
|
||||
string concelierSnapshotHash,
|
||||
string excititorSnapshotHash,
|
||||
string latticePolicyHash,
|
||||
byte[] seed,
|
||||
DateTimeOffset freezeTimestamp,
|
||||
ProofLedger ledger,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user