// ----------------------------------------------------------------------------- // CvssThresholdGate.cs // Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate // Tasks: CVSS-GATE-001 to CVSS-GATE-007 // Description: Policy gate for CVSS score threshold enforcement. // ----------------------------------------------------------------------------- using System.Collections.Immutable; using System.Globalization; using StellaOps.Policy.TrustLattice; namespace StellaOps.Policy.Gates; /// /// Configuration options for CVSS threshold gate. /// public sealed class CvssThresholdGateOptions { /// /// Configuration section name. /// public const string SectionName = "Policy:Gates:CvssThreshold"; /// /// Whether the gate is enabled. /// public bool Enabled { get; init; } = true; /// /// Gate priority (lower = earlier evaluation). /// public int Priority { get; init; } = 15; /// /// Default CVSS threshold (used when environment-specific not configured). /// public double DefaultThreshold { get; init; } = 7.0; /// /// Per-environment CVSS thresholds. /// public IReadOnlyDictionary Thresholds { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["production"] = 7.0, ["staging"] = 8.0, ["development"] = 9.0 }; /// /// Preferred CVSS version for evaluation: "v3.1", "v4.0", or "highest". /// public string CvssVersionPreference { get; init; } = "highest"; /// /// CVEs to always allow regardless of score. /// public IReadOnlySet Allowlist { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); /// /// CVEs to always block regardless of score. /// public IReadOnlySet Denylist { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); /// /// Whether to fail findings without CVSS scores. /// public bool FailOnMissingCvss { get; init; } = false; /// /// Whether to require all CVSS versions to pass (AND) vs any (OR). /// public bool RequireAllVersionsPass { get; init; } = false; } /// /// CVSS score information for a finding. /// public sealed record CvssScoreInfo { /// /// CVSS v3.1 base score (0.0-10.0), null if not available. /// public double? CvssV31BaseScore { get; init; } /// /// CVSS v4.0 base score (0.0-10.0), null if not available. /// public double? CvssV40BaseScore { get; init; } /// /// CVSS v3.1 vector string. /// public string? CvssV31Vector { get; init; } /// /// CVSS v4.0 vector string. /// public string? CvssV40Vector { get; init; } } /// /// Policy gate that enforces CVSS score thresholds. /// Blocks findings with CVSS scores exceeding configured thresholds. /// public sealed class CvssThresholdGate : IPolicyGate { private readonly CvssThresholdGateOptions _options; private readonly Func _cvssLookup; /// /// Initializes the gate with options and optional CVSS lookup. /// /// Gate options. /// Function to look up CVSS scores by CVE ID. If null, uses context metadata. public CvssThresholdGate(CvssThresholdGateOptions? options = null, Func? cvssLookup = null) { _options = options ?? new CvssThresholdGateOptions(); _cvssLookup = cvssLookup ?? (_ => null); } /// public Task EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default) { if (!_options.Enabled) { return Task.FromResult(Pass("disabled")); } var cveId = context.CveId; // Check denylist first (always block) if (!string.IsNullOrEmpty(cveId) && _options.Denylist.Contains(cveId)) { return Task.FromResult(Fail( "denylist", new Dictionary { ["cve_id"] = cveId, ["reason"] = "CVE is on denylist" })); } // Check allowlist (always pass) if (!string.IsNullOrEmpty(cveId) && _options.Allowlist.Contains(cveId)) { return Task.FromResult(Pass( "allowlist", new Dictionary { ["cve_id"] = cveId, ["reason"] = "CVE is on allowlist" })); } // Get CVSS scores var cvssInfo = GetCvssScores(cveId, context); if (cvssInfo is null || (!cvssInfo.CvssV31BaseScore.HasValue && !cvssInfo.CvssV40BaseScore.HasValue)) { if (_options.FailOnMissingCvss) { return Task.FromResult(Fail( "missing_cvss", new Dictionary { ["cve_id"] = cveId ?? "(unknown)", ["reason"] = "No CVSS score available" })); } return Task.FromResult(Pass( "no_cvss_available", new Dictionary { ["cve_id"] = cveId ?? "(unknown)" })); } // Get threshold for environment var threshold = GetThreshold(context.Environment); // Evaluate based on version preference var (passed, selectedScore, selectedVersion) = EvaluateCvss(cvssInfo, threshold); var details = new Dictionary { ["threshold"] = threshold, ["environment"] = context.Environment, ["cvss_version"] = selectedVersion, ["cvss_score"] = selectedScore, ["preference"] = _options.CvssVersionPreference }; if (cvssInfo.CvssV31BaseScore.HasValue) { details["cvss_v31_score"] = cvssInfo.CvssV31BaseScore.Value; } if (cvssInfo.CvssV40BaseScore.HasValue) { details["cvss_v40_score"] = cvssInfo.CvssV40BaseScore.Value; } if (!string.IsNullOrEmpty(cveId)) { details["cve_id"] = cveId; } if (!passed) { return Task.FromResult(Fail( "cvss_exceeds_threshold", details)); } return Task.FromResult(Pass("cvss_within_threshold", details)); } private CvssScoreInfo? GetCvssScores(string? cveId, PolicyGateContext context) { // Try lookup function first var fromLookup = _cvssLookup(cveId); if (fromLookup is not null) { return fromLookup; } // Try to extract from context metadata if (context.Metadata is null) { return null; } double? v31Score = null; double? v40Score = null; string? v31Vector = null; string? v40Vector = null; if (context.Metadata.TryGetValue("cvss_v31_score", out var v31Str) && double.TryParse(v31Str, NumberStyles.Float, CultureInfo.InvariantCulture, out var v31)) { v31Score = v31; } if (context.Metadata.TryGetValue("cvss_v40_score", out var v40Str) && double.TryParse(v40Str, NumberStyles.Float, CultureInfo.InvariantCulture, out var v40)) { v40Score = v40; } if (context.Metadata.TryGetValue("cvss_v31_vector", out var v31Vec)) { v31Vector = v31Vec; } if (context.Metadata.TryGetValue("cvss_v40_vector", out var v40Vec)) { v40Vector = v40Vec; } if (!v31Score.HasValue && !v40Score.HasValue) { return null; } return new CvssScoreInfo { CvssV31BaseScore = v31Score, CvssV40BaseScore = v40Score, CvssV31Vector = v31Vector, CvssV40Vector = v40Vector }; } private double GetThreshold(string environment) { if (_options.Thresholds.TryGetValue(environment, out var threshold)) { return threshold; } return _options.DefaultThreshold; } private (bool Passed, double Score, string Version) EvaluateCvss(CvssScoreInfo cvssInfo, double threshold) { var v31Score = cvssInfo.CvssV31BaseScore; var v40Score = cvssInfo.CvssV40BaseScore; return _options.CvssVersionPreference.ToLowerInvariant() switch { "v3.1" when v31Score.HasValue => (v31Score.Value < threshold, v31Score.Value, "v3.1"), "v4.0" when v40Score.HasValue => (v40Score.Value < threshold, v40Score.Value, "v4.0"), "highest" => EvaluateHighest(v31Score, v40Score, threshold), _ => EvaluateHighest(v31Score, v40Score, threshold) }; } private (bool Passed, double Score, string Version) EvaluateHighest(double? v31Score, double? v40Score, double threshold) { // Use whichever score is available, preferring the higher one for conservative evaluation if (v31Score.HasValue && v40Score.HasValue) { if (_options.RequireAllVersionsPass) { // Both must pass var passed = v31Score.Value < threshold && v40Score.Value < threshold; var higherScore = Math.Max(v31Score.Value, v40Score.Value); var version = v31Score.Value >= v40Score.Value ? "v3.1" : "v4.0"; return (passed, higherScore, $"both ({version} highest)"); } else { // Use the higher score (more conservative) if (v31Score.Value >= v40Score.Value) { return (v31Score.Value < threshold, v31Score.Value, "v3.1"); } return (v40Score.Value < threshold, v40Score.Value, "v4.0"); } } if (v31Score.HasValue) { return (v31Score.Value < threshold, v31Score.Value, "v3.1"); } if (v40Score.HasValue) { return (v40Score.Value < threshold, v40Score.Value, "v4.0"); } // No score available - should not reach here if caller checks first return (true, 0.0, "none"); } private static GateResult Pass(string reason, IDictionary? details = null) => new() { GateName = nameof(CvssThresholdGate), Passed = true, Reason = reason, Details = details?.ToImmutableDictionary() ?? ImmutableDictionary.Empty }; private static GateResult Fail(string reason, IDictionary? details = null) => new() { GateName = nameof(CvssThresholdGate), Passed = false, Reason = reason, Details = details?.ToImmutableDictionary() ?? ImmutableDictionary.Empty }; }