new two advisories and sprints work on them

This commit is contained in:
master
2026-01-16 18:39:36 +02:00
parent 9daf619954
commit c3a6269d55
72 changed files with 15540 additions and 18 deletions

View File

@@ -0,0 +1,80 @@
using Microsoft.Extensions.Configuration;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.Attestation.Checks;
using StellaOps.Doctor.Plugins.Attestation.Configuration;
namespace StellaOps.Doctor.Plugins.Attestation;
/// <summary>
/// Attestation infrastructure diagnostic plugin providing Rekor, Cosign, and offline bundle health checks.
/// </summary>
public sealed class AttestationPlugin : IDoctorPlugin
{
/// <inheritdoc />
public string PluginId => "stellaops.doctor.attestation";
/// <inheritdoc />
public string DisplayName => "Attestation Infrastructure";
/// <inheritdoc />
public DoctorCategory Category => DoctorCategory.Security;
/// <inheritdoc />
public Version Version => new(1, 0, 0);
/// <inheritdoc />
public Version MinEngineVersion => new(1, 0, 0);
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services)
{
// Plugin is available if any attestation configuration exists
return true; // Checks will skip if not configured
}
/// <inheritdoc />
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
{
var options = GetOptions(context);
var checks = new List<IDoctorCheck>
{
new ClockSkewCheck()
};
// Add online checks if not in pure offline mode
if (options.Mode != AttestationMode.Offline)
{
checks.Add(new RekorConnectivityCheck());
checks.Add(new CosignKeyMaterialCheck());
}
// Add offline bundle check if offline or hybrid mode
if (options.Mode is AttestationMode.Offline or AttestationMode.Hybrid)
{
checks.Add(new OfflineBundleCheck());
}
return checks;
}
/// <inheritdoc />
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
{
return Task.CompletedTask;
}
internal static AttestationPluginOptions GetOptions(DoctorPluginContext context)
{
var options = new AttestationPluginOptions();
context.PluginConfig.Bind(options);
// Fall back to Sigstore configuration if plugin-specific config is not set
if (string.IsNullOrEmpty(options.RekorUrl))
{
options.RekorUrl = context.Configuration["Sigstore:RekorUrl"];
}
return options;
}
}

View File

