Refactor SurfaceCacheValidator to simplify oldest entry calculation
Add global using for Xunit in test project Enhance ImportValidatorTests with async validation and quarantine checks Implement FileSystemQuarantineServiceTests for quarantine functionality Add integration tests for ImportValidator to check monotonicity Create BundleVersionTests to validate version parsing and comparison logic Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
@@ -180,6 +180,10 @@ builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
builder.Services.AddSingleton<VexConsensusService>();
|
||||
|
||||
// Alert and Decision services (SPRINT_3602)
|
||||
builder.Services.AddSingleton<IAlertService, AlertService>();
|
||||
builder.Services.AddSingleton<IDecisionService, DecisionService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -1475,6 +1479,204 @@ app.MapGet("/v1/vex-consensus/issuers/{issuerId}", async Task<Results<JsonHttpRe
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Alert Triage Endpoints (SPRINT_3602)
|
||||
const string AlertReadPolicy = LedgerExportPolicy;
|
||||
const string AlertDecidePolicy = LedgerWritePolicy;
|
||||
|
||||
app.MapGet("/v1/alerts", async Task<Results<JsonHttpResult<AlertListResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
IAlertService alertService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var filter = new AlertFilterOptions(
|
||||
Band: httpContext.Request.Query["band"].ToString(),
|
||||
Severity: httpContext.Request.Query["severity"].ToString(),
|
||||
Status: httpContext.Request.Query["status"].ToString(),
|
||||
ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
|
||||
VulnId: httpContext.Request.Query["vuln_id"].ToString(),
|
||||
ComponentPurl: httpContext.Request.Query["component_purl"].ToString(),
|
||||
Limit: ParseInt(httpContext.Request.Query["limit"]) ?? 50,
|
||||
Offset: ParseInt(httpContext.Request.Query["offset"]) ?? 0,
|
||||
SortBy: httpContext.Request.Query["sort_by"].ToString(),
|
||||
SortDescending: ParseBool(httpContext.Request.Query["sort_desc"]) ?? false);
|
||||
|
||||
var result = await alertService.ListAsync(tenantId, filter, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new AlertListResponse(
|
||||
result.Items.Select(a => new AlertSummary
|
||||
{
|
||||
AlertId = a.AlertId,
|
||||
ArtifactId = a.ArtifactId,
|
||||
VulnId = a.VulnId,
|
||||
ComponentPurl = a.ComponentPurl,
|
||||
Severity = a.Severity,
|
||||
Band = a.Band,
|
||||
Status = a.Status,
|
||||
Score = a.Score,
|
||||
CreatedAt = a.CreatedAt,
|
||||
UpdatedAt = a.UpdatedAt,
|
||||
DecisionCount = a.DecisionCount
|
||||
}).ToList(),
|
||||
result.TotalCount,
|
||||
result.NextPageToken);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ListAlerts")
|
||||
.RequireAuthorization(AlertReadPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/alerts/{alertId}", async Task<Results<JsonHttpResult<AlertSummary>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string alertId,
|
||||
IAlertService alertService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var alert = await alertService.GetAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
||||
if (alert is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = new AlertSummary
|
||||
{
|
||||
AlertId = alert.AlertId,
|
||||
ArtifactId = alert.ArtifactId,
|
||||
VulnId = alert.VulnId,
|
||||
ComponentPurl = alert.ComponentPurl,
|
||||
Severity = alert.Severity,
|
||||
Band = alert.Band,
|
||||
Status = alert.Status,
|
||||
Score = alert.Score,
|
||||
CreatedAt = alert.CreatedAt,
|
||||
UpdatedAt = alert.UpdatedAt,
|
||||
DecisionCount = alert.DecisionCount
|
||||
};
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("GetAlert")
|
||||
.RequireAuthorization(AlertReadPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPost("/v1/alerts/{alertId}/decisions", async Task<Results<Created<DecisionResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string alertId,
|
||||
DecisionRequest request,
|
||||
IAlertService alertService,
|
||||
IDecisionService decisionService,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
// Validate alert exists
|
||||
var alert = await alertService.GetAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
||||
if (alert is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
// Get actor from auth context
|
||||
var actorId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
|
||||
|
||||
// Generate simple replay token
|
||||
var tokenInput = $"{alertId}|{actorId}|{request.DecisionStatus}|{timeProvider.GetUtcNow():O}";
|
||||
var replayToken = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tokenInput))).ToLowerInvariant();
|
||||
|
||||
// Record decision (append-only)
|
||||
var decision = await decisionService.RecordAsync(new DecisionEvent
|
||||
{
|
||||
AlertId = alertId,
|
||||
ArtifactId = alert.ArtifactId,
|
||||
ActorId = actorId,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
DecisionStatus = request.DecisionStatus,
|
||||
ReasonCode = request.ReasonCode,
|
||||
ReasonText = request.ReasonText,
|
||||
EvidenceHashes = request.EvidenceHashes?.ToList() ?? new(),
|
||||
PolicyContext = request.PolicyContext,
|
||||
ReplayToken = replayToken
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new DecisionResponse
|
||||
{
|
||||
DecisionId = decision.Id,
|
||||
AlertId = decision.AlertId,
|
||||
ActorId = decision.ActorId,
|
||||
Timestamp = decision.Timestamp,
|
||||
ReplayToken = decision.ReplayToken,
|
||||
EvidenceHashes = decision.EvidenceHashes
|
||||
};
|
||||
|
||||
return TypedResults.Created($"/v1/alerts/{alertId}/audit", response);
|
||||
})
|
||||
.WithName("RecordDecision")
|
||||
.RequireAuthorization(AlertDecidePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/alerts/{alertId}/audit", async Task<Results<JsonHttpResult<AuditTimelineResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string alertId,
|
||||
IDecisionService decisionService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var decisions = await decisionService.GetHistoryAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var events = decisions.Select(d => new AuditEventResponse
|
||||
{
|
||||
EventId = d.Id,
|
||||
EventType = "decision_recorded",
|
||||
ActorId = d.ActorId,
|
||||
Timestamp = d.Timestamp,
|
||||
Details = new
|
||||
{
|
||||
decision_status = d.DecisionStatus,
|
||||
reason_code = d.ReasonCode,
|
||||
reason_text = d.ReasonText,
|
||||
evidence_hashes = d.EvidenceHashes
|
||||
},
|
||||
ReplayToken = d.ReplayToken
|
||||
}).ToList();
|
||||
|
||||
var response = new AuditTimelineResponse
|
||||
{
|
||||
AlertId = alertId,
|
||||
Events = events,
|
||||
TotalCount = events.Count
|
||||
};
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("GetAlertAudit")
|
||||
.RequireAuthorization(AlertReadPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPost("/v1/vex-consensus/issuers", async Task<Results<Created<VexIssuerDetailResponse>, ProblemHttpResult>> (
|
||||
RegisterVexIssuerRequest request,
|
||||
VexConsensusService consensusService,
|
||||
|
||||
Reference in New Issue
Block a user