Files
git.stella-ops.org/src/Policy/__Libraries/StellaOps.Policy/Gates/CvssThresholdGate.cs

350 lines
11 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Configuration options for CVSS threshold gate.
/// </summary>
public sealed class CvssThresholdGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:CvssThreshold";
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gate priority (lower = earlier evaluation).
/// </summary>
public int Priority { get; init; } = 15;
/// <summary>
/// Default CVSS threshold (used when environment-specific not configured).
/// </summary>
public double DefaultThreshold { get; init; } = 7.0;
/// <summary>
/// Per-environment CVSS thresholds.
/// </summary>
public IReadOnlyDictionary<string, double> Thresholds { get; init; } = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 7.0,
["staging"] = 8.0,
["development"] = 9.0
};
/// <summary>
/// Preferred CVSS version for evaluation: "v3.1", "v4.0", or "highest".
/// </summary>
public string CvssVersionPreference { get; init; } = "highest";
/// <summary>
/// CVEs to always allow regardless of score.
/// </summary>
public IReadOnlySet<string> Allowlist { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// CVEs to always block regardless of score.
/// </summary>
public IReadOnlySet<string> Denylist { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether to fail findings without CVSS scores.
/// </summary>
public bool FailOnMissingCvss { get; init; } = false;
/// <summary>
/// Whether to require all CVSS versions to pass (AND) vs any (OR).
/// </summary>
public bool RequireAllVersionsPass { get; init; } = false;
}
/// <summary>
/// CVSS score information for a finding.
/// </summary>
public sealed record CvssScoreInfo
{
/// <summary>
/// CVSS v3.1 base score (0.0-10.0), null if not available.
/// </summary>
public double? CvssV31BaseScore { get; init; }
/// <summary>
/// CVSS v4.0 base score (0.0-10.0), null if not available.
/// </summary>
public double? CvssV40BaseScore { get; init; }
/// <summary>
/// CVSS v3.1 vector string.
/// </summary>
public string? CvssV31Vector { get; init; }
/// <summary>
/// CVSS v4.0 vector string.
/// </summary>
public string? CvssV40Vector { get; init; }
}
/// <summary>
/// Policy gate that enforces CVSS score thresholds.
/// Blocks findings with CVSS scores exceeding configured thresholds.
/// </summary>
public sealed class CvssThresholdGate : IPolicyGate
{
private readonly CvssThresholdGateOptions _options;
private readonly Func<string?, CvssScoreInfo?> _cvssLookup;
/// <summary>
/// Initializes the gate with options and optional CVSS lookup.
/// </summary>
/// <param name="options">Gate options.</param>
/// <param name="cvssLookup">Function to look up CVSS scores by CVE ID. If null, uses context metadata.</param>
public CvssThresholdGate(CvssThresholdGateOptions? options = null, Func<string?, CvssScoreInfo?>? cvssLookup = null)
{
_options = options ?? new CvssThresholdGateOptions();
_cvssLookup = cvssLookup ?? (_ => null);
}
/// <inheritdoc/>
public Task<GateResult> 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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["cve_id"] = cveId ?? "(unknown)",
["reason"] = "No CVSS score available"
}));
}
return Task.FromResult(Pass(
"no_cvss_available",
new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>? details = null) => new()
{
GateName = nameof(CvssThresholdGate),
Passed = true,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
private static GateResult Fail(string reason, IDictionary<string, object>? details = null) => new()
{
GateName = nameof(CvssThresholdGate),
Passed = false,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
}