using StellaOps.Doctor.Models; using StellaOps.Doctor.Plugins; using StellaOps.Doctor.Plugins.Attestation.Configuration; using StellaOps.Doctor.Plugins.Builders; using System.Text.Json; namespace StellaOps.Doctor.Plugins.Attestation.Checks; /// /// Verifies offline attestation bundle is available and valid. /// public sealed class OfflineBundleCheck : AttestationCheckBase { private const int StalenessDaysWarn = 7; private const int StalenessDaysFail = 30; /// public override string CheckId => "check.attestation.offline.bundle"; /// public override string Name => "Offline Attestation Bundle"; /// public override string Description => "Verifies offline attestation bundle is available and not stale"; /// public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn; /// public override IReadOnlyList Tags => ["attestation", "offline", "airgap"]; /// public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2); /// public override bool CanRun(DoctorPluginContext context) { if (!base.CanRun(context)) return false; var options = AttestationPlugin.GetOptions(context); // Only run if in offline or hybrid mode return options.Mode is AttestationMode.Offline or AttestationMode.Hybrid; } /// protected override Task ExecuteCheckAsync( DoctorPluginContext context, AttestationPluginOptions options, CheckResultBuilder result, CancellationToken ct) { if (string.IsNullOrEmpty(options.OfflineBundlePath)) { var severity = options.Mode == AttestationMode.Offline ? result.Fail("Offline bundle path not configured (required for offline mode)") : result.Warn("Offline bundle path not configured (recommended for hybrid mode)"); return Task.FromResult(severity .WithEvidence("Configuration", e => e .Add("Mode", options.Mode.ToString()) .Add("OfflineBundlePath", "(not set)") .Add("ConfigKey", "Doctor:Plugins:Attestation:OfflineBundlePath")) .WithCauses( "Offline bundle path not configured", "Environment variable not set") .WithRemediation(r => r .AddShellStep(1, "Export bundle from online system", "stella attestation bundle export --output /path/to/bundle.json") .AddManualStep(2, "Configure bundle path", "Set Doctor:Plugins:Attestation:OfflineBundlePath to the bundle location") .AddManualStep(3, "Transfer bundle", "Copy the bundle to the target system") .WithRunbookUrl("docs/doctor/articles/attestor/attestation-offline-bundle.md")) .WithVerification($"stella doctor --check {CheckId}") .Build()); } // Check if file exists if (!File.Exists(options.OfflineBundlePath)) { return Task.FromResult(result .Fail($"Offline bundle file not found: {options.OfflineBundlePath}") .WithEvidence("Bundle file", e => e .Add("BundlePath", options.OfflineBundlePath) .Add("FileExists", "false")) .WithCauses( "Bundle file was deleted or moved", "Path is incorrect", "File permissions prevent access") .WithRemediation(r => r .AddShellStep(1, "Check file existence", $"ls -la {options.OfflineBundlePath}") .AddShellStep(2, "Export new bundle", "stella attestation bundle export --output " + options.OfflineBundlePath) .AddManualStep(3, "Verify path", "Ensure the configured path is correct") .WithRunbookUrl("docs/doctor/articles/attestor/attestation-offline-bundle.md")) .WithVerification($"stella doctor --check {CheckId}") .Build()); } // Get file info var fileInfo = new FileInfo(options.OfflineBundlePath); // Try to parse bundle header to check format and timestamp BundleMetadata? metadata = null; string? parseError = null; try { using var stream = File.OpenRead(options.OfflineBundlePath); using var reader = new StreamReader(stream); // Read first few KB to parse header var buffer = new char[4096]; var charsRead = reader.Read(buffer, 0, buffer.Length); var content = new string(buffer, 0, charsRead); // Try to extract metadata from JSON metadata = TryParseBundleMetadata(content); } catch (JsonException ex) { parseError = $"Invalid JSON: {ex.Message}"; } catch (Exception ex) { parseError = ex.Message; } if (parseError is not null) { return Task.FromResult(result .Warn($"Offline bundle may be corrupt: {parseError}") .WithEvidence("Bundle file", e => e .Add("BundlePath", options.OfflineBundlePath) .Add("FileExists", "true") .Add("FileSize", FormatFileSize(fileInfo.Length)) .Add("ParseError", parseError)) .WithRemediation(r => r .AddShellStep(1, "Validate bundle", "stella attestation bundle validate " + options.OfflineBundlePath) .AddShellStep(2, "Export fresh bundle", "stella attestation bundle export --output " + options.OfflineBundlePath) .WithRunbookUrl("docs/doctor/articles/attestor/attestation-offline-bundle.md")) .WithVerification($"stella doctor --check {CheckId}") .Build()); } // Check staleness var bundleAge = context.TimeProvider.GetUtcNow() - (metadata?.ExportedAt ?? fileInfo.LastWriteTimeUtc); var ageDays = bundleAge.TotalDays; if (ageDays > StalenessDaysFail) { return Task.FromResult(result .Fail($"Offline bundle is {ageDays:F0} days old (maximum: {StalenessDaysFail} days)") .WithEvidence("Bundle staleness", e => { e.Add("BundlePath", options.OfflineBundlePath) .Add("FileSize", FormatFileSize(fileInfo.Length)) .Add("AgeDays", ageDays.ToString("F0")) .Add("WarnThresholdDays", StalenessDaysWarn.ToString()) .Add("FailThresholdDays", StalenessDaysFail.ToString()); if (metadata is not null) { e.Add("BundleVersion", metadata.Version ?? "(unknown)") .Add("ExportedAt", metadata.ExportedAt?.ToString("O") ?? "(unknown)"); } }) .WithCauses( "Bundle has not been refreshed recently", "Air-gap environment out of sync") .WithRemediation(r => r .AddShellStep(1, "Export fresh bundle from online system", "stella attestation bundle export --output /path/to/new-bundle.json") .AddManualStep(2, "Transfer to air-gap environment", "Copy the new bundle to the target system") .AddManualStep(3, "Update bundle path if needed", "Point configuration to the new bundle file") .WithRunbookUrl("docs/doctor/articles/attestor/attestation-offline-bundle.md")) .WithVerification($"stella doctor --check {CheckId}") .Build()); } if (ageDays > StalenessDaysWarn) { return Task.FromResult(result .Warn($"Offline bundle is {ageDays:F0} days old (threshold: {StalenessDaysWarn} days)") .WithEvidence("Bundle staleness", e => { e.Add("BundlePath", options.OfflineBundlePath) .Add("FileSize", FormatFileSize(fileInfo.Length)) .Add("AgeDays", ageDays.ToString("F0")) .Add("WarnThresholdDays", StalenessDaysWarn.ToString()); if (metadata is not null) { e.Add("BundleVersion", metadata.Version ?? "(unknown)") .Add("ExportedAt", metadata.ExportedAt?.ToString("O") ?? "(unknown)"); } }) .WithRemediation(r => r .AddShellStep(1, "Export fresh bundle", "stella attestation bundle export --output /path/to/new-bundle.json") .AddManualStep(2, "Schedule regular updates", "Consider automating bundle refresh") .WithRunbookUrl("docs/doctor/articles/attestor/attestation-offline-bundle.md")) .WithVerification($"stella doctor --check {CheckId}") .Build()); } return Task.FromResult(result .Pass($"Offline bundle available (age: {ageDays:F0} days)") .WithEvidence("Bundle info", e => { e.Add("BundlePath", options.OfflineBundlePath) .Add("FileSize", FormatFileSize(fileInfo.Length)) .Add("AgeDays", ageDays.ToString("F0")) .Add("WarnThresholdDays", StalenessDaysWarn.ToString()); if (metadata is not null) { e.Add("BundleVersion", metadata.Version ?? "(unknown)") .Add("ExportedAt", metadata.ExportedAt?.ToString("O") ?? "(unknown)"); } }) .Build()); } private static BundleMetadata? TryParseBundleMetadata(string content) { try { using var doc = JsonDocument.Parse(content); var root = doc.RootElement; return new BundleMetadata { Version = root.TryGetProperty("version", out var v) ? v.GetString() : null, ExportedAt = root.TryGetProperty("exportedAt", out var e) && e.TryGetDateTimeOffset(out var dt) ? dt : null }; } catch { return null; } } private static string FormatFileSize(long bytes) { return bytes switch { < 1024 => $"{bytes} B", < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", < 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB" }; } private sealed record BundleMetadata { public string? Version { get; init; } public DateTimeOffset? ExportedAt { get; init; } } }