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>
|
||||
@@ -0,0 +1,217 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
using StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies policy engine evaluation for test artifact.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineCheck : VerificationCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.verification.policy.engine";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Policy Engine Evaluation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Runs policy engine against test artifact to verify 'no-go if critical vulns without VEX justification'";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["verification", "policy", "security", "compliance"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
if (!base.CanRun(context))
|
||||
return false;
|
||||
|
||||
var options = VerificationPlugin.GetOptions(context);
|
||||
return HasTestArtifactConfigured(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!HasTestArtifactConfigured(options))
|
||||
{
|
||||
return GetNoTestArtifactConfiguredResult(result, CheckId);
|
||||
}
|
||||
|
||||
// Check offline bundle for policy test data
|
||||
if (!string.IsNullOrEmpty(options.TestArtifact.OfflineBundlePath))
|
||||
{
|
||||
return await EvaluateFromOfflineBundle(options, result, ct);
|
||||
}
|
||||
|
||||
// Online policy evaluation
|
||||
return await EvaluateFromOnline(context, options, result, ct);
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> EvaluateFromOfflineBundle(
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var bundlePath = options.TestArtifact.OfflineBundlePath!;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Offline bundle not found: {bundlePath}")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("FileExists", "false"))
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Export bundle", "stella verification bundle export --include-policy --output " + bundlePath))
|
||||
.WithVerification($"stella doctor --check check.verification.policy.engine")
|
||||
.Build());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(bundlePath);
|
||||
|
||||
// Check for policy evaluation results in bundle
|
||||
var hasPolicyResults = content.Contains("\"policyResult\"", StringComparison.OrdinalIgnoreCase)
|
||||
|| content.Contains("\"policyDecision\"", StringComparison.OrdinalIgnoreCase)
|
||||
|| content.Contains("\"decision\"", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!hasPolicyResults)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("No policy evaluation results in offline bundle")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("PolicyResultsFound", "false")
|
||||
.Add("Note", "Bundle should contain pre-computed policy results for offline verification"))
|
||||
.WithCauses(
|
||||
"Bundle was exported without policy results",
|
||||
"Policy evaluation not run before export")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Re-export with policy", "stella verification bundle export --include-policy --output " + bundlePath))
|
||||
.WithVerification($"stella doctor --check check.verification.policy.engine")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// Check expected outcome
|
||||
var expectedOutcome = options.PolicyTest.ExpectedOutcome.ToLowerInvariant();
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("Policy evaluation results present in offline bundle")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("PolicyResultsFound", "true")
|
||||
.Add("ExpectedOutcome", expectedOutcome)
|
||||
.Add("Note", "Full policy evaluation requires runtime policy engine"))
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Cannot read offline bundle: {ex.Message}")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("Error", ex.Message))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> EvaluateFromOnline(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var reference = options.TestArtifact.Reference!;
|
||||
|
||||
// Note: Full policy evaluation requires the Policy Engine service
|
||||
// For doctor check, we verify configuration is in place
|
||||
|
||||
var policyEngineEnabled = context.Configuration.GetValue<bool>("Policy:Engine:Enabled");
|
||||
var defaultPolicyRef = context.Configuration["Policy:DefaultPolicyRef"];
|
||||
var testPolicyRef = options.PolicyTest.PolicyRef ?? defaultPolicyRef;
|
||||
|
||||
if (!policyEngineEnabled)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail("Policy engine not enabled")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("PolicyEngineEnabled", "false")
|
||||
.Add("Note", "Policy engine is required for release verification"))
|
||||
.WithCauses("Policy engine not configured or disabled")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable policy engine", "Set Policy:Engine:Enabled to true")
|
||||
.AddManualStep(2, "Configure default policy", "Set Policy:DefaultPolicyRef to a policy reference"))
|
||||
.WithVerification($"stella doctor --check check.verification.policy.engine")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(testPolicyRef))
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("No policy reference configured for test")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("PolicyEngineEnabled", "true")
|
||||
.Add("PolicyRef", "(not set)")
|
||||
.Add("Note", "Configure a test policy for doctor verification"))
|
||||
.WithCauses("No test policy reference configured")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Configure test policy", "Set Doctor:Plugins:Verification:PolicyTest:PolicyRef")
|
||||
.AddManualStep(2, "Or set default", "Set Policy:DefaultPolicyRef for a default policy"))
|
||||
.WithVerification($"stella doctor --check check.verification.policy.engine")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// Check if VEX-aware policy is configured (key advisory requirement)
|
||||
var vexInPolicy = context.Configuration.GetValue<bool>("Policy:VexAware");
|
||||
|
||||
if (!vexInPolicy)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("Policy may not be VEX-aware")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("PolicyEngineEnabled", "true")
|
||||
.Add("PolicyRef", testPolicyRef)
|
||||
.Add("VexAwarePolicy", "false")
|
||||
.Add("Note", "Advisory requires 'no-go if critical vulns without VEX justification'"))
|
||||
.WithCauses("Policy may not consider VEX statements when evaluating vulnerabilities")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable VEX in policy", "Set Policy:VexAware to true")
|
||||
.AddManualStep(2, "Update policy rules", "Ensure policy considers VEX justifications for vulnerabilities"))
|
||||
.WithVerification($"stella doctor --check check.verification.policy.engine")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("Policy engine configured with VEX-aware evaluation")
|
||||
.WithEvidence("Policy evaluation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("PolicyEngineEnabled", "true")
|
||||
.Add("PolicyRef", testPolicyRef)
|
||||
.Add("VexAwarePolicy", "true")
|
||||
.Add("ExpectedOutcome", options.PolicyTest.ExpectedOutcome)
|
||||
.Add("Note", "Full policy evaluation requires runtime policy engine"))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
using StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies SBOM validation for test artifact.
|
||||
/// </summary>
|
||||
public sealed class SbomValidationCheck : VerificationCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.verification.sbom.validation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "SBOM Validation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Fetches and validates SBOM for test artifact (CycloneDX/SPDX)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["verification", "sbom", "cyclonedx", "spdx", "supply-chain"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
if (!base.CanRun(context))
|
||||
return false;
|
||||
|
||||
var options = VerificationPlugin.GetOptions(context);
|
||||
return HasTestArtifactConfigured(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!HasTestArtifactConfigured(options))
|
||||
{
|
||||
return GetNoTestArtifactConfiguredResult(result, CheckId);
|
||||
}
|
||||
|
||||
// Check offline bundle for SBOM
|
||||
if (!string.IsNullOrEmpty(options.TestArtifact.OfflineBundlePath))
|
||||
{
|
||||
return await ValidateFromOfflineBundle(options, result, ct);
|
||||
}
|
||||
|
||||
// Online SBOM validation
|
||||
return await ValidateFromOnline(context, options, result, ct);
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> ValidateFromOfflineBundle(
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var bundlePath = options.TestArtifact.OfflineBundlePath!;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Offline bundle not found: {bundlePath}")
|
||||
.WithEvidence("SBOM validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("FileExists", "false"))
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Export bundle", "stella verification bundle export --include-sbom --output " + bundlePath))
|
||||
.WithVerification($"stella doctor --check check.verification.sbom.validation")
|
||||
.Build());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(bundlePath);
|
||||
|
||||
// Detect SBOM format
|
||||
var (format, version, componentCount) = DetectSbomFormat(content);
|
||||
|
||||
if (format == SbomFormat.None)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail("No valid SBOM found in offline bundle")
|
||||
.WithEvidence("SBOM validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("SbomFound", "false"))
|
||||
.WithCauses(
|
||||
"Bundle was exported without SBOM",
|
||||
"Test artifact has no SBOM attached")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Re-export with SBOM", "stella verification bundle export --include-sbom --output " + bundlePath)
|
||||
.AddManualStep(2, "Generate SBOM", "Enable SBOM generation in your build pipeline"))
|
||||
.WithVerification($"stella doctor --check check.verification.sbom.validation")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"SBOM valid ({format} {version}, {componentCount} components)")
|
||||
.WithEvidence("SBOM validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("Format", format.ToString())
|
||||
.Add("Version", version ?? "(unknown)")
|
||||
.Add("ComponentCount", componentCount.ToString()))
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Cannot read offline bundle: {ex.Message}")
|
||||
.WithEvidence("SBOM validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("Error", ex.Message))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> ValidateFromOnline(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var reference = options.TestArtifact.Reference!;
|
||||
|
||||
// Note: Full SBOM validation requires the Scanner/Concelier service
|
||||
// For doctor check, we verify configuration is in place
|
||||
|
||||
var sbomGenerationEnabled = context.Configuration.GetValue<bool>("Scanner:SbomGeneration:Enabled");
|
||||
var sbomAttestationEnabled = context.Configuration.GetValue<bool>("Attestor:SbomAttestation:Enabled");
|
||||
|
||||
if (!sbomGenerationEnabled && !sbomAttestationEnabled)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("SBOM generation and attestation not enabled")
|
||||
.WithEvidence("SBOM validation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("SbomGenerationEnabled", sbomGenerationEnabled.ToString())
|
||||
.Add("SbomAttestationEnabled", sbomAttestationEnabled.ToString())
|
||||
.Add("Note", "Enable SBOM generation to attach SBOMs to artifacts"))
|
||||
.WithCauses(
|
||||
"SBOM generation not configured",
|
||||
"SBOM attestation not configured")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable SBOM generation", "Set Scanner:SbomGeneration:Enabled to true")
|
||||
.AddManualStep(2, "Enable SBOM attestation", "Set Attestor:SbomAttestation:Enabled to true"))
|
||||
.WithVerification($"stella doctor --check check.verification.sbom.validation")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("SBOM generation/attestation configured")
|
||||
.WithEvidence("SBOM validation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("SbomGenerationEnabled", sbomGenerationEnabled.ToString())
|
||||
.Add("SbomAttestationEnabled", sbomAttestationEnabled.ToString())
|
||||
.Add("Note", "Full SBOM validation requires runtime scanner service"))
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static (SbomFormat Format, string? Version, int ComponentCount) DetectSbomFormat(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check for CycloneDX
|
||||
if (root.TryGetProperty("bomFormat", out var bomFormat) &&
|
||||
bomFormat.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var version = root.TryGetProperty("specVersion", out var sv) ? sv.GetString() : null;
|
||||
var componentCount = root.TryGetProperty("components", out var c) && c.ValueKind == JsonValueKind.Array
|
||||
? c.GetArrayLength()
|
||||
: 0;
|
||||
return (SbomFormat.CycloneDX, version, componentCount);
|
||||
}
|
||||
|
||||
// Check for SPDX
|
||||
if (root.TryGetProperty("spdxVersion", out var spdxVersion))
|
||||
{
|
||||
var version = spdxVersion.GetString();
|
||||
var componentCount = root.TryGetProperty("packages", out var p) && p.ValueKind == JsonValueKind.Array
|
||||
? p.GetArrayLength()
|
||||
: 0;
|
||||
return (SbomFormat.SPDX, version, componentCount);
|
||||
}
|
||||
|
||||
// Check for embedded SBOM in bundle
|
||||
if (root.TryGetProperty("sbom", out var sbomElement))
|
||||
{
|
||||
var sbomContent = sbomElement.GetRawText();
|
||||
return DetectSbomFormat(sbomContent);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not valid JSON or parsing failed
|
||||
}
|
||||
|
||||
return (SbomFormat.None, null, 0);
|
||||
}
|
||||
|
||||
private enum SbomFormat
|
||||
{
|
||||
None,
|
||||
CycloneDX,
|
||||
SPDX
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
using StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies signature and attestations for test artifact.
|
||||
/// </summary>
|
||||
public sealed class SignatureVerificationCheck : VerificationCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.verification.signature";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Signature Verification";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies signature and attestations for test artifact (DSSE in Rekor or offline bundle)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["verification", "signature", "dsse", "attestation", "security"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
if (!base.CanRun(context))
|
||||
return false;
|
||||
|
||||
var options = VerificationPlugin.GetOptions(context);
|
||||
return HasTestArtifactConfigured(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!HasTestArtifactConfigured(options))
|
||||
{
|
||||
return GetNoTestArtifactConfiguredResult(result, CheckId);
|
||||
}
|
||||
|
||||
// Check for offline bundle
|
||||
if (!string.IsNullOrEmpty(options.TestArtifact.OfflineBundlePath))
|
||||
{
|
||||
return await VerifyFromOfflineBundle(options, result, ct);
|
||||
}
|
||||
|
||||
// Online verification
|
||||
return await VerifyFromOnline(context, options, result, ct);
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> VerifyFromOfflineBundle(
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var bundlePath = options.TestArtifact.OfflineBundlePath!;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Offline bundle not found: {bundlePath}")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("FileExists", "false"))
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Export bundle", "stella verification bundle export --output " + bundlePath))
|
||||
.WithVerification($"stella doctor --check check.verification.signature")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// In a real implementation, we would parse the bundle and verify signatures
|
||||
// For doctor check, we verify the bundle structure contains signature data
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(bundlePath);
|
||||
|
||||
// Check for signature indicators in the bundle
|
||||
var hasSignatures = content.Contains("\"signatures\"", StringComparison.OrdinalIgnoreCase)
|
||||
|| content.Contains("\"payloadType\"", StringComparison.OrdinalIgnoreCase)
|
||||
|| content.Contains("\"dsse\"", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!hasSignatures)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("Offline bundle may not contain signature data")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("SignatureDataFound", "false")
|
||||
.Add("Note", "Bundle should contain DSSE signatures for verification"))
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Re-export with signatures", "stella verification bundle export --include-signatures --output " + bundlePath))
|
||||
.WithVerification($"stella doctor --check check.verification.signature")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("Offline bundle contains signature data")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("SignatureDataFound", "true")
|
||||
.Add("Note", "Full signature verification requires runtime attestor service"))
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Cannot read offline bundle: {ex.Message}")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("Error", ex.Message))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<DoctorCheckResult> VerifyFromOnline(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var reference = options.TestArtifact.Reference!;
|
||||
var rekorUrl = context.Configuration["Sigstore:RekorUrl"] ?? "https://rekor.sigstore.dev";
|
||||
|
||||
// Note: Full signature verification requires the Attestor service
|
||||
// For doctor check, we verify that the infrastructure is in place
|
||||
|
||||
// Check if Sigstore is enabled
|
||||
var sigstoreEnabled = context.Configuration.GetValue<bool>("Sigstore:Enabled");
|
||||
|
||||
if (!sigstoreEnabled)
|
||||
{
|
||||
return result
|
||||
.Info("Signature verification skipped - Sigstore not enabled")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("SigstoreEnabled", "false")
|
||||
.Add("Reference", reference)
|
||||
.Add("Note", "Enable Sigstore to verify artifact signatures"))
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable Sigstore", "Set Sigstore:Enabled to true")
|
||||
.AddManualStep(2, "Configure signing", "Set up signing keys or keyless mode"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check if Rekor is reachable (signature verification requires Rekor)
|
||||
using var httpClient = CreateHttpClient(options);
|
||||
|
||||
try
|
||||
{
|
||||
var rekorHealthUrl = $"{rekorUrl.TrimEnd('/')}/api/v1/log";
|
||||
var response = await httpClient.GetAsync(rekorHealthUrl, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return result
|
||||
.Fail($"Rekor transparency log unavailable ({(int)response.StatusCode})")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("RekorUrl", rekorUrl)
|
||||
.Add("RekorStatus", ((int)response.StatusCode).ToString())
|
||||
.Add("Reference", reference))
|
||||
.WithCauses(
|
||||
"Rekor service is down",
|
||||
"Network connectivity issue")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Test Rekor", $"curl -I {rekorHealthUrl}")
|
||||
.AddManualStep(2, "Or use offline mode", "Configure offline verification bundle"))
|
||||
.WithVerification($"stella doctor --check check.verification.signature")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass("Signature verification infrastructure available")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("SigstoreEnabled", "true")
|
||||
.Add("RekorUrl", rekorUrl)
|
||||
.Add("RekorReachable", "true")
|
||||
.Add("Reference", reference)
|
||||
.Add("Note", "Full signature verification requires runtime attestor service"))
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return result
|
||||
.Fail($"Cannot reach Rekor: {ex.Message}")
|
||||
.WithEvidence("Verification", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("RekorUrl", rekorUrl)
|
||||
.Add("Error", ex.Message)
|
||||
.Add("Reference", reference))
|
||||
.WithCauses("Network connectivity issue")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check network", "Verify connectivity to Rekor")
|
||||
.AddManualStep(2, "Use offline mode", "Configure offline verification bundle"))
|
||||
.WithVerification($"stella doctor --check check.verification.signature")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
using StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies ability to pull a test artifact by digest.
|
||||
/// </summary>
|
||||
public sealed class TestArtifactPullCheck : VerificationCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.verification.artifact.pull";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Test Artifact Pull";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Verifies ability to pull a test artifact by digest from the configured registry";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["verification", "artifact", "registry", "connectivity"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
if (!base.CanRun(context))
|
||||
return false;
|
||||
|
||||
var options = VerificationPlugin.GetOptions(context);
|
||||
return HasTestArtifactConfigured(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!HasTestArtifactConfigured(options))
|
||||
{
|
||||
return GetNoTestArtifactConfiguredResult(result, CheckId);
|
||||
}
|
||||
|
||||
// Check offline bundle first if configured
|
||||
if (!string.IsNullOrEmpty(options.TestArtifact.OfflineBundlePath))
|
||||
{
|
||||
return await CheckOfflineBundle(options, result, ct);
|
||||
}
|
||||
|
||||
// Online artifact pull
|
||||
return await CheckOnlineArtifact(options, result, ct);
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> CheckOfflineBundle(
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var bundlePath = options.TestArtifact.OfflineBundlePath!;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Offline test artifact bundle not found: {bundlePath}")
|
||||
.WithEvidence("Bundle", e => e
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("FileExists", "false"))
|
||||
.WithCauses(
|
||||
"Bundle file was deleted or moved",
|
||||
"Path is incorrect")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Verify file exists", $"ls -la {bundlePath}")
|
||||
.AddShellStep(2, "Export bundle from online system", "stella verification bundle export --output " + bundlePath))
|
||||
.WithVerification($"stella doctor --check check.verification.artifact.pull")
|
||||
.Build());
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(bundlePath);
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"Offline test artifact bundle available ({FormatFileSize(fileInfo.Length)})")
|
||||
.WithEvidence("Bundle", e => e
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("FileSize", FormatFileSize(fileInfo.Length))
|
||||
.Add("Mode", "Offline"))
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static async Task<DoctorCheckResult> CheckOnlineArtifact(
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var reference = options.TestArtifact.Reference!;
|
||||
|
||||
// Parse OCI reference
|
||||
var (registry, repository, digest) = ParseOciReference(reference);
|
||||
|
||||
if (string.IsNullOrEmpty(registry) || string.IsNullOrEmpty(repository))
|
||||
{
|
||||
return result
|
||||
.Fail($"Invalid OCI reference: {reference}")
|
||||
.WithEvidence("Reference", e => e
|
||||
.Add("Reference", reference)
|
||||
.Add("Error", "Could not parse registry and repository"))
|
||||
.WithCauses("Reference format is incorrect")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Fix reference format", "Use format: oci://registry/repository@sha256:digest or registry/repository@sha256:digest"))
|
||||
.WithVerification($"stella doctor --check check.verification.artifact.pull")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check if we can resolve the manifest (metadata only, no full pull)
|
||||
using var httpClient = CreateHttpClient(options);
|
||||
|
||||
// Build registry API URL
|
||||
var manifestUrl = $"https://{registry}/v2/{repository}/manifests/{digest ?? "latest"}";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, manifestUrl);
|
||||
request.Headers.Add("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json");
|
||||
|
||||
var response = await httpClient.SendAsync(request, ct);
|
||||
sw.Stop();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return result
|
||||
.Fail($"Cannot access test artifact: {(int)response.StatusCode} {response.ReasonPhrase}")
|
||||
.WithEvidence("Artifact", e => e
|
||||
.Add("Reference", reference)
|
||||
.Add("Registry", registry)
|
||||
.Add("Repository", repository)
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString())
|
||||
.Add("ResponseTime", $"{sw.ElapsedMilliseconds}ms"))
|
||||
.WithCauses(
|
||||
"Artifact does not exist",
|
||||
"Authentication required",
|
||||
"Insufficient permissions")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Test with crane", $"crane manifest {reference}")
|
||||
.AddManualStep(2, "Check registry credentials", "Ensure registry credentials are configured")
|
||||
.AddManualStep(3, "Verify artifact exists", "Confirm the test artifact has been pushed to the registry"))
|
||||
.WithVerification($"stella doctor --check check.verification.artifact.pull")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Extract digest from response if available
|
||||
var responseDigest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
|
||||
? digestValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
// Verify digest matches expected if configured
|
||||
if (!string.IsNullOrEmpty(options.TestArtifact.ExpectedDigest)
|
||||
&& !string.IsNullOrEmpty(responseDigest)
|
||||
&& !responseDigest.Equals(options.TestArtifact.ExpectedDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return result
|
||||
.Warn("Test artifact digest mismatch")
|
||||
.WithEvidence("Artifact", e => e
|
||||
.Add("Reference", reference)
|
||||
.Add("ExpectedDigest", options.TestArtifact.ExpectedDigest)
|
||||
.Add("ActualDigest", responseDigest)
|
||||
.Add("ResponseTime", $"{sw.ElapsedMilliseconds}ms"))
|
||||
.WithCauses(
|
||||
"Test artifact was updated",
|
||||
"Wrong artifact tag being pulled")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Update expected digest", $"Set Doctor:Plugins:Verification:TestArtifact:ExpectedDigest to {responseDigest}")
|
||||
.AddManualStep(2, "Or use digest in reference", "Use @sha256:... in the reference instead of :tag"))
|
||||
.WithVerification($"stella doctor --check check.verification.artifact.pull")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return result
|
||||
.Pass($"Test artifact accessible ({sw.ElapsedMilliseconds}ms)")
|
||||
.WithEvidence("Artifact", e => e
|
||||
.Add("Reference", reference)
|
||||
.Add("Registry", registry)
|
||||
.Add("Repository", repository)
|
||||
.Add("Digest", responseDigest ?? "(not provided)")
|
||||
.Add("ResponseTime", $"{sw.ElapsedMilliseconds}ms"))
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return result
|
||||
.Fail($"Cannot reach registry: {ex.Message}")
|
||||
.WithEvidence("Artifact", e => e
|
||||
.Add("Reference", reference)
|
||||
.Add("Registry", registry)
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"Registry is unreachable",
|
||||
"Network connectivity issue",
|
||||
"DNS resolution failure")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Test registry connectivity", $"curl -I https://{registry}/v2/")
|
||||
.AddManualStep(2, "Check network configuration", "Ensure HTTPS traffic to the registry is allowed"))
|
||||
.WithVerification($"stella doctor --check check.verification.artifact.pull")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? Registry, string? Repository, string? Digest) ParseOciReference(string reference)
|
||||
{
|
||||
// Remove oci:// prefix if present
|
||||
var cleanRef = reference;
|
||||
if (cleanRef.StartsWith("oci://", StringComparison.OrdinalIgnoreCase))
|
||||
cleanRef = cleanRef[6..];
|
||||
|
||||
// Split by @ to get digest
|
||||
string? digest = null;
|
||||
var atIndex = cleanRef.IndexOf('@');
|
||||
if (atIndex > 0)
|
||||
{
|
||||
digest = cleanRef[(atIndex + 1)..];
|
||||
cleanRef = cleanRef[..atIndex];
|
||||
}
|
||||
|
||||
// Split by : to remove tag (we prefer digest)
|
||||
var colonIndex = cleanRef.LastIndexOf(':');
|
||||
if (colonIndex > 0 && !cleanRef[..colonIndex].Contains('/'))
|
||||
{
|
||||
// This is a port, not a tag
|
||||
}
|
||||
else if (colonIndex > cleanRef.IndexOf('/'))
|
||||
{
|
||||
cleanRef = cleanRef[..colonIndex];
|
||||
}
|
||||
|
||||
// First part is registry, rest is repository
|
||||
var slashIndex = cleanRef.IndexOf('/');
|
||||
if (slashIndex <= 0)
|
||||
return (null, null, null);
|
||||
|
||||
var registry = cleanRef[..slashIndex];
|
||||
var repository = cleanRef[(slashIndex + 1)..];
|
||||
|
||||
return (registry, repository, digest);
|
||||
}
|
||||
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
using StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for verification checks providing common functionality.
|
||||
/// </summary>
|
||||
public abstract class VerificationCheckBase : IDoctorCheck
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin identifier for verification checks.
|
||||
/// </summary>
|
||||
protected const string PluginId = "stellaops.doctor.verification";
|
||||
|
||||
/// <summary>
|
||||
/// Category name for verification 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(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var options = VerificationPlugin.GetOptions(context);
|
||||
return options.Enabled;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var result = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
var options = VerificationPlugin.GetOptions(context);
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return result
|
||||
.Skip("Verification 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",
|
||||
"Registry or endpoint unreachable",
|
||||
"Authentication failure")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Check network connectivity", "Verify the endpoint is reachable")
|
||||
.AddManualStep(2, "Check credentials", "Verify authentication is configured correctly"))
|
||||
.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",
|
||||
"Large artifact size")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Increase timeout", "Set Doctor:Plugins:Verification:HttpTimeoutSeconds to a higher value"))
|
||||
.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,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient with configured timeout.
|
||||
/// </summary>
|
||||
protected static HttpClient CreateHttpClient(VerificationPluginOptions options)
|
||||
{
|
||||
return new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a test artifact is configured.
|
||||
/// </summary>
|
||||
protected static bool HasTestArtifactConfigured(VerificationPluginOptions options)
|
||||
{
|
||||
return !string.IsNullOrEmpty(options.TestArtifact.Reference)
|
||||
|| !string.IsNullOrEmpty(options.TestArtifact.OfflineBundlePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a skip result for when test artifact is not configured.
|
||||
/// </summary>
|
||||
protected static DoctorCheckResult GetNoTestArtifactConfiguredResult(CheckResultBuilder result, string checkId)
|
||||
{
|
||||
return result
|
||||
.Skip("Test artifact not configured")
|
||||
.WithEvidence("Configuration", e => e
|
||||
.Add("TestArtifactReference", "(not set)")
|
||||
.Add("OfflineBundlePath", "(not set)")
|
||||
.Add("Note", "Configure a test artifact to enable verification pipeline checks"))
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Configure test artifact", "Set Doctor:Plugins:Verification:TestArtifact:Reference to an OCI reference")
|
||||
.AddManualStep(2, "Or use offline bundle", "Set Doctor:Plugins:Verification:TestArtifact:OfflineBundlePath for air-gap environments"))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Builders;
|
||||
using StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies VEX validation for test artifact.
|
||||
/// </summary>
|
||||
public sealed class VexValidationCheck : VerificationCheckBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string CheckId => "check.verification.vex.validation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "VEX Validation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Fetches and validates VEX document for test artifact (CSAF, OpenVEX, CycloneDX VEX)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> Tags => ["verification", "vex", "vulnerability", "csaf", "openvex"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
if (!base.CanRun(context))
|
||||
return false;
|
||||
|
||||
var options = VerificationPlugin.GetOptions(context);
|
||||
return HasTestArtifactConfigured(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DoctorCheckResult> ExecuteCheckAsync(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!HasTestArtifactConfigured(options))
|
||||
{
|
||||
return GetNoTestArtifactConfiguredResult(result, CheckId);
|
||||
}
|
||||
|
||||
// Check offline bundle for VEX
|
||||
if (!string.IsNullOrEmpty(options.TestArtifact.OfflineBundlePath))
|
||||
{
|
||||
return await ValidateFromOfflineBundle(options, result, ct);
|
||||
}
|
||||
|
||||
// Online VEX validation
|
||||
return await ValidateFromOnline(context, options, result, ct);
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> ValidateFromOfflineBundle(
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var bundlePath = options.TestArtifact.OfflineBundlePath!;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Offline bundle not found: {bundlePath}")
|
||||
.WithEvidence("VEX validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("FileExists", "false"))
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Export bundle", "stella verification bundle export --include-vex --output " + bundlePath))
|
||||
.WithVerification($"stella doctor --check check.verification.vex.validation")
|
||||
.Build());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(bundlePath);
|
||||
|
||||
// Detect VEX format
|
||||
var (format, statementCount) = DetectVexFormat(content);
|
||||
|
||||
if (format == VexFormat.None)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("No VEX document found in offline bundle")
|
||||
.WithEvidence("VEX validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("VexFound", "false")
|
||||
.Add("Note", "VEX documents provide vulnerability context and may be optional"))
|
||||
.WithCauses(
|
||||
"Bundle was exported without VEX",
|
||||
"No VEX statements exist for this artifact",
|
||||
"Test artifact has no known vulnerabilities")
|
||||
.WithRemediation(r => r
|
||||
.AddShellStep(1, "Re-export with VEX", "stella verification bundle export --include-vex --output " + bundlePath)
|
||||
.AddManualStep(2, "This may be expected", "VEX documents are only needed when vulnerabilities exist"))
|
||||
.WithVerification($"stella doctor --check check.verification.vex.validation")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass($"VEX valid ({format}, {statementCount} statements)")
|
||||
.WithEvidence("VEX validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("Format", format.ToString())
|
||||
.Add("StatementCount", statementCount.ToString()))
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Fail($"Cannot read offline bundle: {ex.Message}")
|
||||
.WithEvidence("VEX validation", e => e
|
||||
.Add("Mode", "Offline")
|
||||
.Add("BundlePath", bundlePath)
|
||||
.Add("Error", ex.Message))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<DoctorCheckResult> ValidateFromOnline(
|
||||
DoctorPluginContext context,
|
||||
VerificationPluginOptions options,
|
||||
CheckResultBuilder result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var reference = options.TestArtifact.Reference!;
|
||||
|
||||
// Note: Full VEX validation requires the VexHub service
|
||||
// For doctor check, we verify configuration is in place
|
||||
|
||||
var vexCollectionEnabled = context.Configuration.GetValue<bool>("VexHub:Collection:Enabled");
|
||||
var vexFeedsConfigured = !string.IsNullOrEmpty(context.Configuration["VexHub:Feeds:0:Url"]);
|
||||
|
||||
if (!vexCollectionEnabled)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Info("VEX collection not enabled")
|
||||
.WithEvidence("VEX validation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("VexCollectionEnabled", "false")
|
||||
.Add("Note", "VEX collection is optional but recommended for vulnerability context"))
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Enable VEX collection", "Set VexHub:Collection:Enabled to true")
|
||||
.AddManualStep(2, "Configure VEX feeds", "Add vendor VEX feeds to VexHub:Feeds"))
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!vexFeedsConfigured)
|
||||
{
|
||||
return Task.FromResult(result
|
||||
.Warn("No VEX feeds configured")
|
||||
.WithEvidence("VEX validation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("VexCollectionEnabled", "true")
|
||||
.Add("VexFeedsConfigured", "false")
|
||||
.Add("Note", "VEX feeds provide vendor vulnerability context"))
|
||||
.WithCauses("No VEX feed URLs configured")
|
||||
.WithRemediation(r => r
|
||||
.AddManualStep(1, "Configure VEX feeds", "Add vendor VEX feeds to VexHub:Feeds array"))
|
||||
.WithVerification($"stella doctor --check check.verification.vex.validation")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(result
|
||||
.Pass("VEX collection configured")
|
||||
.WithEvidence("VEX validation", e => e
|
||||
.Add("Mode", "Online")
|
||||
.Add("Reference", reference)
|
||||
.Add("VexCollectionEnabled", "true")
|
||||
.Add("VexFeedsConfigured", "true")
|
||||
.Add("Note", "Full VEX validation requires runtime VexHub service"))
|
||||
.Build());
|
||||
}
|
||||
|
||||
private static (VexFormat Format, int StatementCount) DetectVexFormat(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check for OpenVEX
|
||||
if (root.TryGetProperty("@context", out var context) &&
|
||||
context.GetString()?.Contains("openvex", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var statementCount = root.TryGetProperty("statements", out var s) && s.ValueKind == JsonValueKind.Array
|
||||
? s.GetArrayLength()
|
||||
: 0;
|
||||
return (VexFormat.OpenVEX, statementCount);
|
||||
}
|
||||
|
||||
// Check for CSAF VEX
|
||||
if (root.TryGetProperty("document", out var csafDoc) &&
|
||||
csafDoc.TryGetProperty("category", out var category) &&
|
||||
category.GetString()?.Contains("vex", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var statementCount = root.TryGetProperty("vulnerabilities", out var v) && v.ValueKind == JsonValueKind.Array
|
||||
? v.GetArrayLength()
|
||||
: 0;
|
||||
return (VexFormat.CSAF, statementCount);
|
||||
}
|
||||
|
||||
// Check for CycloneDX VEX
|
||||
if (root.TryGetProperty("bomFormat", out var bomFormat) &&
|
||||
bomFormat.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true &&
|
||||
root.TryGetProperty("vulnerabilities", out var vulns))
|
||||
{
|
||||
var statementCount = vulns.ValueKind == JsonValueKind.Array ? vulns.GetArrayLength() : 0;
|
||||
return (VexFormat.CycloneDX, statementCount);
|
||||
}
|
||||
|
||||
// Check for embedded VEX in bundle
|
||||
if (root.TryGetProperty("vex", out var vexElement))
|
||||
{
|
||||
var vexContent = vexElement.GetRawText();
|
||||
return DetectVexFormat(vexContent);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not valid JSON or parsing failed
|
||||
}
|
||||
|
||||
return (VexFormat.None, 0);
|
||||
}
|
||||
|
||||
private enum VexFormat
|
||||
{
|
||||
None,
|
||||
OpenVEX,
|
||||
CSAF,
|
||||
CycloneDX
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Verification diagnostic plugin.
|
||||
/// </summary>
|
||||
public sealed class VerificationPluginOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Doctor:Plugins:Verification";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the verification plugin is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Test artifact configuration.
|
||||
/// </summary>
|
||||
public TestArtifactOptions TestArtifact { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Policy test configuration.
|
||||
/// </summary>
|
||||
public PolicyTestOptions PolicyTest { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// HTTP timeout for artifact operations in seconds.
|
||||
/// </summary>
|
||||
public int HttpTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test artifact configuration.
|
||||
/// </summary>
|
||||
public sealed class TestArtifactOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI reference to the test artifact (e.g., oci://registry.example.com/test@sha256:...).
|
||||
/// </summary>
|
||||
public string? Reference { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected digest of the test artifact for verification.
|
||||
/// </summary>
|
||||
public string? ExpectedDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to local test artifact bundle for offline verification.
|
||||
/// </summary>
|
||||
public string? OfflineBundlePath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy test configuration.
|
||||
/// </summary>
|
||||
public sealed class PolicyTestOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Expected outcome of the policy test (pass or fail).
|
||||
/// </summary>
|
||||
public string ExpectedOutcome { get; set; } = "pass";
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference to use for testing.
|
||||
/// </summary>
|
||||
public string? PolicyRef { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the Verification plugin.
|
||||
/// </summary>
|
||||
public static class VerificationPluginExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Verification diagnostic plugin to the Doctor service.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDoctorVerificationPlugin(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDoctorPlugin, VerificationPlugin>();
|
||||
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.Verification</RootNamespace>
|
||||
<Description>Artifact verification pipeline diagnostic checks for Stella Ops Doctor (SBOM, VEX, signatures, policy)</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>
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Plugins.Verification.Checks;
|
||||
using StellaOps.Doctor.Plugins.Verification.Configuration;
|
||||
|
||||
namespace StellaOps.Doctor.Plugins.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Artifact verification pipeline diagnostic plugin providing SBOM, VEX, signature, and policy health checks.
|
||||
/// </summary>
|
||||
public sealed class VerificationPlugin : IDoctorPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.verification";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Artifact Verification Pipeline";
|
||||
|
||||
/// <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 verification configuration exists
|
||||
return true; // Checks will skip if not configured
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
return
|
||||
[
|
||||
new TestArtifactPullCheck(),
|
||||
new SignatureVerificationCheck(),
|
||||
new SbomValidationCheck(),
|
||||
new VexValidationCheck(),
|
||||
new PolicyEngineCheck()
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal static VerificationPluginOptions GetOptions(DoctorPluginContext context)
|
||||
{
|
||||
var options = new VerificationPluginOptions();
|
||||
context.PluginConfig.Bind(options);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user