doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,216 @@
// -----------------------------------------------------------------------------
// AttestationVerificationGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-001 - Attestation Verification Gate
// Description: Policy gate for DSSE attestation verification
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Policy gate that validates DSSE attestation envelopes.
/// Checks payload type, signature validity, and key trust.
/// </summary>
public sealed class AttestationVerificationGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "attestation-verification";
private readonly ITrustedKeyRegistry _keyRegistry;
private readonly AttestationVerificationGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Attestation Verification";
/// <inheritdoc />
public string Description => "Validates DSSE attestation payloadType, signatures, and key trust";
/// <summary>
/// Creates a new attestation verification gate.
/// </summary>
public AttestationVerificationGate(
ITrustedKeyRegistry keyRegistry,
AttestationVerificationGateOptions? options = null)
{
_keyRegistry = keyRegistry ?? throw new ArgumentNullException(nameof(keyRegistry));
_options = options ?? new AttestationVerificationGateOptions();
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var attestation = context.GetAttestation();
if (attestation == null)
{
return GateResult.Fail(Id, "No attestation found in context");
}
// 1. Validate payload type
var payloadTypeResult = ValidatePayloadType(attestation.PayloadType);
if (!payloadTypeResult.Passed)
{
return payloadTypeResult;
}
// 2. Validate signatures
var signatureResult = ValidateSignatures(attestation.Signatures);
if (!signatureResult.Passed)
{
return signatureResult;
}
// 3. Validate trusted keys
var keyResult = await ValidateTrustedKeysAsync(attestation.Signatures, ct);
if (!keyResult.Passed)
{
return keyResult;
}
return GateResult.Pass(Id, "Attestation verification passed");
}
private GateResult ValidatePayloadType(string? payloadType)
{
if (string.IsNullOrWhiteSpace(payloadType))
{
return GateResult.Fail(Id, "PayloadType is missing");
}
if (!_options.AllowedPayloadTypes.Contains(payloadType))
{
return GateResult.Fail(Id, $"PayloadType '{payloadType}' is not in allowed list");
}
return GateResult.Pass(Id, $"PayloadType '{payloadType}' is valid");
}
private GateResult ValidateSignatures(IReadOnlyList<AttestationSignature>? signatures)
{
if (signatures == null || signatures.Count == 0)
{
return GateResult.Fail(Id, "No signatures present in attestation");
}
if (signatures.Count < _options.MinimumSignatures)
{
return GateResult.Fail(Id,
$"Insufficient signatures: {signatures.Count} < {_options.MinimumSignatures}");
}
// Validate signature algorithms
foreach (var sig in signatures)
{
if (!_options.AllowedAlgorithms.Contains(sig.Algorithm))
{
return GateResult.Fail(Id,
$"Signature algorithm '{sig.Algorithm}' is not in allowed list");
}
}
return GateResult.Pass(Id, $"{signatures.Count} valid signatures present");
}
private async Task<GateResult> ValidateTrustedKeysAsync(
IReadOnlyList<AttestationSignature>? signatures,
CancellationToken ct)
{
if (signatures == null)
{
return GateResult.Fail(Id, "No signatures to verify keys");
}
var trustedCount = 0;
foreach (var sig in signatures)
{
if (string.IsNullOrWhiteSpace(sig.KeyId))
continue;
var isTrusted = await _keyRegistry.IsTrustedAsync(sig.KeyId, ct);
if (isTrusted)
{
trustedCount++;
}
}
if (trustedCount < _options.MinimumTrustedSignatures)
{
return GateResult.Fail(Id,
$"Insufficient trusted signatures: {trustedCount} < {_options.MinimumTrustedSignatures}");
}
return GateResult.Pass(Id, $"{trustedCount} signatures from trusted keys");
}
}
/// <summary>
/// Options for attestation verification gate.
/// </summary>
public sealed record AttestationVerificationGateOptions
{
/// <summary>
/// Allowed payload types.
/// </summary>
public IReadOnlySet<string> AllowedPayloadTypes { get; init; } = new HashSet<string>
{
"application/vnd.in-toto+json",
"application/vnd.cyclonedx+json",
"application/vnd.cyclonedx+json;version=1.6",
"application/spdx+json",
"application/vnd.openvex+json"
};
/// <summary>
/// Allowed signature algorithms.
/// </summary>
public IReadOnlySet<string> AllowedAlgorithms { get; init; } = new HashSet<string>
{
"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "EdDSA", "Ed25519"
};
/// <summary>
/// Minimum number of signatures required.
/// </summary>
public int MinimumSignatures { get; init; } = 1;
/// <summary>
/// Minimum number of signatures from trusted keys.
/// </summary>
public int MinimumTrustedSignatures { get; init; } = 1;
}
/// <summary>
/// Attestation model for gate evaluation.
/// </summary>
public sealed record AttestationEnvelope
{
/// <summary>DSSE payload type.</summary>
public required string PayloadType { get; init; }
/// <summary>Base64-encoded payload.</summary>
public required string Payload { get; init; }
/// <summary>Signatures on the envelope.</summary>
public required IReadOnlyList<AttestationSignature> Signatures { get; init; }
}
/// <summary>
/// A signature on an attestation.
/// </summary>
public sealed record AttestationSignature
{
/// <summary>Key ID.</summary>
public string? KeyId { get; init; }
/// <summary>Signature algorithm.</summary>
public required string Algorithm { get; init; }
/// <summary>Base64-encoded signature.</summary>
public required string Signature { get; init; }
}

View File