@@ -0,0 +1,133 @@
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>
/// Base class for attestation checks providing common functionality.
/// </summary>
public abstract class AttestationCheckBase : IDoctorCheck
{
/// <summary>
/// Plugin identifier for attestation checks.
/// </summary>
protected const string PluginId = "stellaops.doctor.attestation";
/// <summary>
/// Category name for attestation checks.
/// </summary>
protected const string CategoryName = "Security";
/// <inheritdoc />
public abstract string CheckId { get; }
/// <inheritdoc />
public abstract string Name { get; }
/// <inheritdoc />
public abstract string Description { get; }
/// <inheritdoc />
public virtual DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
/// <inheritdoc />
public abstract IReadOnlyList<string> Tags { get; }
/// <inheritdoc />
public virtual TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public virtual bool CanRun(DoctorPluginContext context)
{
var options = AttestationPlugin.GetOptions(context);
return options.Enabled;
}
/// <inheritdoc />
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
{
var result = context.CreateResult(CheckId, PluginId, CategoryName);
var options = AttestationPlugin.GetOptions(context);
if (!options.Enabled)
{
return result
.Skip("Attestation plugin is disabled")
.WithEvidence("Configuration", e => e
.Add("Enabled", "false"))
.Build();
}
try
{
return await ExecuteCheckAsync(context, options, result, ct);
}
catch (HttpRequestException ex)
{
return result
.Fail($"Network error: {ex.Message}")
.WithEvidence("Error details", e => e
.Add("ExceptionType", ex.GetType().Name)
.Add("Message", ex.Message)
.Add("StatusCode", ex.StatusCode?.ToString() ?? "(none)"))
.WithCauses(
"Network connectivity issue",
"Endpoint unreachable or blocked by firewall",
"DNS resolution failure")
.WithRemediation(r => r
.AddShellStep(1, "Check network connectivity", "curl -I {ENDPOINT_URL}")
.AddShellStep(2, "Verify DNS resolution", "nslookup {HOSTNAME}")
.AddManualStep(3, "Check firewall rules", "Ensure HTTPS traffic is allowed to the endpoint"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (TaskCanceledException ex) when (ex.CancellationToken != ct)
{
return result
.Fail("Request timed out")
.WithEvidence("Error details", e => e
.Add("ExceptionType", "TimeoutException")
.Add("Message", "The request timed out before completing"))
.WithCauses(
"Endpoint is slow to respond",
"Network latency is high",
"Endpoint may be overloaded")
.WithRemediation(r => r
.AddManualStep(1, "Increase timeout", "Set Doctor:Plugins:Attestation:HttpTimeoutSeconds to a higher value")
.AddManualStep(2, "Check endpoint health", "Verify the endpoint is operational"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
catch (Exception ex)
{
return result
.Fail($"Unexpected error: {ex.Message}")
.WithEvidence("Error details", e => e
.Add("ExceptionType", ex.GetType().Name)
.Add("Message", ex.Message))
.Build();
}
}
/// <summary>
/// Executes the specific check logic.
/// </summary>
protected abstract Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
AttestationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct);
/// <summary>
/// Creates an HttpClient with configured timeout.
/// </summary>
protected static HttpClient CreateHttpClient(AttestationPluginOptions options)
{
return new HttpClient
{
Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds)
};
}
}

View File

@@ -0,0 +1,181 @@
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 system clock is within acceptable range for signature verification.
/// </summary>
public sealed class ClockSkewCheck : AttestationCheckBase
{
/// <inheritdoc />
public override string CheckId => "check.attestation.clock.skew";
/// <inheritdoc />
public override string Name => "Clock Skew Sanity";
/// <inheritdoc />
public override string Description => "Verifies system clock is synchronized within acceptable range for signature verification";
/// <inheritdoc />
public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["quick", "attestation", "security", "time"];
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
AttestationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
var localTime = context.TimeProvider.GetUtcNow();
TimeSpan? skew = null;
string? referenceSource = null;
DateTimeOffset? referenceTime = null;
// Try to get reference time from Rekor if available
if (options.Mode != AttestationMode.Offline && !string.IsNullOrEmpty(options.RekorUrl))
{
try
{
using var httpClient = CreateHttpClient(options);
var response = await httpClient.GetAsync($"{options.RekorUrl.TrimEnd('/')}/api/v1/log", ct);
if (response.IsSuccessStatusCode && response.Headers.Date.HasValue)
{
referenceTime = response.Headers.Date.Value;
skew = localTime - referenceTime.Value;
referenceSource = "Rekor server";
}
}
catch
{
// Rekor unavailable, try alternative methods
}
}
// Fall back to well-known time endpoint if Rekor failed
if (skew is null)
{
try
{
using var httpClient = CreateHttpClient(options);
var response = await httpClient.GetAsync("https://www.google.com/", ct);
if (response.Headers.Date.HasValue)
{
referenceTime = response.Headers.Date.Value;
skew = localTime - referenceTime.Value;
referenceSource = "HTTP Date header (google.com)";
}
}
catch
{
// Network unavailable
}
}
// If we couldn't get a reference time, check against a reasonable expectation
if (skew is null)
{
// In offline mode or network failure, we can only warn that we couldn't verify
return result
.Info("Clock skew could not be verified (no reference time source available)")
.WithEvidence("Time check", e => e
.Add("LocalTime", localTime.ToString("O"))
.Add("ReferenceSource", "(none)")
.Add("Mode", options.Mode.ToString())
.Add("Note", "Clock skew verification skipped - no network reference available"))
.WithRemediation(r => r
.AddShellStep(1, "Check system time", GetTimeCheckCommand())
.AddManualStep(2, "Configure NTP", "Ensure NTP is configured for time synchronization"))
.Build();
}
var skewSeconds = Math.Abs(skew.Value.TotalSeconds);
// Evaluate against thresholds
if (skewSeconds > options.ClockSkewFailThresholdSeconds)
{
return result
.Fail($"System clock is off by {skewSeconds:F1} seconds (threshold: {options.ClockSkewFailThresholdSeconds}s)")
.WithEvidence("Time comparison", e => e
.Add("LocalTime", localTime.ToString("O"))
.Add("ReferenceTime", referenceTime!.Value.ToString("O"))
.Add("ReferenceSource", referenceSource!)
.Add("SkewSeconds", skewSeconds.ToString("F1"))
.Add("WarnThreshold", options.ClockSkewWarnThresholdSeconds.ToString())
.Add("FailThreshold", options.ClockSkewFailThresholdSeconds.ToString()))
.WithCauses(
"System clock is not synchronized",
"NTP service is not running",
"NTP server is unreachable",
"Hardware clock is misconfigured")
.WithRemediation(r => r
.AddShellStep(1, "Check current time", GetTimeCheckCommand())
.AddShellStep(2, "Force NTP sync", GetNtpSyncCommand())
.AddManualStep(3, "Configure NTP", "Ensure NTP is properly configured and the NTP service is running"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
if (skewSeconds > options.ClockSkewWarnThresholdSeconds)
{
return result
.Warn($"System clock is off by {skewSeconds:F1} seconds (threshold: {options.ClockSkewWarnThresholdSeconds}s)")
.WithEvidence("Time comparison", e => e
.Add("LocalTime", localTime.ToString("O"))
.Add("ReferenceTime", referenceTime!.Value.ToString("O"))
.Add("ReferenceSource", referenceSource!)
.Add("SkewSeconds", skewSeconds.ToString("F1"))
.Add("WarnThreshold", options.ClockSkewWarnThresholdSeconds.ToString())
.Add("FailThreshold", options.ClockSkewFailThresholdSeconds.ToString()))
.WithCauses(
"NTP synchronization drift",
"Infrequent NTP sync interval")
.WithRemediation(r => r
.AddShellStep(1, "Check NTP status", GetNtpStatusCommand())
.AddShellStep(2, "Force NTP sync", GetNtpSyncCommand()))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
return result
.Pass($"System clock synchronized (skew: {skewSeconds:F1}s)")
.WithEvidence("Time comparison", e => e
.Add("LocalTime", localTime.ToString("O"))
.Add("ReferenceTime", referenceTime!.Value.ToString("O"))
.Add("ReferenceSource", referenceSource!)
.Add("SkewSeconds", skewSeconds.ToString("F1"))
.Add("WarnThreshold", options.ClockSkewWarnThresholdSeconds.ToString()))
.Build();
}
private static string GetTimeCheckCommand()
{
return OperatingSystem.IsWindows()
? "w32tm /query /status"
: "timedatectl status";
}
private static string GetNtpSyncCommand()
{
return OperatingSystem.IsWindows()
? "w32tm /resync"
: "sudo systemctl restart systemd-timesyncd || sudo ntpdate -u pool.ntp.org";
}
private static string GetNtpStatusCommand()
{
return OperatingSystem.IsWindows()
? "w32tm /query /peers"
: "timedatectl timesync-status || ntpq -p";
}
}

View File

@@ -0,0 +1,290 @@
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 that signing key material is available and accessible.
/// </summary>
public sealed class CosignKeyMaterialCheck : AttestationCheckBase
{
/// <inheritdoc />
public override string CheckId => "check.attestation.cosign.keymaterial";
/// <inheritdoc />
public override string Name => "Cosign Key Material Availability";
/// <inheritdoc />
public override string Description => "Verifies signing key material is present and accessible (file, KMS, or keyless)";
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["quick", "attestation", "cosign", "signing", "security"];
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(3);
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
if (!base.CanRun(context))
return false;
var options = AttestationPlugin.GetOptions(context);
// Skip if in pure offline mode (keys handled via bundle)
if (options.Mode == AttestationMode.Offline)
return false;
return true;
}
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
AttestationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
// Check for different signing modes
var sigstoreEnabled = context.Configuration.GetValue<bool>("Sigstore:Enabled");
var keyPath = context.Configuration["Sigstore:KeyPath"];
var keylessEnabled = context.Configuration.GetValue<bool>("Sigstore:Keyless:Enabled");
var kmsKeyRef = context.Configuration["Sigstore:KMS:KeyRef"];
// Determine signing mode
var signingMode = DetermineSigningMode(keyPath, keylessEnabled, kmsKeyRef);
return signingMode switch
{
SigningMode.None => await CheckNoSigningConfigured(result, sigstoreEnabled),
SigningMode.File => await CheckFileBasedKey(result, keyPath!, ct),
SigningMode.Keyless => await CheckKeylessMode(context, options, result, ct),
SigningMode.KMS => await CheckKmsMode(result, kmsKeyRef!),
_ => result.Fail("Unknown signing mode").Build()
};
}
private static SigningMode DetermineSigningMode(string? keyPath, bool keylessEnabled, string? kmsKeyRef)
{
if (!string.IsNullOrEmpty(kmsKeyRef))
return SigningMode.KMS;
if (keylessEnabled)
return SigningMode.Keyless;
if (!string.IsNullOrEmpty(keyPath))
return SigningMode.File;
return SigningMode.None;
}
private static Task<DoctorCheckResult> CheckNoSigningConfigured(CheckResultBuilder result, bool sigstoreEnabled)
{
if (!sigstoreEnabled)
{
return Task.FromResult(result
.Skip("Sigstore signing is not enabled")
.WithEvidence("Configuration", e => e
.Add("SigstoreEnabled", "false")
.Add("Note", "Enable Sigstore to use attestation signing"))
.WithRemediation(r => r
.AddManualStep(1, "Enable Sigstore", "Set Sigstore:Enabled to true in configuration")
.AddManualStep(2, "Configure signing mode", "Set either Sigstore:KeyPath, Sigstore:Keyless:Enabled, or Sigstore:KMS:KeyRef"))
.Build());
}
return Task.FromResult(result
.Fail("Sigstore enabled but no signing key configured")
.WithEvidence("Configuration", e => e
.Add("SigstoreEnabled", "true")
.Add("KeyPath", "(not set)")
.Add("KeylessEnabled", "false")
.Add("KMSKeyRef", "(not set)"))
.WithCauses(
"No signing key file path configured",
"Keyless signing not enabled",
"KMS key reference not configured")
.WithRemediation(r => r
.AddShellStep(1, "Generate a signing key pair", "cosign generate-key-pair")
.AddManualStep(2, "Configure key path", "Set Sigstore:KeyPath to the path of the private key")
.AddManualStep(3, "Or enable keyless", "Set Sigstore:Keyless:Enabled to true for OIDC-based signing")
.AddManualStep(4, "Or use KMS", "Set Sigstore:KMS:KeyRef to your KMS key reference"))
.WithVerification($"stella doctor --check check.attestation.cosign.keymaterial")
.Build());
}
private static Task<DoctorCheckResult> CheckFileBasedKey(CheckResultBuilder result, string keyPath, CancellationToken ct)
{
var fileExists = File.Exists(keyPath);
if (!fileExists)
{
return Task.FromResult(result
.Fail($"Signing key file not found: {keyPath}")
.WithEvidence("Key file", e => e
.Add("KeyPath", keyPath)
.Add("FileExists", "false"))
.WithCauses(
"Key file path is incorrect",
"Key file was deleted or moved",
"Key file permissions prevent access")
.WithRemediation(r => r
.AddShellStep(1, "Verify file exists", $"ls -la {keyPath}")
.AddShellStep(2, "Generate new key pair if needed", "cosign generate-key-pair")
.AddManualStep(3, "Update configuration", "Ensure Sigstore:KeyPath points to the correct file"))
.WithVerification($"stella doctor --check check.attestation.cosign.keymaterial")
.Build());
}
// Check file is readable (don't expose contents)
try
{
using var stream = File.OpenRead(keyPath);
var buffer = new byte[32];
var bytesRead = stream.Read(buffer, 0, buffer.Length);
// Check for PEM header
var header = System.Text.Encoding.ASCII.GetString(buffer, 0, bytesRead);
var isPem = header.StartsWith("-----BEGIN", StringComparison.Ordinal);
return Task.FromResult(result
.Pass("Signing key file found and readable")
.WithEvidence("Key file", e => e
.Add("KeyPath", keyPath)
.Add("FileExists", "true")
.Add("Readable", "true")
.Add("Format", isPem ? "PEM" : "Unknown"))
.Build());
}
catch (UnauthorizedAccessException)
{
return Task.FromResult(result
.Fail($"Signing key file not readable: {keyPath}")
.WithEvidence("Key file", e => e
.Add("KeyPath", keyPath)
.Add("FileExists", "true")
.Add("Readable", "false")
.Add("Error", "Permission denied"))
.WithCauses("File permissions prevent reading the key file")
.WithRemediation(r => r
.AddShellStep(1, "Check file permissions", $"ls -la {keyPath}")
.AddShellStep(2, "Fix permissions if needed", $"chmod 600 {keyPath}"))
.WithVerification($"stella doctor --check check.attestation.cosign.keymaterial")
.Build());
}
}
private static async Task<DoctorCheckResult> CheckKeylessMode(
DoctorPluginContext context,
AttestationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
var oidcIssuer = context.Configuration["Sigstore:Keyless:OIDCIssuer"]
?? context.Configuration["Sigstore:OidcIssuer"]
?? "https://oauth2.sigstore.dev/auth";
var fulcioUrl = context.Configuration["Sigstore:FulcioUrl"]
?? "https://fulcio.sigstore.dev";
// Check Fulcio endpoint reachability
using var httpClient = CreateHttpClient(options);
try
{
var fulcioApiUrl = $"{fulcioUrl.TrimEnd('/')}/api/v2/configuration";
var response = await httpClient.GetAsync(fulcioApiUrl, ct);
if (!response.IsSuccessStatusCode)
{
return result
.Fail($"Fulcio endpoint returned {(int)response.StatusCode}")
.WithEvidence("Keyless configuration", e => e
.Add("Mode", "Keyless")
.Add("OIDCIssuer", oidcIssuer)
.Add("FulcioUrl", fulcioUrl)
.Add("FulcioStatus", ((int)response.StatusCode).ToString()))
.WithCauses(
"Fulcio service is unavailable",
"Network connectivity issue",
"Fulcio URL is incorrect")
.WithRemediation(r => r
.AddShellStep(1, "Test Fulcio endpoint", $"curl -I {fulcioApiUrl}")
.AddManualStep(2, "Check service status", "Visit https://status.sigstore.dev"))
.WithVerification($"stella doctor --check check.attestation.cosign.keymaterial")
.Build();
}
return result
.Pass("Keyless signing configured and Fulcio reachable")
.WithEvidence("Keyless configuration", e => e
.Add("Mode", "Keyless")
.Add("OIDCIssuer", oidcIssuer)
.Add("FulcioUrl", fulcioUrl)
.Add("FulcioReachable", "true"))
.Build();
}
catch (HttpRequestException ex)
{
return result
.Fail($"Cannot reach Fulcio: {ex.Message}")
.WithEvidence("Keyless configuration", e => e
.Add("Mode", "Keyless")
.Add("OIDCIssuer", oidcIssuer)
.Add("FulcioUrl", fulcioUrl)
.Add("Error", ex.Message))
.WithCauses(
"Network connectivity issue",
"DNS resolution failure",
"Firewall blocking HTTPS traffic")
.WithRemediation(r => r
.AddShellStep(1, "Test connectivity", $"curl -I {fulcioUrl}")
.AddManualStep(2, "Check network configuration", "Ensure HTTPS traffic to Fulcio is allowed"))
.WithVerification($"stella doctor --check check.attestation.cosign.keymaterial")
.Build();
}
}
private static Task<DoctorCheckResult> CheckKmsMode(CheckResultBuilder result, string kmsKeyRef)
{
// Parse KMS reference to determine provider
var provider = DetermineKmsProvider(kmsKeyRef);
// Note: Actually validating KMS access would require the KMS SDK
// Here we just verify the reference format is valid
return Task.FromResult(result
.Pass($"KMS signing configured ({provider})")
.WithEvidence("KMS configuration", e => e
.Add("Mode", "KMS")
.Add("KeyRef", DoctorPluginContext.Redact(kmsKeyRef))
.Add("Provider", provider)
.Add("Note", "KMS connectivity not verified - requires runtime SDK"))
.Build());
}
private static string DetermineKmsProvider(string kmsKeyRef)
{
if (kmsKeyRef.StartsWith("awskms://", StringComparison.OrdinalIgnoreCase))
return "AWS KMS";
if (kmsKeyRef.StartsWith("gcpkms://", StringComparison.OrdinalIgnoreCase))
return "GCP KMS";
if (kmsKeyRef.StartsWith("azurekms://", StringComparison.OrdinalIgnoreCase) ||
kmsKeyRef.StartsWith("azurekeyvault://", StringComparison.OrdinalIgnoreCase))
return "Azure Key Vault";
if (kmsKeyRef.StartsWith("hashivault://", StringComparison.OrdinalIgnoreCase))
return "HashiCorp Vault";
if (kmsKeyRef.StartsWith("pkcs11://", StringComparison.OrdinalIgnoreCase))
return "PKCS#11 HSM";
return "Unknown KMS";
}
private enum SigningMode
{
None,
File,
Keyless,
KMS
}
}

View File

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

View File

@@ -0,0 +1,138 @@
using System.Net.Http.Json;
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 connectivity to the Rekor transparency log endpoint.
/// </summary>
public sealed class RekorConnectivityCheck : AttestationCheckBase
{
/// <inheritdoc />
public override string CheckId => "check.attestation.rekor.connectivity";
/// <inheritdoc />
public override string Name => "Rekor Transparency Log Connectivity";
/// <inheritdoc />
public override string Description => "Verifies the Rekor transparency log endpoint is reachable and operational";
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["quick", "attestation", "rekor", "connectivity", "sigstore"];
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
if (!base.CanRun(context))
return false;
var options = AttestationPlugin.GetOptions(context);
// Skip if in pure offline mode
if (options.Mode == AttestationMode.Offline)
return false;
// Need a Rekor URL to check
return !string.IsNullOrEmpty(options.RekorUrl);
}
/// <inheritdoc />
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
AttestationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
if (string.IsNullOrEmpty(options.RekorUrl))
{
return result
.Skip("Rekor URL not configured")
.WithEvidence("Configuration", e => e
.Add("RekorUrl", "(not set)")
.Add("ConfigKey", "Doctor:Plugins:Attestation:RekorUrl or Sigstore:RekorUrl"))
.WithRemediation(r => r
.AddManualStep(1, "Configure Rekor URL", "Set the Rekor URL in configuration: STELLA_REKOR_URL=https://rekor.sigstore.dev")
.AddManualStep(2, "Or use offline mode", "Set Doctor:Plugins:Attestation:Mode to 'offline' and configure OfflineBundlePath"))
.Build();
}
using var httpClient = CreateHttpClient(options);
// Query Rekor log info endpoint
var logInfoUrl = $"{options.RekorUrl.TrimEnd('/')}/api/v1/log";
var response = await httpClient.GetAsync(logInfoUrl, ct);
if (!response.IsSuccessStatusCode)
{
return result
.Fail($"Rekor endpoint returned {(int)response.StatusCode} {response.ReasonPhrase}")
.WithEvidence("Response", e => e
.Add("RekorUrl", options.RekorUrl)
.Add("Endpoint", logInfoUrl)
.Add("StatusCode", ((int)response.StatusCode).ToString())
.Add("ReasonPhrase", response.ReasonPhrase ?? "(none)"))
.WithCauses(
"Rekor service is unavailable",
"URL is incorrect or outdated",
"Authentication required but not provided")
.WithRemediation(r => r
.AddShellStep(1, "Test endpoint manually", $"curl -I {logInfoUrl}")
.AddManualStep(2, "Verify Rekor URL", "Ensure the URL is correct (default: https://rekor.sigstore.dev)")
.AddManualStep(3, "Check service status", "Visit https://status.sigstore.dev for public Rekor status"))
.WithVerification($"stella doctor --check {CheckId}")
.Build();
}
// Parse log info to extract tree size and root hash
var logInfo = await response.Content.ReadFromJsonAsync<RekorLogInfo>(ct);
if (logInfo is null)
{
return result
.Warn("Rekor endpoint reachable but response could not be parsed")
.WithEvidence("Response", e => e
.Add("RekorUrl", options.RekorUrl)
.Add("Endpoint", logInfoUrl)
.Add("StatusCode", "200")
.Add("ParseError", "Response JSON could not be deserialized"))
.Build();
}
// Extract server time from response headers for clock skew check
string? serverTime = null;
if (response.Headers.Date.HasValue)
{
serverTime = response.Headers.Date.Value.UtcDateTime.ToString("O");
}
return result
.Pass($"Rekor transparency log operational (tree size: {logInfo.TreeSize:N0})")
.WithEvidence("Log info", e =>
{
e.Add("RekorUrl", options.RekorUrl)
.Add("TreeSize", logInfo.TreeSize.ToString())
.Add("RootHash", logInfo.RootHash ?? "(not provided)");
if (serverTime is not null)
e.Add("ServerTime", serverTime);
})
.Build();
}
/// <summary>
/// Rekor log info response model.
/// </summary>
private sealed record RekorLogInfo
{
public long TreeSize { get; init; }
public string? RootHash { get; init; }
public long TreeId { get; init; }
}
}

View File

@@ -0,0 +1,73 @@
namespace StellaOps.Doctor.Plugins.Attestation.Configuration;
/// <summary>
/// Configuration options for the Attestation diagnostic plugin.
/// </summary>
public sealed class AttestationPluginOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Doctor:Plugins:Attestation";
/// <summary>
/// Whether the attestation plugin is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Attestation mode: online, offline, or hybrid.
/// </summary>
public AttestationMode Mode { get; set; } = AttestationMode.Online;
/// <summary>
/// Rekor transparency log URL.
/// </summary>
public string? RekorUrl { get; set; }
/// <summary>
/// Local Rekor mirror URL for air-gap deployments.
/// </summary>
public string? RekorMirrorUrl { get; set; }
/// <summary>
/// Path to offline attestation bundle.
/// </summary>
public string? OfflineBundlePath { get; set; }
/// <summary>
/// Clock skew threshold in seconds for warning level.
/// </summary>
public int ClockSkewWarnThresholdSeconds { get; set; } = 5;
/// <summary>
/// Clock skew threshold in seconds for failure level.
/// </summary>
public int ClockSkewFailThresholdSeconds { get; set; } = 30;
/// <summary>
/// HTTP timeout for connectivity checks in seconds.
/// </summary>
public int HttpTimeoutSeconds { get; set; } = 10;
}
/// <summary>
/// Attestation operation mode.
/// </summary>
public enum AttestationMode
{
/// <summary>
/// All operations use network endpoints (Rekor, Fulcio).
/// </summary>
Online,
/// <summary>
/// All operations use local offline bundles.
/// </summary>
Offline,
/// <summary>
/// Try online first, fall back to offline if unavailable.
/// </summary>
Hybrid
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Doctor.Plugins;
namespace StellaOps.Doctor.Plugins.Attestation.DependencyInjection;
/// <summary>
/// Extension methods for registering the Attestation plugin.
/// </summary>
public static class AttestationPluginExtensions
{
/// <summary>
/// Adds the Attestation diagnostic plugin to the Doctor service.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDoctorAttestationPlugin(this IServiceCollection services)
{
services.AddSingleton<IDoctorPlugin, AttestationPlugin>();
return services;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Doctor.Plugins.Attestation</RootNamespace>
<Description>Attestation infrastructure diagnostic checks for Stella Ops Doctor (Rekor, Cosign, offline bundles)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Doctor\StellaOps.Doctor.csproj" />
</ItemGroup>
</Project>