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:
@@ -0,0 +1,353 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Alert filter query parameters.
|
||||
/// </summary>
|
||||
public sealed record AlertFilterQuery(
|
||||
string? Band,
|
||||
string? Severity,
|
||||
string? Status,
|
||||
string? ArtifactId,
|
||||
string? VulnId,
|
||||
string? ComponentPurl,
|
||||
int Limit = 50,
|
||||
int Offset = 0,
|
||||
string? SortBy = null,
|
||||
bool SortDescending = false);
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing alerts.
|
||||
/// </summary>
|
||||
public sealed record AlertListResponse(
|
||||
IReadOnlyList<AlertSummary> Items,
|
||||
int TotalCount,
|
||||
string? NextPageToken);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an alert for list views.
|
||||
/// </summary>
|
||||
public sealed record AlertSummary
|
||||
{
|
||||
[JsonPropertyName("alert_id")]
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_id")]
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
[JsonPropertyName("vuln_id")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
[JsonPropertyName("component_purl")]
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required string Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("band")]
|
||||
public required string Band { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("decision_count")]
|
||||
public int DecisionCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence payload response for an alert.
|
||||
/// </summary>
|
||||
public sealed record EvidencePayloadResponse
|
||||
{
|
||||
[JsonPropertyName("alert_id")]
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public EvidenceSectionResponse? Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("callstack")]
|
||||
public EvidenceSectionResponse? Callstack { get; init; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public EvidenceSectionResponse? Provenance { get; init; }
|
||||
|
||||
[JsonPropertyName("vex")]
|
||||
public VexEvidenceSectionResponse? Vex { get; init; }
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public required IReadOnlyList<string> Hashes { get; init; }
|
||||
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence section with status and proof.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSectionResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string? Hash { get; init; }
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public object? Proof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX evidence section with current and history.
|
||||
/// </summary>
|
||||
public sealed record VexEvidenceSectionResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("current")]
|
||||
public VexStatementResponse? Current { get; init; }
|
||||
|
||||
[JsonPropertyName("history")]
|
||||
public IReadOnlyList<VexStatementResponse>? History { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement summary.
|
||||
/// </summary>
|
||||
public sealed record VexStatementResponse
|
||||
{
|
||||
[JsonPropertyName("statement_id")]
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("impact_statement")]
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to record a triage decision.
|
||||
/// </summary>
|
||||
public sealed record DecisionRequest
|
||||
{
|
||||
[JsonPropertyName("decision_status")]
|
||||
public required string DecisionStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("reason_code")]
|
||||
public required string ReasonCode { get; init; }
|
||||
|
||||
[JsonPropertyName("reason_text")]
|
||||
public string? ReasonText { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_hashes")]
|
||||
public IReadOnlyList<string>? EvidenceHashes { get; init; }
|
||||
|
||||
[JsonPropertyName("policy_context")]
|
||||
public string? PolicyContext { get; init; }
|
||||
|
||||
[JsonPropertyName("rules_version")]
|
||||
public string? RulesVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response after recording a decision.
|
||||
/// </summary>
|
||||
public sealed record DecisionResponse
|
||||
{
|
||||
[JsonPropertyName("decision_id")]
|
||||
public required string DecisionId { get; init; }
|
||||
|
||||
[JsonPropertyName("alert_id")]
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
[JsonPropertyName("actor_id")]
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("replay_token")]
|
||||
public required string ReplayToken { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_hashes")]
|
||||
public IReadOnlyList<string>? EvidenceHashes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit timeline for an alert.
|
||||
/// </summary>
|
||||
public sealed record AuditTimelineResponse
|
||||
{
|
||||
[JsonPropertyName("alert_id")]
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
[JsonPropertyName("events")]
|
||||
public required IReadOnlyList<AuditEventResponse> Events { get; init; }
|
||||
|
||||
[JsonPropertyName("total_count")]
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single audit event in timeline.
|
||||
/// </summary>
|
||||
public sealed record AuditEventResponse
|
||||
{
|
||||
[JsonPropertyName("event_id")]
|
||||
public required string EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("event_type")]
|
||||
public required string EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("actor_id")]
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public object? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("replay_token")]
|
||||
public string? ReplayToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM/VEX diff response.
|
||||
/// </summary>
|
||||
public sealed record AlertDiffResponse
|
||||
{
|
||||
[JsonPropertyName("alert_id")]
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
[JsonPropertyName("baseline_scan_id")]
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("current_scan_id")]
|
||||
public required string CurrentScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbom_diff")]
|
||||
public SbomDiffSummary? SbomDiff { get; init; }
|
||||
|
||||
[JsonPropertyName("vex_diff")]
|
||||
public VexDiffSummary? VexDiff { get; init; }
|
||||
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM diff summary.
|
||||
/// </summary>
|
||||
public sealed record SbomDiffSummary
|
||||
{
|
||||
[JsonPropertyName("added_components")]
|
||||
public int AddedComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("removed_components")]
|
||||
public int RemovedComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("changed_components")]
|
||||
public int ChangedComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("changes")]
|
||||
public IReadOnlyList<ComponentChange>? Changes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single component change.
|
||||
/// </summary>
|
||||
public sealed record ComponentChange
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("change_type")]
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
[JsonPropertyName("old_version")]
|
||||
public string? OldVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("new_version")]
|
||||
public string? NewVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX diff summary.
|
||||
/// </summary>
|
||||
public sealed record VexDiffSummary
|
||||
{
|
||||
[JsonPropertyName("status_changes")]
|
||||
public int StatusChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("new_statements")]
|
||||
public int NewStatements { get; init; }
|
||||
|
||||
[JsonPropertyName("changes")]
|
||||
public IReadOnlyList<VexStatusChange>? Changes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single VEX status change.
|
||||
/// </summary>
|
||||
public sealed record VexStatusChange
|
||||
{
|
||||
[JsonPropertyName("vuln_id")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
[JsonPropertyName("old_status")]
|
||||
public string? OldStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("new_status")]
|
||||
public required string NewStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification result.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle_id")]
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("merkle_root")]
|
||||
public string? MerkleRoot { get; init; }
|
||||
|
||||
[JsonPropertyName("signature_valid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp_valid")]
|
||||
public bool? TimestampValid { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("verified_at")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
@@ -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