@@ -0,0 +1,234 @@
// -----------------------------------------------------------------------------
// CompositeAttestationGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-004 - Composite Attestation Gate
// Description: Orchestrates multiple attestation gates with configurable logic
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Composite gate that orchestrates multiple attestation gates.
/// Supports AND, OR, and threshold-based composition.
/// </summary>
public sealed class CompositeAttestationGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "composite-attestation";
private readonly IReadOnlyList<IPolicyGate> _gates;
private readonly CompositeAttestationGateOptions _options;
/// <inheritdoc />
public string Id => _options.CustomId ?? GateId;
/// <inheritdoc />
public string DisplayName => _options.DisplayName ?? "Composite Attestation";
/// <inheritdoc />
public string Description => _options.Description ?? "Orchestrates multiple attestation verification gates";
/// <summary>
/// Creates a new composite attestation gate.
/// </summary>
public CompositeAttestationGate(
IEnumerable<IPolicyGate> gates,
CompositeAttestationGateOptions? options = null)
{
_gates = gates?.ToList() ?? throw new ArgumentNullException(nameof(gates));
_options = options ?? new CompositeAttestationGateOptions();
if (_gates.Count == 0)
{
throw new ArgumentException("At least one gate is required.", nameof(gates));
}
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var results = new List<GateResult>();
var passed = 0;
var failed = 0;
foreach (var gate in _gates)
{
ct.ThrowIfCancellationRequested();
try
{
var result = await gate.EvaluateAsync(context, ct);
results.Add(result);
if (result.Passed)
{
passed++;
// Short-circuit on OR mode
if (_options.Mode == CompositeMode.Or)
{
return GateResult.Pass(Id,
$"Composite gate passed (OR mode): {gate.Id} passed",
childResults: results);
}
}
else
{
failed++;
// Short-circuit on AND mode
if (_options.Mode == CompositeMode.And && !_options.ContinueOnFailure)
{
return GateResult.Fail(Id,
$"Composite gate failed (AND mode): {gate.Id} failed - {result.Message}",
childResults: results);
}
}
}
catch (Exception ex) when (_options.ContinueOnError)
{
results.Add(GateResult.Fail(gate.Id, $"Gate error: {ex.Message}"));
failed++;
}
}
// Evaluate final result based on mode
return EvaluateFinalResult(passed, failed, results);
}
private GateResult EvaluateFinalResult(int passed, int failed, List<GateResult> results)
{
switch (_options.Mode)
{
case CompositeMode.And:
if (failed == 0)
{
return GateResult.Pass(Id,
$"All {passed} gates passed",
childResults: results);
}
return GateResult.Fail(Id,
$"Composite gate failed: {failed} of {_gates.Count} gates failed",
childResults: results);
case CompositeMode.Or:
if (passed > 0)
{
return GateResult.Pass(Id,
$"At least one gate passed ({passed} of {_gates.Count})",
childResults: results);
}
return GateResult.Fail(Id,
$"Composite gate failed: no gates passed",
childResults: results);
case CompositeMode.Threshold:
if (passed >= _options.PassThreshold)
{
return GateResult.Pass(Id,
$"Threshold met: {passed} >= {_options.PassThreshold} gates passed",
childResults: results);
}
return GateResult.Fail(Id,
$"Threshold not met: {passed} < {_options.PassThreshold} gates passed",
childResults: results);
default:
return GateResult.Fail(Id, $"Unknown composite mode: {_options.Mode}");
}
}
/// <summary>
/// Creates a composite gate with AND logic.
/// </summary>
public static CompositeAttestationGate And(params IPolicyGate[] gates)
{
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
{
Mode = CompositeMode.And
});
}
/// <summary>
/// Creates a composite gate with OR logic.
/// </summary>
public static CompositeAttestationGate Or(params IPolicyGate[] gates)
{
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
{
Mode = CompositeMode.Or
});
}
/// <summary>
/// Creates a composite gate with threshold logic.
/// </summary>
public static CompositeAttestationGate Threshold(int threshold, params IPolicyGate[] gates)
{
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
{
Mode = CompositeMode.Threshold,
PassThreshold = threshold
});
}
}
/// <summary>
/// Options for composite attestation gate.
/// </summary>
public sealed record CompositeAttestationGateOptions
{
/// <summary>
/// Composition mode.
/// </summary>
public CompositeMode Mode { get; init; } = CompositeMode.And;
/// <summary>
/// Minimum number of gates that must pass (for Threshold mode).
/// </summary>
public int PassThreshold { get; init; } = 1;
/// <summary>
/// Whether to continue evaluating after a failure (for AND mode).
/// </summary>
public bool ContinueOnFailure { get; init; } = false;
/// <summary>
/// Whether to continue evaluating after an error.
/// </summary>
public bool ContinueOnError { get; init; } = true;
/// <summary>
/// Custom gate ID.
/// </summary>
public string? CustomId { get; init; }
/// <summary>
/// Custom display name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Custom description.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Composite gate evaluation mode.
/// </summary>
public enum CompositeMode
{
/// <summary>All gates must pass.</summary>
And,
/// <summary>At least one gate must pass.</summary>
Or,
/// <summary>A threshold number of gates must pass.</summary>
Threshold
}

View File

@@ -0,0 +1,225 @@
// -----------------------------------------------------------------------------
// ITrustedKeyRegistry.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: Interface and implementation for trusted key management
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Registry of trusted signing keys for attestation verification.
/// </summary>
public interface ITrustedKeyRegistry
{
/// <summary>
/// Checks if a key ID is trusted.
/// </summary>
/// <param name="keyId">The key ID to check.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the key is trusted.</returns>
Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default);
/// <summary>
/// Gets a trusted key by ID.
/// </summary>
/// <param name="keyId">The key ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The trusted key or null.</returns>
Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default);
/// <summary>
/// Gets a trusted key by fingerprint.
/// </summary>
/// <param name="fingerprint">SHA-256 fingerprint of the public key.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The trusted key or null.</returns>
Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default);
/// <summary>
/// Lists all trusted keys.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>All trusted keys.</returns>
IAsyncEnumerable<TrustedKey> ListAsync(CancellationToken ct = default);
/// <summary>
/// Adds a trusted key.
/// </summary>
/// <param name="key">The key to add.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The added key.</returns>
Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default);
/// <summary>
/// Revokes a trusted key.
/// </summary>
/// <param name="keyId">The key ID to revoke.</param>
/// <param name="reason">Revocation reason.</param>
/// <param name="ct">Cancellation token.</param>
Task RevokeAsync(string keyId, string reason, CancellationToken ct = default);
}
/// <summary>
/// A trusted signing key.
/// </summary>
public sealed record TrustedKey
{
/// <summary>
/// Unique key identifier.
/// </summary>
public required string KeyId { get; init; }
/// <summary>
/// SHA-256 fingerprint of the public key.
/// </summary>
public required string Fingerprint { get; init; }
/// <summary>
/// Key algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_2048").
/// </summary>
public required string Algorithm { get; init; }
/// <summary>
/// PEM-encoded public key.
/// </summary>
public string? PublicKeyPem { get; init; }
/// <summary>
/// Key owner/issuer identity.
/// </summary>
public string? Owner { get; init; }
/// <summary>
/// Key purpose (e.g., "sbom-signing", "vex-signing", "release-signing").
/// </summary>
public IReadOnlyList<string> Purposes { get; init; } = [];
/// <summary>
/// When the key was trusted.
/// </summary>
public DateTimeOffset TrustedAt { get; init; }
/// <summary>
/// When the key expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Whether the key is currently active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// Revocation reason (if revoked).
/// </summary>
public string? RevokedReason { get; init; }
/// <summary>
/// When the key was revoked.
/// </summary>
public DateTimeOffset? RevokedAt { get; init; }
/// <summary>
/// Tenant ID for multi-tenancy.
/// </summary>
public Guid TenantId { get; init; }
}
/// <summary>
/// In-memory implementation of trusted key registry.
/// </summary>
public sealed class InMemoryTrustedKeyRegistry : ITrustedKeyRegistry
{
private readonly Dictionary<string, TrustedKey> _keysByKeyId = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TrustedKey> _keysByFingerprint = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <inheritdoc />
public Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default)
{
lock (_lock)
{
if (_keysByKeyId.TryGetValue(keyId, out var key))
{
var isTrusted = key.IsActive &&
key.RevokedAt == null &&
(key.ExpiresAt == null || key.ExpiresAt > DateTimeOffset.UtcNow);
return Task.FromResult(isTrusted);
}
}
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default)
{
lock (_lock)
{
return Task.FromResult(_keysByKeyId.GetValueOrDefault(keyId));
}
}
/// <inheritdoc />
public Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default)
{
lock (_lock)
{
return Task.FromResult(_keysByFingerprint.GetValueOrDefault(fingerprint));
}
}
/// <inheritdoc />
public async IAsyncEnumerable<TrustedKey> ListAsync(CancellationToken ct = default)
{
List<TrustedKey> keys;
lock (_lock)
{
keys = _keysByKeyId.Values.ToList();
}
foreach (var key in keys)
{
ct.ThrowIfCancellationRequested();
yield return key;
}
await Task.CompletedTask;
}
/// <inheritdoc />
public Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(key);
lock (_lock)
{
_keysByKeyId[key.KeyId] = key;
_keysByFingerprint[key.Fingerprint] = key;
}
return Task.FromResult(key);
}
/// <inheritdoc />
public Task RevokeAsync(string keyId, string reason, CancellationToken ct = default)
{
lock (_lock)
{
if (_keysByKeyId.TryGetValue(keyId, out var key))
{
var revokedKey = key with
{
IsActive = false,
RevokedAt = DateTimeOffset.UtcNow,
RevokedReason = reason
};
_keysByKeyId[keyId] = revokedKey;
_keysByFingerprint[key.Fingerprint] = revokedKey;
}
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,142 @@
// -----------------------------------------------------------------------------
// RekorFreshnessGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-002 - Rekor Freshness Gate
// Description: Policy gate for Rekor integratedTime freshness enforcement
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Policy gate that enforces Rekor entry freshness based on integratedTime.
/// Rejects attestations older than the configured cutoff.
/// </summary>
public sealed class RekorFreshnessGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "rekor-freshness";
private readonly RekorFreshnessGateOptions _options;
private readonly TimeProvider _timeProvider;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Rekor Freshness";
/// <inheritdoc />
public string Description => "Enforces Rekor integratedTime freshness cutoffs";
/// <summary>
/// Creates a new Rekor freshness gate.
/// </summary>
public RekorFreshnessGate(
RekorFreshnessGateOptions? options = null,
TimeProvider? timeProvider = null)
{
_options = options ?? new RekorFreshnessGateOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var rekorProof = context.GetRekorProof();
if (rekorProof == null)
{
if (_options.RequireRekorProof)
{
return Task.FromResult(GateResult.Fail(Id, "No Rekor proof found in context"));
}
return Task.FromResult(GateResult.Pass(Id, "Rekor proof not required, skipping freshness check"));
}
// Get integrated time from proof
var integratedTime = rekorProof.IntegratedTime;
if (integratedTime == null)
{
return Task.FromResult(GateResult.Fail(Id, "Rekor proof missing integratedTime"));
}
var now = _timeProvider.GetUtcNow();
var age = now - integratedTime.Value;
// Check maximum age
if (age > _options.MaxAge)
{
return Task.FromResult(GateResult.Fail(Id,
$"Rekor entry too old: {age.TotalHours:F1}h > {_options.MaxAge.TotalHours:F1}h max"));
}
// Check minimum age (to prevent clock skew issues)
if (age < _options.MinAge)
{
return Task.FromResult(GateResult.Fail(Id,
$"Rekor entry too new (possible clock skew): {age.TotalSeconds:F1}s < {_options.MinAge.TotalSeconds:F1}s min"));
}
// Check for future timestamp
if (integratedTime.Value > now.Add(_options.FutureTimeTolerance))
{
return Task.FromResult(GateResult.Fail(Id,
$"Rekor entry has future timestamp: {integratedTime.Value:O} > {now:O}"));
}
return Task.FromResult(GateResult.Pass(Id,
$"Rekor entry age {age.TotalMinutes:F1}m is within acceptable range"));
}
}
/// <summary>
/// Options for Rekor freshness gate.
/// </summary>
public sealed record RekorFreshnessGateOptions
{
/// <summary>
/// Maximum age for Rekor entries.
/// Default: 24 hours.
/// </summary>
public TimeSpan MaxAge { get; init; } = TimeSpan.FromHours(24);
/// <summary>
/// Minimum age for Rekor entries (to account for clock skew).
/// Default: 0 (no minimum).
/// </summary>
public TimeSpan MinAge { get; init; } = TimeSpan.Zero;
/// <summary>
/// Tolerance for future timestamps.
/// Default: 5 minutes.
/// </summary>
public TimeSpan FutureTimeTolerance { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Whether to require a Rekor proof.
/// If false, missing proofs are allowed (gate passes).
/// </summary>
public bool RequireRekorProof { get; init; } = true;
}
/// <summary>
/// Rekor proof context for gate evaluation.
/// </summary>
public sealed record RekorProofContext
{
/// <summary>Log index.</summary>
public long LogIndex { get; init; }
/// <summary>Integrated timestamp.</summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>UUID.</summary>
public string? Uuid { get; init; }
/// <summary>Whether the proof has been verified.</summary>
public bool IsVerified { get; init; }
}

View File

@@ -0,0 +1,233 @@
// -----------------------------------------------------------------------------
// VexStatusPromotionGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-003 - VEX Status Promotion Gate
// Description: Reachability-aware VEX status gate for release blocking
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Policy gate that enforces VEX status requirements with reachability awareness.
/// Blocks promotion based on affected + reachable combinations.
/// </summary>
public sealed class VexStatusPromotionGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "vex-status-promotion";
private readonly VexStatusPromotionGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "VEX Status Promotion";
/// <inheritdoc />
public string Description => "Enforces VEX status requirements with reachability awareness";
/// <summary>
/// Creates a new VEX status promotion gate.
/// </summary>
public VexStatusPromotionGate(VexStatusPromotionGateOptions? options = null)
{
_options = options ?? new VexStatusPromotionGateOptions();
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var vexSummary = context.GetVexSummary();
if (vexSummary == null)
{
if (_options.RequireVexSummary)
{
return Task.FromResult(GateResult.Fail(Id, "No VEX summary found in context"));
}
return Task.FromResult(GateResult.Pass(Id, "VEX summary not required, skipping"));
}
var findings = new List<string>();
// Check for blocking combinations
foreach (var statement in vexSummary.Statements)
{
var isBlocking = EvaluateStatement(statement, findings);
if (isBlocking && !_options.AllowBlockingVulnerabilities)
{
return Task.FromResult(GateResult.Fail(Id,
$"Blocking vulnerability found: {statement.VulnerabilityId} - {string.Join(", ", findings)}"));
}
}
// Check aggregate thresholds
if (vexSummary.AffectedReachableCount > _options.MaxAffectedReachable)
{
return Task.FromResult(GateResult.Fail(Id,
$"Too many affected+reachable vulnerabilities: {vexSummary.AffectedReachableCount} > {_options.MaxAffectedReachable}"));
}
if (vexSummary.UnderInvestigationCount > _options.MaxUnderInvestigation)
{
return Task.FromResult(GateResult.Fail(Id,
$"Too many under-investigation vulnerabilities: {vexSummary.UnderInvestigationCount} > {_options.MaxUnderInvestigation}"));
}
return Task.FromResult(GateResult.Pass(Id,
$"VEX status check passed: {vexSummary.NotAffectedCount} not_affected, {vexSummary.FixedCount} fixed"));
}
private bool EvaluateStatement(VexStatementSummary statement, List<string> findings)
{
// Affected + Reachable = Blocking
if (statement.Status == VexStatus.Affected && statement.IsReachable)
{
findings.Add($"{statement.VulnerabilityId}: affected and reachable");
return true;
}
// Affected + Unknown reachability with high severity = Blocking
if (statement.Status == VexStatus.Affected &&
statement.ReachabilityStatus == ReachabilityStatus.Unknown &&
statement.Severity >= _options.BlockingSeverityThreshold)
{
findings.Add($"{statement.VulnerabilityId}: affected with unknown reachability and severity {statement.Severity}");
return true;
}
// Under investigation with high severity = Warning (not blocking by default)
if (statement.Status == VexStatus.UnderInvestigation &&
statement.Severity >= _options.WarningSeverityThreshold)
{
findings.Add($"{statement.VulnerabilityId}: under investigation with severity {statement.Severity}");
// Not blocking by default, but tracked
}
return false;
}
}
/// <summary>
/// Options for VEX status promotion gate.
/// </summary>
public sealed record VexStatusPromotionGateOptions
{
/// <summary>
/// Whether to require a VEX summary.
/// </summary>
public bool RequireVexSummary { get; init; } = true;
/// <summary>
/// Whether to allow blocking vulnerabilities (gate passes with warning).
/// </summary>
public bool AllowBlockingVulnerabilities { get; init; } = false;
/// <summary>
/// Maximum number of affected+reachable vulnerabilities allowed.
/// </summary>
public int MaxAffectedReachable { get; init; } = 0;
/// <summary>
/// Maximum number of under-investigation vulnerabilities allowed.
/// </summary>
public int MaxUnderInvestigation { get; init; } = 5;
/// <summary>
/// Severity threshold for blocking (affected + unknown reachability).
/// </summary>
public double BlockingSeverityThreshold { get; init; } = 9.0; // Critical
/// <summary>
/// Severity threshold for warnings.
/// </summary>
public double WarningSeverityThreshold { get; init; } = 7.0; // High
}
/// <summary>
/// VEX summary for gate evaluation.
/// </summary>
public sealed record VexSummary
{
/// <summary>Individual VEX statements.</summary>
public IReadOnlyList<VexStatementSummary> Statements { get; init; } = [];
/// <summary>Count of not_affected vulnerabilities.</summary>
public int NotAffectedCount { get; init; }
/// <summary>Count of affected vulnerabilities.</summary>
public int AffectedCount { get; init; }
/// <summary>Count of fixed vulnerabilities.</summary>
public int FixedCount { get; init; }
/// <summary>Count of under_investigation vulnerabilities.</summary>
public int UnderInvestigationCount { get; init; }
/// <summary>Count of affected + reachable vulnerabilities.</summary>
public int AffectedReachableCount { get; init; }
}
/// <summary>
/// Summary of a single VEX statement.
/// </summary>
public sealed record VexStatementSummary
{
/// <summary>Vulnerability ID (e.g., CVE-2024-12345).</summary>
public required string VulnerabilityId { get; init; }
/// <summary>VEX status.</summary>
public required VexStatus Status { get; init; }
/// <summary>Whether the vulnerability is reachable.</summary>
public bool IsReachable { get; init; }
/// <summary>Reachability determination status.</summary>
public ReachabilityStatus ReachabilityStatus { get; init; }
/// <summary>CVSS severity score.</summary>
public double Severity { get; init; }
/// <summary>Justification for the status.</summary>
public string? Justification { get; init; }
}
/// <summary>
/// VEX status values.
/// </summary>
public enum VexStatus
{
/// <summary>Not affected by the vulnerability.</summary>
NotAffected,
/// <summary>Affected by the vulnerability.</summary>
Affected,
/// <summary>Fixed in this version.</summary>
Fixed,
/// <summary>Under investigation.</summary>
UnderInvestigation
}
/// <summary>
/// Reachability determination status.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Reachability not determined.</summary>
Unknown,
/// <summary>Confirmed reachable.</summary>
Reachable,
/// <summary>Confirmed not reachable.</summary>
NotReachable,
/// <summary>Partially reachable (some paths blocked).</summary>
PartiallyReachable
}

View File

