Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}
}

View 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);
}
}
}

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

View File

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

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Policy.Engine": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62533;http://localhost:62534"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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