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