@@ -0,0 +1,311 @@
// -----------------------------------------------------------------------------
// CveDeltaGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-05 - CVE Delta Gate
// Description: Policy gate that blocks releases introducing new high-severity CVEs
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that blocks releases introducing new high-severity CVEs compared to baseline.
/// Prevents security regressions by tracking CVE delta between releases.
/// </summary>
public sealed class CveDeltaGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "cve-delta";
private readonly ICveDeltaProvider? _deltaProvider;
private readonly CveDeltaGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "CVE Delta";
/// <inheritdoc />
public string Description => "Blocks releases that introduce new CVEs above severity threshold";
/// <summary>
/// Creates a new CVE delta gate.
/// </summary>
public CveDeltaGate(
CveDeltaGateOptions? options = null,
ICveDeltaProvider? deltaProvider = null)
{
_options = options ?? new CveDeltaGateOptions();
_deltaProvider = deltaProvider;
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "CVE delta gate disabled");
}
var envOptions = GetEnvironmentOptions(context.Environment);
// Get current and baseline CVEs
var currentCves = context.GetCveFindings();
if (currentCves == null || currentCves.Count == 0)
{
return GateResult.Pass(Id, "No CVE findings in current release");
}
// Get baseline CVEs
IReadOnlyList<CveFinding> baselineCves;
if (_deltaProvider != null && !string.IsNullOrWhiteSpace(context.BaselineReference))
{
baselineCves = await _deltaProvider.GetBaselineCvesAsync(
context.BaselineReference,
ct).ConfigureAwait(false);
}
else if (context.BaselineCves != null)
{
baselineCves = context.BaselineCves;
}
else
{
// No baseline available - treat as first release
return EvaluateWithoutBaseline(currentCves, envOptions);
}
// Compute delta
var baselineCveIds = baselineCves.Select(c => c.CveId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var currentCveIds = currentCves.Select(c => c.CveId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var newCves = currentCves
.Where(c => !baselineCveIds.Contains(c.CveId))
.ToList();
var fixedCves = baselineCves
.Where(c => !currentCveIds.Contains(c.CveId))
.ToList();
var unchangedCves = currentCves
.Where(c => baselineCveIds.Contains(c.CveId))
.ToList();
// Check for blocking new CVEs
var blockingNewCves = newCves
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.NewCveSeverityThreshold)
.ToList();
// Apply reachability filter if enabled
if (envOptions.OnlyBlockReachable)
{
blockingNewCves = blockingNewCves.Where(c => c.IsReachable).ToList();
}
var warnings = new List<string>();
// Check remediation SLA for existing CVEs
if (envOptions.RemediationSlaDays.HasValue)
{
var overdueRemediations = CheckRemediationSla(unchangedCves, envOptions.RemediationSlaDays.Value, context);
if (overdueRemediations.Count > 0)
{
warnings.Add($"{overdueRemediations.Count} CVE(s) past remediation SLA: " +
string.Join(", ", overdueRemediations.Take(3).Select(c => c.CveId)));
}
}
// Report improvements
if (fixedCves.Count > 0)
{
var highFixed = fixedCves.Count(c => c.CvssScore >= 7.0);
if (highFixed > 0)
{
warnings.Add($"Improvement: {highFixed} high+ severity CVE(s) fixed");
}
}
if (blockingNewCves.Count > 0)
{
var message = $"Release introduces {blockingNewCves.Count} new CVE(s) at or above severity {envOptions.NewCveSeverityThreshold:F1}: " +
string.Join(", ", blockingNewCves.Take(5).Select(c =>
$"{c.CveId} (CVSS: {c.CvssScore:F1}{(c.IsReachable ? ", reachable" : "")})"));
if (blockingNewCves.Count > 5)
{
message += $" and {blockingNewCves.Count - 5} more";
}
return GateResult.Fail(Id, message);
}
var passMessage = $"CVE delta check passed. " +
$"New: {newCves.Count}, Fixed: {fixedCves.Count}, Unchanged: {unchangedCves.Count}";
if (newCves.Count > 0)
{
var lowSeverityNew = newCves.Count(c => !c.CvssScore.HasValue || c.CvssScore.Value < envOptions.NewCveSeverityThreshold);
if (lowSeverityNew > 0)
{
passMessage += $" ({lowSeverityNew} new low-severity allowed)";
}
}
return GateResult.Pass(Id, passMessage, warnings: warnings);
}
private GateResult EvaluateWithoutBaseline(
IReadOnlyList<CveFinding> currentCves,
CveDeltaGateOptions options)
{
if (options.AllowFirstRelease)
{
var highSeverity = currentCves.Count(c => c.CvssScore >= options.NewCveSeverityThreshold);
var message = $"First release (no baseline). {currentCves.Count} CVE(s) found, {highSeverity} high+ severity.";
if (highSeverity > 0)
{
return GateResult.Pass(Id, message,
warnings: new[] { $"First release contains {highSeverity} high+ severity CVE(s)" });
}
return GateResult.Pass(Id, message);
}
// Require baseline
return GateResult.Fail(Id, "CVE delta gate requires baseline reference but none provided");
}
private static List<CveFinding> CheckRemediationSla(
IReadOnlyList<CveFinding> cves,
int slaDays,
PolicyGateContext context)
{
var overdue = new List<CveFinding>();
foreach (var cve in cves)
{
// Only check high+ severity CVEs for SLA
if (!cve.CvssScore.HasValue || cve.CvssScore.Value < 7.0)
continue;
// Get first seen date from context metadata
if (context.CveFirstSeenDates?.TryGetValue(cve.CveId, out var firstSeen) == true)
{
var daysSinceFirstSeen = (DateTimeOffset.UtcNow - firstSeen).TotalDays;
if (daysSinceFirstSeen > slaDays)
{
overdue.Add(cve);
}
}
}
return overdue;
}
private CveDeltaGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
NewCveSeverityThreshold = envOverride.NewCveSeverityThreshold ?? _options.NewCveSeverityThreshold,
OnlyBlockReachable = envOverride.OnlyBlockReachable ?? _options.OnlyBlockReachable,
RemediationSlaDays = envOverride.RemediationSlaDays ?? _options.RemediationSlaDays,
AllowFirstRelease = envOverride.AllowFirstRelease ?? _options.AllowFirstRelease
};
}
return _options;
}
}
/// <summary>
/// Options for CVE delta gate.
/// </summary>
public sealed record CveDeltaGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:CveDelta";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Minimum CVSS severity for new CVEs to trigger a block.
/// Only new CVEs at or above this severity are blocked.
/// Default: 7.0 (High).
/// </summary>
public double NewCveSeverityThreshold { get; init; } = 7.0;
/// <summary>
/// Whether to only block reachable new CVEs.
/// If true, unreachable new CVEs are allowed regardless of severity.
/// </summary>
public bool OnlyBlockReachable { get; init; } = false;
/// <summary>
/// Remediation SLA in days for existing CVEs.
/// CVEs present longer than this SLA generate warnings.
/// Null to disable SLA checking.
/// </summary>
public int? RemediationSlaDays { get; init; }
/// <summary>
/// Whether to allow first release without baseline.
/// If false, gate fails when no baseline is available.
/// </summary>
public bool AllowFirstRelease { get; init; } = true;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, CveDeltaGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, CveDeltaGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record CveDeltaGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for NewCveSeverityThreshold.</summary>
public double? NewCveSeverityThreshold { get; init; }
/// <summary>Override for OnlyBlockReachable.</summary>
public bool? OnlyBlockReachable { get; init; }
/// <summary>Override for RemediationSlaDays.</summary>
public int? RemediationSlaDays { get; init; }
/// <summary>Override for AllowFirstRelease.</summary>
public bool? AllowFirstRelease { get; init; }
}
/// <summary>
/// Provider for CVE delta data.
/// </summary>
public interface ICveDeltaProvider
{
/// <summary>
/// Gets CVEs from a baseline reference.
/// </summary>
/// <param name="baselineReference">Baseline reference (image digest, release ID, etc.).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>CVE findings from the baseline.</returns>
Task<IReadOnlyList<CveFinding>> GetBaselineCvesAsync(
string baselineReference,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,197 @@
// -----------------------------------------------------------------------------
// CveGateHelpers.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-01 - Gate Infrastructure Extensions
// Description: Helper classes and extension methods for CVE gates
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Static helper methods for creating GateResult instances.
/// Simplifies gate implementation with consistent result creation.
/// </summary>
public static class GateResult
{
/// <summary>
/// Creates a passing gate result.
/// </summary>
public static Gates.GateResult Pass(string gateName, string reason, IEnumerable<string>? warnings = null)
{
var details = ImmutableDictionary<string, object>.Empty;
if (warnings != null)
{
var warningList = warnings.ToList();
if (warningList.Count > 0)
{
details = details.Add("warnings", warningList);
}
}
return new Gates.GateResult
{
GateName = gateName,
Passed = true,
Reason = reason,
Details = details
};
}
/// <summary>
/// Creates a failing gate result.
/// </summary>
public static Gates.GateResult Fail(string gateName, string reason, ImmutableDictionary<string, object>? details = null)
{
return new Gates.GateResult
{
GateName = gateName,
Passed = false,
Reason = reason,
Details = details ?? ImmutableDictionary<string, object>.Empty
};
}
/// <summary>
/// Creates a failing gate result with simple details.
/// </summary>
public static Gates.GateResult Fail(string gateName, string reason, IDictionary<string, object>? details)
{
return new Gates.GateResult
{
GateName = gateName,
Passed = false,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
}
}
/// <summary>
/// Extension methods for PolicyGateContext to support CVE gates.
/// </summary>
public static class PolicyGateContextExtensions
{
private const string CveFindingsKey = "CveFindings";
private const string BaselineCvesKey = "BaselineCves";
private const string BaselineReferenceKey = "BaselineReference";
private const string CveFirstSeenDatesKey = "CveFirstSeenDates";
/// <summary>
/// Gets CVE findings from the context.
/// </summary>
public static IReadOnlyList<CveFinding>? GetCveFindings(this PolicyGateContext context)
{
if (context.Metadata?.TryGetValue(CveFindingsKey, out var findings) == true)
{
// If stored as JSON string, deserialize
if (findings is string json)
{
return System.Text.Json.JsonSerializer.Deserialize<List<CveFinding>>(json);
}
}
// Check extension properties
if (context is ExtendedPolicyGateContext extended)
{
return extended.CveFindings;
}
return null;
}
/// <summary>
/// Gets baseline CVEs from the context.
/// </summary>
public static IReadOnlyList<CveFinding>? GetBaselineCves(this PolicyGateContext context)
{
if (context is ExtendedPolicyGateContext extended)
{
return extended.BaselineCves;
}
return null;
}
/// <summary>
/// Gets baseline reference from the context.
/// </summary>
public static string? GetBaselineReference(this PolicyGateContext context)
{
if (context is ExtendedPolicyGateContext extended)
{
return extended.BaselineReference;
}
return context.Metadata?.TryGetValue(BaselineReferenceKey, out var reference) == true
? reference
: null;
}
/// <summary>
/// Gets CVE first seen dates from the context.
/// </summary>
public static IReadOnlyDictionary<string, DateTimeOffset>? GetCveFirstSeenDates(this PolicyGateContext context)
{
if (context is ExtendedPolicyGateContext extended)
{
return extended.CveFirstSeenDates;
}
return null;
}
}
/// <summary>
/// Extended PolicyGateContext with CVE-specific properties.
/// </summary>
public sealed record ExtendedPolicyGateContext : PolicyGateContext
{
/// <summary>
/// CVE findings for the current release.
/// </summary>
public IReadOnlyList<CveFinding>? CveFindings { get; init; }
/// <summary>
/// CVE findings from the baseline release.
/// </summary>
public IReadOnlyList<CveFinding>? BaselineCves { get; init; }
/// <summary>
/// Baseline reference (image digest, release ID, etc.).
/// </summary>
public string? BaselineReference { get; init; }
/// <summary>
/// Map of CVE ID to first seen date for SLA tracking.
/// </summary>
public IReadOnlyDictionary<string, DateTimeOffset>? CveFirstSeenDates { get; init; }
}
/// <summary>
/// IPolicyGate interface for CVE gates.
/// Simplified interface without MergeResult for CVE-specific gates.
/// </summary>
public interface IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
string Id { get; }
/// <summary>
/// Display name for the gate.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Description of what the gate checks.
/// </summary>
string Description { get; }
/// <summary>
/// Evaluates the gate against the given context.
/// </summary>
Task<Gates.GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default);
}

View File

@@ -0,0 +1,218 @@
// -----------------------------------------------------------------------------
// CveGatesServiceCollectionExtensions.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-07 - Gate Registration and Documentation
// Description: DI registration for CVE policy gates
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Extension methods for registering CVE gates in the DI container.
/// </summary>
public static class CveGatesServiceCollectionExtensions
{
/// <summary>
/// Adds all CVE policy gates to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Optional configuration for gate options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddCvePolicyGates(
this IServiceCollection services,
IConfiguration? configuration = null)
{
// Register EPSS threshold gate
services.AddEpssThresholdGate(configuration);
// Register KEV blocker gate
services.AddKevBlockerGate(configuration);
// Register reachable CVE gate
services.AddReachableCveGate(configuration);
// Register CVE delta gate
services.AddCveDeltaGate(configuration);
// Register release aggregate CVE gate
services.AddReleaseAggregateCveGate(configuration);
return services;
}
/// <summary>
/// Adds the EPSS threshold gate.
/// </summary>
public static IServiceCollection AddEpssThresholdGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<EpssThresholdGateOptions>(
configuration.GetSection(EpssThresholdGateOptions.SectionName));
}
services.AddSingleton<EpssThresholdGate>(sp =>
{
var options = configuration?.GetSection(EpssThresholdGateOptions.SectionName)
.Get<EpssThresholdGateOptions>();
var epssProvider = sp.GetService<IEpssDataProvider>();
return new EpssThresholdGate(
epssProvider ?? new NullEpssDataProvider(),
options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<EpssThresholdGate>());
return services;
}
/// <summary>
/// Adds the KEV blocker gate.
/// </summary>
public static IServiceCollection AddKevBlockerGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<KevBlockerGateOptions>(
configuration.GetSection(KevBlockerGateOptions.SectionName));
}
services.AddSingleton<KevBlockerGate>(sp =>
{
var options = configuration?.GetSection(KevBlockerGateOptions.SectionName)
.Get<KevBlockerGateOptions>();
var kevProvider = sp.GetService<IKevDataProvider>();
return new KevBlockerGate(
kevProvider ?? new NullKevDataProvider(),
options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<KevBlockerGate>());
return services;
}
/// <summary>
/// Adds the reachable CVE gate.
/// </summary>
public static IServiceCollection AddReachableCveGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<ReachableCveGateOptions>(
configuration.GetSection(ReachableCveGateOptions.SectionName));
}
services.AddSingleton<ReachableCveGate>(sp =>
{
var options = configuration?.GetSection(ReachableCveGateOptions.SectionName)
.Get<ReachableCveGateOptions>();
return new ReachableCveGate(options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<ReachableCveGate>());
return services;
}
/// <summary>
/// Adds the CVE delta gate.
/// </summary>
public static IServiceCollection AddCveDeltaGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<CveDeltaGateOptions>(
configuration.GetSection(CveDeltaGateOptions.SectionName));
}
services.AddSingleton<CveDeltaGate>(sp =>
{
var options = configuration?.GetSection(CveDeltaGateOptions.SectionName)
.Get<CveDeltaGateOptions>();
var deltaProvider = sp.GetService<ICveDeltaProvider>();
return new CveDeltaGate(options, deltaProvider);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<CveDeltaGate>());
return services;
}
/// <summary>
/// Adds the release aggregate CVE gate.
/// </summary>
public static IServiceCollection AddReleaseAggregateCveGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<ReleaseAggregateCveGateOptions>(
configuration.GetSection(ReleaseAggregateCveGateOptions.SectionName));
}
services.AddSingleton<ReleaseAggregateCveGate>(sp =>
{
var options = configuration?.GetSection(ReleaseAggregateCveGateOptions.SectionName)
.Get<ReleaseAggregateCveGateOptions>();
return new ReleaseAggregateCveGate(options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<ReleaseAggregateCveGate>());
return services;
}
}
/// <summary>
/// Null EPSS data provider for when no provider is configured.
/// </summary>
internal sealed class NullEpssDataProvider : IEpssDataProvider
{
public Task<EpssScore?> GetScoreAsync(string cveId, CancellationToken ct = default)
=> Task.FromResult<EpssScore?>(null);
public Task<IReadOnlyDictionary<string, EpssScore>> GetScoresBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default)
=> Task.FromResult<IReadOnlyDictionary<string, EpssScore>>(
new Dictionary<string, EpssScore>());
}
/// <summary>
/// Null KEV data provider for when no provider is configured.
/// </summary>
internal sealed class NullKevDataProvider : IKevDataProvider
{
public Task<KevEntry?> GetKevEntryAsync(string cveId, CancellationToken ct = default)
=> Task.FromResult<KevEntry?>(null);
public Task<IReadOnlyDictionary<string, KevEntry>> GetKevEntriesBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default)
=> Task.FromResult<IReadOnlyDictionary<string, KevEntry>>(
new Dictionary<string, KevEntry>());
public Task<bool> IsKevAsync(string cveId, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<DateTimeOffset?> GetCatalogUpdateTimeAsync(CancellationToken ct = default)
=> Task.FromResult<DateTimeOffset?>(null);
}

