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

- 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:
StellaOps Bot
2025-12-06 01:30:08 +02:00
parent 6c1177a6ce
commit 2eaf0f699b
144 changed files with 7578 additions and 2581 deletions

View File

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

View File

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

View File

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