Implement remediation-aware health checks across all Doctor plugin modules (Agent, Attestor, Auth, BinaryAnalysis, Compliance, Crypto, Environment, EvidenceLocker, Notify, Observability, Operations, Policy, Postgres, Release, Scanner, Storage, Vex) and their backing library counterparts (AI, Attestation, Authority, Core, Cryptography, Database, Docker, Integration, Notify, Observability, Security, ServiceGraph, Sources, Verification). Each check now emits structured remediation metadata (severity, category, runbook links, and fix suggestions) consumed by the Doctor dashboard remediation panel. Also adds: - docs/doctor/articles/ knowledge base for check explanations - Advisory AI search seed and allowlist updates for doctor content - Sprint plan for doctor checks documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
11 KiB
C#
260 lines
11 KiB
C#
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Verifies offline attestation bundle is available and valid.
|
|
/// </summary>
|
|
public sealed class OfflineBundleCheck : AttestationCheckBase
|
|
{
|
|
private const int StalenessDaysWarn = 7;
|
|
private const int StalenessDaysFail = 30;
|
|
|
|
/// <inheritdoc />
|
|
public override string CheckId => "check.attestation.offline.bundle";
|
|
|
|
/// <inheritdoc />
|
|
public override string Name => "Offline Attestation Bundle";
|
|
|
|
/// <inheritdoc />
|
|
public override string Description => "Verifies offline attestation bundle is available and not stale";
|
|
|
|
/// <inheritdoc />
|
|
public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
|
|
|
/// <inheritdoc />
|
|
public override IReadOnlyList<string> Tags => ["attestation", "offline", "airgap"];
|
|
|
|
/// <inheritdoc />
|
|
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override Task<DoctorCheckResult> 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; }
|
|
}
|
|
}
|