View File

@@ -0,0 +1,327 @@
// -----------------------------------------------------------------------------
// EpssThresholdGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-02 - EPSS Threshold Gate
// Description: Policy gate that blocks releases based on EPSS exploitation probability
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that blocks releases based on EPSS exploitation probability.
/// EPSS + reachability enables accurate risk-based gating.
/// </summary>
public sealed class EpssThresholdGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "epss-threshold";
private readonly IEpssDataProvider _epssProvider;
private readonly EpssThresholdGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "EPSS Threshold";
/// <inheritdoc />
public string Description => "Blocks releases based on EPSS exploitation probability thresholds";
/// <summary>
/// Creates a new EPSS threshold gate.
/// </summary>
public EpssThresholdGate(
IEpssDataProvider epssProvider,
EpssThresholdGateOptions? options = null)
{
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_options = options ?? new EpssThresholdGateOptions();
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "EPSS threshold gate disabled");
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return GateResult.Pass(Id, "No CVE findings to evaluate");
}
// Batch fetch EPSS scores
var cveIds = cves.Select(c => c.CveId).Distinct().ToList();
var epssScores = await _epssProvider.GetScoresBatchAsync(cveIds, ct);
var violations = new List<EpssViolation>();
var warnings = new List<string>();
foreach (var cve in cves)
{
ct.ThrowIfCancellationRequested();
// Skip if reachability-aware and not reachable
if (envOptions.OnlyReachable && !cve.IsReachable)
{
continue;
}
if (!epssScores.TryGetValue(cve.CveId, out var score))
{
// Handle missing EPSS
HandleMissingEpss(cve, envOptions, warnings, violations);
continue;
}
// Check percentile threshold
if (envOptions.PercentileThreshold.HasValue &&
score.Percentile >= envOptions.PercentileThreshold.Value)
{
violations.Add(new EpssViolation
{
CveId = cve.CveId,
Score = score.Score,
Percentile = score.Percentile,
Threshold = $"percentile >= {envOptions.PercentileThreshold.Value:P0}",
IsReachable = cve.IsReachable
});
}
// Check score threshold
else if (envOptions.ScoreThreshold.HasValue &&
score.Score >= envOptions.ScoreThreshold.Value)
{
violations.Add(new EpssViolation
{
CveId = cve.CveId,
Score = score.Score,
Percentile = score.Percentile,
Threshold = $"score >= {envOptions.ScoreThreshold.Value:F2}",
IsReachable = cve.IsReachable
});
}
}
if (violations.Count > 0)
{
var message = $"EPSS threshold exceeded for {violations.Count} CVE(s): " +
string.Join(", ", violations.Take(5).Select(v =>
$"{v.CveId} (EPSS: {v.Score:F3}, {v.Percentile:P0})"));
if (violations.Count > 5)
{
message += $" and {violations.Count - 5} more";
}
return GateResult.Fail(Id, message);
}
var passMessage = $"EPSS check passed for {cves.Count} CVE(s)";
if (warnings.Count > 0)
{
passMessage += $" ({warnings.Count} warnings)";
}
return GateResult.Pass(Id, passMessage, warnings: warnings);
}
private void HandleMissingEpss(
CveFinding cve,
EpssThresholdGateOptions options,
List<string> warnings,
List<EpssViolation> violations)
{
switch (options.MissingEpssAction)
{
case MissingEpssAction.Allow:
// Silently allow
break;
case MissingEpssAction.Warn:
warnings.Add($"{cve.CveId}: no EPSS score available");
break;
case MissingEpssAction.Fail:
violations.Add(new EpssViolation
{
CveId = cve.CveId,
Score = 0,
Percentile = 0,
Threshold = "EPSS score required but missing",
IsReachable = cve.IsReachable
});
break;
}
}
private EpssThresholdGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
PercentileThreshold = envOverride.PercentileThreshold ?? _options.PercentileThreshold,
ScoreThreshold = envOverride.ScoreThreshold ?? _options.ScoreThreshold,
OnlyReachable = envOverride.OnlyReachable ?? _options.OnlyReachable,
MissingEpssAction = envOverride.MissingEpssAction ?? _options.MissingEpssAction
};
}
return _options;
}
private sealed record EpssViolation
{
public required string CveId { get; init; }
public double Score { get; init; }
public double Percentile { get; init; }
public required string Threshold { get; init; }
public bool IsReachable { get; init; }
}
}
/// <summary>
/// Options for EPSS threshold gate.
/// </summary>
public sealed record EpssThresholdGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:EpssThreshold";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Percentile threshold (0.0-1.0). CVEs at or above this percentile are blocked.
/// Example: 0.75 = block top 25% of exploitable CVEs.
/// </summary>
public double? PercentileThreshold { get; init; } = 0.75;
/// <summary>
/// Score threshold (0.0-1.0). CVEs at or above this score are blocked.
/// Alternative to percentile threshold.
/// </summary>
public double? ScoreThreshold { get; init; }
/// <summary>
/// Whether to only apply to reachable CVEs.
/// If true, unreachable CVEs are ignored regardless of EPSS score.
/// </summary>
public bool OnlyReachable { get; init; } = false;
/// <summary>
/// Action when EPSS score is missing for a CVE.
/// </summary>
public MissingEpssAction MissingEpssAction { get; init; } = MissingEpssAction.Warn;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, EpssThresholdGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, EpssThresholdGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record EpssThresholdGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for PercentileThreshold.</summary>
public double? PercentileThreshold { get; init; }
/// <summary>Override for ScoreThreshold.</summary>
public double? ScoreThreshold { get; init; }
/// <summary>Override for OnlyReachable.</summary>
public bool? OnlyReachable { get; init; }
/// <summary>Override for MissingEpssAction.</summary>
public MissingEpssAction? MissingEpssAction { get; init; }
}
/// <summary>
/// Action when EPSS score is missing.
/// </summary>
public enum MissingEpssAction
{
/// <summary>Allow the CVE to pass.</summary>
Allow,
/// <summary>Pass but log a warning.</summary>
Warn,
/// <summary>Fail the gate.</summary>
Fail
}
/// <summary>
/// Provider for EPSS data.
/// </summary>
public interface IEpssDataProvider
{
/// <summary>
/// Gets EPSS score for a single CVE.
/// </summary>
Task<EpssScore?> GetScoreAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Gets EPSS scores for multiple CVEs.
/// </summary>
Task<IReadOnlyDictionary<string, EpssScore>> GetScoresBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default);
}
/// <summary>
/// EPSS score data.
/// </summary>
public sealed record EpssScore
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>EPSS score (0.0-1.0).</summary>
public required double Score { get; init; }
/// <summary>EPSS percentile (0.0-1.0).</summary>
public required double Percentile { get; init; }
/// <summary>Score date.</summary>
public DateTimeOffset ScoreDate { get; init; }
}
/// <summary>
/// CVE finding for gate evaluation.
/// </summary>
public record CveFinding
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Whether the CVE is reachable.</summary>
public bool IsReachable { get; init; }
/// <summary>CVSS score.</summary>
public double? CvssScore { get; init; }
/// <summary>Affected component PURL.</summary>
public string? ComponentPurl { get; init; }
}

View File

