new two advisories and sprints work on them
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Attestation.Configuration;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
|
||||
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"))
|
||||
.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"))
|
||||
.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))
|
||||
.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"))
|
||||
.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"))
|
||||
.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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user