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:
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal file
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user