@@ -0,0 +1,270 @@
// -----------------------------------------------------------------------------
// KevBlockerGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-03 - KEV Blocker Gate
// Description: Policy gate that blocks releases containing CISA KEV CVEs
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that blocks releases containing CVEs in the CISA Known Exploited
/// Vulnerabilities (KEV) catalog. KEV entries are actively exploited in the wild.
/// </summary>
public sealed class KevBlockerGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "kev-blocker";
private readonly IKevDataProvider _kevProvider;
private readonly KevBlockerGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "KEV Blocker";
/// <inheritdoc />
public string Description => "Blocks releases containing CVEs in the CISA Known Exploited Vulnerabilities catalog";
/// <summary>
/// Creates a new KEV blocker gate.
/// </summary>
public KevBlockerGate(
IKevDataProvider kevProvider,
KevBlockerGateOptions? options = null)
{
_kevProvider = kevProvider ?? throw new ArgumentNullException(nameof(kevProvider));
_options = options ?? new KevBlockerGateOptions();
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "KEV blocker gate disabled");
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return GateResult.Pass(Id, "No CVE findings to evaluate");
}
// Batch check KEV membership
var cveIds = cves.Select(c => c.CveId).Distinct().ToList();
var kevEntries = await _kevProvider.GetKevEntriesBatchAsync(cveIds, ct);
var violations = new List<KevViolation>();
foreach (var cve in cves)
{
ct.ThrowIfCancellationRequested();
// Skip if reachability-aware and not reachable
if (envOptions.OnlyReachable && !cve.IsReachable)
{
continue;
}
if (kevEntries.TryGetValue(cve.CveId, out var kevEntry))
{
// Check if past due date
var isPastDue = kevEntry.DueDate.HasValue &&
kevEntry.DueDate.Value < DateTimeOffset.UtcNow;
// Check severity filter
if (envOptions.MinimumSeverity.HasValue &&
cve.CvssScore.HasValue &&
cve.CvssScore.Value < envOptions.MinimumSeverity.Value)
{
// Below minimum severity, skip
continue;
}
violations.Add(new KevViolation
{
CveId = cve.CveId,
VendorProject = kevEntry.VendorProject,
Product = kevEntry.Product,
VulnerabilityName = kevEntry.VulnerabilityName,
DueDate = kevEntry.DueDate,
IsPastDue = isPastDue,
IsReachable = cve.IsReachable
});
}
}
if (violations.Count > 0)
{
var pastDueCount = violations.Count(v => v.IsPastDue);
var message = $"Found {violations.Count} CVE(s) in CISA KEV catalog";
if (pastDueCount > 0)
{
message += $" ({pastDueCount} past remediation due date)";
}
message += ": " + string.Join(", ", violations.Take(5).Select(v =>
$"{v.CveId} ({v.VendorProject}/{v.Product})"));
if (violations.Count > 5)
{
message += $" and {violations.Count - 5} more";
}
return GateResult.Fail(Id, message);
}
return GateResult.Pass(Id, $"No KEV entries found among {cves.Count} CVE(s)");
}
private KevBlockerGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
OnlyReachable = envOverride.OnlyReachable ?? _options.OnlyReachable,
MinimumSeverity = envOverride.MinimumSeverity ?? _options.MinimumSeverity,
BlockPastDueOnly = envOverride.BlockPastDueOnly ?? _options.BlockPastDueOnly
};
}
return _options;
}
private sealed record KevViolation
{
public required string CveId { get; init; }
public string? VendorProject { get; init; }
public string? Product { get; init; }
public string? VulnerabilityName { get; init; }
public DateTimeOffset? DueDate { get; init; }
public bool IsPastDue { get; init; }
public bool IsReachable { get; init; }
}
}
/// <summary>
/// Options for KEV blocker gate.
/// </summary>
public sealed record KevBlockerGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:KevBlocker";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Whether to only apply to reachable CVEs.
/// </summary>
public bool OnlyReachable { get; init; } = false;
/// <summary>
/// Minimum CVSS severity to block.
/// Set to 0 to block all KEV CVEs.
/// </summary>
public double? MinimumSeverity { get; init; }
/// <summary>
/// Whether to only block KEV entries past their due date.
/// If true, upcoming KEV entries are allowed.
/// </summary>
public bool BlockPastDueOnly { get; init; } = false;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, KevBlockerGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, KevBlockerGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record KevBlockerGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for OnlyReachable.</summary>
public bool? OnlyReachable { get; init; }
/// <summary>Override for MinimumSeverity.</summary>
public double? MinimumSeverity { get; init; }
/// <summary>Override for BlockPastDueOnly.</summary>
public bool? BlockPastDueOnly { get; init; }
}
/// <summary>
/// Provider for KEV data.
/// </summary>
public interface IKevDataProvider
{
/// <summary>
/// Checks if a CVE is in the KEV catalog.
/// </summary>
Task<KevEntry?> GetKevEntryAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Batch check for KEV membership.
/// </summary>
Task<IReadOnlyDictionary<string, KevEntry>> GetKevEntriesBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default);
/// <summary>
/// Gets the last catalog update timestamp.
/// </summary>
Task<DateTimeOffset?> GetCatalogUpdateTimeAsync(CancellationToken ct = default);
}
/// <summary>
/// KEV catalog entry.
/// </summary>
public sealed record KevEntry
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Vendor/project name.</summary>
public string? VendorProject { get; init; }
/// <summary>Product name.</summary>
public string? Product { get; init; }
/// <summary>Vulnerability name.</summary>
public string? VulnerabilityName { get; init; }
/// <summary>Date added to KEV catalog.</summary>
public DateTimeOffset DateAdded { get; init; }
/// <summary>Short description.</summary>
public string? ShortDescription { get; init; }
/// <summary>Required action.</summary>
public string? RequiredAction { get; init; }
/// <summary>Remediation due date.</summary>
public DateTimeOffset? DueDate { get; init; }
/// <summary>Notes.</summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// ReachableCveGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-04 - Reachable CVE Gate
// Description: Policy gate that blocks only reachable CVEs, reducing noise
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that only blocks CVEs that are confirmed reachable in the application.
/// Reduces false positives by ignoring unreachable vulnerable code.
/// </summary>
public sealed class ReachableCveGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "reachable-cve";
private readonly ReachableCveGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Reachable CVE";
/// <inheritdoc />
public string Description => "Blocks releases containing reachable CVEs above severity threshold";
/// <summary>
/// Creates a new reachable CVE gate.
/// </summary>
public ReachableCveGate(ReachableCveGateOptions? options = null)
{
_options = options ?? new ReachableCveGateOptions();
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return Task.FromResult(GateResult.Pass(Id, "Reachable CVE gate disabled"));
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return Task.FromResult(GateResult.Pass(Id, "No CVE findings to evaluate"));
}
var reachableCves = new List<CveFinding>();
var unknownReachability = new List<CveFinding>();
var unreachableCves = new List<CveFinding>();
foreach (var cve in cves)
{
// Classify by reachability
if (cve.IsReachable)
{
reachableCves.Add(cve);
}
else if (cve.ReachabilityStatus == ReachabilityStatus.Unknown)
{
unknownReachability.Add(cve);
}
else
{
unreachableCves.Add(cve);
}
}
// Filter by severity threshold
var blocking = reachableCves
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.MinimumSeverity)
.ToList();
// Handle unknown reachability
if (envOptions.TreatUnknownAsReachable)
{
var unknownBlocking = unknownReachability
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.MinimumSeverity)
.ToList();
blocking.AddRange(unknownBlocking);
}
var warnings = new List<string>();
// Warn about unknown reachability if not treating as reachable
if (!envOptions.TreatUnknownAsReachable && unknownReachability.Count > 0)
{
warnings.Add($"{unknownReachability.Count} CVE(s) have unknown reachability status");
}
if (blocking.Count > 0)
{
var message = $"Found {blocking.Count} reachable CVE(s) at or above severity {envOptions.MinimumSeverity}: " +
string.Join(", ", blocking.Take(5).Select(c =>
$"{c.CveId} (CVSS: {c.CvssScore:F1})"));
if (blocking.Count > 5)
{
message += $" and {blocking.Count - 5} more";
}
return Task.FromResult(GateResult.Fail(Id, message));
}
var passMessage = $"No blocking reachable CVEs. " +
$"Reachable: {reachableCves.Count}, " +
$"Unreachable: {unreachableCves.Count}, " +
$"Unknown: {unknownReachability.Count}";
return Task.FromResult(GateResult.Pass(Id, passMessage, warnings: warnings));
}
private ReachableCveGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
MinimumSeverity = envOverride.MinimumSeverity ?? _options.MinimumSeverity,
TreatUnknownAsReachable = envOverride.TreatUnknownAsReachable ?? _options.TreatUnknownAsReachable
};
}
return _options;
}
}
/// <summary>
/// Options for reachable CVE gate.
/// </summary>
public sealed record ReachableCveGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:ReachableCve";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Minimum CVSS severity to block.
/// Only reachable CVEs at or above this severity are blocked.
/// Default: 7.0 (High).
/// </summary>
public double MinimumSeverity { get; init; } = 7.0;
/// <summary>
/// Whether to treat CVEs with unknown reachability as reachable.
/// If true, unknown reachability is conservative (blocks).
/// If false, unknown reachability is permissive (allows).
/// Default: false (permissive).
/// </summary>
public bool TreatUnknownAsReachable { get; init; } = false;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, ReachableCveGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, ReachableCveGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record ReachableCveGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for MinimumSeverity.</summary>
public double? MinimumSeverity { get; init; }
/// <summary>Override for TreatUnknownAsReachable.</summary>
public bool? TreatUnknownAsReachable { get; init; }
}
/// <summary>
/// Extended CVE finding with reachability status.
/// </summary>
public sealed record CveFindingWithReachability : CveFinding
{
/// <summary>Detailed reachability status.</summary>
public ReachabilityStatus ReachabilityStatus { get; init; }
/// <summary>Reachability confidence score (0-1).</summary>
public double? ReachabilityConfidence { get; init; }
/// <summary>Whether the CVE has been witnessed at runtime.</summary>
public bool IsWitnessed { get; init; }
}
/// <summary>
/// Reachability determination status.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Reachability not yet determined.</summary>
Unknown,
/// <summary>Confirmed reachable via static analysis.</summary>
ReachableStatic,
/// <summary>Confirmed reachable via runtime witness.</summary>
ReachableWitnessed,
/// <summary>Confirmed not reachable.</summary>
NotReachable,
/// <summary>Partially reachable (some paths blocked).</summary>
PartiallyReachable
}

View File

@@ -0,0 +1,412 @@
// -----------------------------------------------------------------------------
// ReleaseAggregateCveGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-06 - Release Aggregate CVE Gate
// Description: Policy gate that enforces aggregate CVE limits per release
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that enforces aggregate CVE limits per release.
/// Unlike CvssThresholdGate which operates per-finding, this operates per-release.
/// </summary>
public sealed class ReleaseAggregateCveGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "release-aggregate-cve";
private readonly ReleaseAggregateCveGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Release Aggregate CVE";
/// <inheritdoc />
public string Description => "Enforces aggregate CVE count limits per release by severity";
/// <summary>
/// Creates a new release aggregate CVE gate.
/// </summary>
public ReleaseAggregateCveGate(ReleaseAggregateCveGateOptions? options = null)
{
_options = options ?? new ReleaseAggregateCveGateOptions();
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return Task.FromResult(GateResult.Pass(Id, "Release aggregate CVE gate disabled"));
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return Task.FromResult(GateResult.Pass(Id, "No CVE findings in release"));
}
// Filter CVEs based on options
var cvesToCount = FilterCves(cves, envOptions);
// Count by severity
var counts = CountBySeverity(cvesToCount);
// Check limits
var violations = CheckLimits(counts, envOptions);
var warnings = new List<string>();
// Add warnings for near-limit counts
AddNearLimitWarnings(counts, envOptions, warnings);
if (violations.Count > 0)
{
var message = "Release CVE aggregate limits exceeded: " +
string.Join(", ", violations.Select(v =>
$"{v.Severity}: {v.Count}/{v.Limit}"));
return Task.FromResult(GateResult.Fail(Id, message));
}
var passMessage = $"Release CVE counts within limits. " +
$"Critical: {counts.Critical}, High: {counts.High}, Medium: {counts.Medium}, Low: {counts.Low}";
return Task.FromResult(GateResult.Pass(Id, passMessage, warnings: warnings));
}
private IReadOnlyList<CveFinding> FilterCves(
IReadOnlyList<CveFinding> cves,
ReleaseAggregateCveGateOptions options)
{
var filtered = cves.AsEnumerable();
// Filter by suppression status
if (!options.CountSuppressed && cves is IReadOnlyList<CveFindingWithStatus> statusCves)
{
filtered = statusCves.Where(c => !c.IsSuppressed);
}
// Filter by reachability
if (options.OnlyCountReachable)
{
filtered = filtered.Where(c => c.IsReachable);
}
return filtered.ToList();
}
private static CveSeverityCounts CountBySeverity(IReadOnlyList<CveFinding> cves)
{
var critical = 0;
var high = 0;
var medium = 0;
var low = 0;
var unknown = 0;
foreach (var cve in cves)
{
var severity = ClassifySeverity(cve.CvssScore);
switch (severity)
{
case CveSeverity.Critical:
critical++;
break;
case CveSeverity.High:
high++;
break;
case CveSeverity.Medium:
medium++;
break;
case CveSeverity.Low:
low++;
break;
default:
unknown++;
break;
}
}
return new CveSeverityCounts
{
Critical = critical,
High = high,
Medium = medium,
Low = low,
Unknown = unknown,
Total = cves.Count
};
}
private static CveSeverity ClassifySeverity(double? cvssScore)
{
if (!cvssScore.HasValue)
return CveSeverity.Unknown;
return cvssScore.Value switch
{
>= 9.0 => CveSeverity.Critical,
>= 7.0 => CveSeverity.High,
>= 4.0 => CveSeverity.Medium,
>= 0.1 => CveSeverity.Low,
_ => CveSeverity.Unknown
};
}
private static List<LimitViolation> CheckLimits(
CveSeverityCounts counts,
ReleaseAggregateCveGateOptions options)
{
var violations = new List<LimitViolation>();
if (options.MaxCritical.HasValue && counts.Critical > options.MaxCritical.Value)
{
violations.Add(new LimitViolation
{
Severity = "Critical",
Count = counts.Critical,
Limit = options.MaxCritical.Value
});
}
if (options.MaxHigh.HasValue && counts.High > options.MaxHigh.Value)
{
violations.Add(new LimitViolation
{
Severity = "High",
Count = counts.High,
Limit = options.MaxHigh.Value
});
}
if (options.MaxMedium.HasValue && counts.Medium > options.MaxMedium.Value)
{
violations.Add(new LimitViolation
{
Severity = "Medium",
Count = counts.Medium,
Limit = options.MaxMedium.Value
});
}
if (options.MaxLow.HasValue && counts.Low > options.MaxLow.Value)
{
violations.Add(new LimitViolation
{
Severity = "Low",
Count = counts.Low,
Limit = options.MaxLow.Value
});
}
if (options.MaxTotal.HasValue && counts.Total > options.MaxTotal.Value)
{
violations.Add(new LimitViolation
{
Severity = "Total",
Count = counts.Total,
Limit = options.MaxTotal.Value
});
}
return violations;
}
private static void AddNearLimitWarnings(
CveSeverityCounts counts,
ReleaseAggregateCveGateOptions options,
List<string> warnings)
{
const double WarningThreshold = 0.8; // Warn at 80% of limit
if (options.MaxCritical.HasValue && counts.Critical > 0)
{
var ratio = (double)counts.Critical / options.MaxCritical.Value;
if (ratio >= WarningThreshold && ratio < 1.0)
{
warnings.Add($"Critical CVE count ({counts.Critical}) approaching limit ({options.MaxCritical.Value})");
}
}
if (options.MaxHigh.HasValue && counts.High > 0)
{
var ratio = (double)counts.High / options.MaxHigh.Value;
if (ratio >= WarningThreshold && ratio < 1.0)
{
warnings.Add($"High CVE count ({counts.High}) approaching limit ({options.MaxHigh.Value})");
}
}
}
private ReleaseAggregateCveGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
MaxCritical = envOverride.MaxCritical ?? _options.MaxCritical,
MaxHigh = envOverride.MaxHigh ?? _options.MaxHigh,
MaxMedium = envOverride.MaxMedium ?? _options.MaxMedium,
MaxLow = envOverride.MaxLow ?? _options.MaxLow,
MaxTotal = envOverride.MaxTotal ?? _options.MaxTotal,
CountSuppressed = envOverride.CountSuppressed ?? _options.CountSuppressed,
OnlyCountReachable = envOverride.OnlyCountReachable ?? _options.OnlyCountReachable
};
}
return _options;
}
private sealed record LimitViolation
{
public required string Severity { get; init; }
public int Count { get; init; }
public int Limit { get; init; }
}
private sealed record CveSeverityCounts
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Unknown { get; init; }
public int Total { get; init; }
}
}
/// <summary>
/// CVE severity classification.
/// </summary>
public enum CveSeverity
{
/// <summary>Unknown severity.</summary>
Unknown,
/// <summary>Low severity (CVSS 0.1-3.9).</summary>
Low,
/// <summary>Medium severity (CVSS 4.0-6.9).</summary>
Medium,
/// <summary>High severity (CVSS 7.0-8.9).</summary>
High,
/// <summary>Critical severity (CVSS 9.0-10.0).</summary>
Critical
}
/// <summary>
/// Options for release aggregate CVE gate.
/// </summary>
public sealed record ReleaseAggregateCveGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:ReleaseAggregateCve";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Maximum allowed critical CVEs (CVSS 9.0+).
/// Default: 0 (no critical CVEs allowed in production).
/// </summary>
public int? MaxCritical { get; init; } = 0;
/// <summary>
/// Maximum allowed high CVEs (CVSS 7.0-8.9).
/// Default: 3.
/// </summary>
public int? MaxHigh { get; init; } = 3;
/// <summary>
/// Maximum allowed medium CVEs (CVSS 4.0-6.9).
/// Default: 20.
/// </summary>
public int? MaxMedium { get; init; } = 20;
/// <summary>
/// Maximum allowed low CVEs (CVSS 0.1-3.9).
/// Null means unlimited.
/// </summary>
public int? MaxLow { get; init; }
/// <summary>
/// Maximum total CVEs regardless of severity.
/// Null means no total limit.
/// </summary>
public int? MaxTotal { get; init; }
/// <summary>
/// Whether to count suppressed/excepted CVEs.
/// If false, suppressed CVEs are excluded from counts.
/// </summary>
public bool CountSuppressed { get; init; } = false;
/// <summary>
/// Whether to only count reachable CVEs.
/// If true, unreachable CVEs are excluded from counts.
/// </summary>
public bool OnlyCountReachable { get; init; } = false;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, ReleaseAggregateCveGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, ReleaseAggregateCveGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record ReleaseAggregateCveGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for MaxCritical.</summary>
public int? MaxCritical { get; init; }
/// <summary>Override for MaxHigh.</summary>
public int? MaxHigh { get; init; }
/// <summary>Override for MaxMedium.</summary>
public int? MaxMedium { get; init; }
/// <summary>Override for MaxLow.</summary>
public int? MaxLow { get; init; }
/// <summary>Override for MaxTotal.</summary>
public int? MaxTotal { get; init; }
/// <summary>Override for CountSuppressed.</summary>
public bool? CountSuppressed { get; init; }
/// <summary>Override for OnlyCountReachable.</summary>
public bool? OnlyCountReachable { get; init; }
}
/// <summary>
/// CVE finding with suppression status.
/// </summary>
public sealed record CveFindingWithStatus : CveFinding
{
/// <summary>Whether the CVE is suppressed/excepted.</summary>
public bool IsSuppressed { get; init; }
/// <summary>Exception ID if suppressed.</summary>
public string? ExceptionId { get; init; }
/// <summary>Exception expiry date.</summary>
public DateTimeOffset? ExceptionExpiry { get; init; }
}

