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,
|
||||
|
||||
452
src/Findings/StellaOps.Findings.Ledger/Domain/DecisionModels.cs
Normal file
452
src/Findings/StellaOps.Findings.Ledger/Domain/DecisionModels.cs
Normal file
@@ -0,0 +1,452 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable decision event per advisory §11.
|
||||
/// </summary>
|
||||
public sealed class DecisionEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this decision event.
|
||||
/// </summary>
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Alert identifier.
|
||||
/// </summary>
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact identifier (image digest/commit hash).
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor who made the decision.
|
||||
/// </summary>
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was recorded (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decision status: affected, not_affected, under_investigation.
|
||||
/// </summary>
|
||||
public required string DecisionStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Preset reason code.
|
||||
/// </summary>
|
||||
public required string ReasonCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom reason text.
|
||||
/// </summary>
|
||||
public string? ReasonText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed evidence hashes.
|
||||
/// </summary>
|
||||
public required List<string> EvidenceHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy context (ruleset version, policy id).
|
||||
/// </summary>
|
||||
public string? PolicyContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic replay token for reproducibility.
|
||||
/// </summary>
|
||||
public required string ReplayToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert entity for triage.
|
||||
/// </summary>
|
||||
public sealed class Alert
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique alert identifier.
|
||||
/// </summary>
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact identifier (image digest/commit hash).
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected component PURL.
|
||||
/// </summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level (critical, high, medium, low).
|
||||
/// </summary>
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Triage band (hot, warm, cold).
|
||||
/// </summary>
|
||||
public required string Band { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alert status (open, in_review, decided, closed).
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Composite triage score.
|
||||
/// </summary>
|
||||
public double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the alert was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the alert was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of decisions recorded for this alert.
|
||||
/// </summary>
|
||||
public int DecisionCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence bundle for an alert.
|
||||
/// </summary>
|
||||
public sealed class EvidenceBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Alert identifier.
|
||||
/// </summary>
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability evidence.
|
||||
/// </summary>
|
||||
public EvidenceSection? Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call stack evidence.
|
||||
/// </summary>
|
||||
public EvidenceSection? CallStack { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance evidence.
|
||||
/// </summary>
|
||||
public EvidenceSection? Provenance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status evidence.
|
||||
/// </summary>
|
||||
public VexStatusEvidence? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed hashes for all evidence.
|
||||
/// </summary>
|
||||
public required EvidenceHashes Hashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was computed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence section with status and proof.
|
||||
/// </summary>
|
||||
public sealed class EvidenceSection
|
||||
{
|
||||
/// <summary>
|
||||
/// Status: available, loading, unavailable, error.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for this evidence.
|
||||
/// </summary>
|
||||
public string? Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proof data (type-specific).
|
||||
/// </summary>
|
||||
public object? Proof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status evidence with history.
|
||||
/// </summary>
|
||||
public sealed class VexStatusEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Status: available, unavailable.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current VEX statement.
|
||||
/// </summary>
|
||||
public VexStatement? Current { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Historical VEX statements.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VexStatement>? History { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement summary.
|
||||
/// </summary>
|
||||
public sealed class VexStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Statement identifier.
|
||||
/// </summary>
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification code.
|
||||
/// </summary>
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact statement.
|
||||
/// </summary>
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the statement was issued.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statement issuer.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed hashes for evidence bundle.
|
||||
/// </summary>
|
||||
public sealed class EvidenceHashes
|
||||
{
|
||||
/// <summary>
|
||||
/// All hashes for the bundle.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Hashes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit timeline for an alert.
|
||||
/// </summary>
|
||||
public sealed class AuditTimeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Alert identifier.
|
||||
/// </summary>
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of audit events.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AuditEvent> Events { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count of events.
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single audit event.
|
||||
/// </summary>
|
||||
public sealed class AuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event identifier.
|
||||
/// </summary>
|
||||
public required string EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of audit event.
|
||||
/// </summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor who triggered the event.
|
||||
/// </summary>
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the event occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event-specific details.
|
||||
/// </summary>
|
||||
public object? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay token if applicable.
|
||||
/// </summary>
|
||||
public string? ReplayToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert diff result.
|
||||
/// </summary>
|
||||
public sealed class AlertDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Alert identifier.
|
||||
/// </summary>
|
||||
public required string AlertId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Baseline scan identifier.
|
||||
/// </summary>
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current scan identifier.
|
||||
/// </summary>
|
||||
public required string CurrentScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM diff summary.
|
||||
/// </summary>
|
||||
public SbomDiff? SbomDiff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX diff summary.
|
||||
/// </summary>
|
||||
public VexDiff? VexDiff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the diff was computed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM diff summary.
|
||||
/// </summary>
|
||||
public sealed class SbomDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of added components.
|
||||
/// </summary>
|
||||
public int AddedComponents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of removed components.
|
||||
/// </summary>
|
||||
public int RemovedComponents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of changed components.
|
||||
/// </summary>
|
||||
public int ChangedComponents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed changes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ComponentDiff>? Changes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single component diff.
|
||||
/// </summary>
|
||||
public sealed class ComponentDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of change: added, removed, changed.
|
||||
/// </summary>
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Old version if changed/removed.
|
||||
/// </summary>
|
||||
public string? OldVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New version if changed/added.
|
||||
/// </summary>
|
||||
public string? NewVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX diff summary.
|
||||
/// </summary>
|
||||
public sealed class VexDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of status changes.
|
||||
/// </summary>
|
||||
public int StatusChanges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of new statements.
|
||||
/// </summary>
|
||||
public int NewStatements { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed changes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VexStatusDiff>? Changes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single VEX status diff.
|
||||
/// </summary>
|
||||
public sealed class VexStatusDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Old status.
|
||||
/// </summary>
|
||||
public string? OldStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New status.
|
||||
/// </summary>
|
||||
public required string NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the change occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
163
src/Findings/StellaOps.Findings.Ledger/Services/AlertService.cs
Normal file
163
src/Findings/StellaOps.Findings.Ledger/Services/AlertService.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for alert operations, wrapping the scored findings query system.
|
||||
/// </summary>
|
||||
public sealed class AlertService : IAlertService
|
||||
{
|
||||
private readonly IScoredFindingsQueryService _queryService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AlertService> _logger;
|
||||
|
||||
public AlertService(
|
||||
IScoredFindingsQueryService queryService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AlertService> logger)
|
||||
{
|
||||
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists alerts with filtering and pagination.
|
||||
/// </summary>
|
||||
public async Task<AlertListResult> ListAsync(
|
||||
string tenantId,
|
||||
AlertFilterOptions filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Listing alerts for tenant {TenantId} with filter: band={Band}, severity={Severity}, status={Status}",
|
||||
tenantId, filter.Band, filter.Severity, filter.Status);
|
||||
|
||||
// Convert band filter to score range
|
||||
var (minScore, maxScore) = GetScoreRangeForBand(filter.Band);
|
||||
|
||||
// Build query
|
||||
var query = new ScoredFindingsQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
MinScore = minScore,
|
||||
MaxScore = maxScore,
|
||||
Severities = !string.IsNullOrWhiteSpace(filter.Severity) ? new[] { filter.Severity } : null,
|
||||
Statuses = !string.IsNullOrWhiteSpace(filter.Status) ? new[] { filter.Status } : null,
|
||||
Limit = filter.Limit,
|
||||
Descending = filter.SortDescending,
|
||||
SortBy = MapSortField(filter.SortBy)
|
||||
};
|
||||
|
||||
var result = await _queryService.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map findings to alerts
|
||||
var alerts = result.Findings.Select(f => MapToAlert(f)).ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Found {Count} alerts for tenant {TenantId} (total: {Total})",
|
||||
alerts.Count, tenantId, result.TotalCount);
|
||||
|
||||
return new AlertListResult(alerts, result.TotalCount, result.NextCursor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific alert by ID.
|
||||
/// </summary>
|
||||
public async Task<Alert?> GetAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
|
||||
|
||||
_logger.LogDebug("Getting alert {AlertId} for tenant {TenantId}", alertId, tenantId);
|
||||
|
||||
// Query for the specific finding
|
||||
var query = new ScoredFindingsQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Limit = 1
|
||||
};
|
||||
|
||||
var result = await _queryService.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var finding = result.Findings.FirstOrDefault(f => f.FindingId == alertId);
|
||||
|
||||
if (finding is null)
|
||||
{
|
||||
_logger.LogDebug("Alert {AlertId} not found for tenant {TenantId}", alertId, tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToAlert(finding);
|
||||
}
|
||||
|
||||
private static Alert MapToAlert(ScoredFinding finding)
|
||||
{
|
||||
// Compute band based on risk score
|
||||
var score = finding.RiskScore.HasValue ? (double)finding.RiskScore.Value : 0.0;
|
||||
var band = ComputeBand(score);
|
||||
|
||||
// Parse finding ID to extract components (format: tenantId|artifactId|vulnId)
|
||||
var parts = finding.FindingId.Split('|');
|
||||
var artifactId = parts.Length > 1 ? parts[1] : "unknown";
|
||||
var vulnId = parts.Length > 2 ? parts[2] : "unknown";
|
||||
|
||||
return new Alert
|
||||
{
|
||||
AlertId = finding.FindingId,
|
||||
TenantId = finding.TenantId,
|
||||
ArtifactId = artifactId,
|
||||
VulnId = vulnId,
|
||||
ComponentPurl = null, // Not available in ScoredFinding
|
||||
Severity = finding.RiskSeverity ?? "unknown",
|
||||
Band = band,
|
||||
Status = finding.Status ?? "open",
|
||||
Score = score,
|
||||
CreatedAt = finding.UpdatedAt,
|
||||
UpdatedAt = finding.UpdatedAt,
|
||||
DecisionCount = 0 // Would need additional query
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeBand(double score)
|
||||
{
|
||||
// Compute band based on score thresholds
|
||||
// Hot: score >= 0.70
|
||||
// Warm: 0.40 <= score < 0.70
|
||||
// Cold: score < 0.40
|
||||
return score switch
|
||||
{
|
||||
>= 0.70 => "hot",
|
||||
>= 0.40 => "warm",
|
||||
_ => "cold"
|
||||
};
|
||||
}
|
||||
|
||||
private static (decimal? MinScore, decimal? MaxScore) GetScoreRangeForBand(string? band)
|
||||
{
|
||||
return band?.ToLowerInvariant() switch
|
||||
{
|
||||
"hot" => (0.70m, null),
|
||||
"warm" => (0.40m, 0.70m),
|
||||
"cold" => (null, 0.40m),
|
||||
_ => (null, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static ScoredFindingsSortField MapSortField(string? sortBy)
|
||||
{
|
||||
return sortBy?.ToLowerInvariant() switch
|
||||
{
|
||||
"severity" => ScoredFindingsSortField.RiskSeverity,
|
||||
"updated" => ScoredFindingsSortField.UpdatedAt,
|
||||
"score" => ScoredFindingsSortField.RiskScore,
|
||||
_ => ScoredFindingsSortField.RiskScore
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for recording and querying triage decisions.
|
||||
/// </summary>
|
||||
public sealed class DecisionService : IDecisionService
|
||||
{
|
||||
private readonly ILedgerEventWriteService _writeService;
|
||||
private readonly ILedgerEventRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DecisionService> _logger;
|
||||
|
||||
private static readonly string[] ValidStatuses = { "affected", "not_affected", "under_investigation" };
|
||||
|
||||
public DecisionService(
|
||||
ILedgerEventWriteService writeService,
|
||||
ILedgerEventRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DecisionService> logger)
|
||||
{
|
||||
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a decision event (append-only, immutable).
|
||||
/// </summary>
|
||||
public async Task<DecisionEvent> RecordAsync(
|
||||
DecisionEvent decision,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate decision
|
||||
ValidateDecision(decision);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantId = GetTenantIdFromAlert(decision.AlertId);
|
||||
var chainId = LedgerChainIdGenerator.FromTenantSubject(tenantId, decision.AlertId);
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
// Build payload
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["decision_id"] = decision.Id,
|
||||
["alert_id"] = decision.AlertId,
|
||||
["artifact_id"] = decision.ArtifactId,
|
||||
["decision_status"] = decision.DecisionStatus,
|
||||
["reason_code"] = decision.ReasonCode,
|
||||
["replay_token"] = decision.ReplayToken
|
||||
};
|
||||
|
||||
if (decision.ReasonText is not null)
|
||||
{
|
||||
payload["reason_text"] = decision.ReasonText;
|
||||
}
|
||||
|
||||
if (decision.EvidenceHashes?.Count > 0)
|
||||
{
|
||||
var hashArray = new JsonArray();
|
||||
foreach (var hash in decision.EvidenceHashes)
|
||||
{
|
||||
hashArray.Add(hash);
|
||||
}
|
||||
payload["evidence_hashes"] = hashArray;
|
||||
}
|
||||
|
||||
if (decision.PolicyContext is not null)
|
||||
{
|
||||
payload["policy_context"] = decision.PolicyContext;
|
||||
}
|
||||
|
||||
// Create canonical envelope
|
||||
var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(payload);
|
||||
|
||||
// Create draft event using the "finding.status_changed" event type
|
||||
// as decisions represent status transitions
|
||||
var draft = new LedgerEventDraft(
|
||||
TenantId: tenantId,
|
||||
ChainId: chainId,
|
||||
SequenceNumber: 0, // Will be determined by write service
|
||||
EventId: eventId,
|
||||
EventType: LedgerEventConstants.EventFindingStatusChanged,
|
||||
PolicyVersion: "1.0.0",
|
||||
FindingId: decision.AlertId,
|
||||
ArtifactId: decision.ArtifactId,
|
||||
SourceRunId: null,
|
||||
ActorId: decision.ActorId,
|
||||
ActorType: "operator",
|
||||
OccurredAt: decision.Timestamp,
|
||||
RecordedAt: now,
|
||||
Payload: payload,
|
||||
CanonicalEnvelope: canonicalEnvelope,
|
||||
ProvidedPreviousHash: null,
|
||||
EvidenceBundleReference: null);
|
||||
|
||||
var result = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Status != LedgerWriteStatus.Success && result.Status != LedgerWriteStatus.Idempotent)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to record decision: {string.Join(", ", result.Errors)}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Decision {DecisionId} recorded for alert {AlertId}: {Status}",
|
||||
decision.Id, decision.AlertId, decision.DecisionStatus);
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets decision history for an alert (immutable timeline).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DecisionEvent>> GetHistoryAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Decision history would need to be fetched from projections
|
||||
// or by querying events for the alert's chain.
|
||||
// For now, return empty list as the full implementation requires
|
||||
// additional repository support.
|
||||
_logger.LogInformation(
|
||||
"Getting decision history for alert {AlertId} in tenant {TenantId}",
|
||||
alertId, tenantId);
|
||||
|
||||
// This would need to be implemented with a projection repository
|
||||
// or by scanning ledger events for the alert's chain
|
||||
return Array.Empty<DecisionEvent>();
|
||||
}
|
||||
|
||||
private static void ValidateDecision(DecisionEvent decision)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(decision.AlertId))
|
||||
throw new ArgumentException("AlertId is required");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(decision.DecisionStatus))
|
||||
throw new ArgumentException("DecisionStatus is required");
|
||||
|
||||
if (!ValidStatuses.Contains(decision.DecisionStatus))
|
||||
throw new ArgumentException($"Invalid DecisionStatus: {decision.DecisionStatus}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(decision.ReasonCode))
|
||||
throw new ArgumentException("ReasonCode is required");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(decision.ReplayToken))
|
||||
throw new ArgumentException("ReplayToken is required");
|
||||
}
|
||||
|
||||
private static string GetTenantIdFromAlert(string alertId)
|
||||
{
|
||||
// Extract tenant from alert ID format: tenant|artifact|vuln
|
||||
var parts = alertId.Split('|');
|
||||
return parts.Length > 0 ? parts[0] : "default";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for alert operations.
|
||||
/// </summary>
|
||||
public interface IAlertService
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists alerts with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<AlertListResult> ListAsync(
|
||||
string tenantId,
|
||||
AlertFilterOptions filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific alert by ID.
|
||||
/// </summary>
|
||||
Task<Alert?> GetAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter options for alert listing.
|
||||
/// </summary>
|
||||
public sealed record AlertFilterOptions(
|
||||
string? Band = null,
|
||||
string? Severity = null,
|
||||
string? Status = null,
|
||||
string? ArtifactId = null,
|
||||
string? VulnId = null,
|
||||
string? ComponentPurl = null,
|
||||
int Limit = 50,
|
||||
int Offset = 0,
|
||||
string? SortBy = null,
|
||||
bool SortDescending = false);
|
||||
|
||||
/// <summary>
|
||||
/// Result of alert listing.
|
||||
/// </summary>
|
||||
public sealed record AlertListResult(
|
||||
IReadOnlyList<Alert> Items,
|
||||
int TotalCount,
|
||||
string? NextPageToken);
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for audit timeline retrieval.
|
||||
/// </summary>
|
||||
public interface IAuditService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the audit timeline for an alert.
|
||||
/// </summary>
|
||||
Task<AuditTimeline?> GetTimelineAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for recording and querying triage decisions.
|
||||
/// </summary>
|
||||
public interface IDecisionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a decision event (append-only, immutable).
|
||||
/// </summary>
|
||||
Task<DecisionEvent> RecordAsync(
|
||||
DecisionEvent decision,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets decision history for an alert (immutable timeline).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DecisionEvent>> GetHistoryAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing SBOM/VEX diffs.
|
||||
/// </summary>
|
||||
public interface IDiffService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a diff for an alert against a baseline.
|
||||
/// </summary>
|
||||
Task<AlertDiff?> ComputeDiffAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
string? baselineScanId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for evidence bundle retrieval.
|
||||
/// </summary>
|
||||
public interface IEvidenceBundleService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evidence bundle for an alert.
|
||||
/// </summary>
|
||||
Task<EvidenceBundle?> GetBundleAsync(
|
||||
string tenantId,
|
||||
string alertId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user