feat: Implement air-gap functionality with timeline impact and evidence snapshot services
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
- Added AirgapTimelineImpact, AirgapTimelineImpactInput, and AirgapTimelineImpactResult records for managing air-gap bundle import impacts. - Introduced EvidenceSnapshotRecord, EvidenceSnapshotLinkInput, and EvidenceSnapshotLinkResult records for linking findings to evidence snapshots. - Created IEvidenceSnapshotRepository interface for managing evidence snapshot records. - Developed StalenessValidationService to validate staleness and enforce freshness thresholds. - Implemented AirgapTimelineService for emitting timeline events related to bundle imports. - Added EvidenceSnapshotService for linking findings to evidence snapshots and verifying their validity. - Introduced AirGapOptions for configuring air-gap staleness enforcement and thresholds. - Added minimal jsPDF stub for offline/testing builds in the web application. - Created TypeScript definitions for jsPDF to enhance type safety in the web application.
This commit is contained in:
@@ -15,6 +15,8 @@ public static class LedgerEventConstants
|
||||
public const string EventFindingAttachmentAdded = "finding.attachment_added";
|
||||
public const string EventFindingClosed = "finding.closed";
|
||||
public const string EventAirgapBundleImported = "airgap.bundle_imported";
|
||||
public const string EventEvidenceSnapshotLinked = "airgap.evidence_snapshot_linked";
|
||||
public const string EventAirgapTimelineImpact = "airgap.timeline_impact";
|
||||
public const string EventOrchestratorExportRecorded = "orchestrator.export_recorded";
|
||||
|
||||
public static readonly ImmutableHashSet<string> SupportedEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
|
||||
@@ -29,6 +31,8 @@ public static class LedgerEventConstants
|
||||
EventFindingAttachmentAdded,
|
||||
EventFindingClosed,
|
||||
EventAirgapBundleImported,
|
||||
EventEvidenceSnapshotLinked,
|
||||
EventAirgapTimelineImpact,
|
||||
EventOrchestratorExportRecorded);
|
||||
|
||||
public static readonly ImmutableHashSet<string> FindingEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the impact of an air-gap bundle import on findings.
|
||||
/// </summary>
|
||||
public sealed record AirgapTimelineImpact(
|
||||
string TenantId,
|
||||
string BundleId,
|
||||
int NewFindings,
|
||||
int ResolvedFindings,
|
||||
int CriticalDelta,
|
||||
int HighDelta,
|
||||
int MediumDelta,
|
||||
int LowDelta,
|
||||
DateTimeOffset TimeAnchor,
|
||||
bool SealedMode,
|
||||
DateTimeOffset CalculatedAt,
|
||||
Guid? LedgerEventId);
|
||||
|
||||
/// <summary>
|
||||
/// Input for calculating and emitting bundle import timeline impact.
|
||||
/// </summary>
|
||||
public sealed record AirgapTimelineImpactInput(
|
||||
string TenantId,
|
||||
string BundleId,
|
||||
DateTimeOffset TimeAnchor,
|
||||
bool SealedMode);
|
||||
|
||||
/// <summary>
|
||||
/// Result of emitting a timeline impact event.
|
||||
/// </summary>
|
||||
public sealed record AirgapTimelineImpactResult(
|
||||
bool Success,
|
||||
AirgapTimelineImpact? Impact,
|
||||
Guid? EventId,
|
||||
string? Error);
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Record linking a finding to an evidence snapshot in a portable bundle.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSnapshotRecord(
|
||||
string TenantId,
|
||||
string FindingId,
|
||||
string BundleUri,
|
||||
string DsseDigest,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
Guid? LedgerEventId);
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating an evidence snapshot link.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSnapshotLinkInput(
|
||||
string TenantId,
|
||||
string FindingId,
|
||||
string BundleUri,
|
||||
string DsseDigest,
|
||||
TimeSpan? ValidFor);
|
||||
|
||||
/// <summary>
|
||||
/// Result of linking an evidence snapshot.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSnapshotLinkResult(
|
||||
bool Success,
|
||||
Guid? EventId,
|
||||
string? Error);
|
||||
@@ -3,4 +3,27 @@ namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
public interface IAirgapImportRepository
|
||||
{
|
||||
Task InsertAsync(AirgapImportRecord record, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest import record for a specific domain.
|
||||
/// </summary>
|
||||
Task<AirgapImportRecord?> GetLatestByDomainAsync(
|
||||
string tenantId,
|
||||
string domainId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest import records for all domains in a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AirgapImportRecord>> GetAllLatestByDomainAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of bundles imported for a specific domain.
|
||||
/// </summary>
|
||||
Task<int> GetBundleCountByDomainAsync(
|
||||
string tenantId,
|
||||
string domainId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing evidence snapshot links.
|
||||
/// </summary>
|
||||
public interface IEvidenceSnapshotRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts a new evidence snapshot record.
|
||||
/// </summary>
|
||||
Task InsertAsync(EvidenceSnapshotRecord record, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence snapshots for a finding.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EvidenceSnapshotRecord>> GetByFindingIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest evidence snapshot for a finding.
|
||||
/// </summary>
|
||||
Task<EvidenceSnapshotRecord?> GetLatestByFindingIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all evidence snapshots for a bundle.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EvidenceSnapshotRecord>> GetByBundleUriAsync(
|
||||
string tenantId,
|
||||
string bundleUri,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an evidence snapshot exists and is not expired.
|
||||
/// </summary>
|
||||
Task<bool> ExistsValidAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string dsseDigest,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a staleness validation check.
|
||||
/// </summary>
|
||||
public sealed record StalenessValidationResult(
|
||||
bool Passed,
|
||||
string? DomainId,
|
||||
long StalenessSeconds,
|
||||
int ThresholdSeconds,
|
||||
StalenessEnforcementMode EnforcementMode,
|
||||
StalenessError? Error,
|
||||
IReadOnlyList<StalenessWarning> Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Error returned when staleness validation fails.
|
||||
/// </summary>
|
||||
public sealed record StalenessError(
|
||||
StalenessErrorCode Code,
|
||||
string Message,
|
||||
string? DomainId,
|
||||
long StalenessSeconds,
|
||||
int ThresholdSeconds,
|
||||
string Recommendation);
|
||||
|
||||
/// <summary>
|
||||
/// Warning generated during staleness validation.
|
||||
/// </summary>
|
||||
public sealed record StalenessWarning(
|
||||
StalenessWarningCode Code,
|
||||
string Message,
|
||||
double PercentOfThreshold,
|
||||
DateTimeOffset? ProjectedStaleAt);
|
||||
|
||||
/// <summary>
|
||||
/// Staleness error codes.
|
||||
/// </summary>
|
||||
public enum StalenessErrorCode
|
||||
{
|
||||
ErrAirgapStale,
|
||||
ErrAirgapNoBundle,
|
||||
ErrAirgapTimeAnchorMissing,
|
||||
ErrAirgapTimeDrift,
|
||||
ErrAirgapAttestationInvalid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness warning codes.
|
||||
/// </summary>
|
||||
public enum StalenessWarningCode
|
||||
{
|
||||
WarnAirgapApproachingStale,
|
||||
WarnAirgapTimeUncertaintyHigh,
|
||||
WarnAirgapBundleOld,
|
||||
WarnAirgapNoRecentImport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness metrics for a domain.
|
||||
/// </summary>
|
||||
public sealed record DomainStalenessMetric(
|
||||
string DomainId,
|
||||
long StalenessSeconds,
|
||||
DateTimeOffset LastImportAt,
|
||||
DateTimeOffset? LastSourceTimestamp,
|
||||
int BundleCount,
|
||||
bool IsStale,
|
||||
double PercentOfThreshold,
|
||||
DateTimeOffset? ProjectedStaleAt);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate staleness metrics.
|
||||
/// </summary>
|
||||
public sealed record AggregateStalenessMetrics(
|
||||
int TotalDomains,
|
||||
int StaleDomains,
|
||||
int WarningDomains,
|
||||
int HealthyDomains,
|
||||
long MaxStalenessSeconds,
|
||||
double AvgStalenessSeconds,
|
||||
DateTimeOffset? OldestBundle);
|
||||
|
||||
/// <summary>
|
||||
/// Complete staleness metrics snapshot.
|
||||
/// </summary>
|
||||
public sealed record StalenessMetricsSnapshot(
|
||||
DateTimeOffset CollectedAt,
|
||||
string? TenantId,
|
||||
IReadOnlyList<DomainStalenessMetric> DomainMetrics,
|
||||
AggregateStalenessMetrics Aggregates);
|
||||
@@ -2,6 +2,17 @@ using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about finding changes since a given timestamp.
|
||||
/// </summary>
|
||||
public sealed record FindingStatsResult(
|
||||
int NewFindings,
|
||||
int ResolvedFindings,
|
||||
int CriticalDelta,
|
||||
int HighDelta,
|
||||
int MediumDelta,
|
||||
int LowDelta);
|
||||
|
||||
public interface IFindingProjectionRepository
|
||||
{
|
||||
Task<FindingProjection?> GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken);
|
||||
@@ -15,4 +26,12 @@ public interface IFindingProjectionRepository
|
||||
Task<ProjectionCheckpoint> GetCheckpointAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets finding statistics since a given timestamp for timeline impact calculation.
|
||||
/// </summary>
|
||||
Task<FindingStatsResult> GetFindingStatsSinceAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,51 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
ledger_event_id = EXCLUDED.ledger_event_id;
|
||||
""";
|
||||
|
||||
private const string SelectLatestByDomainSql = """
|
||||
SELECT
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
mirror_generation,
|
||||
merkle_root,
|
||||
time_anchor,
|
||||
publisher,
|
||||
hash_algorithm,
|
||||
contents,
|
||||
imported_at,
|
||||
import_operator,
|
||||
ledger_event_id
|
||||
FROM airgap_imports
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @domain_id
|
||||
ORDER BY time_anchor DESC
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
private const string SelectAllLatestByDomainSql = """
|
||||
SELECT DISTINCT ON (bundle_id)
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
mirror_generation,
|
||||
merkle_root,
|
||||
time_anchor,
|
||||
publisher,
|
||||
hash_algorithm,
|
||||
contents,
|
||||
imported_at,
|
||||
import_operator,
|
||||
ledger_event_id
|
||||
FROM airgap_imports
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY bundle_id, time_anchor DESC;
|
||||
""";
|
||||
|
||||
private const string SelectBundleCountSql = """
|
||||
SELECT COUNT(*)
|
||||
FROM airgap_imports
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @domain_id;
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresAirgapImportRepository> _logger;
|
||||
|
||||
@@ -91,4 +136,95 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AirgapImportRecord?> GetLatestByDomainAsync(
|
||||
string tenantId,
|
||||
string domainId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectLatestByDomainSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("domain_id", domainId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AirgapImportRecord>> GetAllLatestByDomainAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var results = new List<AirgapImportRecord>();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectAllLatestByDomainSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> GetBundleCountByDomainAsync(
|
||||
string tenantId,
|
||||
string domainId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectBundleCountSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("domain_id", domainId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static AirgapImportRecord MapRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var contentsJson = reader.GetString(7);
|
||||
var contents = JsonNode.Parse(contentsJson) as JsonArray ?? new JsonArray();
|
||||
|
||||
return new AirgapImportRecord(
|
||||
TenantId: reader.GetString(0),
|
||||
BundleId: reader.GetString(1),
|
||||
MirrorGeneration: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
MerkleRoot: reader.GetString(3),
|
||||
TimeAnchor: reader.GetDateTime(4),
|
||||
Publisher: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
HashAlgorithm: reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
Contents: contents,
|
||||
ImportedAt: reader.GetDateTime(8),
|
||||
ImportOperator: reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
LedgerEventId: reader.IsDBNull(10) ? null : reader.GetGuid(10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,22 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
updated_at = EXCLUDED.updated_at;
|
||||
""";
|
||||
|
||||
private const string SelectFindingStatsSql = """
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'new' AND updated_at >= @since THEN 1 ELSE 0 END), 0) as new_findings,
|
||||
COALESCE(SUM(CASE WHEN status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as resolved_findings,
|
||||
COALESCE(SUM(CASE WHEN risk_severity = 'critical' AND updated_at >= @since THEN 1 ELSE 0 END) -
|
||||
SUM(CASE WHEN risk_severity = 'critical' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as critical_delta,
|
||||
COALESCE(SUM(CASE WHEN risk_severity = 'high' AND updated_at >= @since THEN 1 ELSE 0 END) -
|
||||
SUM(CASE WHEN risk_severity = 'high' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as high_delta,
|
||||
COALESCE(SUM(CASE WHEN risk_severity = 'medium' AND updated_at >= @since THEN 1 ELSE 0 END) -
|
||||
SUM(CASE WHEN risk_severity = 'medium' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as medium_delta,
|
||||
COALESCE(SUM(CASE WHEN risk_severity = 'low' AND updated_at >= @since THEN 1 ELSE 0 END) -
|
||||
SUM(CASE WHEN risk_severity = 'low' AND status IN ('resolved', 'closed', 'fixed') AND updated_at >= @since THEN 1 ELSE 0 END), 0) as low_delta
|
||||
FROM findings_projection
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
private const string DefaultWorkerId = "default";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
@@ -350,4 +366,33 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FindingStatsResult> GetFindingStatsSinceAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectFindingStatsSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("since", since);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return new FindingStatsResult(
|
||||
NewFindings: reader.GetInt32(0),
|
||||
ResolvedFindings: reader.GetInt32(1),
|
||||
CriticalDelta: reader.GetInt32(2),
|
||||
HighDelta: reader.GetInt32(3),
|
||||
MediumDelta: reader.GetInt32(4),
|
||||
LowDelta: reader.GetInt32(5));
|
||||
}
|
||||
|
||||
return new FindingStatsResult(0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,21 @@ internal static class LedgerMetrics
|
||||
"ledger_attachments_encryption_failures_total",
|
||||
description: "Count of attachment encryption/signing/upload failures.");
|
||||
|
||||
private static readonly Histogram<double> AirgapStalenessSeconds = Meter.CreateHistogram<double>(
|
||||
"ledger_airgap_staleness_seconds",
|
||||
unit: "s",
|
||||
description: "Current staleness of air-gap imported data by domain.");
|
||||
|
||||
private static readonly Counter<long> StalenessValidationFailures = Meter.CreateCounter<long>(
|
||||
"ledger_staleness_validation_failures_total",
|
||||
description: "Count of staleness validation failures blocking exports.");
|
||||
|
||||
private static readonly ObservableGauge<double> AirgapStalenessGauge =
|
||||
Meter.CreateObservableGauge("ledger_airgap_staleness_gauge_seconds", ObserveAirgapStaleness, unit: "s",
|
||||
description: "Current staleness of air-gap data by domain.");
|
||||
|
||||
private static readonly ConcurrentDictionary<string, double> AirgapStalenessByDomain = new(StringComparer.Ordinal);
|
||||
|
||||
private static readonly ObservableGauge<double> ProjectionLagGauge =
|
||||
Meter.CreateObservableGauge("ledger_projection_lag_seconds", ObserveProjectionLag, unit: "s",
|
||||
description: "Lag between ledger recorded_at and projection application time.");
|
||||
@@ -228,6 +243,27 @@ internal static class LedgerMetrics
|
||||
public static void RecordProjectionLag(TimeSpan lag, string? tenantId) =>
|
||||
UpdateProjectionLag(tenantId, lag.TotalSeconds);
|
||||
|
||||
public static void RecordAirgapStaleness(string? domainId, long stalenessSeconds)
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(domainId) ? "unknown" : domainId;
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("domain", key)
|
||||
};
|
||||
AirgapStalenessSeconds.Record(stalenessSeconds, tags);
|
||||
AirgapStalenessByDomain[key] = stalenessSeconds;
|
||||
}
|
||||
|
||||
public static void RecordStalenessValidationFailure(string? domainId)
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(domainId) ? "unknown" : domainId;
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("domain", key)
|
||||
};
|
||||
StalenessValidationFailures.Add(1, tags);
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<double>> ObserveProjectionLag()
|
||||
{
|
||||
foreach (var kvp in ProjectionLagByTenant)
|
||||
@@ -267,6 +303,14 @@ internal static class LedgerMetrics
|
||||
new KeyValuePair<string, object?>("git_sha", GitSha));
|
||||
}
|
||||
|
||||
private static IEnumerable<Measurement<double>> ObserveAirgapStaleness()
|
||||
{
|
||||
foreach (var kvp in AirgapStalenessByDomain)
|
||||
{
|
||||
yield return new Measurement<double>(kvp.Value, new KeyValuePair<string, object?>("domain", kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRole(string role) => string.IsNullOrWhiteSpace(role) ? "unspecified" : role.ToLowerInvariant();
|
||||
|
||||
private static string NormalizeTenant(string? tenantId) => string.IsNullOrWhiteSpace(tenantId) ? string.Empty : tenantId;
|
||||
|
||||
@@ -15,6 +15,8 @@ internal static class LedgerTimeline
|
||||
private static readonly EventId ProjectionUpdated = new(6201, "ledger.projection.updated");
|
||||
private static readonly EventId OrchestratorExport = new(6301, "ledger.export.recorded");
|
||||
private static readonly EventId AirgapImport = new(6401, "ledger.airgap.imported");
|
||||
private static readonly EventId EvidenceSnapshotLinkedEvent = new(6501, "ledger.evidence.snapshot_linked");
|
||||
private static readonly EventId AirgapTimelineImpactEvent = new(6601, "ledger.airgap.timeline_impact");
|
||||
|
||||
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
|
||||
{
|
||||
@@ -99,4 +101,47 @@ internal static class LedgerTimeline
|
||||
merkleRoot,
|
||||
ledgerEventId?.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
public static void EmitEvidenceSnapshotLinked(ILogger logger, string tenantId, string findingId, string bundleUri, string dsseDigest)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
EvidenceSnapshotLinkedEvent,
|
||||
"timeline ledger.evidence.snapshot_linked tenant={Tenant} finding={FindingId} bundle_uri={BundleUri} dsse_digest={DsseDigest}",
|
||||
tenantId,
|
||||
findingId,
|
||||
bundleUri,
|
||||
dsseDigest);
|
||||
}
|
||||
|
||||
public static void EmitAirgapTimelineImpact(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
int newFindings,
|
||||
int resolvedFindings,
|
||||
int criticalDelta,
|
||||
DateTimeOffset timeAnchor,
|
||||
bool sealedMode)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
AirgapTimelineImpactEvent,
|
||||
"timeline ledger.airgap.timeline_impact tenant={Tenant} bundle={BundleId} new_findings={NewFindings} resolved_findings={ResolvedFindings} critical_delta={CriticalDelta} time_anchor={TimeAnchor} sealed_mode={SealedMode}",
|
||||
tenantId,
|
||||
bundleId,
|
||||
newFindings,
|
||||
resolvedFindings,
|
||||
criticalDelta,
|
||||
timeAnchor.ToString("O"),
|
||||
sealedMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace StellaOps.Findings.Ledger.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for air-gap staleness enforcement and freshness thresholds.
|
||||
/// </summary>
|
||||
public sealed class AirGapOptions
|
||||
{
|
||||
public const string SectionName = "findings:ledger:airgap";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age in seconds before data is considered stale.
|
||||
/// Default: 604800 seconds (7 days).
|
||||
/// </summary>
|
||||
public int FreshnessThresholdSeconds { get; set; } = 604800;
|
||||
|
||||
/// <summary>
|
||||
/// Grace period in seconds after threshold before hard enforcement.
|
||||
/// Default: 86400 seconds (1 day).
|
||||
/// </summary>
|
||||
public int GracePeriodSeconds { get; set; } = 86400;
|
||||
|
||||
/// <summary>
|
||||
/// How staleness violations are handled.
|
||||
/// </summary>
|
||||
public StalenessEnforcementMode EnforcementMode { get; set; } = StalenessEnforcementMode.Strict;
|
||||
|
||||
/// <summary>
|
||||
/// Domains exempt from staleness enforcement.
|
||||
/// </summary>
|
||||
public IList<string> AllowedDomains { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Percentage thresholds for warning notifications.
|
||||
/// </summary>
|
||||
public IList<NotificationThresholdConfig> NotificationThresholds { get; } = new List<NotificationThresholdConfig>
|
||||
{
|
||||
new() { PercentOfThreshold = 75, Severity = NotificationSeverity.Warning },
|
||||
new() { PercentOfThreshold = 90, Severity = NotificationSeverity.Critical }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit staleness metrics.
|
||||
/// </summary>
|
||||
public bool EmitMetrics { get; set; } = true;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (FreshnessThresholdSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("FreshnessThresholdSeconds must be greater than zero.");
|
||||
}
|
||||
|
||||
if (GracePeriodSeconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("GracePeriodSeconds must be non-negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness enforcement mode.
|
||||
/// </summary>
|
||||
public enum StalenessEnforcementMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Block exports when stale.
|
||||
/// </summary>
|
||||
Strict,
|
||||
|
||||
/// <summary>
|
||||
/// Warn but allow exports when stale.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// No enforcement.
|
||||
/// </summary>
|
||||
Disabled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification threshold configuration.
|
||||
/// </summary>
|
||||
public sealed class NotificationThresholdConfig
|
||||
{
|
||||
public int PercentOfThreshold { get; set; }
|
||||
public NotificationSeverity Severity { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification severity levels.
|
||||
/// </summary>
|
||||
public enum NotificationSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for emitting timeline events for bundle import impacts.
|
||||
/// </summary>
|
||||
public sealed class AirgapTimelineService
|
||||
{
|
||||
private readonly ILedgerEventRepository _ledgerEventRepository;
|
||||
private readonly ILedgerEventWriteService _writeService;
|
||||
private readonly IFindingProjectionRepository _projectionRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AirgapTimelineService> _logger;
|
||||
|
||||
public AirgapTimelineService(
|
||||
ILedgerEventRepository ledgerEventRepository,
|
||||
ILedgerEventWriteService writeService,
|
||||
IFindingProjectionRepository projectionRepository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AirgapTimelineService> logger)
|
||||
{
|
||||
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
|
||||
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
|
||||
_projectionRepository = projectionRepository ?? throw new ArgumentNullException(nameof(projectionRepository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates and emits a timeline event for bundle import impact.
|
||||
/// </summary>
|
||||
public async Task<AirgapTimelineImpactResult> EmitImpactAsync(
|
||||
AirgapTimelineImpactInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.BundleId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Calculate impact by comparing findings before and after bundle time anchor
|
||||
var impact = await CalculateImpactAsync(input, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create ledger event for the timeline impact
|
||||
var chainId = LedgerChainIdGenerator.FromTenantSubject(input.TenantId, $"timeline::{input.BundleId}");
|
||||
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(input.TenantId, chainId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
|
||||
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["airgapImpact"] = new JsonObject
|
||||
{
|
||||
["bundleId"] = input.BundleId,
|
||||
["newFindings"] = impact.NewFindings,
|
||||
["resolvedFindings"] = impact.ResolvedFindings,
|
||||
["criticalDelta"] = impact.CriticalDelta,
|
||||
["highDelta"] = impact.HighDelta,
|
||||
["mediumDelta"] = impact.MediumDelta,
|
||||
["lowDelta"] = impact.LowDelta,
|
||||
["timeAnchor"] = input.TimeAnchor.ToString("O"),
|
||||
["sealedMode"] = input.SealedMode
|
||||
}
|
||||
};
|
||||
|
||||
var envelope = new JsonObject
|
||||
{
|
||||
["event"] = new JsonObject
|
||||
{
|
||||
["id"] = eventId.ToString(),
|
||||
["type"] = LedgerEventConstants.EventAirgapTimelineImpact,
|
||||
["tenant"] = input.TenantId,
|
||||
["chainId"] = chainId.ToString(),
|
||||
["sequence"] = sequence,
|
||||
["policyVersion"] = "airgap-timeline",
|
||||
["artifactId"] = input.BundleId,
|
||||
["finding"] = new JsonObject
|
||||
{
|
||||
["id"] = input.BundleId,
|
||||
["artifactId"] = input.BundleId,
|
||||
["vulnId"] = "timeline-impact"
|
||||
},
|
||||
["actor"] = new JsonObject
|
||||
{
|
||||
["id"] = "timeline-service",
|
||||
["type"] = "system"
|
||||
},
|
||||
["occurredAt"] = FormatTimestamp(input.TimeAnchor),
|
||||
["recordedAt"] = FormatTimestamp(now),
|
||||
["payload"] = payload.DeepClone()
|
||||
}
|
||||
};
|
||||
|
||||
var draft = new LedgerEventDraft(
|
||||
input.TenantId,
|
||||
chainId,
|
||||
sequence,
|
||||
eventId,
|
||||
LedgerEventConstants.EventAirgapTimelineImpact,
|
||||
"airgap-timeline",
|
||||
input.BundleId,
|
||||
input.BundleId,
|
||||
SourceRunId: null,
|
||||
ActorId: "timeline-service",
|
||||
ActorType: "system",
|
||||
OccurredAt: input.TimeAnchor,
|
||||
RecordedAt: now,
|
||||
Payload: payload,
|
||||
CanonicalEnvelope: envelope,
|
||||
ProvidedPreviousHash: previousHash);
|
||||
|
||||
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
||||
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
|
||||
{
|
||||
var error = string.Join(";", writeResult.Errors);
|
||||
return new AirgapTimelineImpactResult(false, null, null, error);
|
||||
}
|
||||
|
||||
var ledgerEventId = writeResult.Record?.EventId;
|
||||
var finalImpact = impact with { LedgerEventId = ledgerEventId };
|
||||
|
||||
// Emit structured log for Console/Notify subscribers
|
||||
LedgerTimeline.EmitAirgapTimelineImpact(
|
||||
_logger,
|
||||
input.TenantId,
|
||||
input.BundleId,
|
||||
impact.NewFindings,
|
||||
impact.ResolvedFindings,
|
||||
impact.CriticalDelta,
|
||||
input.TimeAnchor,
|
||||
input.SealedMode);
|
||||
|
||||
return new AirgapTimelineImpactResult(true, finalImpact, ledgerEventId, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the impact of a bundle import on findings.
|
||||
/// </summary>
|
||||
private async Task<AirgapTimelineImpact> CalculateImpactAsync(
|
||||
AirgapTimelineImpactInput input,
|
||||
DateTimeOffset calculatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Query projection repository for finding changes since last import
|
||||
// For now, we calculate based on current projections updated since the bundle time anchor
|
||||
var stats = await _projectionRepository.GetFindingStatsSinceAsync(
|
||||
input.TenantId,
|
||||
input.TimeAnchor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AirgapTimelineImpact(
|
||||
input.TenantId,
|
||||
input.BundleId,
|
||||
NewFindings: stats.NewFindings,
|
||||
ResolvedFindings: stats.ResolvedFindings,
|
||||
CriticalDelta: stats.CriticalDelta,
|
||||
HighDelta: stats.HighDelta,
|
||||
MediumDelta: stats.MediumDelta,
|
||||
LowDelta: stats.LowDelta,
|
||||
TimeAnchor: input.TimeAnchor,
|
||||
SealedMode: input.SealedMode,
|
||||
CalculatedAt: calculatedAt,
|
||||
LedgerEventId: null);
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for linking findings evidence to portable bundles.
|
||||
/// </summary>
|
||||
public sealed class EvidenceSnapshotService
|
||||
{
|
||||
private readonly ILedgerEventRepository _ledgerEventRepository;
|
||||
private readonly ILedgerEventWriteService _writeService;
|
||||
private readonly IEvidenceSnapshotRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<EvidenceSnapshotService> _logger;
|
||||
|
||||
public EvidenceSnapshotService(
|
||||
ILedgerEventRepository ledgerEventRepository,
|
||||
ILedgerEventWriteService writeService,
|
||||
IEvidenceSnapshotRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidenceSnapshotService> logger)
|
||||
{
|
||||
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
|
||||
_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>
|
||||
/// Links a finding to an evidence snapshot in a portable bundle.
|
||||
/// </summary>
|
||||
public async Task<EvidenceSnapshotLinkResult> LinkAsync(
|
||||
EvidenceSnapshotLinkInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.FindingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.BundleUri);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.DsseDigest);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = input.ValidFor.HasValue ? now.Add(input.ValidFor.Value) : (DateTimeOffset?)null;
|
||||
|
||||
// Check if already linked (idempotency)
|
||||
var exists = await _repository.ExistsValidAsync(
|
||||
input.TenantId,
|
||||
input.FindingId,
|
||||
input.DsseDigest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Evidence snapshot already linked for finding {FindingId} with digest {DsseDigest}",
|
||||
input.FindingId, input.DsseDigest);
|
||||
return new EvidenceSnapshotLinkResult(true, null, null);
|
||||
}
|
||||
|
||||
// Create ledger event for the linkage
|
||||
var chainId = LedgerChainIdGenerator.FromTenantSubject(input.TenantId, $"evidence::{input.FindingId}");
|
||||
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(input.TenantId, chainId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
|
||||
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["airgap"] = new JsonObject
|
||||
{
|
||||
["evidenceSnapshot"] = new JsonObject
|
||||
{
|
||||
["bundleUri"] = input.BundleUri,
|
||||
["dsseDigest"] = input.DsseDigest,
|
||||
["expiresAt"] = expiresAt?.ToString("O")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var envelope = new JsonObject
|
||||
{
|
||||
["event"] = new JsonObject
|
||||
{
|
||||
["id"] = eventId.ToString(),
|
||||
["type"] = LedgerEventConstants.EventEvidenceSnapshotLinked,
|
||||
["tenant"] = input.TenantId,
|
||||
["chainId"] = chainId.ToString(),
|
||||
["sequence"] = sequence,
|
||||
["policyVersion"] = "evidence-snapshot",
|
||||
["artifactId"] = input.FindingId,
|
||||
["finding"] = new JsonObject
|
||||
{
|
||||
["id"] = input.FindingId,
|
||||
["artifactId"] = input.FindingId,
|
||||
["vulnId"] = "evidence-snapshot"
|
||||
},
|
||||
["actor"] = new JsonObject
|
||||
{
|
||||
["id"] = "evidence-linker",
|
||||
["type"] = "system"
|
||||
},
|
||||
["occurredAt"] = FormatTimestamp(now),
|
||||
["recordedAt"] = FormatTimestamp(now),
|
||||
["payload"] = payload.DeepClone()
|
||||
}
|
||||
};
|
||||
|
||||
var draft = new LedgerEventDraft(
|
||||
input.TenantId,
|
||||
chainId,
|
||||
sequence,
|
||||
eventId,
|
||||
LedgerEventConstants.EventEvidenceSnapshotLinked,
|
||||
"evidence-snapshot",
|
||||
input.FindingId,
|
||||
input.FindingId,
|
||||
SourceRunId: null,
|
||||
ActorId: "evidence-linker",
|
||||
ActorType: "system",
|
||||
OccurredAt: now,
|
||||
RecordedAt: now,
|
||||
Payload: payload,
|
||||
CanonicalEnvelope: envelope,
|
||||
ProvidedPreviousHash: previousHash);
|
||||
|
||||
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
||||
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
|
||||
{
|
||||
var error = string.Join(";", writeResult.Errors);
|
||||
return new EvidenceSnapshotLinkResult(false, null, error);
|
||||
}
|
||||
|
||||
var ledgerEventId = writeResult.Record?.EventId;
|
||||
|
||||
var record = new EvidenceSnapshotRecord(
|
||||
input.TenantId,
|
||||
input.FindingId,
|
||||
input.BundleUri,
|
||||
input.DsseDigest,
|
||||
now,
|
||||
expiresAt,
|
||||
ledgerEventId);
|
||||
|
||||
await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
LedgerTimeline.EmitEvidenceSnapshotLinked(_logger, input.TenantId, input.FindingId, input.BundleUri, input.DsseDigest);
|
||||
|
||||
return new EvidenceSnapshotLinkResult(true, ledgerEventId, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence snapshots for a finding.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<EvidenceSnapshotRecord>> GetSnapshotsAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
return await _repository.GetByFindingIdAsync(tenantId, findingId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an evidence snapshot exists and is valid for cross-enclave verification.
|
||||
/// </summary>
|
||||
public async Task<bool> VerifyCrossEnclaveAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string expectedDsseDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expectedDsseDigest);
|
||||
|
||||
var snapshot = await _repository.GetLatestByFindingIdAsync(tenantId, findingId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (snapshot is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No evidence snapshot found for finding {FindingId}",
|
||||
findingId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (snapshot.ExpiresAt.HasValue && snapshot.ExpiresAt.Value < _timeProvider.GetUtcNow())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Evidence snapshot for finding {FindingId} has expired at {ExpiresAt}",
|
||||
findingId, snapshot.ExpiresAt);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify DSSE digest matches
|
||||
if (!string.Equals(snapshot.DsseDigest, expectedDsseDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Evidence snapshot DSSE digest mismatch for finding {FindingId}: expected {Expected}, got {Actual}",
|
||||
findingId, expectedDsseDigest, snapshot.DsseDigest);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating staleness and enforcing freshness thresholds.
|
||||
/// </summary>
|
||||
public sealed class StalenessValidationService
|
||||
{
|
||||
private readonly IAirgapImportRepository _importRepository;
|
||||
private readonly AirGapOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<StalenessValidationService> _logger;
|
||||
|
||||
public StalenessValidationService(
|
||||
IAirgapImportRepository importRepository,
|
||||
IOptions<AirGapOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StalenessValidationService> logger)
|
||||
{
|
||||
_importRepository = importRepository ?? throw new ArgumentNullException(nameof(importRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates staleness for a specific domain before allowing an export.
|
||||
/// </summary>
|
||||
public async Task<StalenessValidationResult> ValidateForExportAsync(
|
||||
string tenantId,
|
||||
string domainId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
|
||||
|
||||
// Check if domain is exempt
|
||||
if (_options.AllowedDomains.Contains(domainId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreatePassedResult(domainId, 0);
|
||||
}
|
||||
|
||||
var latestImport = await _importRepository.GetLatestByDomainAsync(tenantId, domainId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (latestImport is null)
|
||||
{
|
||||
return CreateNoBundleError(domainId);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var stalenessSeconds = (long)(now - latestImport.TimeAnchor).TotalSeconds;
|
||||
|
||||
return Validate(domainId, stalenessSeconds, latestImport.TimeAnchor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates staleness using an explicit staleness value.
|
||||
/// </summary>
|
||||
public StalenessValidationResult Validate(
|
||||
string? domainId,
|
||||
long stalenessSeconds,
|
||||
DateTimeOffset? timeAnchor = null)
|
||||
{
|
||||
var warnings = new List<StalenessWarning>();
|
||||
var thresholdSeconds = _options.FreshnessThresholdSeconds;
|
||||
var percentOfThreshold = (double)stalenessSeconds / thresholdSeconds * 100.0;
|
||||
|
||||
// Check notification thresholds for warnings
|
||||
foreach (var threshold in _options.NotificationThresholds.OrderBy(t => t.PercentOfThreshold))
|
||||
{
|
||||
if (percentOfThreshold >= threshold.PercentOfThreshold && percentOfThreshold < 100)
|
||||
{
|
||||
var projectedStaleAt = timeAnchor?.AddSeconds(thresholdSeconds);
|
||||
warnings.Add(new StalenessWarning(
|
||||
StalenessWarningCode.WarnAirgapApproachingStale,
|
||||
$"Data is {percentOfThreshold:F1}% of staleness threshold ({threshold.Severity})",
|
||||
percentOfThreshold,
|
||||
projectedStaleAt));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if stale
|
||||
if (stalenessSeconds > thresholdSeconds)
|
||||
{
|
||||
var actualThresholdWithGrace = thresholdSeconds + _options.GracePeriodSeconds;
|
||||
var isInGracePeriod = stalenessSeconds <= actualThresholdWithGrace;
|
||||
|
||||
if (_options.EnforcementMode == StalenessEnforcementMode.Disabled)
|
||||
{
|
||||
return CreatePassedResult(domainId, stalenessSeconds, warnings);
|
||||
}
|
||||
|
||||
if (_options.EnforcementMode == StalenessEnforcementMode.Warn || isInGracePeriod)
|
||||
{
|
||||
warnings.Add(new StalenessWarning(
|
||||
StalenessWarningCode.WarnAirgapBundleOld,
|
||||
$"Data is stale ({stalenessSeconds / 86400.0:F1} days old, threshold {thresholdSeconds / 86400.0:F0} days)",
|
||||
percentOfThreshold,
|
||||
null));
|
||||
|
||||
// Emit metric
|
||||
if (_options.EmitMetrics)
|
||||
{
|
||||
LedgerMetrics.RecordAirgapStaleness(domainId, stalenessSeconds);
|
||||
}
|
||||
|
||||
return CreatePassedResult(domainId, stalenessSeconds, warnings);
|
||||
}
|
||||
|
||||
// Strict enforcement - block the export
|
||||
var error = new StalenessError(
|
||||
StalenessErrorCode.ErrAirgapStale,
|
||||
$"Data is stale ({stalenessSeconds / 86400.0:F1} days old, threshold {thresholdSeconds / 86400.0:F0} days)",
|
||||
domainId,
|
||||
stalenessSeconds,
|
||||
thresholdSeconds,
|
||||
$"Import a fresh bundle from upstream using 'stella airgap import --domain {domainId}'");
|
||||
|
||||
_logger.LogWarning(
|
||||
"Staleness validation failed for domain {DomainId}: {StalenessSeconds}s > {ThresholdSeconds}s",
|
||||
domainId, stalenessSeconds, thresholdSeconds);
|
||||
|
||||
// Emit metric
|
||||
if (_options.EmitMetrics)
|
||||
{
|
||||
LedgerMetrics.RecordAirgapStaleness(domainId, stalenessSeconds);
|
||||
LedgerMetrics.RecordStalenessValidationFailure(domainId);
|
||||
}
|
||||
|
||||
return new StalenessValidationResult(
|
||||
false,
|
||||
domainId,
|
||||
stalenessSeconds,
|
||||
thresholdSeconds,
|
||||
_options.EnforcementMode,
|
||||
error,
|
||||
warnings);
|
||||
}
|
||||
|
||||
// Emit metric for healthy staleness
|
||||
if (_options.EmitMetrics)
|
||||
{
|
||||
LedgerMetrics.RecordAirgapStaleness(domainId, stalenessSeconds);
|
||||
}
|
||||
|
||||
return CreatePassedResult(domainId, stalenessSeconds, warnings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects staleness metrics for all domains in a tenant.
|
||||
/// </summary>
|
||||
public async Task<StalenessMetricsSnapshot> CollectMetricsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var thresholdSeconds = _options.FreshnessThresholdSeconds;
|
||||
|
||||
var imports = await _importRepository.GetAllLatestByDomainAsync(tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var domainMetrics = new List<DomainStalenessMetric>();
|
||||
var staleDomains = 0;
|
||||
var warningDomains = 0;
|
||||
var healthyDomains = 0;
|
||||
var totalStaleness = 0L;
|
||||
var maxStaleness = 0L;
|
||||
DateTimeOffset? oldestBundle = null;
|
||||
|
||||
foreach (var import in imports)
|
||||
{
|
||||
var stalenessSeconds = (long)(now - import.TimeAnchor).TotalSeconds;
|
||||
var percentOfThreshold = (double)stalenessSeconds / thresholdSeconds * 100.0;
|
||||
var isStale = stalenessSeconds > thresholdSeconds;
|
||||
var projectedStaleAt = import.TimeAnchor.AddSeconds(thresholdSeconds);
|
||||
|
||||
if (isStale)
|
||||
{
|
||||
staleDomains++;
|
||||
}
|
||||
else if (percentOfThreshold >= 75)
|
||||
{
|
||||
warningDomains++;
|
||||
}
|
||||
else
|
||||
{
|
||||
healthyDomains++;
|
||||
}
|
||||
|
||||
totalStaleness += stalenessSeconds;
|
||||
maxStaleness = Math.Max(maxStaleness, stalenessSeconds);
|
||||
|
||||
if (oldestBundle is null || import.TimeAnchor < oldestBundle)
|
||||
{
|
||||
oldestBundle = import.TimeAnchor;
|
||||
}
|
||||
|
||||
var bundleCount = await _importRepository.GetBundleCountByDomainAsync(tenantId, import.BundleId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
domainMetrics.Add(new DomainStalenessMetric(
|
||||
import.BundleId, // Using BundleId as domain since we don't have domain in the record
|
||||
stalenessSeconds,
|
||||
import.ImportedAt,
|
||||
import.TimeAnchor,
|
||||
bundleCount,
|
||||
isStale,
|
||||
percentOfThreshold,
|
||||
isStale ? null : projectedStaleAt));
|
||||
|
||||
// Emit per-domain metric
|
||||
if (_options.EmitMetrics)
|
||||
{
|
||||
LedgerMetrics.RecordAirgapStaleness(import.BundleId, stalenessSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
var totalDomains = domainMetrics.Count;
|
||||
var avgStaleness = totalDomains > 0 ? (double)totalStaleness / totalDomains : 0.0;
|
||||
|
||||
var aggregates = new AggregateStalenessMetrics(
|
||||
totalDomains,
|
||||
staleDomains,
|
||||
warningDomains,
|
||||
healthyDomains,
|
||||
maxStaleness,
|
||||
avgStaleness,
|
||||
oldestBundle);
|
||||
|
||||
return new StalenessMetricsSnapshot(now, tenantId, domainMetrics, aggregates);
|
||||
}
|
||||
|
||||
private StalenessValidationResult CreatePassedResult(
|
||||
string? domainId,
|
||||
long stalenessSeconds,
|
||||
IReadOnlyList<StalenessWarning>? warnings = null)
|
||||
{
|
||||
return new StalenessValidationResult(
|
||||
true,
|
||||
domainId,
|
||||
stalenessSeconds,
|
||||
_options.FreshnessThresholdSeconds,
|
||||
_options.EnforcementMode,
|
||||
null,
|
||||
warnings ?? Array.Empty<StalenessWarning>());
|
||||
}
|
||||
|
||||
private StalenessValidationResult CreateNoBundleError(string domainId)
|
||||
{
|
||||
var error = new StalenessError(
|
||||
StalenessErrorCode.ErrAirgapNoBundle,
|
||||
$"No bundle found for domain '{domainId}'",
|
||||
domainId,
|
||||
0,
|
||||
_options.FreshnessThresholdSeconds,
|
||||
$"Import a bundle using 'stella airgap import --domain {domainId}'");
|
||||
|
||||
return new StalenessValidationResult(
|
||||
false,
|
||||
domainId,
|
||||
0,
|
||||
_options.FreshnessThresholdSeconds,
|
||||
_options.EnforcementMode,
|
||||
error,
|
||||
Array.Empty<StalenessWarning>());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
| LEDGER-34-101 | DONE | Orchestrator export linkage | 2025-11-22 |
|
||||
| LEDGER-AIRGAP-56-001 | DONE | Mirror bundle provenance recording | 2025-11-22 |
|
||||
|
||||
Status changes must be mirrored in `docs/implplan/SPRINT_0120_0000_0001_policy_reasoning.md`.
|
||||
Status changes must be mirrored in `docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md`.
|
||||
|
||||
# Findings Ledger · Sprint 0121-0001-0001
|
||||
|
||||
|
||||
Reference in New Issue
Block a user