View File

@@ -0,0 +1,325 @@
// -----------------------------------------------------------------------------
// HttpOpaClient.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: HTTP client implementation for Open Policy Agent (OPA)
// -----------------------------------------------------------------------------
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Gates.Opa;
/// <summary>
/// HTTP client for interacting with an external OPA server.
/// </summary>
public sealed class HttpOpaClient : IOpaClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<HttpOpaClient> _logger;
private readonly OpaClientOptions _options;
private readonly bool _ownsHttpClient;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Creates a new HTTP OPA client with the specified options.
/// </summary>
public HttpOpaClient(
IOptions<OpaClientOptions> options,
ILogger<HttpOpaClient> logger,
HttpClient? httpClient = null)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (httpClient is null)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(_options.BaseUrl),
Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
/// <inheritdoc />
public async Task<OpaEvaluationResult> EvaluateAsync(
string policyPath,
object input,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyPath);
ArgumentNullException.ThrowIfNull(input);
try
{
var requestPath = BuildQueryPath(policyPath);
var request = new OpaQueryRequest { Input = input };
_logger.LogDebug("Evaluating OPA policy at {Path}", requestPath);
var response = await _httpClient.PostAsJsonAsync(requestPath, request, JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"OPA evaluation failed: {StatusCode} - {Error}",
response.StatusCode, errorContent);
return new OpaEvaluationResult
{
Success = false,
Error = $"OPA returned {response.StatusCode}: {errorContent}"
};
}
var result = await response.Content.ReadFromJsonAsync<OpaQueryResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return new OpaEvaluationResult
{
Success = true,
DecisionId = result?.DecisionId,
Result = result?.Result,
Metrics = result?.Metrics is not null ? MapMetrics(result.Metrics) : null
};
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error connecting to OPA at {BaseUrl}", _options.BaseUrl);
return new OpaEvaluationResult
{
Success = false,
Error = $"HTTP error: {ex.Message}"
};
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
_logger.LogError(ex, "OPA request timed out after {Timeout}s", _options.TimeoutSeconds);
return new OpaEvaluationResult
{
Success = false,
Error = $"Request timed out after {_options.TimeoutSeconds} seconds"
};
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse OPA response");
return new OpaEvaluationResult
{
Success = false,
Error = $"JSON parse error: {ex.Message}"
};
}
}
/// <inheritdoc />
public async Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
string policyPath,
object input,
CancellationToken cancellationToken = default)
{
var result = await EvaluateAsync(policyPath, input, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
return new OpaTypedResult<TResult>
{
Success = false,
DecisionId = result.DecisionId,
Error = result.Error,
Metrics = result.Metrics
};
}
try
{
var typedResult = default(TResult);
if (result.Result is JsonElement jsonElement)
{
typedResult = jsonElement.Deserialize<TResult>(JsonOptions);
}
else if (result.Result is TResult directResult)
{
typedResult = directResult;
}
else if (result.Result is not null)
{
// Try re-serializing and deserializing
var json = JsonSerializer.Serialize(result.Result, JsonOptions);
typedResult = JsonSerializer.Deserialize<TResult>(json, JsonOptions);
}
return new OpaTypedResult<TResult>
{
Success = true,
DecisionId = result.DecisionId,
Result = typedResult,
Metrics = result.Metrics
};
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize OPA result to {Type}", typeof(TResult).Name);
return new OpaTypedResult<TResult>
{
Success = false,
DecisionId = result.DecisionId,
Error = $"Failed to deserialize result: {ex.Message}",
Metrics = result.Metrics
};
}
}
/// <inheritdoc />
public async Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync("health", cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OPA health check failed");
return false;
}
}
/// <inheritdoc />
public async Task UploadPolicyAsync(
string policyId,
string regoContent,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
ArgumentException.ThrowIfNullOrWhiteSpace(regoContent);
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
using var content = new StringContent(regoContent, System.Text.Encoding.UTF8, "text/plain");
var response = await _httpClient.PutAsync(requestPath, content, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to upload policy: {response.StatusCode} - {errorContent}");
}
_logger.LogInformation("Uploaded policy {PolicyId} to OPA", policyId);
}
/// <inheritdoc />
public async Task DeletePolicyAsync(
string policyId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
var response = await _httpClient.DeleteAsync(requestPath, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to delete policy: {response.StatusCode} - {errorContent}");
}
_logger.LogInformation("Deleted policy {PolicyId} from OPA", policyId);
}
/// <summary>
/// Disposes the HTTP client if owned.
/// </summary>
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
private string BuildQueryPath(string policyPath)
{
// Normalize path: remove leading "data/" if present
var normalizedPath = policyPath.TrimStart('/');
if (normalizedPath.StartsWith("data/", StringComparison.OrdinalIgnoreCase))
{
normalizedPath = normalizedPath[5..];
}
// Use v1/data endpoint for queries
return $"v1/data/{normalizedPath}?metrics={_options.IncludeMetrics.ToString().ToLowerInvariant()}";
}
private static OpaMetrics MapMetrics(Dictionary<string, long> metrics) => new()
{
TimerRegoQueryCompileNs = metrics.GetValueOrDefault("timer_rego_query_compile_ns"),
TimerRegoQueryEvalNs = metrics.GetValueOrDefault("timer_rego_query_eval_ns"),
TimerServerHandlerNs = metrics.GetValueOrDefault("timer_server_handler_ns")
};
private sealed record OpaQueryRequest
{
public required object Input { get; init; }
}
private sealed record OpaQueryResponse
{
[JsonPropertyName("decision_id")]
public string? DecisionId { get; init; }
public object? Result { get; init; }
public Dictionary<string, long>? Metrics { get; init; }
}
}
/// <summary>
/// Configuration options for the OPA client.
/// </summary>
public sealed class OpaClientOptions
{
/// <summary>
/// Section name in configuration.
/// </summary>
public const string SectionName = "Opa";
/// <summary>
/// Base URL of the OPA server (e.g., "http://localhost:8181").
/// </summary>
public string BaseUrl { get; set; } = "http://localhost:8181";
/// <summary>
/// Request timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 10;
/// <summary>
/// Whether to include metrics in responses.
/// </summary>
public bool IncludeMetrics { get; set; } = true;
/// <summary>
/// Optional API key for authenticated OPA servers.
/// </summary>
public string? ApiKey { get; set; }
}

View File

