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