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