@@ -0,0 +1,150 @@
// -----------------------------------------------------------------------------
// IOpaClient.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: Interface for Open Policy Agent (OPA) client
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Opa;
/// <summary>
/// Client interface for interacting with Open Policy Agent (OPA).
/// </summary>
public interface IOpaClient
{
/// <summary>
/// Evaluates a policy decision against OPA.
/// </summary>
/// <param name="policyPath">The policy path (e.g., "data/stella/attestation/allow").</param>
/// <param name="input">The input data for policy evaluation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The policy evaluation result.</returns>
Task<OpaEvaluationResult> EvaluateAsync(
string policyPath,
object input,
CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates a policy and returns a typed result.
/// </summary>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="policyPath">The policy path.</param>
/// <param name="input">The input data for policy evaluation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The typed policy evaluation result.</returns>
Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
string policyPath,
object input,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks OPA server health.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if OPA is healthy.</returns>
Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Uploads a policy to OPA.
/// </summary>
/// <param name="policyId">Unique policy identifier.</param>
/// <param name="regoContent">The Rego policy content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UploadPolicyAsync(
string policyId,
string regoContent,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a policy from OPA.
/// </summary>
/// <param name="policyId">The policy identifier to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeletePolicyAsync(
string policyId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an OPA policy evaluation.
/// </summary>
public sealed record OpaEvaluationResult
{
/// <summary>
/// Whether the evaluation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The decision ID for tracing.
/// </summary>
public string? DecisionId { get; init; }
/// <summary>
/// The raw result object.
/// </summary>
public object? Result { get; init; }
/// <summary>
/// Error message if evaluation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Metrics from OPA (timing, etc.).
/// </summary>
public OpaMetrics? Metrics { get; init; }
}
/// <summary>
/// Typed result of an OPA policy evaluation.
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
public sealed record OpaTypedResult<T>
{
/// <summary>
/// Whether the evaluation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The decision ID for tracing.
/// </summary>
public string? DecisionId { get; init; }
/// <summary>
/// The typed result.
/// </summary>
public T? Result { get; init; }
/// <summary>
/// Error message if evaluation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Metrics from OPA.
/// </summary>
public OpaMetrics? Metrics { get; init; }
}
/// <summary>
/// Metrics from OPA policy evaluation.
/// </summary>
public sealed record OpaMetrics
{
/// <summary>
/// Time taken to compile the query (nanoseconds).
/// </summary>
public long? TimerRegoQueryCompileNs { get; init; }
/// <summary>
/// Time taken to evaluate the query (nanoseconds).
/// </summary>
public long? TimerRegoQueryEvalNs { get; init; }
/// <summary>
/// Total server handler time (nanoseconds).
/// </summary>
public long? TimerServerHandlerNs { get; init; }
}

View File

@@ -0,0 +1,251 @@
// -----------------------------------------------------------------------------
// OpaGateAdapter.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: Adapter that wraps OPA policy evaluation as an IPolicyGate
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates.Opa;
/// <summary>
/// Adapter that wraps an OPA policy evaluation as an <see cref="IPolicyGate"/>.
/// This enables Rego policies to be used alongside C# gates in the gate registry.
/// </summary>
public sealed class OpaGateAdapter : IPolicyGate
{
private readonly IOpaClient _opaClient;
private readonly ILogger<OpaGateAdapter> _logger;
private readonly OpaGateOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public OpaGateAdapter(
IOpaClient opaClient,
IOptions<OpaGateOptions> options,
ILogger<OpaGateAdapter> logger)
{
_opaClient = opaClient ?? throw new ArgumentNullException(nameof(opaClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
try
{
var input = BuildOpaInput(mergeResult, context);
_logger.LogDebug(
"Evaluating OPA gate {GateName} at policy path {PolicyPath}",
_options.GateName, _options.PolicyPath);
var result = await _opaClient.EvaluateAsync<OpaGateResult>(
_options.PolicyPath,
input,
ct).ConfigureAwait(false);
if (!result.Success)
{
_logger.LogWarning(
"OPA gate {GateName} evaluation failed: {Error}",
_options.GateName, result.Error);
return BuildFailureResult(
_options.FailOnError ? false : true,
$"OPA evaluation error: {result.Error}");
}
var opaResult = result.Result;
if (opaResult is null)
{
_logger.LogWarning("OPA gate {GateName} returned null result", _options.GateName);
return BuildFailureResult(
_options.FailOnError ? false : true,
"OPA returned null result");
}
var passed = opaResult.Allow ?? false;
var reason = opaResult.Reason ?? (passed ? "Policy allowed" : "Policy denied");
_logger.LogDebug(
"OPA gate {GateName} result: Passed={Passed}, Reason={Reason}",
_options.GateName, passed, reason);
return new GateResult
{
GateName = _options.GateName,
Passed = passed,
Reason = reason,
Details = BuildDetails(result.DecisionId, opaResult, result.Metrics)
};
}
catch (Exception ex)
{
_logger.LogError(ex, "OPA gate {GateName} threw exception", _options.GateName);
return BuildFailureResult(
_options.FailOnError ? false : true,
$"OPA gate exception: {ex.Message}");
}
}
private object BuildOpaInput(MergeResult mergeResult, PolicyGateContext context)
{
// Build a comprehensive input object for OPA evaluation
return new
{
MergeResult = new
{
mergeResult.Findings,
mergeResult.TotalFindings,
mergeResult.CriticalCount,
mergeResult.HighCount,
mergeResult.MediumCount,
mergeResult.LowCount,
mergeResult.UnknownCount,
mergeResult.NewFindings,
mergeResult.RemovedFindings,
mergeResult.UnchangedFindings
},
Context = new
{
context.Environment,
context.UnknownCount,
context.HasReachabilityProof,
context.Severity,
context.CveId,
context.SubjectKey,
ReasonCodes = context.ReasonCodes.ToArray()
},
Policy = new
{
_options.TrustedKeyIds,
_options.IntegratedTimeCutoff,
_options.AllowedPayloadTypes,
_options.CustomData
}
};
}
private GateResult BuildFailureResult(bool passed, string reason)
{
return new GateResult
{
GateName = _options.GateName,
Passed = passed,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty
};
}
private ImmutableDictionary<string, object> BuildDetails(
string? decisionId,
OpaGateResult opaResult,
OpaMetrics? metrics)
{
var builder = ImmutableDictionary.CreateBuilder<string, object>();
if (decisionId is not null)
{
builder.Add("opaDecisionId", decisionId);
}
if (opaResult.Violations is not null && opaResult.Violations.Count > 0)
{
builder.Add("violations", opaResult.Violations);
}
if (opaResult.Warnings is not null && opaResult.Warnings.Count > 0)
{
builder.Add("warnings", opaResult.Warnings);
}
if (metrics is not null)
{
builder.Add("opaEvalTimeNs", metrics.TimerRegoQueryEvalNs ?? 0);
}
return builder.ToImmutable();
}
/// <summary>
/// Expected structure of the OPA gate evaluation result.
/// </summary>
private sealed record OpaGateResult
{
/// <summary>
/// Whether the policy allows the action.
/// </summary>
public bool? Allow { get; init; }
/// <summary>
/// Human-readable reason for the decision.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// List of policy violations (if denied).
/// </summary>
public IReadOnlyList<string>? Violations { get; init; }
/// <summary>
/// List of policy warnings (even if allowed).
/// </summary>
public IReadOnlyList<string>? Warnings { get; init; }
}
}
/// <summary>
/// Configuration options for an OPA gate adapter.
/// </summary>
public sealed class OpaGateOptions
{
/// <summary>
/// Name of the gate (used in results and logging).
/// </summary>
public string GateName { get; set; } = "OpaGate";
/// <summary>
/// The OPA policy path to evaluate (e.g., "stella/attestation/allow").
/// </summary>
public string PolicyPath { get; set; } = "stella/policy/allow";
/// <summary>
/// Whether to fail the gate if OPA evaluation fails.
/// If false, gate passes on OPA errors.
/// </summary>
public bool FailOnError { get; set; } = true;
/// <summary>
/// List of trusted key IDs to pass to the policy.
/// </summary>
public IReadOnlyList<string> TrustedKeyIds { get; set; } = [];
/// <summary>
/// Integrated time cutoff for Rekor freshness checks.
/// </summary>
public DateTimeOffset? IntegratedTimeCutoff { get; set; }
/// <summary>
/// List of allowed payload types.
/// </summary>
public IReadOnlyList<string> AllowedPayloadTypes { get; set; } = [];
/// <summary>
/// Custom data to pass to the policy.
/// </summary>
public IReadOnlyDictionary<string, object>? CustomData { get; set; }
}

View File

@@ -0,0 +1,187 @@
# -----------------------------------------------------------------------------
# attestation.rego
# Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
# Task: TASK-017-007 - OPA Client Integration
# Description: Sample Rego policy for attestation verification
# -----------------------------------------------------------------------------
package stella.attestation
import future.keywords.if
import future.keywords.in
import future.keywords.contains
# Default deny
default allow := false
# Allow if all attestation checks pass
allow if {
valid_payload_type
trusted_key
rekor_fresh_enough
vex_status_acceptable
}
# Build comprehensive response
result := {
"allow": allow,
"reason": reason,
"violations": violations,
"warnings": warnings,
}
# Determine reason for decision
reason := "All attestation checks passed" if {
allow
}
reason := concat("; ", violations) if {
not allow
count(violations) > 0
}
reason := "Unknown policy failure" if {
not allow
count(violations) == 0
}
# Collect all violations
violations contains msg if {
not valid_payload_type
msg := sprintf("Invalid payload type: got %v, expected one of %v",
[input.attestation.payloadType, input.policy.allowedPayloadTypes])
}
violations contains msg if {
not trusted_key
msg := sprintf("Untrusted signing key: %v not in trusted set",
[input.attestation.keyId])
}
violations contains msg if {
not rekor_fresh_enough
msg := sprintf("Rekor proof too old or too new: integratedTime %v outside valid range",
[input.rekor.integratedTime])
}
violations contains msg if {
some vuln in input.vex.vulnerabilities
vuln.status == "affected"
vuln.reachable == true
msg := sprintf("Reachable vulnerability with affected status: %v", [vuln.id])
}
# Collect warnings
warnings contains msg if {
some vuln in input.vex.vulnerabilities
vuln.status == "under_investigation"
msg := sprintf("Vulnerability under investigation: %v", [vuln.id])
}
warnings contains msg if {
input.rekor.integratedTime
time_since_integrated := time.now_ns() / 1000000000 - input.rekor.integratedTime
time_since_integrated > 86400 * 7 # More than 7 days old
msg := sprintf("Rekor proof is %v days old", [time_since_integrated / 86400])
}
# Check payload type is in allowed list
valid_payload_type if {
input.attestation.payloadType in input.policy.allowedPayloadTypes
}
valid_payload_type if {
count(input.policy.allowedPayloadTypes) == 0 # No restrictions
}
# Check if signing key is trusted
trusted_key if {
input.attestation.keyId in input.policy.trustedKeyIds
}
trusted_key if {
count(input.policy.trustedKeyIds) == 0 # No restrictions
}
# Check if the key fingerprint matches
trusted_key if {
some key in input.policy.trustedKeys
key.fingerprint == input.attestation.fingerprint
key.active == true
not key.revoked
}
# Check Rekor freshness
rekor_fresh_enough if {
not input.policy.integratedTimeCutoff # No cutoff set
}
rekor_fresh_enough if {
input.rekor.integratedTime
input.rekor.integratedTime <= input.policy.integratedTimeCutoff
}
rekor_fresh_enough if {
not input.rekor.integratedTime
not input.policy.requireRekorProof
}
# Check VEX status
vex_status_acceptable if {
not input.vex # No VEX data
}
vex_status_acceptable if {
not affected_and_reachable
}
# Helper: check if any vulnerability is both affected and reachable
affected_and_reachable if {
some vuln in input.vex.vulnerabilities
vuln.status == "affected"
vuln.reachable == true
}
# -----------------------------------------------------------------------------
# Additional policy rules for composite checks
# -----------------------------------------------------------------------------
# Minimum confidence score check
minimum_confidence_met if {
input.context.confidenceScore >= input.policy.minimumConfidence
}
minimum_confidence_met if {
not input.policy.minimumConfidence
}
# SBOM presence check
sbom_present if {
input.artifacts.sbom
input.artifacts.sbom.present == true
}
sbom_present if {
not input.policy.requireSbom
}
# Signature algorithm allowlist
allowed_algorithm if {
input.attestation.algorithm in input.policy.allowedAlgorithms
}
allowed_algorithm if {
count(input.policy.allowedAlgorithms) == 0
}
# Environment-specific rules
production_ready if {
input.context.environment != "production"
}
production_ready if {
input.context.environment == "production"
minimum_confidence_met
sbom_present
allowed_algorithm
}

View File

@@ -0,0 +1,372 @@
// -----------------------------------------------------------------------------
// RuntimeWitnessGate.cs
// Sprint: SPRINT_20260118_018_Policy_runtime_witness_gate
// Tasks: TASK-018-001 through TASK-018-006
// Description: Policy gate requiring runtime witness confirmation
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.RuntimeWitness;
/// <summary>
/// Policy gate that requires runtime witness confirmation for reachability claims.
/// Follows VexProofGate anchor-aware pattern.
/// </summary>
public sealed class RuntimeWitnessGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "runtime-witness";
private readonly IWitnessVerifier? _verifier;
private readonly RuntimeWitnessGateOptions _options;
private readonly TimeProvider _timeProvider;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Runtime Witness";
/// <inheritdoc />
public string Description => "Requires runtime witness confirmation for reachability claims";
/// <summary>
/// Creates a new runtime witness gate.
/// </summary>
public RuntimeWitnessGate(
IWitnessVerifier? verifier = null,
RuntimeWitnessGateOptions? options = null,
TimeProvider? timeProvider = null)
{
_verifier = verifier;
_options = options ?? new RuntimeWitnessGateOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "Runtime witness gate disabled");
}
// Get environment-specific options
var envOptions = GetEnvironmentOptions(context.Environment);
// Get findings with reachability evidence
var findings = context.GetReachabilityFindings();
if (findings == null || findings.Count == 0)
{
if (envOptions.RequireRuntimeWitness)
{
return GateResult.Fail(Id, "No reachability findings to verify");
}
return GateResult.Pass(Id, "No reachability findings - skipping witness check");
}
var witnessed = 0;
var unwitnessed = 0;
var warnings = new List<string>();
var failures = new List<string>();
foreach (var finding in findings)
{
ct.ThrowIfCancellationRequested();
var result = await EvaluateFindingAsync(finding, envOptions, ct);
if (result.IsWitnessed)
{
witnessed++;
// Check freshness
if (result.WitnessAge > TimeSpan.FromHours(envOptions.MaxWitnessAgeHours))
{
if (envOptions.AllowUnwitnessedAdvisory)
{
warnings.Add($"{finding.VulnerabilityId}: witness expired ({result.WitnessAge.TotalHours:F1}h)");
}
else
{
failures.Add($"{finding.VulnerabilityId}: witness expired ({result.WitnessAge.TotalHours:F1}h)");
}
}
// Check confidence
if (result.MatchConfidence < envOptions.MinMatchConfidence)
{
warnings.Add($"{finding.VulnerabilityId}: low match confidence ({result.MatchConfidence:P0})");
}
}
else
{
unwitnessed++;
if (envOptions.RequireRuntimeWitness && !envOptions.AllowUnwitnessedAdvisory)
{
failures.Add($"{finding.VulnerabilityId}: no runtime witness found");
}
else if (envOptions.RequireRuntimeWitness)
{
warnings.Add($"{finding.VulnerabilityId}: no runtime witness (advisory)");
}
}
}
// Determine result
if (failures.Count > 0)
{
return GateResult.Fail(Id,
$"Runtime witness check failed: {string.Join("; ", failures)}");
}
var message = $"Witnessed: {witnessed}/{findings.Count}";
if (warnings.Count > 0)
{
message += $" (warnings: {warnings.Count})";
}
return GateResult.Pass(Id, message, warnings: warnings);
}
private async Task<WitnessEvaluationResult> EvaluateFindingAsync(
ReachabilityFinding finding,
RuntimeWitnessGateOptions envOptions,
CancellationToken ct)
{
// Check if finding has witness evidence
if (finding.WitnessDigest == null)
{
return new WitnessEvaluationResult { IsWitnessed = false };
}
// If verifier is available, do full verification
if (_verifier != null && finding.ClaimId != null)
{
var verification = await _verifier.VerifyAsync(finding.ClaimId, ct);
if (verification.Status == WitnessVerificationStatus.Verified && verification.BestMatch != null)
{
var now = _timeProvider.GetUtcNow();
var age = verification.BestMatch.IntegratedTime.HasValue
? now - verification.BestMatch.IntegratedTime.Value
: TimeSpan.Zero;
return new WitnessEvaluationResult
{
IsWitnessed = true,
WitnessAge = age,
MatchConfidence = verification.BestMatch.Confidence,
ObservationCount = 1,
IsRekorAnchored = verification.BestMatch.RekorVerified
};
}
}
// Fall back to metadata check
return new WitnessEvaluationResult
{
IsWitnessed = finding.WitnessedAt.HasValue,
WitnessAge = finding.WitnessedAt.HasValue
? _timeProvider.GetUtcNow() - finding.WitnessedAt.Value
: TimeSpan.Zero,
MatchConfidence = 1.0, // Assume full confidence for metadata-only
ObservationCount = 1
};
}
private RuntimeWitnessGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
{
return _options;
}
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
RequireRuntimeWitness = envOverride.RequireRuntimeWitness ?? _options.RequireRuntimeWitness,
MaxWitnessAgeHours = envOverride.MaxWitnessAgeHours ?? _options.MaxWitnessAgeHours,
MinObservationCount = envOverride.MinObservationCount ?? _options.MinObservationCount,
RequireRekorAnchoring = envOverride.RequireRekorAnchoring ?? _options.RequireRekorAnchoring,
MinMatchConfidence = envOverride.MinMatchConfidence ?? _options.MinMatchConfidence,
AllowUnwitnessedAdvisory = envOverride.AllowUnwitnessedAdvisory ?? _options.AllowUnwitnessedAdvisory
};
}
return _options;
}
private sealed record WitnessEvaluationResult
{
public bool IsWitnessed { get; init; }
public TimeSpan WitnessAge { get; init; }
public double MatchConfidence { get; init; }
public int ObservationCount { get; init; }
public bool IsRekorAnchored { get; init; }
}
}
/// <summary>
/// Options for runtime witness gate.
/// </summary>
public sealed record RuntimeWitnessGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:RuntimeWitness";
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Whether to require runtime witnesses (false = opt-in).
/// </summary>
public bool RequireRuntimeWitness { get; init; } = false;
/// <summary>
/// Maximum age for witnesses in hours.
/// Default: 168 (7 days), following VexProofGate convention.
/// </summary>
public int MaxWitnessAgeHours { get; init; } = 168;
/// <summary>
/// Minimum number of observations required.
/// </summary>
public int MinObservationCount { get; init; } = 1;
/// <summary>
/// Whether to require Rekor anchoring for witnesses.
/// </summary>
public bool RequireRekorAnchoring { get; init; } = true;
/// <summary>
/// Minimum match confidence threshold.
/// </summary>
public double MinMatchConfidence { get; init; } = 0.8;
/// <summary>
/// Whether to pass with advisory for unwitnessed paths.
/// If false, unwitnessed paths cause gate failure.
/// </summary>
public bool AllowUnwitnessedAdvisory { get; init; } = true;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, RuntimeWitnessGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, RuntimeWitnessGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides for runtime witness gate.
/// </summary>
public sealed record RuntimeWitnessGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for RequireRuntimeWitness.</summary>
public bool? RequireRuntimeWitness { get; init; }
/// <summary>Override for MaxWitnessAgeHours.</summary>
public int? MaxWitnessAgeHours { get; init; }
/// <summary>Override for MinObservationCount.</summary>
public int? MinObservationCount { get; init; }
/// <summary>Override for RequireRekorAnchoring.</summary>
public bool? RequireRekorAnchoring { get; init; }
/// <summary>Override for MinMatchConfidence.</summary>
public double? MinMatchConfidence { get; init; }
/// <summary>Override for AllowUnwitnessedAdvisory.</summary>
public bool? AllowUnwitnessedAdvisory { get; init; }
}
/// <summary>
/// A reachability finding for gate evaluation.
/// </summary>
public sealed record ReachabilityFinding
{
/// <summary>Vulnerability ID.</summary>
public required string VulnerabilityId { get; init; }
/// <summary>Claim ID for witness lookup.</summary>
public string? ClaimId { get; init; }
/// <summary>Witness digest if witnessed.</summary>
public string? WitnessDigest { get; init; }
/// <summary>When the finding was witnessed.</summary>
public DateTimeOffset? WitnessedAt { get; init; }
/// <summary>Whether the path is reachable.</summary>
public bool IsReachable { get; init; }
/// <summary>Component PURL.</summary>
public string? ComponentPurl { get; init; }
}
/// <summary>
/// Interface for witness verification in gate context.
/// </summary>
public interface IWitnessVerifier
{
/// <summary>
/// Verifies witnesses for a claim.
/// </summary>
Task<WitnessVerificationResult> VerifyAsync(string claimId, CancellationToken ct = default);
}
/// <summary>
/// Result of witness verification.
/// </summary>
public sealed record WitnessVerificationResult
{
/// <summary>Verification status.</summary>
public required WitnessVerificationStatus Status { get; init; }
/// <summary>Best matching witness.</summary>
public WitnessMatchResult? BestMatch { get; init; }
}
/// <summary>
/// Verification status.
/// </summary>
public enum WitnessVerificationStatus
{
/// <summary>Verified successfully.</summary>
Verified,
/// <summary>No witness found.</summary>
NoWitnessFound,
/// <summary>Verification failed.</summary>
Failed
}
/// <summary>
/// Match result for a witness.
/// </summary>
public sealed record WitnessMatchResult
{
/// <summary>Match confidence (0.0-1.0).</summary>
public double Confidence { get; init; }
/// <summary>Integrated time from Rekor.</summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>Whether Rekor verification passed.</summary>
public bool RekorVerified { get; init; }
}

