new two advisories and sprints work on them
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user