new two advisories and sprints work on them

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,253 @@
using System.Text.Json;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.Attestation.Configuration;
using StellaOps.Doctor.Plugins.Builders;
namespace StellaOps.Doctor.Plugins.Attestation.Checks;
/// <summary>
/// Verifies offline attestation bundle is available and valid.
/// </summary>
public sealed class OfflineBundleCheck : AttestationCheckBase
{
private const int StalenessDaysWarn = 7;
private const int StalenessDaysFail = 30;
/// <inheritdoc />
public override string CheckId => "check.attestation.offline.bundle";
/// <inheritdoc />
public override string Name => "Offline Attestation Bundle";
/// <inheritdoc />
public override string Description => "Verifies offline attestation bundle is available and not stale";
/// <inheritdoc />
public override DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
/// <inheritdoc />
public override IReadOnlyList<string> Tags => ["attestation", "offline", "airgap"];
/// <inheritdoc />
public override TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
/// <inheritdoc />
public override bool CanRun(DoctorPluginContext context)
{
if (!base.CanRun(context))
return false;
var options = AttestationPlugin.GetOptions(context);
// Only run if in offline or hybrid mode
return options.Mode is AttestationMode.Offline or AttestationMode.Hybrid;
}
/// <inheritdoc />
protected override Task<DoctorCheckResult> ExecuteCheckAsync(
DoctorPluginContext context,
AttestationPluginOptions options,
CheckResultBuilder result,
CancellationToken ct)
{
if (string.IsNullOrEmpty(options.OfflineBundlePath))
{
var severity = options.Mode == AttestationMode.Offline
? result.Fail("Offline bundle path not configured (required for offline mode)")
: result.Warn("Offline bundle path not configured (recommended for hybrid mode)");
return Task.FromResult(severity
.WithEvidence("Configuration", e => e
.Add("Mode", options.Mode.ToString())
.Add("OfflineBundlePath", "(not set)")
.Add("ConfigKey", "Doctor:Plugins:Attestation:OfflineBundlePath"))
.WithCauses(
"Offline bundle path not configured",
"Environment variable not set")
.WithRemediation(r => r
.AddShellStep(1, "Export bundle from online system", "stella attestation bundle export --output /path/to/bundle.json")
.AddManualStep(2, "Configure bundle path", "Set Doctor:Plugins:Attestation:OfflineBundlePath to the bundle location")
.AddManualStep(3, "Transfer bundle", "Copy the bundle to the target system"))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
// Check if file exists
if (!File.Exists(options.OfflineBundlePath))
{
return Task.FromResult(result
.Fail($"Offline bundle file not found: {options.OfflineBundlePath}")
.WithEvidence("Bundle file", e => e
.Add("BundlePath", options.OfflineBundlePath)
.Add("FileExists", "false"))
.WithCauses(
"Bundle file was deleted or moved",
"Path is incorrect",
"File permissions prevent access")
.WithRemediation(r => r
.AddShellStep(1, "Check file existence", $"ls -la {options.OfflineBundlePath}")
.AddShellStep(2, "Export new bundle", "stella attestation bundle export --output " + options.OfflineBundlePath)
.AddManualStep(3, "Verify path", "Ensure the configured path is correct"))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
// Get file info
var fileInfo = new FileInfo(options.OfflineBundlePath);
// Try to parse bundle header to check format and timestamp
BundleMetadata? metadata = null;
string? parseError = null;
try
{
using var stream = File.OpenRead(options.OfflineBundlePath);
using var reader = new StreamReader(stream);
// Read first few KB to parse header
var buffer = new char[4096];
var charsRead = reader.Read(buffer, 0, buffer.Length);
var content = new string(buffer, 0, charsRead);
// Try to extract metadata from JSON
metadata = TryParseBundleMetadata(content);
}
catch (JsonException ex)
{
parseError = $"Invalid JSON: {ex.Message}";
}
catch (Exception ex)
{
parseError = ex.Message;
}
if (parseError is not null)
{
return Task.FromResult(result
.Warn($"Offline bundle may be corrupt: {parseError}")
.WithEvidence("Bundle file", e => e
.Add("BundlePath", options.OfflineBundlePath)
.Add("FileExists", "true")
.Add("FileSize", FormatFileSize(fileInfo.Length))
.Add("ParseError", parseError))
.WithRemediation(r => r
.AddShellStep(1, "Validate bundle", "stella attestation bundle validate " + options.OfflineBundlePath)
.AddShellStep(2, "Export fresh bundle", "stella attestation bundle export --output " + options.OfflineBundlePath))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
// Check staleness
var bundleAge = context.TimeProvider.GetUtcNow() - (metadata?.ExportedAt ?? fileInfo.LastWriteTimeUtc);
var ageDays = bundleAge.TotalDays;
if (ageDays > StalenessDaysFail)
{
return Task.FromResult(result
.Fail($"Offline bundle is {ageDays:F0} days old (maximum: {StalenessDaysFail} days)")
.WithEvidence("Bundle staleness", e =>
{
e.Add("BundlePath", options.OfflineBundlePath)
.Add("FileSize", FormatFileSize(fileInfo.Length))
.Add("AgeDays", ageDays.ToString("F0"))
.Add("WarnThresholdDays", StalenessDaysWarn.ToString())
.Add("FailThresholdDays", StalenessDaysFail.ToString());
if (metadata is not null)
{
e.Add("BundleVersion", metadata.Version ?? "(unknown)")
.Add("ExportedAt", metadata.ExportedAt?.ToString("O") ?? "(unknown)");
}
})
.WithCauses(
"Bundle has not been refreshed recently",
"Air-gap environment out of sync")
.WithRemediation(r => r
.AddShellStep(1, "Export fresh bundle from online system", "stella attestation bundle export --output /path/to/new-bundle.json")
.AddManualStep(2, "Transfer to air-gap environment", "Copy the new bundle to the target system")
.AddManualStep(3, "Update bundle path if needed", "Point configuration to the new bundle file"))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
if (ageDays > StalenessDaysWarn)
{
return Task.FromResult(result
.Warn($"Offline bundle is {ageDays:F0} days old (threshold: {StalenessDaysWarn} days)")
.WithEvidence("Bundle staleness", e =>
{
e.Add("BundlePath", options.OfflineBundlePath)
.Add("FileSize", FormatFileSize(fileInfo.Length))
.Add("AgeDays", ageDays.ToString("F0"))
.Add("WarnThresholdDays", StalenessDaysWarn.ToString());
if (metadata is not null)
{
e.Add("BundleVersion", metadata.Version ?? "(unknown)")
.Add("ExportedAt", metadata.ExportedAt?.ToString("O") ?? "(unknown)");
}
})
.WithRemediation(r => r
.AddShellStep(1, "Export fresh bundle", "stella attestation bundle export --output /path/to/new-bundle.json")
.AddManualStep(2, "Schedule regular updates", "Consider automating bundle refresh"))
.WithVerification($"stella doctor --check {CheckId}")
.Build());
}
return Task.FromResult(result
.Pass($"Offline bundle available (age: {ageDays:F0} days)")
.WithEvidence("Bundle info", e =>
{
e.Add("BundlePath", options.OfflineBundlePath)
.Add("FileSize", FormatFileSize(fileInfo.Length))
.Add("AgeDays", ageDays.ToString("F0"))
.Add("WarnThresholdDays", StalenessDaysWarn.ToString());
if (metadata is not null)
{
e.Add("BundleVersion", metadata.Version ?? "(unknown)")
.Add("ExportedAt", metadata.ExportedAt?.ToString("O") ?? "(unknown)");
}
})
.Build());
}
private static BundleMetadata? TryParseBundleMetadata(string content)
{
try
{
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
return new BundleMetadata
{
Version = root.TryGetProperty("version", out var v) ? v.GetString() : null,
ExportedAt = root.TryGetProperty("exportedAt", out var e) && e.TryGetDateTimeOffset(out var dt)
? dt
: null
};
}
catch
{
return null;
}
}
private static string FormatFileSize(long bytes)
{
return bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
< 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB",
_ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB"
};
}
private sealed record BundleMetadata
{
public string? Version { get; init; }
public DateTimeOffset? ExportedAt { get; init; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Doctor.Plugins.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>

View File

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