350 lines
11 KiB
C#
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
|
|
};
|
|
}
|