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:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View 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; }
}

View 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
};
}
}

View File

@@ -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";
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}