Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
// VexTrustConfidenceFactorProvider - Confidence factor from VEX trust data
|
||||
// Part of SPRINT_1227_0004_0003: VexTrustGate Policy Integration
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Confidence;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for providers that contribute confidence factors.
|
||||
/// </summary>
|
||||
public interface IConfidenceFactorProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of confidence factor this provider produces.
|
||||
/// </summary>
|
||||
ConfidenceFactorType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Compute a confidence factor from available context.
|
||||
/// Returns null if insufficient data to compute.
|
||||
/// </summary>
|
||||
ConfidenceFactor? ComputeFactor(
|
||||
ConfidenceFactorContext context,
|
||||
ConfidenceFactorOptions options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for confidence factor computation.
|
||||
/// </summary>
|
||||
public sealed record ConfidenceFactorContext
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX trust status from signature verification.
|
||||
/// </summary>
|
||||
public VexTrustStatus? VexTrustStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment (production, staging, development).
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability identifier.
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product/component identifier.
|
||||
/// </summary>
|
||||
public string? ProductId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for confidence factor computation.
|
||||
/// </summary>
|
||||
public sealed record ConfidenceFactorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Weight to assign to VEX trust factor.
|
||||
/// </summary>
|
||||
public decimal VexTrustWeight { get; init; } = 0.20m;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum trust score to contribute positively.
|
||||
/// </summary>
|
||||
public decimal MinTrustScoreContribution { get; init; } = 0.30m;
|
||||
|
||||
/// <summary>
|
||||
/// Bonus for signature verification.
|
||||
/// </summary>
|
||||
public decimal SignatureVerifiedBonus { get; init; } = 0.10m;
|
||||
|
||||
/// <summary>
|
||||
/// Bonus for Rekor transparency.
|
||||
/// </summary>
|
||||
public decimal TransparencyBonus { get; init; } = 0.05m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes VEX trust confidence factor from signature verification results.
|
||||
/// </summary>
|
||||
public sealed class VexTrustConfidenceFactorProvider : IConfidenceFactorProvider
|
||||
{
|
||||
public ConfidenceFactorType Type => ConfidenceFactorType.Vex;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConfidenceFactor? ComputeFactor(
|
||||
ConfidenceFactorContext context,
|
||||
ConfidenceFactorOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var trustStatus = context.VexTrustStatus;
|
||||
|
||||
// No trust status means we can't contribute a factor
|
||||
if (trustStatus is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var score = trustStatus.TrustScore;
|
||||
var tier = ComputeTier(score);
|
||||
|
||||
// Apply bonuses for verification and transparency
|
||||
var adjustedScore = score;
|
||||
|
||||
if (trustStatus.SignatureVerified == true)
|
||||
{
|
||||
adjustedScore += options.SignatureVerifiedBonus;
|
||||
}
|
||||
|
||||
if (trustStatus.RekorLogIndex.HasValue)
|
||||
{
|
||||
adjustedScore += options.TransparencyBonus;
|
||||
}
|
||||
|
||||
// Clamp to [0, 1]
|
||||
adjustedScore = Math.Clamp(adjustedScore, 0m, 1m);
|
||||
|
||||
return new ConfidenceFactor
|
||||
{
|
||||
Type = ConfidenceFactorType.Vex,
|
||||
Weight = options.VexTrustWeight,
|
||||
RawValue = adjustedScore,
|
||||
Reason = BuildReason(trustStatus, tier),
|
||||
EvidenceDigests = BuildEvidenceDigests(trustStatus)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeTier(decimal score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 0.9m => "VeryHigh",
|
||||
>= 0.7m => "High",
|
||||
>= 0.5m => "Medium",
|
||||
>= 0.3m => "Low",
|
||||
_ => "VeryLow"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildReason(VexTrustStatus status, string tier)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"VEX trust: {tier} ({status.TrustScore:P0})"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(status.IssuerName))
|
||||
{
|
||||
parts.Add($"issuer: {status.IssuerName}");
|
||||
}
|
||||
|
||||
if (status.SignatureVerified == true)
|
||||
{
|
||||
parts.Add("signature verified");
|
||||
|
||||
if (!string.IsNullOrEmpty(status.SignatureMethod))
|
||||
{
|
||||
parts.Add($"method: {status.SignatureMethod}");
|
||||
}
|
||||
}
|
||||
|
||||
if (status.RekorLogIndex.HasValue)
|
||||
{
|
||||
parts.Add($"Rekor: #{status.RekorLogIndex}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(status.Freshness))
|
||||
{
|
||||
parts.Add($"freshness: {status.Freshness}");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildEvidenceDigests(VexTrustStatus status)
|
||||
{
|
||||
var digests = new List<string>();
|
||||
|
||||
// Add Rekor log info as an evidence reference
|
||||
if (!string.IsNullOrEmpty(status.RekorLogId) && status.RekorLogIndex.HasValue)
|
||||
{
|
||||
digests.Add($"rekor:{status.RekorLogId}@{status.RekorLogIndex}");
|
||||
}
|
||||
|
||||
// Add issuer reference
|
||||
if (!string.IsNullOrEmpty(status.IssuerId))
|
||||
{
|
||||
digests.Add($"issuer:{status.IssuerId}");
|
||||
}
|
||||
|
||||
return digests;
|
||||
}
|
||||
}
|
||||
200
src/Policy/StellaOps.Policy.Engine/Crypto/CryptoRiskAtoms.cs
Normal file
200
src/Policy/StellaOps.Policy.Engine/Crypto/CryptoRiskAtoms.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Policy atoms for cryptographic risk evaluation.
|
||||
/// These atoms can be used in policy rules to gate or warn on crypto-related issues.
|
||||
/// </summary>
|
||||
public static class CryptoRiskAtoms
|
||||
{
|
||||
#region Atom Names
|
||||
|
||||
/// <summary>
|
||||
/// Atom for deprecated cryptographic algorithms (MD5, SHA-1, DES, etc.).
|
||||
/// </summary>
|
||||
public const string WeakCrypto = "WEAK_CRYPTO";
|
||||
|
||||
/// <summary>
|
||||
/// Atom for algorithms vulnerable to quantum computing attacks.
|
||||
/// </summary>
|
||||
public const string QuantumVulnerable = "QUANTUM_VULNERABLE";
|
||||
|
||||
/// <summary>
|
||||
/// Atom for deprecated algorithms that are cryptographically broken.
|
||||
/// </summary>
|
||||
public const string DeprecatedCrypto = "DEPRECATED_CRYPTO";
|
||||
|
||||
/// <summary>
|
||||
/// Atom for algorithms with insufficient key sizes.
|
||||
/// </summary>
|
||||
public const string InsufficientKeySize = "INSUFFICIENT_KEY_SIZE";
|
||||
|
||||
/// <summary>
|
||||
/// Atom for post-quantum ready algorithms (positive indicator).
|
||||
/// </summary>
|
||||
public const string PostQuantumReady = "POST_QUANTUM_READY";
|
||||
|
||||
/// <summary>
|
||||
/// Atom for algorithms that are FIPS-140 compliant.
|
||||
/// </summary>
|
||||
public const string FipsCompliant = "FIPS_COMPLIANT";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Algorithm Classifications
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated/broken algorithms that should never be used for security.
|
||||
/// </summary>
|
||||
public static readonly ImmutableHashSet<string> DeprecatedAlgorithms = ImmutableHashSet.Create(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
"MD2", "MD4", "MD5",
|
||||
"SHA1", "SHA-1",
|
||||
"DES", "3DES", "TRIPLE-DES", "TRIPLEDES",
|
||||
"RC2", "RC4",
|
||||
"BLOWFISH",
|
||||
"IDEA");
|
||||
|
||||
/// <summary>
|
||||
/// Algorithms considered weak but not yet deprecated.
|
||||
/// </summary>
|
||||
public static readonly ImmutableHashSet<string> WeakAlgorithms = ImmutableHashSet.Create(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
"SHA-224",
|
||||
"AES-128-ECB", "AES-192-ECB", "AES-256-ECB");
|
||||
|
||||
/// <summary>
|
||||
/// Algorithms vulnerable to quantum computing attacks (Shor's algorithm).
|
||||
/// </summary>
|
||||
public static readonly ImmutableHashSet<string> QuantumVulnerableAlgorithms = ImmutableHashSet.Create(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
"RSA", "RSA-1024", "RSA-2048", "RSA-3072", "RSA-4096",
|
||||
"DSA", "DSA-1024", "DSA-2048", "DSA-3072",
|
||||
"ECDSA", "ECDSA-P256", "ECDSA-P384", "ECDSA-P521",
|
||||
"ECDH", "ECDHE", "ECDH-P256", "ECDH-P384",
|
||||
"DH", "DHE", "DH-2048", "DH-3072",
|
||||
"ED25519", "ED448",
|
||||
"X25519", "X448");
|
||||
|
||||
/// <summary>
|
||||
/// Post-quantum cryptography algorithms (NIST standards and candidates).
|
||||
/// </summary>
|
||||
public static readonly ImmutableHashSet<string> PostQuantumAlgorithms = ImmutableHashSet.Create(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
// NIST PQC Standards
|
||||
"ML-KEM", "ML-KEM-512", "ML-KEM-768", "ML-KEM-1024",
|
||||
"ML-DSA", "ML-DSA-44", "ML-DSA-65", "ML-DSA-87",
|
||||
"SLH-DSA", "SLH-DSA-128s", "SLH-DSA-128f", "SLH-DSA-192s", "SLH-DSA-192f", "SLH-DSA-256s", "SLH-DSA-256f",
|
||||
// Legacy names
|
||||
"KYBER", "KYBER-512", "KYBER-768", "KYBER-1024",
|
||||
"DILITHIUM", "DILITHIUM-2", "DILITHIUM-3", "DILITHIUM-5",
|
||||
"FALCON", "FALCON-512", "FALCON-1024",
|
||||
"SPHINCS+", "SPHINCS+-128s", "SPHINCS+-128f", "SPHINCS+-192s", "SPHINCS+-192f", "SPHINCS+-256s", "SPHINCS+-256f");
|
||||
|
||||
/// <summary>
|
||||
/// FIPS 140-2/140-3 approved algorithms.
|
||||
/// </summary>
|
||||
public static readonly ImmutableHashSet<string> FipsApprovedAlgorithms = ImmutableHashSet.Create(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
// Hash functions
|
||||
"SHA-224", "SHA-256", "SHA-384", "SHA-512", "SHA-512/224", "SHA-512/256",
|
||||
"SHA3-224", "SHA3-256", "SHA3-384", "SHA3-512",
|
||||
"SHAKE128", "SHAKE256",
|
||||
// Symmetric encryption
|
||||
"AES-128", "AES-192", "AES-256",
|
||||
"AES-128-CBC", "AES-192-CBC", "AES-256-CBC",
|
||||
"AES-128-GCM", "AES-192-GCM", "AES-256-GCM",
|
||||
"AES-128-CCM", "AES-192-CCM", "AES-256-CCM",
|
||||
"AES-128-CTR", "AES-192-CTR", "AES-256-CTR",
|
||||
// Asymmetric
|
||||
"RSA-2048", "RSA-3072", "RSA-4096",
|
||||
"ECDSA-P256", "ECDSA-P384", "ECDSA-P521",
|
||||
"ECDH-P256", "ECDH-P384", "ECDH-P521",
|
||||
// MACs
|
||||
"HMAC-SHA-256", "HMAC-SHA-384", "HMAC-SHA-512",
|
||||
"CMAC-AES-128", "CMAC-AES-192", "CMAC-AES-256");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Minimum Key Sizes
|
||||
|
||||
/// <summary>
|
||||
/// Minimum acceptable key sizes by algorithm type.
|
||||
/// </summary>
|
||||
public static readonly ImmutableDictionary<string, int> MinimumKeySizes = ImmutableDictionary.CreateRange(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
new Dictionary<string, int>
|
||||
{
|
||||
["RSA"] = 2048,
|
||||
["DSA"] = 2048,
|
||||
["DH"] = 2048,
|
||||
["DHE"] = 2048,
|
||||
["ECDSA"] = 256,
|
||||
["ECDH"] = 256,
|
||||
["ECDHE"] = 256,
|
||||
["ED25519"] = 256,
|
||||
["AES"] = 128,
|
||||
["CHACHA20"] = 256,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Recommended key sizes for future-proofing.
|
||||
/// </summary>
|
||||
public static readonly ImmutableDictionary<string, int> RecommendedKeySizes = ImmutableDictionary.CreateRange(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
new Dictionary<string, int>
|
||||
{
|
||||
["RSA"] = 3072,
|
||||
["DSA"] = 3072,
|
||||
["DH"] = 3072,
|
||||
["DHE"] = 3072,
|
||||
["ECDSA"] = 384,
|
||||
["ECDH"] = 384,
|
||||
["ECDHE"] = 384,
|
||||
["AES"] = 256,
|
||||
});
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating a crypto risk atom.
|
||||
/// </summary>
|
||||
public sealed record CryptoAtomResult
|
||||
{
|
||||
/// <summary>Atom name that was evaluated.</summary>
|
||||
public required string AtomName { get; init; }
|
||||
|
||||
/// <summary>Whether the atom condition is triggered.</summary>
|
||||
public required bool Triggered { get; init; }
|
||||
|
||||
/// <summary>Severity level when triggered.</summary>
|
||||
public required CryptoSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>Human-readable explanation.</summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Algorithms that caused this atom to trigger.</summary>
|
||||
public ImmutableArray<string> TriggeringAlgorithms { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Recommendation for remediation.</summary>
|
||||
public string? Recommendation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for crypto findings.
|
||||
/// </summary>
|
||||
public enum CryptoSeverity
|
||||
{
|
||||
/// <summary>Informational only.</summary>
|
||||
Info,
|
||||
/// <summary>Low severity - should be addressed eventually.</summary>
|
||||
Low,
|
||||
/// <summary>Medium severity - should be addressed in near term.</summary>
|
||||
Medium,
|
||||
/// <summary>High severity - should be addressed promptly.</summary>
|
||||
High,
|
||||
/// <summary>Critical severity - immediate action required.</summary>
|
||||
Critical
|
||||
}
|
||||
319
src/Policy/StellaOps.Policy.Engine/Crypto/CryptoRiskEvaluator.cs
Normal file
319
src/Policy/StellaOps.Policy.Engine/Crypto/CryptoRiskEvaluator.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Crypto;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates cryptographic risk based on CBOM analysis results.
|
||||
/// Produces policy findings for crypto-related issues.
|
||||
/// </summary>
|
||||
public sealed class CryptoRiskEvaluator
|
||||
{
|
||||
private readonly CryptoRiskOptions _options;
|
||||
|
||||
public CryptoRiskEvaluator(CryptoRiskOptions? options = null)
|
||||
{
|
||||
_options = options ?? CryptoRiskOptions.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates all crypto risk atoms for a set of crypto assets.
|
||||
/// </summary>
|
||||
public ImmutableArray<CryptoAtomResult> Evaluate(ImmutableArray<CryptoAsset> assets)
|
||||
{
|
||||
var results = new List<CryptoAtomResult>();
|
||||
|
||||
// Evaluate each atom type
|
||||
results.Add(EvaluateDeprecatedCrypto(assets));
|
||||
results.Add(EvaluateWeakCrypto(assets));
|
||||
results.Add(EvaluateQuantumVulnerable(assets));
|
||||
results.Add(EvaluateInsufficientKeySize(assets));
|
||||
results.Add(EvaluatePostQuantumReady(assets));
|
||||
results.Add(EvaluateFipsCompliance(assets));
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the DEPRECATED_CRYPTO atom.
|
||||
/// Triggered when cryptographically broken algorithms are detected.
|
||||
/// </summary>
|
||||
public CryptoAtomResult EvaluateDeprecatedCrypto(ImmutableArray<CryptoAsset> assets)
|
||||
{
|
||||
var deprecated = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asset.AlgorithmName))
|
||||
continue;
|
||||
|
||||
var algUpper = asset.AlgorithmName.ToUpperInvariant();
|
||||
|
||||
if (CryptoRiskAtoms.DeprecatedAlgorithms.Contains(asset.AlgorithmName) ||
|
||||
CryptoRiskAtoms.DeprecatedAlgorithms.Any(d => algUpper.Contains(d)))
|
||||
{
|
||||
deprecated.Add(asset.AlgorithmName);
|
||||
}
|
||||
}
|
||||
|
||||
return new CryptoAtomResult
|
||||
{
|
||||
AtomName = CryptoRiskAtoms.DeprecatedCrypto,
|
||||
Triggered = deprecated.Count > 0,
|
||||
Severity = deprecated.Count > 0 ? CryptoSeverity.Critical : CryptoSeverity.Info,
|
||||
Reason = deprecated.Count > 0
|
||||
? $"Found {deprecated.Count} deprecated cryptographic algorithm(s): {string.Join(", ", deprecated.OrderBy(x => x))}"
|
||||
: "No deprecated algorithms detected",
|
||||
TriggeringAlgorithms = deprecated.OrderBy(x => x).ToImmutableArray(),
|
||||
Recommendation = deprecated.Count > 0
|
||||
? "Replace deprecated algorithms with modern alternatives: MD5/SHA-1 → SHA-256+, DES/3DES → AES-256-GCM"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the WEAK_CRYPTO atom.
|
||||
/// Triggered when algorithms are weak but not yet deprecated.
|
||||
/// </summary>
|
||||
public CryptoAtomResult EvaluateWeakCrypto(ImmutableArray<CryptoAsset> assets)
|
||||
{
|
||||
var weak = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asset.AlgorithmName))
|
||||
continue;
|
||||
|
||||
var algUpper = asset.AlgorithmName.ToUpperInvariant();
|
||||
|
||||
// Check explicit weak algorithms
|
||||
if (CryptoRiskAtoms.WeakAlgorithms.Contains(asset.AlgorithmName) ||
|
||||
CryptoRiskAtoms.WeakAlgorithms.Any(w => algUpper.Contains(w)))
|
||||
{
|
||||
weak.Add(asset.AlgorithmName);
|
||||
}
|
||||
|
||||
// Check ECB mode (weak for block ciphers)
|
||||
if (algUpper.Contains("ECB"))
|
||||
{
|
||||
weak.Add(asset.AlgorithmName);
|
||||
}
|
||||
}
|
||||
|
||||
return new CryptoAtomResult
|
||||
{
|
||||
AtomName = CryptoRiskAtoms.WeakCrypto,
|
||||
Triggered = weak.Count > 0,
|
||||
Severity = weak.Count > 0 ? CryptoSeverity.High : CryptoSeverity.Info,
|
||||
Reason = weak.Count > 0
|
||||
? $"Found {weak.Count} weak cryptographic algorithm(s): {string.Join(", ", weak.OrderBy(x => x))}"
|
||||
: "No weak algorithms detected",
|
||||
TriggeringAlgorithms = weak.OrderBy(x => x).ToImmutableArray(),
|
||||
Recommendation = weak.Count > 0
|
||||
? "Replace weak algorithms with stronger variants: ECB → GCM/CBC, SHA-224 → SHA-256+"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the QUANTUM_VULNERABLE atom.
|
||||
/// Triggered when algorithms are vulnerable to quantum computing attacks.
|
||||
/// </summary>
|
||||
public CryptoAtomResult EvaluateQuantumVulnerable(ImmutableArray<CryptoAsset> assets)
|
||||
{
|
||||
var vulnerable = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asset.AlgorithmName))
|
||||
continue;
|
||||
|
||||
// Check if algorithm is quantum-vulnerable
|
||||
if (CryptoRiskAtoms.QuantumVulnerableAlgorithms.Contains(asset.AlgorithmName) ||
|
||||
CryptoRiskAtoms.QuantumVulnerableAlgorithms.Any(q => asset.AlgorithmName.StartsWith(q, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
vulnerable.Add(asset.AlgorithmName);
|
||||
}
|
||||
|
||||
// RSA, DSA, ECDSA, ECDH, DH are all vulnerable
|
||||
var algUpper = asset.AlgorithmName.ToUpperInvariant();
|
||||
if (algUpper.StartsWith("RSA") || algUpper.StartsWith("DSA") ||
|
||||
algUpper.StartsWith("ECDSA") || algUpper.StartsWith("ECDH") ||
|
||||
algUpper.StartsWith("DH") || algUpper.StartsWith("DHE"))
|
||||
{
|
||||
vulnerable.Add(asset.AlgorithmName);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine severity based on options
|
||||
var severity = _options.TreatQuantumVulnerableAs;
|
||||
if (!_options.EnableQuantumRiskWarnings)
|
||||
{
|
||||
severity = CryptoSeverity.Info;
|
||||
}
|
||||
|
||||
return new CryptoAtomResult
|
||||
{
|
||||
AtomName = CryptoRiskAtoms.QuantumVulnerable,
|
||||
Triggered = vulnerable.Count > 0 && _options.EnableQuantumRiskWarnings,
|
||||
Severity = vulnerable.Count > 0 ? severity : CryptoSeverity.Info,
|
||||
Reason = vulnerable.Count > 0
|
||||
? $"Found {vulnerable.Count} quantum-vulnerable algorithm(s): {string.Join(", ", vulnerable.OrderBy(x => x))}"
|
||||
: "No quantum-vulnerable algorithms detected",
|
||||
TriggeringAlgorithms = vulnerable.OrderBy(x => x).ToImmutableArray(),
|
||||
Recommendation = vulnerable.Count > 0
|
||||
? "Plan migration to post-quantum cryptography: RSA/ECDSA → ML-DSA, ECDH → ML-KEM"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the INSUFFICIENT_KEY_SIZE atom.
|
||||
/// Triggered when key sizes are below minimum thresholds.
|
||||
/// </summary>
|
||||
public CryptoAtomResult EvaluateInsufficientKeySize(ImmutableArray<CryptoAsset> assets)
|
||||
{
|
||||
var insufficient = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asset.AlgorithmName) || !asset.KeySizeBits.HasValue)
|
||||
continue;
|
||||
|
||||
var keySize = asset.KeySizeBits.Value;
|
||||
|
||||
// Check minimum key size for algorithm type
|
||||
foreach (var (algType, minSize) in CryptoRiskAtoms.MinimumKeySizes)
|
||||
{
|
||||
if (asset.AlgorithmName.StartsWith(algType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (keySize < minSize)
|
||||
{
|
||||
insufficient.Add($"{asset.AlgorithmName}-{keySize}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new CryptoAtomResult
|
||||
{
|
||||
AtomName = CryptoRiskAtoms.InsufficientKeySize,
|
||||
Triggered = insufficient.Count > 0,
|
||||
Severity = insufficient.Count > 0 ? CryptoSeverity.High : CryptoSeverity.Info,
|
||||
Reason = insufficient.Count > 0
|
||||
? $"Found {insufficient.Count} algorithm(s) with insufficient key size: {string.Join(", ", insufficient.OrderBy(x => x))}"
|
||||
: "All algorithms have sufficient key sizes",
|
||||
TriggeringAlgorithms = insufficient.OrderBy(x => x).ToImmutableArray(),
|
||||
Recommendation = insufficient.Count > 0
|
||||
? "Increase key sizes: RSA ≥2048, ECDSA ≥256, AES ≥128"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the POST_QUANTUM_READY atom.
|
||||
/// This is a positive indicator - triggered when PQ algorithms are present.
|
||||
/// </summary>
|
||||
public CryptoAtomResult EvaluatePostQuantumReady(ImmutableArray<CryptoAsset> assets)
|
||||
{
|
||||
var pqReady = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asset.AlgorithmName))
|
||||
continue;
|
||||
|
||||
if (CryptoRiskAtoms.PostQuantumAlgorithms.Contains(asset.AlgorithmName) ||
|
||||
CryptoRiskAtoms.PostQuantumAlgorithms.Any(pq => asset.AlgorithmName.StartsWith(pq, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
pqReady.Add(asset.AlgorithmName);
|
||||
}
|
||||
}
|
||||
|
||||
return new CryptoAtomResult
|
||||
{
|
||||
AtomName = CryptoRiskAtoms.PostQuantumReady,
|
||||
Triggered = pqReady.Count > 0,
|
||||
Severity = CryptoSeverity.Info, // Positive indicator
|
||||
Reason = pqReady.Count > 0
|
||||
? $"Found {pqReady.Count} post-quantum algorithm(s): {string.Join(", ", pqReady.OrderBy(x => x))}"
|
||||
: "No post-quantum algorithms detected",
|
||||
TriggeringAlgorithms = pqReady.OrderBy(x => x).ToImmutableArray(),
|
||||
Recommendation = pqReady.Count == 0
|
||||
? "Consider adopting ML-KEM/ML-DSA for quantum-resistant cryptography"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the FIPS_COMPLIANT atom.
|
||||
/// Checks if all algorithms are FIPS 140-2/140-3 approved.
|
||||
/// </summary>
|
||||
public CryptoAtomResult EvaluateFipsCompliance(ImmutableArray<CryptoAsset> assets)
|
||||
{
|
||||
var nonFips = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
int fipsCount = 0;
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asset.AlgorithmName))
|
||||
continue;
|
||||
|
||||
if (CryptoRiskAtoms.FipsApprovedAlgorithms.Contains(asset.AlgorithmName) ||
|
||||
CryptoRiskAtoms.FipsApprovedAlgorithms.Any(f => asset.AlgorithmName.StartsWith(f, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
fipsCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
nonFips.Add(asset.AlgorithmName);
|
||||
}
|
||||
}
|
||||
|
||||
var isCompliant = nonFips.Count == 0 && fipsCount > 0;
|
||||
|
||||
return new CryptoAtomResult
|
||||
{
|
||||
AtomName = CryptoRiskAtoms.FipsCompliant,
|
||||
Triggered = !isCompliant && _options.RequireFipsCompliance,
|
||||
Severity = !isCompliant && _options.RequireFipsCompliance ? CryptoSeverity.High : CryptoSeverity.Info,
|
||||
Reason = isCompliant
|
||||
? $"All {fipsCount} algorithm(s) are FIPS compliant"
|
||||
: nonFips.Count > 0
|
||||
? $"Found {nonFips.Count} non-FIPS algorithm(s): {string.Join(", ", nonFips.OrderBy(x => x))}"
|
||||
: "No cryptographic algorithms detected",
|
||||
TriggeringAlgorithms = nonFips.OrderBy(x => x).ToImmutableArray(),
|
||||
Recommendation = !isCompliant && _options.RequireFipsCompliance
|
||||
? "Replace non-FIPS algorithms with FIPS 140-2/140-3 approved alternatives"
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for crypto risk evaluation.
|
||||
/// </summary>
|
||||
public sealed record CryptoRiskOptions
|
||||
{
|
||||
/// <summary>Default options.</summary>
|
||||
public static readonly CryptoRiskOptions Default = new();
|
||||
|
||||
/// <summary>Whether to enable quantum vulnerability warnings.</summary>
|
||||
public bool EnableQuantumRiskWarnings { get; init; } = true;
|
||||
|
||||
/// <summary>Severity level for quantum-vulnerable algorithms.</summary>
|
||||
public CryptoSeverity TreatQuantumVulnerableAs { get; init; } = CryptoSeverity.Medium;
|
||||
|
||||
/// <summary>Whether FIPS compliance is required.</summary>
|
||||
public bool RequireFipsCompliance { get; init; } = false;
|
||||
|
||||
/// <summary>Whether to block on deprecated algorithms.</summary>
|
||||
public bool BlockOnDeprecated { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to block on weak algorithms.</summary>
|
||||
public bool BlockOnWeak { get; init; } = false;
|
||||
|
||||
/// <summary>Whether to block on insufficient key sizes.</summary>
|
||||
public bool BlockOnInsufficientKeySize { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// VexTrustGateServiceCollectionExtensions - DI registration
|
||||
// Part of SPRINT_1227_0004_0003: VexTrustGate Policy Integration
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Policy.Engine.Confidence;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering VEX trust gate services.
|
||||
/// </summary>
|
||||
public static class VexTrustGateServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add VEX trust gate services to the DI container.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration root.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddVexTrustGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Bind options
|
||||
services.Configure<VexTrustGateOptions>(
|
||||
configuration.GetSection(VexTrustGateOptions.SectionKey));
|
||||
|
||||
// Register gate
|
||||
services.TryAddSingleton<IVexTrustGate, VexTrustGate>();
|
||||
|
||||
// Register metrics
|
||||
services.TryAddSingleton<VexTrustGateMetrics>();
|
||||
|
||||
// Register confidence factor provider
|
||||
services.TryAddSingleton<IConfidenceFactorProvider, VexTrustConfidenceFactorProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add VEX trust gate with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddVexTrustGate(
|
||||
this IServiceCollection services,
|
||||
Action<VexTrustGateOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.TryAddSingleton<IVexTrustGate, VexTrustGate>();
|
||||
services.TryAddSingleton<VexTrustGateMetrics>();
|
||||
services.TryAddSingleton<IConfidenceFactorProvider, VexTrustConfidenceFactorProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
|
||||
@@ -64,9 +64,8 @@ internal static class RiskProfileSchemaEndpoints
|
||||
}
|
||||
|
||||
var schema = RiskProfileSchemaProvider.GetSchema();
|
||||
var jsonText = profileDocument.GetRawText();
|
||||
|
||||
var result = schema.Evaluate(System.Text.Json.Nodes.JsonNode.Parse(jsonText));
|
||||
var result = schema.Evaluate(profileDocument);
|
||||
var issues = new List<RiskProfileValidationIssue>();
|
||||
|
||||
if (!result.IsValid)
|
||||
@@ -89,9 +88,9 @@ internal static class RiskProfileSchemaEndpoints
|
||||
{
|
||||
foreach (var (key, message) in results.Errors)
|
||||
{
|
||||
var instancePath = results.InstanceLocation?.ToString() ?? path;
|
||||
var instancePath = results.InstanceLocation.ToString();
|
||||
issues.Add(new RiskProfileValidationIssue(
|
||||
Path: instancePath,
|
||||
Path: string.IsNullOrEmpty(instancePath) ? path : instancePath,
|
||||
Error: key,
|
||||
Message: message));
|
||||
}
|
||||
@@ -103,7 +102,8 @@ internal static class RiskProfileSchemaEndpoints
|
||||
{
|
||||
if (!detail.IsValid)
|
||||
{
|
||||
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
|
||||
var detailPath = detail.InstanceLocation.ToString();
|
||||
CollectValidationIssues(detail, issues, string.IsNullOrEmpty(detailPath) ? path : detailPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ExceptionCache;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ExceptionCache;
|
||||
|
||||
@@ -329,4 +329,41 @@ public sealed record PolicyGateRequest
|
||||
/// Override justification if AllowOverride is true.
|
||||
/// </summary>
|
||||
public string? OverrideJustification { get; init; }
|
||||
|
||||
// VEX Trust fields (added for VexTrustGate integration)
|
||||
|
||||
/// <summary>
|
||||
/// Environment context (production, staging, development).
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX trust score (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public decimal? VexTrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the VEX signature was verified.
|
||||
/// </summary>
|
||||
public bool? VexSignatureVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX issuer identifier.
|
||||
/// </summary>
|
||||
public string? VexIssuerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX issuer display name.
|
||||
/// </summary>
|
||||
public string? VexIssuerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX freshness status (fresh, stale, superseded, expired).
|
||||
/// </summary>
|
||||
public string? VexFreshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification method (dsse, cosign, pgp, x509).
|
||||
/// </summary>
|
||||
public string? VexSignatureMethod { get; init; }
|
||||
}
|
||||
|
||||
@@ -99,8 +99,8 @@ public sealed class PolicyGateEvaluator : IPolicyGateEvaluator
|
||||
return Task.FromResult(CreateAllowDecision(gateId, request.RequestedStatus, subject, evidence, now, "Gates disabled"));
|
||||
}
|
||||
|
||||
// Evaluate gates in order: Evidence -> Lattice -> Uncertainty -> Confidence
|
||||
var gateResults = new List<PolicyGateResult>(4);
|
||||
// Evaluate gates in order: Evidence -> Lattice -> VexTrust -> Uncertainty -> Confidence
|
||||
var gateResults = new List<PolicyGateResult>(5);
|
||||
string? blockedBy = null;
|
||||
string? blockReason = null;
|
||||
string? suggestion = null;
|
||||
@@ -137,6 +137,23 @@ public sealed class PolicyGateEvaluator : IPolicyGateEvaluator
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5. VEX Trust Gate (only if not already blocked, after Lattice)
|
||||
if (blockedBy is null && options.VexTrust.Enabled)
|
||||
{
|
||||
var vexTrustResult = EvaluateVexTrustGate(request, options.VexTrust);
|
||||
gateResults.Add(vexTrustResult);
|
||||
if (vexTrustResult.Result == PolicyGateResultType.Block)
|
||||
{
|
||||
blockedBy = vexTrustResult.Name;
|
||||
blockReason = vexTrustResult.Reason;
|
||||
suggestion = GetVexTrustSuggestion(request);
|
||||
}
|
||||
else if (vexTrustResult.Result == PolicyGateResultType.Warn || vexTrustResult.Result == PolicyGateResultType.PassWithNote)
|
||||
{
|
||||
warnings.Add(vexTrustResult.Note ?? vexTrustResult.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Uncertainty Tier Gate (only if not already blocked)
|
||||
if (blockedBy is null)
|
||||
{
|
||||
@@ -743,4 +760,122 @@ public sealed class PolicyGateEvaluator : IPolicyGateEvaluator
|
||||
TierT2 => "Consider providing override with justification or reducing uncertainty through additional analysis",
|
||||
_ => "Review uncertainty sources and address where possible"
|
||||
};
|
||||
|
||||
private PolicyGateResult EvaluateVexTrustGate(PolicyGateRequest request, VexTrustGateOptions options)
|
||||
{
|
||||
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// Check if status applies
|
||||
if (!options.ApplyToStatuses.Contains(status, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PolicyGateResult
|
||||
{
|
||||
Name = "VexTrust",
|
||||
Result = PolicyGateResultType.Skip,
|
||||
Reason = $"VexTrust gate does not apply to status: {status}"
|
||||
};
|
||||
}
|
||||
|
||||
// Check if trust data is present
|
||||
if (request.VexTrustScore is null)
|
||||
{
|
||||
return options.MissingTrustBehavior switch
|
||||
{
|
||||
MissingTrustBehavior.Block => new PolicyGateResult
|
||||
{
|
||||
Name = "VexTrust",
|
||||
Result = PolicyGateResultType.Block,
|
||||
Reason = "VEX trust data required but not present"
|
||||
},
|
||||
MissingTrustBehavior.Warn => new PolicyGateResult
|
||||
{
|
||||
Name = "VexTrust",
|
||||
Result = PolicyGateResultType.Warn,
|
||||
Reason = "VEX trust data not present",
|
||||
Note = "Consider obtaining a verified VEX statement"
|
||||
},
|
||||
_ => new PolicyGateResult
|
||||
{
|
||||
Name = "VexTrust",
|
||||
Result = PolicyGateResultType.Pass,
|
||||
Reason = "VEX trust data not present (allowed)"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get environment-specific thresholds
|
||||
var environment = request.Environment?.ToLowerInvariant() ?? "default";
|
||||
var thresholds = options.Thresholds.TryGetValue(environment, out var envThresholds)
|
||||
? envThresholds
|
||||
: options.Thresholds.TryGetValue("default", out var defaultThresholds)
|
||||
? defaultThresholds
|
||||
: new VexTrustThresholds();
|
||||
|
||||
// Check composite score
|
||||
if (request.VexTrustScore < thresholds.MinCompositeScore)
|
||||
{
|
||||
var result = thresholds.FailureAction == FailureAction.Block
|
||||
? PolicyGateResultType.Block
|
||||
: PolicyGateResultType.Warn;
|
||||
|
||||
return new PolicyGateResult
|
||||
{
|
||||
Name = "VexTrust",
|
||||
Result = result,
|
||||
Reason = $"VEX trust score {request.VexTrustScore:F2} below threshold {thresholds.MinCompositeScore:F2}",
|
||||
Note = result == PolicyGateResultType.Warn
|
||||
? "Consider obtaining a VEX statement from a higher-trust source"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
// Check signature verification if required
|
||||
if (thresholds.RequireIssuerVerified && request.VexSignatureVerified != true)
|
||||
{
|
||||
var result = thresholds.FailureAction == FailureAction.Block
|
||||
? PolicyGateResultType.Block
|
||||
: PolicyGateResultType.Warn;
|
||||
|
||||
return new PolicyGateResult
|
||||
{
|
||||
Name = "VexTrust",
|
||||
Result = result,
|
||||
Reason = "VEX signature verification required but not verified",
|
||||
Note = "Ensure VEX document is signed and key is registered in IssuerDirectory"
|
||||
};
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
var tier = request.VexTrustScore switch
|
||||
{
|
||||
>= 0.9m => "VeryHigh",
|
||||
>= 0.7m => "High",
|
||||
>= 0.5m => "Medium",
|
||||
>= 0.3m => "Low",
|
||||
_ => "VeryLow"
|
||||
};
|
||||
|
||||
return new PolicyGateResult
|
||||
{
|
||||
Name = "VexTrust",
|
||||
Result = PolicyGateResultType.Pass,
|
||||
Reason = $"VEX trust adequate: {tier} ({request.VexTrustScore:F2})",
|
||||
Note = request.VexIssuerId is not null ? $"Issuer: {request.VexIssuerId}" : null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetVexTrustSuggestion(PolicyGateRequest request)
|
||||
{
|
||||
if (request.VexTrustScore is null)
|
||||
{
|
||||
return "Obtain a verified VEX statement from a trusted source and ensure it is signed";
|
||||
}
|
||||
|
||||
if (request.VexSignatureVerified != true)
|
||||
{
|
||||
return "Ensure VEX document is signed and the signing key is registered in IssuerDirectory";
|
||||
}
|
||||
|
||||
return "Obtain VEX from a higher-trust source or verify the existing VEX signature";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ public sealed class PolicyGateOptions
|
||||
/// </summary>
|
||||
public EvidenceCompletenessGateOptions EvidenceCompleteness { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// VEX trust gate options.
|
||||
/// </summary>
|
||||
public VexTrustGateOptions VexTrust { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Override mechanism options.
|
||||
/// </summary>
|
||||
|
||||
489
src/Policy/StellaOps.Policy.Engine/Gates/VexTrustGate.cs
Normal file
489
src/Policy/StellaOps.Policy.Engine/Gates/VexTrustGate.cs
Normal file
@@ -0,0 +1,489 @@
|
||||
// VexTrustGate - Policy gate for VEX trust verification
|
||||
// Part of SPRINT_1227_0004_0003: VexTrustGate Policy Integration
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces VEX trust thresholds.
|
||||
/// Evaluates trust score, issuer verification, and freshness.
|
||||
/// </summary>
|
||||
public interface IVexTrustGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate VEX trust for a status transition request.
|
||||
/// </summary>
|
||||
Task<VexTrustGateResult> EvaluateAsync(
|
||||
VexTrustGateRequest request,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for VEX trust gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record VexTrustGateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Requested VEX status (not_affected, fixed, etc.).
|
||||
/// </summary>
|
||||
public required string RequestedStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target environment (production, staging, development).
|
||||
/// </summary>
|
||||
public required string Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX trust status with scores and breakdown.
|
||||
/// </summary>
|
||||
public VexTrustStatus? VexTrustStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier for tenant-specific overrides.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX trust status containing scores and verification details.
|
||||
/// </summary>
|
||||
public sealed record VexTrustStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Composite trust score (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public decimal TrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy threshold for this context.
|
||||
/// </summary>
|
||||
public decimal PolicyTrustThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the trust score meets the policy threshold.
|
||||
/// </summary>
|
||||
public bool MeetsPolicyThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust score breakdown by factor.
|
||||
/// </summary>
|
||||
public TrustScoreBreakdown? TrustBreakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer display name.
|
||||
/// </summary>
|
||||
public string? IssuerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer identifier.
|
||||
/// </summary>
|
||||
public string? IssuerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature was cryptographically verified.
|
||||
/// </summary>
|
||||
public bool? SignatureVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature method used (dsse, cosign, pgp, etc.).
|
||||
/// </summary>
|
||||
public string? SignatureMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log index.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log identifier.
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness status (fresh, stale, superseded, expired).
|
||||
/// </summary>
|
||||
public string? Freshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX was verified.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust score breakdown by factor.
|
||||
/// </summary>
|
||||
public sealed record TrustScoreBreakdown
|
||||
{
|
||||
public decimal? OriginScore { get; init; }
|
||||
public decimal? FreshnessScore { get; init; }
|
||||
public decimal? AccuracyScore { get; init; }
|
||||
public decimal? VerificationScore { get; init; }
|
||||
public decimal? AuthorityScore { get; init; }
|
||||
public decimal? CoverageScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX trust gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record VexTrustGateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public required string GateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate decision (allow, warn, block).
|
||||
/// </summary>
|
||||
public required VexTrustGateDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason code for the decision.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks that were evaluated.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VexTrustCheck> Checks { get; init; } = Array.Empty<VexTrustCheck>();
|
||||
|
||||
/// <summary>
|
||||
/// Trust score observed.
|
||||
/// </summary>
|
||||
public decimal? TrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust tier computed from score.
|
||||
/// </summary>
|
||||
public string? TrustTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer identifier.
|
||||
/// </summary>
|
||||
public string? IssuerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether signature was verified.
|
||||
/// </summary>
|
||||
public bool? SignatureVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggestion for resolving a block.
|
||||
/// </summary>
|
||||
public string? Suggestion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when decision was made.
|
||||
/// </summary>
|
||||
public DateTimeOffset EvaluatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Additional details for audit.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, object>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual trust check result.
|
||||
/// </summary>
|
||||
public sealed record VexTrustCheck(
|
||||
string Name,
|
||||
bool Passed,
|
||||
string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// VEX trust gate decision type.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexTrustGateDecision>))]
|
||||
public enum VexTrustGateDecision
|
||||
{
|
||||
/// <summary>Trust is adequate, allow transition.</summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>Trust is below threshold but transition allowed with warning.</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Trust is insufficient, block transition.</summary>
|
||||
Block
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of VEX trust gate.
|
||||
/// </summary>
|
||||
public sealed class VexTrustGate : IVexTrustGate
|
||||
{
|
||||
private readonly IOptionsMonitor<VexTrustGateOptions> _options;
|
||||
private readonly ILogger<VexTrustGate> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public const string GateIdPrefix = "vex-trust";
|
||||
public const int GateOrder = 250; // After LatticeState (200), before UncertaintyTier (300)
|
||||
|
||||
public VexTrustGate(
|
||||
IOptionsMonitor<VexTrustGateOptions> options,
|
||||
ILogger<VexTrustGate> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VexTrustGateResult> EvaluateAsync(
|
||||
VexTrustGateRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var gateId = $"{GateIdPrefix}:{request.RequestedStatus}:{now:O}";
|
||||
|
||||
// Check if gate is enabled
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return Task.FromResult(CreateAllowResult(gateId, "gate_disabled", request.VexTrustStatus));
|
||||
}
|
||||
|
||||
// Check if status applies to this gate
|
||||
if (!options.ApplyToStatuses.Contains(request.RequestedStatus, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(CreateAllowResult(gateId, "status_not_applicable", request.VexTrustStatus));
|
||||
}
|
||||
|
||||
// Check if trust data is present
|
||||
var trustStatus = request.VexTrustStatus;
|
||||
if (trustStatus is null)
|
||||
{
|
||||
return Task.FromResult(HandleMissingTrust(gateId, options, request));
|
||||
}
|
||||
|
||||
// Get environment-specific thresholds
|
||||
var thresholds = GetThresholds(request.Environment, options);
|
||||
|
||||
// Evaluate trust checks
|
||||
var checks = new List<VexTrustCheck>();
|
||||
|
||||
// Check 1: Composite score
|
||||
var compositeCheck = new VexTrustCheck(
|
||||
"composite_score",
|
||||
trustStatus.TrustScore >= thresholds.MinCompositeScore,
|
||||
$"Score {trustStatus.TrustScore:F2} vs required {thresholds.MinCompositeScore:F2}");
|
||||
checks.Add(compositeCheck);
|
||||
|
||||
// Check 2: Issuer verified
|
||||
if (thresholds.RequireIssuerVerified)
|
||||
{
|
||||
var verifiedCheck = new VexTrustCheck(
|
||||
"issuer_verified",
|
||||
trustStatus.SignatureVerified == true,
|
||||
trustStatus.SignatureVerified == true
|
||||
? "Signature verified"
|
||||
: "Signature not verified");
|
||||
checks.Add(verifiedCheck);
|
||||
}
|
||||
|
||||
// Check 3: Freshness
|
||||
var freshnessCheck = new VexTrustCheck(
|
||||
"freshness",
|
||||
IsAcceptableFreshness(trustStatus.Freshness, thresholds),
|
||||
$"Freshness: {trustStatus.Freshness ?? "unknown"}");
|
||||
checks.Add(freshnessCheck);
|
||||
|
||||
// Check 4: Accuracy rate (optional)
|
||||
if (thresholds.MinAccuracyRate.HasValue &&
|
||||
trustStatus.TrustBreakdown?.AccuracyScore.HasValue == true)
|
||||
{
|
||||
var accuracyCheck = new VexTrustCheck(
|
||||
"accuracy_rate",
|
||||
trustStatus.TrustBreakdown.AccuracyScore >= thresholds.MinAccuracyRate,
|
||||
$"Accuracy {trustStatus.TrustBreakdown.AccuracyScore:P0} vs required {thresholds.MinAccuracyRate:P0}");
|
||||
checks.Add(accuracyCheck);
|
||||
}
|
||||
|
||||
// Aggregate results
|
||||
var failedChecks = checks.Where(c => !c.Passed).ToList();
|
||||
|
||||
if (failedChecks.Count > 0)
|
||||
{
|
||||
var decision = thresholds.FailureAction == FailureAction.Block
|
||||
? VexTrustGateDecision.Block
|
||||
: VexTrustGateDecision.Warn;
|
||||
|
||||
_logger.LogInformation(
|
||||
"VexTrustGate {Decision} for status {Status} in {Environment}: {FailedChecks}",
|
||||
decision,
|
||||
request.RequestedStatus,
|
||||
request.Environment,
|
||||
string.Join(", ", failedChecks.Select(c => c.Name)));
|
||||
|
||||
return Task.FromResult(new VexTrustGateResult
|
||||
{
|
||||
GateId = gateId,
|
||||
Decision = decision,
|
||||
Reason = "vex_trust_below_threshold",
|
||||
Checks = checks,
|
||||
TrustScore = trustStatus.TrustScore,
|
||||
TrustTier = ComputeTier(trustStatus.TrustScore),
|
||||
IssuerId = trustStatus.IssuerId,
|
||||
SignatureVerified = trustStatus.SignatureVerified,
|
||||
Suggestion = BuildSuggestion(failedChecks, request),
|
||||
EvaluatedAt = now,
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("failed_checks", failedChecks.Select(c => c.Name).ToList())
|
||||
.Add("threshold", thresholds.MinCompositeScore)
|
||||
.Add("environment", request.Environment)
|
||||
});
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
_logger.LogDebug(
|
||||
"VexTrustGate allowed status {Status} for issuer {Issuer} with score {Score}",
|
||||
request.RequestedStatus,
|
||||
trustStatus.IssuerId,
|
||||
trustStatus.TrustScore);
|
||||
|
||||
return Task.FromResult(new VexTrustGateResult
|
||||
{
|
||||
GateId = gateId,
|
||||
Decision = VexTrustGateDecision.Allow,
|
||||
Reason = "vex_trust_adequate",
|
||||
Checks = checks,
|
||||
TrustScore = trustStatus.TrustScore,
|
||||
TrustTier = ComputeTier(trustStatus.TrustScore),
|
||||
IssuerId = trustStatus.IssuerId,
|
||||
SignatureVerified = trustStatus.SignatureVerified,
|
||||
EvaluatedAt = now,
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("issuer", trustStatus.IssuerName ?? "unknown")
|
||||
.Add("verified", trustStatus.SignatureVerified ?? false)
|
||||
});
|
||||
}
|
||||
|
||||
private VexTrustGateResult HandleMissingTrust(
|
||||
string gateId,
|
||||
VexTrustGateOptions options,
|
||||
VexTrustGateRequest request)
|
||||
{
|
||||
var decision = options.MissingTrustBehavior switch
|
||||
{
|
||||
MissingTrustBehavior.Block => VexTrustGateDecision.Block,
|
||||
MissingTrustBehavior.Warn => VexTrustGateDecision.Warn,
|
||||
MissingTrustBehavior.Allow => VexTrustGateDecision.Allow,
|
||||
_ => VexTrustGateDecision.Warn
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"VexTrustGate {Decision} for status {Status}: missing trust data",
|
||||
decision,
|
||||
request.RequestedStatus);
|
||||
|
||||
return new VexTrustGateResult
|
||||
{
|
||||
GateId = gateId,
|
||||
Decision = decision,
|
||||
Reason = "missing_vex_trust_data",
|
||||
Checks = Array.Empty<VexTrustCheck>(),
|
||||
Suggestion = "Ensure VEX document has signature verification and issuer is in IssuerDirectory",
|
||||
EvaluatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
private static VexTrustGateResult CreateAllowResult(
|
||||
string gateId,
|
||||
string reason,
|
||||
VexTrustStatus? trustStatus)
|
||||
{
|
||||
return new VexTrustGateResult
|
||||
{
|
||||
GateId = gateId,
|
||||
Decision = VexTrustGateDecision.Allow,
|
||||
Reason = reason,
|
||||
TrustScore = trustStatus?.TrustScore,
|
||||
TrustTier = trustStatus is not null
|
||||
? ComputeTier(trustStatus.TrustScore)
|
||||
: null,
|
||||
IssuerId = trustStatus?.IssuerId,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static VexTrustThresholds GetThresholds(
|
||||
string environment,
|
||||
VexTrustGateOptions options)
|
||||
{
|
||||
if (options.Thresholds.TryGetValue(environment, out var thresholds))
|
||||
{
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
// Fallback to default thresholds
|
||||
return options.Thresholds.TryGetValue("default", out var defaultThresholds)
|
||||
? defaultThresholds
|
||||
: new VexTrustThresholds();
|
||||
}
|
||||
|
||||
private static bool IsAcceptableFreshness(
|
||||
string? freshness,
|
||||
VexTrustThresholds thresholds)
|
||||
{
|
||||
if (string.IsNullOrEmpty(freshness))
|
||||
{
|
||||
// Unknown freshness - treat as acceptable if fresh is not required
|
||||
return thresholds.AcceptableFreshness.Count == 0 ||
|
||||
thresholds.AcceptableFreshness.Contains("unknown", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return thresholds.AcceptableFreshness.Contains(freshness, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ComputeTier(decimal score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 0.9m => "VeryHigh",
|
||||
>= 0.7m => "High",
|
||||
>= 0.5m => "Medium",
|
||||
>= 0.3m => "Low",
|
||||
_ => "VeryLow"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildSuggestion(
|
||||
List<VexTrustCheck> failedChecks,
|
||||
VexTrustGateRequest request)
|
||||
{
|
||||
var suggestions = new List<string>();
|
||||
|
||||
foreach (var check in failedChecks)
|
||||
{
|
||||
var suggestion = check.Name switch
|
||||
{
|
||||
"composite_score" =>
|
||||
"Obtain VEX from a higher-trust source or verify the existing VEX signature",
|
||||
"issuer_verified" =>
|
||||
"Ensure VEX document is signed and the signing key is registered in IssuerDirectory",
|
||||
"freshness" =>
|
||||
"Obtain a more recent VEX statement; the current one may be stale or superseded",
|
||||
"accuracy_rate" =>
|
||||
"The issuer's historical accuracy is below threshold; consider alternative sources",
|
||||
_ => $"Address {check.Name} check failure"
|
||||
};
|
||||
|
||||
suggestions.Add(suggestion);
|
||||
}
|
||||
|
||||
return string.Join("; ", suggestions);
|
||||
}
|
||||
}
|
||||
125
src/Policy/StellaOps.Policy.Engine/Gates/VexTrustGateMetrics.cs
Normal file
125
src/Policy/StellaOps.Policy.Engine/Gates/VexTrustGateMetrics.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
// VexTrustGateMetrics - OpenTelemetry metrics for VEX trust gate
|
||||
// Part of SPRINT_1227_0004_0003: VexTrustGate Policy Integration
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry metrics for VEX trust gate decisions.
|
||||
/// </summary>
|
||||
public sealed class VexTrustGateMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _evaluationsTotal;
|
||||
private readonly Counter<long> _decisionsTotal;
|
||||
private readonly Histogram<double> _trustScoreDistribution;
|
||||
private readonly Histogram<double> _evaluationDurationMs;
|
||||
|
||||
public const string MeterName = "StellaOps.Policy.Engine.VexTrustGate";
|
||||
|
||||
public VexTrustGateMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName, "1.0.0");
|
||||
|
||||
_evaluationsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.policy.vex_trust_gate.evaluations.total",
|
||||
unit: "{evaluations}",
|
||||
description: "Total number of VEX trust gate evaluations");
|
||||
|
||||
_decisionsTotal = _meter.CreateCounter<long>(
|
||||
"stellaops.policy.vex_trust_gate.decisions.total",
|
||||
unit: "{decisions}",
|
||||
description: "VEX trust gate decisions by outcome");
|
||||
|
||||
_trustScoreDistribution = _meter.CreateHistogram<double>(
|
||||
"stellaops.policy.vex_trust_gate.trust_score",
|
||||
unit: "1",
|
||||
description: "Distribution of trust scores evaluated");
|
||||
|
||||
_evaluationDurationMs = _meter.CreateHistogram<double>(
|
||||
"stellaops.policy.vex_trust_gate.evaluation_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration of trust gate evaluations");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a gate evaluation.
|
||||
/// </summary>
|
||||
public void RecordEvaluation(
|
||||
string environment,
|
||||
string requestedStatus)
|
||||
{
|
||||
_evaluationsTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("environment", environment),
|
||||
new KeyValuePair<string, object?>("requested_status", requestedStatus));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a gate decision.
|
||||
/// </summary>
|
||||
public void RecordDecision(
|
||||
VexTrustGateDecision decision,
|
||||
string reason,
|
||||
string environment,
|
||||
decimal? trustScore)
|
||||
{
|
||||
_decisionsTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("decision", decision.ToString().ToLowerInvariant()),
|
||||
new KeyValuePair<string, object?>("reason", reason),
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
|
||||
if (trustScore.HasValue)
|
||||
{
|
||||
_trustScoreDistribution.Record(
|
||||
(double)trustScore.Value,
|
||||
new KeyValuePair<string, object?>("environment", environment),
|
||||
new KeyValuePair<string, object?>("decision", decision.ToString().ToLowerInvariant()));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record evaluation duration.
|
||||
/// </summary>
|
||||
public void RecordDuration(TimeSpan duration, string environment)
|
||||
{
|
||||
_evaluationDurationMs.Record(
|
||||
duration.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("environment", environment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a timer scope for measuring evaluation duration.
|
||||
/// </summary>
|
||||
public IDisposable StartEvaluationTimer(string environment)
|
||||
{
|
||||
return new DurationScope(this, environment);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
|
||||
private sealed class DurationScope : IDisposable
|
||||
{
|
||||
private readonly VexTrustGateMetrics _metrics;
|
||||
private readonly string _environment;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
|
||||
public DurationScope(VexTrustGateMetrics metrics, string environment)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_environment = environment;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
_metrics.RecordDuration(_stopwatch.Elapsed, _environment);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/Policy/StellaOps.Policy.Engine/Gates/VexTrustGateOptions.cs
Normal file
165
src/Policy/StellaOps.Policy.Engine/Gates/VexTrustGateOptions.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
// VexTrustGateOptions - Configuration for VEX trust gate
|
||||
// Part of SPRINT_1227_0004_0003: VexTrustGate Policy Integration
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the VEX trust gate.
|
||||
/// Defines thresholds, behaviors, and per-environment policies.
|
||||
/// </summary>
|
||||
public sealed class VexTrustGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "Policy:Gates:VexTrust";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the VEX trust gate is enabled.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// VEX statuses to which this gate applies.
|
||||
/// Default: ["not_affected", "fixed"].
|
||||
/// </summary>
|
||||
public HashSet<string> ApplyToStatuses { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"not_affected",
|
||||
"fixed"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment trust thresholds.
|
||||
/// Keys: "production", "staging", "development", "default".
|
||||
/// </summary>
|
||||
public Dictionary<string, VexTrustThresholds> Thresholds { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = new VexTrustThresholds
|
||||
{
|
||||
MinCompositeScore = 0.80m,
|
||||
RequireIssuerVerified = true,
|
||||
MinAccuracyRate = 0.85m,
|
||||
AcceptableFreshness = { "fresh" },
|
||||
FailureAction = FailureAction.Block
|
||||
},
|
||||
["staging"] = new VexTrustThresholds
|
||||
{
|
||||
MinCompositeScore = 0.60m,
|
||||
RequireIssuerVerified = true,
|
||||
MinAccuracyRate = null,
|
||||
AcceptableFreshness = { "fresh", "stale" },
|
||||
FailureAction = FailureAction.Warn
|
||||
},
|
||||
["development"] = new VexTrustThresholds
|
||||
{
|
||||
MinCompositeScore = 0.40m,
|
||||
RequireIssuerVerified = false,
|
||||
MinAccuracyRate = null,
|
||||
AcceptableFreshness = { "fresh", "stale", "superseded" },
|
||||
FailureAction = FailureAction.Warn
|
||||
},
|
||||
["default"] = new VexTrustThresholds
|
||||
{
|
||||
MinCompositeScore = 0.70m,
|
||||
RequireIssuerVerified = true,
|
||||
MinAccuracyRate = null,
|
||||
AcceptableFreshness = { "fresh", "stale" },
|
||||
FailureAction = FailureAction.Warn
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Behavior when VEX trust data is missing.
|
||||
/// Default: Warn.
|
||||
/// </summary>
|
||||
public MissingTrustBehavior MissingTrustBehavior { get; set; } = MissingTrustBehavior.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit OpenTelemetry metrics for gate decisions.
|
||||
/// </summary>
|
||||
public bool EmitMetrics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-specific threshold overrides.
|
||||
/// Key is tenant ID, value is a dictionary of environment→thresholds.
|
||||
/// </summary>
|
||||
public Dictionary<string, Dictionary<string, VexTrustThresholds>> TenantOverrides { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust thresholds for a specific environment or context.
|
||||
/// </summary>
|
||||
public sealed class VexTrustThresholds
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum composite trust score required (0.0 - 1.0).
|
||||
/// </summary>
|
||||
[Range(0.0, 1.0)]
|
||||
public decimal MinCompositeScore { get; set; } = 0.70m;
|
||||
|
||||
/// <summary>
|
||||
/// Whether issuer signature verification is required.
|
||||
/// </summary>
|
||||
public bool RequireIssuerVerified { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum issuer accuracy rate required (null = not checked).
|
||||
/// </summary>
|
||||
[Range(0.0, 1.0)]
|
||||
public decimal? MinAccuracyRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Acceptable freshness states.
|
||||
/// </summary>
|
||||
public HashSet<string> AcceptableFreshness { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"fresh",
|
||||
"stale"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age of VEX statement (null = no limit).
|
||||
/// </summary>
|
||||
public TimeSpan? MaxAge { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when thresholds are not met.
|
||||
/// </summary>
|
||||
public FailureAction FailureAction { get; set; } = FailureAction.Warn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action when trust thresholds are not met.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FailureAction>))]
|
||||
public enum FailureAction
|
||||
{
|
||||
/// <summary>Allow with warning (logged and reported).</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Block the transition.</summary>
|
||||
Block
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Behavior when VEX trust data is missing.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<MissingTrustBehavior>))]
|
||||
public enum MissingTrustBehavior
|
||||
{
|
||||
/// <summary>Allow the transition (trust data optional).</summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>Allow with warning.</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Block the transition (trust data required).</summary>
|
||||
Block
|
||||
}
|
||||
@@ -25,7 +25,7 @@ using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Storage.InMemory;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using StellaOps.Policy.Persistence.Postgres;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -374,3 +374,9 @@ app.MapViolationEventsApi();
|
||||
app.MapConflictsApi();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class partial to allow integration testing while keeping it minimal
|
||||
namespace StellaOps.Policy.Engine
|
||||
{
|
||||
public partial class Program { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Policy.Engine": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62533;http://localhost:62534"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,138 @@ public interface IReachabilityFactsSignalsClient
|
||||
Task<bool> TriggerRecomputeAsync(
|
||||
SignalsRecomputeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a reachability fact with its associated subgraph slice.
|
||||
/// Fetches from Signals for the fact and ReachGraph Store for the subgraph.
|
||||
/// </summary>
|
||||
/// <param name="subjectKey">Subject key (scan ID or component key).</param>
|
||||
/// <param name="cveId">Optional CVE ID to slice by.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The reachability fact with subgraph, or null if not found.</returns>
|
||||
Task<ReachabilityFactWithSubgraph?> GetWithSubgraphAsync(
|
||||
string subjectKey,
|
||||
string? cveId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing both the reachability fact and its subgraph slice.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFactWithSubgraph(
|
||||
SignalsReachabilityFactResponse Fact,
|
||||
ReachGraphSlice? Subgraph);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a slice of the reachability graph for a specific query.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphSlice
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version.
|
||||
/// </summary>
|
||||
public string? SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Slice query information.
|
||||
/// </summary>
|
||||
public ReachGraphSliceQuery? SliceQuery { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent graph digest.
|
||||
/// </summary>
|
||||
public string? ParentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 digest of this slice.
|
||||
/// </summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Nodes in the slice.
|
||||
/// </summary>
|
||||
public List<ReachGraphSliceNode>? Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Edges in the slice.
|
||||
/// </summary>
|
||||
public List<ReachGraphSliceEdge>? Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of nodes.
|
||||
/// </summary>
|
||||
public int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of edges.
|
||||
/// </summary>
|
||||
public int EdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink node IDs.
|
||||
/// </summary>
|
||||
public List<string>? Sinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Paths from entrypoints to sinks.
|
||||
/// </summary>
|
||||
public List<ReachGraphPath>? Paths { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice query information.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphSliceQuery
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Query { get; init; }
|
||||
public string? Cve { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Node in a reachability graph slice.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphSliceNode
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Kind { get; init; }
|
||||
public string? Ref { get; init; }
|
||||
public string? File { get; init; }
|
||||
public int? Line { get; init; }
|
||||
public bool IsEntrypoint { get; init; }
|
||||
public bool IsSink { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge in a reachability graph slice.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphSliceEdge
|
||||
{
|
||||
public string? From { get; init; }
|
||||
public string? To { get; init; }
|
||||
public ReachGraphEdgeExplanation? Why { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge explanation in a reachability graph.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphEdgeExplanation
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Loc { get; init; }
|
||||
public string? Guard { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path from entrypoint to sink.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphPath
|
||||
{
|
||||
public string? Entrypoint { get; init; }
|
||||
public string? Sink { get; init; }
|
||||
public List<string>? Hops { get; init; }
|
||||
public List<ReachGraphSliceEdge>? Edges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -190,6 +190,83 @@ public sealed class ReachabilityFactsSignalsClient : IReachabilityFactsSignalsCl
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachabilityFactWithSubgraph?> GetWithSubgraphAsync(
|
||||
string subjectKey,
|
||||
string? cveId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
|
||||
"signals_client.get_fact_with_subgraph",
|
||||
ActivityKind.Client);
|
||||
activity?.SetTag("signals.subject_key", subjectKey);
|
||||
if (cveId is not null)
|
||||
{
|
||||
activity?.SetTag("signals.cve_id", cveId);
|
||||
}
|
||||
|
||||
// Get base reachability fact from Signals
|
||||
var fact = await GetBySubjectAsync(subjectKey, cancellationToken).ConfigureAwait(false);
|
||||
if (fact is null)
|
||||
{
|
||||
_logger.LogDebug("No reachability fact found for subject {SubjectKey}", subjectKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(fact.CallgraphId))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Reachability fact for subject {SubjectKey} has no callgraph ID",
|
||||
subjectKey);
|
||||
return new ReachabilityFactWithSubgraph(fact, null);
|
||||
}
|
||||
|
||||
// Fetch subgraph slice from ReachGraph Store
|
||||
var sliceQuery = cveId is not null
|
||||
? $"?cve={Uri.EscapeDataString(cveId)}"
|
||||
: "";
|
||||
|
||||
try
|
||||
{
|
||||
var slicePath = _options.ReachGraphStoreBaseUri is not null
|
||||
? $"v1/reachgraphs/{Uri.EscapeDataString(fact.CallgraphId)}/slice{sliceQuery}"
|
||||
: $"reachgraph/v1/reachgraphs/{Uri.EscapeDataString(fact.CallgraphId)}/slice{sliceQuery}";
|
||||
|
||||
var response = await _httpClient.GetAsync(slicePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to fetch subgraph slice for callgraph {CallgraphId}: {StatusCode}",
|
||||
fact.CallgraphId,
|
||||
response.StatusCode);
|
||||
return new ReachabilityFactWithSubgraph(fact, null);
|
||||
}
|
||||
|
||||
var slice = await response.Content
|
||||
.ReadFromJsonAsync<ReachGraphSlice>(SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetched subgraph slice for callgraph {CallgraphId}: {NodeCount} nodes, {PathCount} paths",
|
||||
fact.CallgraphId,
|
||||
slice?.NodeCount ?? 0,
|
||||
slice?.Paths?.Count ?? 0);
|
||||
|
||||
return new ReachabilityFactWithSubgraph(fact, slice);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Error fetching subgraph slice for callgraph {CallgraphId}",
|
||||
fact.CallgraphId);
|
||||
return new ReachabilityFactWithSubgraph(fact, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -207,6 +284,12 @@ public sealed class ReachabilityFactsSignalsClientOptions
|
||||
/// </summary>
|
||||
public Uri? BaseUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base URI for the ReachGraph Store service.
|
||||
/// If null, uses the same base URI as Signals.
|
||||
/// </summary>
|
||||
public Uri? ReachGraphStoreBaseUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrent requests for batch operations.
|
||||
/// Default: 10.
|
||||
|
||||
@@ -147,21 +147,21 @@ public sealed record DualEmitComparison
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsTierBucketMatch(Confidence.Models.ConfidenceTier tier, ScoreBucket bucket)
|
||||
private static bool IsTierBucketMatch(StellaOps.Policy.Confidence.Models.ConfidenceTier tier, ScoreBucket bucket)
|
||||
{
|
||||
// Map inverted semantics:
|
||||
// High Confidence (safe) → Watchlist (low priority)
|
||||
// Low Confidence (risky) → ActNow (high priority)
|
||||
return (tier, bucket) switch
|
||||
{
|
||||
(Confidence.Models.ConfidenceTier.VeryHigh, ScoreBucket.Watchlist) => true,
|
||||
(Confidence.Models.ConfidenceTier.High, ScoreBucket.Watchlist) => true,
|
||||
(Confidence.Models.ConfidenceTier.High, ScoreBucket.Investigate) => true,
|
||||
(Confidence.Models.ConfidenceTier.Medium, ScoreBucket.Investigate) => true,
|
||||
(Confidence.Models.ConfidenceTier.Medium, ScoreBucket.ScheduleNext) => true,
|
||||
(Confidence.Models.ConfidenceTier.Low, ScoreBucket.ScheduleNext) => true,
|
||||
(Confidence.Models.ConfidenceTier.Low, ScoreBucket.ActNow) => true,
|
||||
(Confidence.Models.ConfidenceTier.VeryLow, ScoreBucket.ActNow) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.VeryHigh, ScoreBucket.Watchlist) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.High, ScoreBucket.Watchlist) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.High, ScoreBucket.Investigate) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.Medium, ScoreBucket.Investigate) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.Medium, ScoreBucket.ScheduleNext) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.Low, ScoreBucket.ScheduleNext) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.Low, ScoreBucket.ActNow) => true,
|
||||
(StellaOps.Policy.Confidence.Models.ConfidenceTier.VeryLow, ScoreBucket.ActNow) => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictLinkService.cs
|
||||
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-012)
|
||||
// Task: Link verdicts to SBOM versions on policy evaluation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that links VEX verdicts to SBOM versions after policy evaluation.
|
||||
/// </summary>
|
||||
public interface IVerdictLinkService
|
||||
{
|
||||
/// <summary>
|
||||
/// Links VEX verdicts to an SBOM version.
|
||||
/// </summary>
|
||||
Task LinkVerdictsAsync(VerdictLinkRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to link verdicts to an SBOM.
|
||||
/// </summary>
|
||||
public sealed record VerdictLinkRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM version ID.
|
||||
/// </summary>
|
||||
public required Guid SbomVersionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verdicts from policy evaluation.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<VerdictInfo> Verdicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a single verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status (affected, not_affected, etc.).
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0-1).
|
||||
/// </summary>
|
||||
public double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Consensus projection ID if available.
|
||||
/// </summary>
|
||||
public Guid? ConsensusProjectionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether reachability was confirmed.
|
||||
/// </summary>
|
||||
public bool? ReachabilityConfirmed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IVerdictLinkService"/>.
|
||||
/// </summary>
|
||||
public sealed class VerdictLinkService : IVerdictLinkService
|
||||
{
|
||||
private readonly ISbomVerdictLinkRepository _repository;
|
||||
private readonly ILogger<VerdictLinkService> _logger;
|
||||
|
||||
public VerdictLinkService(
|
||||
ISbomVerdictLinkRepository repository,
|
||||
ILogger<VerdictLinkService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task LinkVerdictsAsync(VerdictLinkRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (request.Verdicts.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No verdicts to link for SBOM {SbomVersionId}", request.SbomVersionId);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var links = new List<SbomVerdictLink>();
|
||||
|
||||
foreach (var verdict in request.Verdicts)
|
||||
{
|
||||
var link = new SbomVerdictLink
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SbomVersionId = request.SbomVersionId,
|
||||
Cve = verdict.Cve,
|
||||
ConsensusProjectionId = verdict.ConsensusProjectionId,
|
||||
VerdictStatus = verdict.Status,
|
||||
ConfidenceScore = verdict.ConfidenceScore,
|
||||
TenantId = request.TenantId,
|
||||
LinkedAt = now,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
ComponentPurl = verdict.ComponentPurl,
|
||||
Severity = verdict.Severity,
|
||||
ReachabilityConfirmed = verdict.ReachabilityConfirmed
|
||||
};
|
||||
|
||||
links.Add(link);
|
||||
}
|
||||
|
||||
var count = await _repository.LinkBatchAsync(links, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Linked {Count} verdicts to SBOM {SbomVersionId} (artifact {ArtifactDigest})",
|
||||
count,
|
||||
request.SbomVersionId,
|
||||
TruncateDigest(request.ArtifactDigest));
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
|
||||
{
|
||||
return $"{digest[..(colonIndex + 13)]}...";
|
||||
}
|
||||
|
||||
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
|
||||
}
|
||||
}
|
||||
@@ -10,21 +10,21 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
@@ -40,9 +40,10 @@
|
||||
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Persistence/StellaOps.Policy.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
<ProjectReference Include="../../SbomService/__Libraries/StellaOps.SbomService.Persistence/StellaOps.SbomService.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.InMemory;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Vex;
|
||||
@@ -51,6 +52,8 @@ public sealed class VexDecisionEmitter : IVexDecisionEmitter
|
||||
private readonly IOptionsMonitor<VexDecisionEmitterOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<VexDecisionEmitter> _logger;
|
||||
// LIN-BE-012: Optional verdict link service for SBOM linking
|
||||
private readonly IVerdictLinkService? _verdictLinkService;
|
||||
|
||||
// Status constants
|
||||
private const string StatusNotAffected = "not_affected";
|
||||
@@ -73,13 +76,15 @@ public sealed class VexDecisionEmitter : IVexDecisionEmitter
|
||||
IPolicyGateEvaluator gateEvaluator,
|
||||
IOptionsMonitor<VexDecisionEmitterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VexDecisionEmitter> logger)
|
||||
ILogger<VexDecisionEmitter> logger,
|
||||
IVerdictLinkService? verdictLinkService = null)
|
||||
{
|
||||
_factsService = factsService ?? throw new ArgumentNullException(nameof(factsService));
|
||||
_gateEvaluator = gateEvaluator ?? throw new ArgumentNullException(nameof(gateEvaluator));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verdictLinkService = verdictLinkService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -195,6 +200,31 @@ public sealed class VexDecisionEmitter : IVexDecisionEmitter
|
||||
statements.Count,
|
||||
blocked.Count);
|
||||
|
||||
// LIN-BE-012: Link verdicts to SBOM if service available and SBOM context provided
|
||||
if (_verdictLinkService != null && request.SbomVersionId.HasValue && !string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
var verdicts = statements.Select(s => new VerdictInfo
|
||||
{
|
||||
Cve = s.Vulnerability.Id,
|
||||
Status = s.Status,
|
||||
ConfidenceScore = s.Evidence?.Confidence ?? 0.0,
|
||||
ComponentPurl = s.Products.FirstOrDefault()?.Id,
|
||||
ReachabilityConfirmed = s.Evidence is not null &&
|
||||
(s.Evidence.LatticeState == "CR" || s.Evidence.LatticeState == "CU" ||
|
||||
s.Evidence.LatticeState == "RO" || s.Evidence.LatticeState == "RU")
|
||||
}).ToList();
|
||||
|
||||
await _verdictLinkService.LinkVerdictsAsync(
|
||||
new VerdictLinkRequest
|
||||
{
|
||||
SbomVersionId = request.SbomVersionId.Value,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
TenantId = request.TenantId,
|
||||
Verdicts = verdicts
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new VexDecisionEmitResult
|
||||
{
|
||||
Document = document,
|
||||
|
||||
@@ -339,6 +339,17 @@ public sealed record VexDecisionEmitRequest
|
||||
/// Whether to submit to Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool SubmitToRekor { get; init; }
|
||||
|
||||
// LIN-BE-012: SBOM linking context
|
||||
/// <summary>
|
||||
/// SBOM version ID to link verdicts to.
|
||||
/// </summary>
|
||||
public Guid? SbomVersionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest to link verdicts to.
|
||||
/// </summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Workers;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user