View File

@@ -0,0 +1,433 @@
// -----------------------------------------------------------------------------
// IUnknownsGateChecker.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-003 - Implement fail-closed gate integration
// Description: Interface and implementation for unknowns gate checking
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Result of an unknowns gate check.
/// </summary>
public sealed record UnknownsGateCheckResult
{
/// <summary>
/// Gate decision: pass, warn, or block.
/// </summary>
public required GateDecision Decision { get; init; }
/// <summary>
/// Current state of unknowns for this component.
/// </summary>
public required string State { get; init; }
/// <summary>
/// IDs of blocking unknowns.
/// </summary>
public ImmutableArray<Guid> BlockingUnknownIds { get; init; } = [];
/// <summary>
/// Human-readable reason for the decision.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Whether an exception was granted to bypass the block.
/// </summary>
public bool ExceptionGranted { get; init; }
/// <summary>
/// Exception reference if granted.
/// </summary>
public string? ExceptionRef { get; init; }
}
/// <summary>
/// Gate decision types.
/// </summary>
public enum GateDecision
{
/// <summary>Gate passed, no blocking unknowns.</summary>
Pass,
/// <summary>Warning: unknowns present but not blocking.</summary>
Warn,
/// <summary>Blocked: HOT unknowns or SLA breached.</summary>
Block
}
/// <summary>
/// Unknown state for gate checks.
/// </summary>
public sealed record UnknownState
{
/// <summary>Unknown ID.</summary>
public required Guid UnknownId { get; init; }
/// <summary>CVE ID if applicable.</summary>
public string? CveId { get; init; }
/// <summary>Priority band.</summary>
public required string Band { get; init; }
/// <summary>Current state (pending, under_review, escalated, resolved, rejected).</summary>
public required string State { get; init; }
/// <summary>Hours remaining in SLA.</summary>
public double? SlaRemainingHours { get; init; }
/// <summary>Whether SLA is breached.</summary>
public bool SlaBreach { get; init; }
/// <summary>Whether in CISA KEV.</summary>
public bool InKev { get; init; }
}
/// <summary>
/// Interface for checking unknowns gate.
/// </summary>
public interface IUnknownsGateChecker
{
/// <summary>
/// Checks if a component can pass the unknowns gate.
/// </summary>
/// <param name="bomRef">BOM reference of the component.</param>
/// <param name="proposedVerdict">Proposed VEX verdict (e.g., "not_affected").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Gate check result.</returns>
Task<UnknownsGateCheckResult> CheckAsync(
string bomRef,
string? proposedVerdict = null,
CancellationToken ct = default);
/// <summary>
/// Gets all unknowns for a component.
/// </summary>
Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
string bomRef,
CancellationToken ct = default);
/// <summary>
/// Requests an exception to bypass the gate.
/// </summary>
Task<ExceptionResult> RequestExceptionAsync(
string bomRef,
IEnumerable<Guid> unknownIds,
string justification,
string requestedBy,
CancellationToken ct = default);
}
/// <summary>
/// Exception request result.
/// </summary>
public sealed record ExceptionResult
{
/// <summary>Whether exception was granted.</summary>
public bool Granted { get; init; }
/// <summary>Exception reference.</summary>
public string? ExceptionRef { get; init; }
/// <summary>Reason for denial if not granted.</summary>
public string? DenialReason { get; init; }
/// <summary>When exception expires.</summary>
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Configuration for unknowns gate checker.
/// </summary>
public sealed record UnknownsGateOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Policy:UnknownsGate";
/// <summary>Whether to fail-closed (block on HOT unknowns).</summary>
public bool FailClosed { get; init; } = true;
/// <summary>Whether to block "not_affected" verdicts when unknowns exist.</summary>
public bool BlockNotAffectedWithUnknowns { get; init; } = true;
/// <summary>Whether to require exception approval for KEV items.</summary>
public bool RequireKevException { get; init; } = true;
/// <summary>Whether to force manual review when SLA is breached.</summary>
public bool ForceReviewOnSlaBreach { get; init; } = true;
/// <summary>Cache TTL for gate checks (seconds).</summary>
public int CacheTtlSeconds { get; init; } = 30;
/// <summary>Base URL for Unknowns API.</summary>
public string UnknownsApiUrl { get; init; } = "http://unknowns-api:8080";
}
/// <summary>
/// Default implementation of unknowns gate checker.
/// </summary>
public sealed class UnknownsGateChecker : IUnknownsGateChecker
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly UnknownsGateOptions _options;
private readonly ILogger<UnknownsGateChecker> _logger;
public UnknownsGateChecker(
HttpClient httpClient,
IMemoryCache cache,
IOptions<UnknownsGateOptions> options,
ILogger<UnknownsGateChecker> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? new UnknownsGateOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<UnknownsGateCheckResult> CheckAsync(
string bomRef,
string? proposedVerdict = null,
CancellationToken ct = default)
{
var cacheKey = $"unknowns-gate:{bomRef}:{proposedVerdict}";
if (_cache.TryGetValue<UnknownsGateCheckResult>(cacheKey, out var cached) && cached != null)
{
return cached;
}
var unknowns = await GetUnknownsAsync(bomRef, ct);
// No unknowns = pass
if (unknowns.Count == 0)
{
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Pass,
State = "resolved",
Reason = "No pending unknowns"
});
}
// Check for blocking conditions
var hotUnknowns = unknowns.Where(u => u.Band == "hot").ToList();
var kevUnknowns = unknowns.Where(u => u.InKev).ToList();
var slaBreached = unknowns.Where(u => u.SlaBreach).ToList();
// Block: HOT unknowns in fail-closed mode
if (_options.FailClosed && hotUnknowns.Count > 0)
{
_logger.LogWarning(
"Blocking gate for {BomRef}: {Count} HOT unknowns",
bomRef, hotUnknowns.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_unknowns",
BlockingUnknownIds = [..hotUnknowns.Select(u => u.UnknownId)],
Reason = $"{hotUnknowns.Count} HOT unknown(s) require resolution"
});
}
// Block: "not_affected" verdict with any unknowns
if (_options.BlockNotAffectedWithUnknowns &&
proposedVerdict?.Equals("not_affected", StringComparison.OrdinalIgnoreCase) == true)
{
_logger.LogWarning(
"Blocking not_affected verdict for {BomRef}: {Count} unknowns exist",
bomRef, unknowns.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_unknowns",
BlockingUnknownIds = [..unknowns.Select(u => u.UnknownId)],
Reason = "Cannot claim 'not_affected' with unresolved unknowns"
});
}
// Block: KEV items require exception
if (_options.RequireKevException && kevUnknowns.Count > 0)
{
_logger.LogWarning(
"Blocking gate for {BomRef}: {Count} KEV unknowns require exception",
bomRef, kevUnknowns.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_kev",
BlockingUnknownIds = [..kevUnknowns.Select(u => u.UnknownId)],
Reason = $"{kevUnknowns.Count} KEV unknown(s) require exception approval"
});
}
// Block: SLA breached requires manual review
if (_options.ForceReviewOnSlaBreach && slaBreached.Count > 0)
{
_logger.LogWarning(
"Blocking gate for {BomRef}: {Count} unknowns with breached SLA",
bomRef, slaBreached.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_sla",
BlockingUnknownIds = [..slaBreached.Select(u => u.UnknownId)],
Reason = $"{slaBreached.Count} unknown(s) have breached SLA - manual review required"
});
}
// Warn: Non-HOT unknowns present
var worstState = unknowns.Any(u => u.State == "escalated") ? "escalated" :
unknowns.Any(u => u.State == "under_review") ? "under_review" : "pending";
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Warn,
State = worstState,
Reason = $"{unknowns.Count} unknown(s) pending, but not blocking"
});
}
public async Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
string bomRef,
CancellationToken ct = default)
{
try
{
// In production, call Unknowns API
// var response = await _httpClient.GetAsync($"{_options.UnknownsApiUrl}/api/v1/unknowns?bom_ref={Uri.EscapeDataString(bomRef)}", ct);
// Simulate lookup
await Task.Delay(10, ct);
// Return simulated data
return GenerateSimulatedUnknowns(bomRef);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch unknowns for {BomRef}", bomRef);
// Fail-closed: treat as if HOT unknowns exist
if (_options.FailClosed)
{
return
[
new UnknownState
{
UnknownId = Guid.NewGuid(),
Band = "hot",
State = "pending",
SlaRemainingHours = 0,
SlaBreach = true
}
];
}
return [];
}
}
public async Task<ExceptionResult> RequestExceptionAsync(
string bomRef,
IEnumerable<Guid> unknownIds,
string justification,
string requestedBy,
CancellationToken ct = default)
{
_logger.LogInformation(
"Exception requested for {BomRef} by {RequestedBy}: {Justification}",
bomRef, requestedBy, justification);
// In production, this would create an exception record
await Task.Delay(10, ct);
return new ExceptionResult
{
Granted = false,
DenialReason = "Automatic exceptions not enabled - requires manual approval",
ExpiresAt = null
};
}
private UnknownsGateCheckResult CacheAndReturn(string key, UnknownsGateCheckResult result)
{
_cache.Set(key, result, TimeSpan.FromSeconds(_options.CacheTtlSeconds));
return result;
}
private static List<UnknownState> GenerateSimulatedUnknowns(string bomRef)
{
// Deterministic simulation based on bomRef hash
var hash = bomRef.GetHashCode();
var random = new Random(hash);
if (random.NextDouble() > 0.3)
{
return []; // 70% have no unknowns
}
var count = random.Next(1, 3);
var unknowns = new List<UnknownState>();
for (var i = 0; i < count; i++)
{
var band = random.NextDouble() switch
{
< 0.2 => "hot",
< 0.5 => "warm",
_ => "cold"
};
unknowns.Add(new UnknownState
{
UnknownId = Guid.NewGuid(),
CveId = $"CVE-2026-{random.Next(1000, 9999)}",
Band = band,
State = random.NextDouble() < 0.3 ? "under_review" : "pending",
SlaRemainingHours = random.Next(1, 168),
SlaBreach = random.NextDouble() < 0.1,
InKev = random.NextDouble() < 0.05
});
}
return unknowns;
}
}
/// <summary>
/// Gate bypass audit entry for tracking unknown-related bypasses.
/// </summary>
public sealed record GateBypassAuditEntry
{
/// <summary>Audit entry ID.</summary>
public required Guid AuditId { get; init; }
/// <summary>BOM reference that was bypassed.</summary>
public required string BomRef { get; init; }
/// <summary>Unknown IDs that were bypassed.</summary>
public ImmutableArray<Guid> BypassedUnknownIds { get; init; } = [];
/// <summary>Justification for bypass.</summary>
public required string Justification { get; init; }
/// <summary>Who approved the bypass.</summary>
public required string ApprovedBy { get; init; }
/// <summary>When bypass was approved.</summary>
public DateTimeOffset ApprovedAt { get; init; }
/// <summary>When bypass expires.</summary>
public DateTimeOffset? ExpiresAt { get; init; }
}