doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -1,19 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Canonical.Json;
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Evidence weights for score calculation.
/// All weights except MIT should sum to approximately 1.0 (normalizable).
/// MIT is subtractive and applied separately.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-004)
/// </summary>
public sealed record EvidenceWeights
{
#region Advisory Formula Weights (SPRINT-029)
/// <summary>
/// CVSS base score weight (advisory: 0.25).
/// Additive dimension.
/// </summary>
public double Cvss { get; init; } = 0.25;
/// <summary>
/// EPSS probability weight (advisory: 0.30).
/// Additive dimension.
/// </summary>
public double Epss { get; init; } = 0.30;
/// <summary>
/// Reachability weight (advisory: 0.20).
/// Additive dimension.
/// </summary>
public double Reachability { get; init; } = 0.20;
/// <summary>
/// Exploit maturity weight (advisory: 0.10).
/// Additive dimension.
/// </summary>
public double ExploitMaturity { get; init; } = 0.10;
/// <summary>
/// Patch proof confidence weight - SUBTRACTIVE (advisory: 0.15).
/// Higher patch proof confidence reduces the score.
/// </summary>
public double PatchProof { get; init; } = 0.15;
#endregion
#region Legacy Weights (backward compatibility)
/// <summary>Weight for reachability dimension [0, 1].</summary>
public required double Rch { get; init; }
@@ -32,8 +66,10 @@ public sealed record EvidenceWeights
/// <summary>Weight for mitigation dimension (subtractive) [0, 1].</summary>
public required double Mit { get; init; }
#endregion
/// <summary>
/// Default weights as specified in the scoring model.
/// Default weights as specified in the legacy scoring model.
/// </summary>
public static EvidenceWeights Default => new()
{
@@ -42,7 +78,54 @@ public sealed record EvidenceWeights
Bkp = 0.15,
Xpl = 0.15,
Src = 0.10,
Mit = 0.10
Mit = 0.10,
// Advisory weights at defaults
Cvss = 0.25,
Epss = 0.30,
Reachability = 0.20,
ExploitMaturity = 0.10,
PatchProof = 0.15
};
/// <summary>
/// Advisory formula weights per advisory specification.
/// raw = 0.25*cvss + 0.30*epss + 0.20*reachability + 0.10*exploit_maturity - 0.15*patch_proof
/// </summary>
public static EvidenceWeights Advisory => new()
{
// Advisory weights
Cvss = 0.25,
Epss = 0.30,
Reachability = 0.20,
ExploitMaturity = 0.10,
PatchProof = 0.15,
// Legacy weights mapped to advisory
Rch = 0.20, // maps to Reachability
Rts = 0.0, // runtime - consider merging with reachability
Bkp = 0.0, // maps to PatchProof (but inverted)
Xpl = 0.30, // maps to Epss
Src = 0.0, // source trust - not in advisory formula
Mit = 0.0 // mitigations - not in advisory formula
};
/// <summary>
/// Legacy weights for backward compatibility.
/// Uses the original 6-dimension formula.
/// </summary>
public static EvidenceWeights Legacy => new()
{
Rch = 0.30,
Rts = 0.25,
Bkp = 0.15,
Xpl = 0.15,
Src = 0.10,
Mit = 0.10,
// Advisory weights zeroed
Cvss = 0.0,
Epss = 0.0,
Reachability = 0.0,
ExploitMaturity = 0.0,
PatchProof = 0.0
};
/// <summary>
@@ -52,6 +135,14 @@ public sealed record EvidenceWeights
{
var errors = new List<string>();
// Advisory weights
ValidateWeight(nameof(Cvss), Cvss, errors);
ValidateWeight(nameof(Epss), Epss, errors);
ValidateWeight(nameof(Reachability), Reachability, errors);
ValidateWeight(nameof(ExploitMaturity), ExploitMaturity, errors);
ValidateWeight(nameof(PatchProof), PatchProof, errors);
// Legacy weights
ValidateWeight(nameof(Rch), Rch, errors);
ValidateWeight(nameof(Rts), Rts, errors);
ValidateWeight(nameof(Bkp), Bkp, errors);
@@ -63,10 +154,15 @@ public sealed record EvidenceWeights
}
/// <summary>
/// Gets the sum of additive weights (excludes MIT).
/// Gets the sum of additive legacy weights (excludes MIT).
/// </summary>
public double AdditiveSum => Rch + Rts + Bkp + Xpl + Src;
/// <summary>
/// Gets the sum of additive advisory weights (excludes PatchProof).
/// </summary>
public double AdvisoryAdditiveSum => Cvss + Epss + Reachability + ExploitMaturity;
/// <summary>
/// Returns normalized weights where additive weights sum to 1.0.
/// MIT is preserved as-is (subtractive).
@@ -77,14 +173,14 @@ public sealed record EvidenceWeights
if (sum <= 0)
return Default;
return new EvidenceWeights
return this with
{
Rch = Rch / sum,
Rts = Rts / sum,
Bkp = Bkp / sum,
Xpl = Xpl / sum,
Src = Src / sum,
Mit = Mit // MIT is not normalized
Src = Src / sum
// MIT is not normalized
};
}
@@ -97,6 +193,24 @@ public sealed record EvidenceWeights
}
}
/// <summary>
/// Formula mode for score calculation.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-005)
/// </summary>
public enum FormulaMode
{
/// <summary>
/// Legacy 6-dimension formula (RCH, RTS, BKP, XPL, SRC, MIT).
/// </summary>
Legacy = 0,
/// <summary>
/// Advisory 5-dimension formula (CVSS, EPSS, Reachability, ExploitMaturity, PatchProof).
/// raw = 0.25*cvss + 0.30*epss + 0.20*reachability + 0.10*exploit_maturity - 0.15*patch_proof
/// </summary>
Advisory = 1
}
/// <summary>
/// Guardrail configuration for score caps and floors.
/// </summary>
@@ -263,10 +377,11 @@ public sealed record BucketThresholds
/// <summary>
/// Complete evidence weight policy with version tracking.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-004)
/// </summary>
public sealed record EvidenceWeightPolicy
{
/// <summary>Policy schema version (e.g., "ews.v1").</summary>
/// <summary>Policy schema version (e.g., "ews.v1", "ews.v2").</summary>
public required string Version { get; init; }
/// <summary>Policy profile name (e.g., "production", "development").</summary>
@@ -275,6 +390,18 @@ public sealed record EvidenceWeightPolicy
/// <summary>Dimension weights.</summary>
public required EvidenceWeights Weights { get; init; }
/// <summary>
/// Formula mode for score calculation (Legacy or Advisory).
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-005)
/// </summary>
public FormulaMode FormulaMode { get; init; } = FormulaMode.Legacy;
/// <summary>
/// Trusted VEX key identifiers for authoritative override checks.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-006)
/// </summary>
public IReadOnlyList<string> TrustedVexKeys { get; init; } = [];
/// <summary>Guardrail configuration.</summary>
public GuardrailConfig Guardrails { get; init; } = GuardrailConfig.Default;
@@ -291,45 +418,78 @@ public sealed record EvidenceWeightPolicy
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Default production policy.
/// Default production policy (legacy formula).
/// </summary>
public static EvidenceWeightPolicy DefaultProduction => new()
{
Version = "ews.v1",
Profile = "production",
Weights = EvidenceWeights.Default
Weights = EvidenceWeights.Default,
FormulaMode = FormulaMode.Legacy
};
/// <summary>
/// Advisory formula policy per advisory specification.
/// </summary>
public static EvidenceWeightPolicy AdvisoryProduction => new()
{
Version = "ews.v2",
Profile = "advisory",
Weights = EvidenceWeights.Advisory,
FormulaMode = FormulaMode.Advisory
};
private static readonly JsonSerializerOptions CanonicalSerializerOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
private string? _cachedDigest;
/// <summary>
/// Computes a deterministic digest of this policy for versioning.
/// Uses canonical JSON serialization -> SHA256.
/// Uses CanonJson canonical JSON serialization with SHA-256.
/// </summary>
public string ComputeDigest()
{
if (_cachedDigest is not null)
return _cachedDigest;
var canonical = GetCanonicalJson();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
_cachedDigest = Convert.ToHexStringLower(hash);
var canonicalBytes = CanonJson.Canonicalize(BuildCanonicalProjection(), CanonicalSerializerOptions);
_cachedDigest = CanonJson.Sha256Hex(canonicalBytes);
return _cachedDigest;
}
/// <summary>
/// Gets the canonical JSON representation for hashing.
/// Uses deterministic property ordering and formatting.
/// Uses CanonJson for deterministic property ordering and formatting.
/// </summary>
public string GetCanonicalJson()
{
// Use a deterministic structure for hashing
var canonical = new
return CanonJson.Serialize(BuildCanonicalProjection(), CanonicalSerializerOptions);
}
/// <summary>
/// Builds the canonical projection for serialization.
/// Properties are explicitly defined to ensure stable output.
/// </summary>
private object BuildCanonicalProjection()
{
return new
{
version = Version,
profile = Profile,
formula_mode = FormulaMode.ToString().ToLowerInvariant(),
weights = new
{
// Advisory weights
cvss = Weights.Cvss,
epss = Weights.Epss,
reachability = Weights.Reachability,
exploit_maturity = Weights.ExploitMaturity,
patch_proof = Weights.PatchProof,
// Legacy weights
rch = Weights.Rch,
rts = Weights.Rts,
bkp = Weights.Bkp,
@@ -337,6 +497,7 @@ public sealed record EvidenceWeightPolicy
src = Weights.Src,
mit = Weights.Mit
},
trusted_vex_keys = TrustedVexKeys.OrderBy(k => k, StringComparer.Ordinal).ToArray(),
guardrails = new
{
not_affected_cap = new
@@ -380,12 +541,6 @@ public sealed record EvidenceWeightPolicy
skip_epss_when_anchored = AttestedReduction.SkipEpssWhenAnchored
}
};
return JsonSerializer.Serialize(canonical, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
}
/// <summary>

View File

@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
using System.Text.Json;
using StellaOps.Canonical.Json;
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
@@ -88,9 +91,16 @@ public sealed record EvidenceInputValues(
/// <summary>
/// Result of evidence-weighted score calculation.
/// Implements ICanonicalizable for deterministic serialization and content-addressed hashing.
/// </summary>
public sealed record EvidenceWeightedScoreResult
public sealed record EvidenceWeightedScoreResult : ICanonicalizable
{
private static readonly JsonSerializerOptions CanonicalSerializerOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
/// <summary>Finding identifier.</summary>
public required string FindingId { get; init; }
@@ -123,6 +133,84 @@ public sealed record EvidenceWeightedScoreResult
/// <summary>Calculation timestamp (UTC ISO-8601).</summary>
public required DateTimeOffset CalculatedAt { get; init; }
/// <summary>
/// Canonical digest of this result (sha256 hex).
/// Excluded from digest computation to avoid circular reference.
/// </summary>
public string? CanonicalDigest { get; init; }
/// <inheritdoc />
public string GetCanonicalJson()
{
// Exclude CanonicalDigest from serialization to avoid circular dependency
var projection = BuildCanonicalProjection();
return CanonJson.Serialize(projection, CanonicalSerializerOptions);
}
/// <inheritdoc />
public string ComputeDigest()
{
var canonicalBytes = CanonJson.Canonicalize(BuildCanonicalProjection(), CanonicalSerializerOptions);
return CanonJson.Sha256Hex(canonicalBytes);
}
/// <summary>
/// Returns this result with the CanonicalDigest field populated.
/// </summary>
public EvidenceWeightedScoreResult WithComputedDigest()
{
return this with { CanonicalDigest = ComputeDigest() };
}
private object BuildCanonicalProjection()
{
// Explicitly build projection excluding CanonicalDigest
return new
{
finding_id = FindingId,
score = Score,
bucket = Bucket.ToString().ToLowerInvariant(),
inputs = new
{
rch = Inputs.Rch,
rts = Inputs.Rts,
bkp = Inputs.Bkp,
xpl = Inputs.Xpl,
src = Inputs.Src,
mit = Inputs.Mit
},
weights = new
{
rch = Weights.Rch,
rts = Weights.Rts,
bkp = Weights.Bkp,
xpl = Weights.Xpl,
src = Weights.Src,
mit = Weights.Mit
},
breakdown = Breakdown.Select(b => new
{
dimension = b.Dimension,
symbol = b.Symbol,
input_value = b.InputValue,
weight = b.Weight,
contribution = b.Contribution,
is_subtractive = b.IsSubtractive
}).ToArray(),
flags = Flags.OrderBy(f => f, StringComparer.Ordinal).ToArray(),
caps = new
{
speculative_cap = Caps.SpeculativeCap,
not_affected_cap = Caps.NotAffectedCap,
runtime_floor = Caps.RuntimeFloor,
original_score = Caps.OriginalScore,
adjusted_score = Caps.AdjustedScore
},
policy_digest = PolicyDigest,
calculated_at = CalculatedAt.ToString("o")
};
}
}
/// <summary>
@@ -161,13 +249,26 @@ public sealed class EvidenceWeightedScoreCalculator : IEvidenceWeightedScoreCalc
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(policy);
// Check for VEX override first (TASK-029-006)
var vexOverride = CheckVexOverride(input, policy);
if (vexOverride is not null)
{
return vexOverride;
}
// Check if attested-reduction scoring is enabled
if (policy.AttestedReduction.Enabled)
{
return CalculateAttestedReduction(input, policy);
}
return CalculateStandard(input, policy);
// Route to appropriate formula based on mode (TASK-029-005)
return policy.FormulaMode switch
{
FormulaMode.Advisory => CalculateAdvisoryFormula(input, policy),
FormulaMode.Legacy => CalculateStandard(input, policy),
_ => CalculateStandard(input, policy)
};
}
/// <summary>
@@ -225,7 +326,333 @@ public sealed class EvidenceWeightedScoreCalculator : IEvidenceWeightedScoreCalc
Caps = guardrails,
PolicyDigest = policy.ComputeDigest(),
CalculatedAt = _timeProvider.GetUtcNow()
}.WithComputedDigest();
}
/// <summary>
/// Advisory formula scoring path.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-005)
/// Formula: raw = 0.25*cvss + 0.30*epss + 0.20*reachability + 0.10*exploit_maturity - 0.15*patch_proof
/// </summary>
private EvidenceWeightedScoreResult CalculateAdvisoryFormula(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy)
{
var clampedInput = input.Clamp();
var weights = policy.Weights;
// Normalize CVSS [0, 10] -> [0, 1]
var normalizedCvss = clampedInput.GetNormalizedCvss();
// Get normalized exploit maturity
var normalizedExploitMaturity = clampedInput.GetNormalizedExploitMaturity();
// Calculate raw score using advisory formula
var rawScore =
weights.Cvss * normalizedCvss +
weights.Epss * clampedInput.EpssScore +
weights.Reachability * clampedInput.Rch + // Use Rch as reachability for now
weights.ExploitMaturity * normalizedExploitMaturity -
weights.PatchProof * clampedInput.PatchProofConfidence; // SUBTRACTIVE
// Clamp to [0, 1] and scale to [0, 100]
var clampedScore = Math.Clamp(rawScore, 0.0, 1.0);
var scaledScore = (int)Math.Round(clampedScore * 100);
// Apply guardrails
var (finalScore, guardrails) = ApplyGuardrails(
scaledScore,
clampedInput,
policy.Guardrails);
// Calculate advisory breakdown (TASK-029-007)
var breakdown = CalculateAdvisoryBreakdown(clampedInput, weights, normalizedCvss, normalizedExploitMaturity);
// Generate flags
var flags = GenerateAdvisoryFlags(clampedInput, guardrails, normalizedExploitMaturity);
// Generate explanations
var explanations = GenerateAdvisoryExplanations(clampedInput, breakdown, guardrails);
// Determine bucket
var bucket = GetBucket(finalScore, policy.Buckets);
return new EvidenceWeightedScoreResult
{
FindingId = input.FindingId,
Score = finalScore,
Bucket = bucket,
Inputs = new EvidenceInputValues(
clampedInput.Rch, clampedInput.Rts, clampedInput.Bkp,
clampedInput.Xpl, clampedInput.Src, clampedInput.Mit),
Weights = weights,
Breakdown = breakdown,
Flags = flags,
Explanations = explanations,
Caps = guardrails,
PolicyDigest = policy.ComputeDigest(),
CalculatedAt = _timeProvider.GetUtcNow()
}.WithComputedDigest();
}
/// <summary>
/// Checks for authoritative VEX override.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-006)
/// If OpenVEX says authoritative "not_affected" or "fixed" (trusted vendor key), set final_score := 0.
/// </summary>
private EvidenceWeightedScoreResult? CheckVexOverride(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy)
{
// Check for authoritative VEX status
if (string.IsNullOrEmpty(input.VexStatus))
return null;
var isNotAffected = string.Equals(input.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase);
var isFixed = string.Equals(input.VexStatus, "fixed", StringComparison.OrdinalIgnoreCase);
if (!isNotAffected && !isFixed)
return null;
// Check if VEX source is authoritative
if (!IsAuthoritativeVexSource(input.VexSource, policy.TrustedVexKeys))
return null;
// Authoritative VEX override - score 0
var clampedInput = input.Clamp();
var weights = policy.Weights;
var flags = new List<string> { "vex-override", "vendor-na" };
var explanations = new List<string>
{
$"Authoritative VEX: {input.VexStatus} from {input.VexSource ?? "trusted source"}"
};
var breakdown = policy.FormulaMode == FormulaMode.Advisory
? CalculateAdvisoryBreakdown(clampedInput, weights, clampedInput.GetNormalizedCvss(), clampedInput.GetNormalizedExploitMaturity())
: CalculateBreakdown(clampedInput, weights);
return new EvidenceWeightedScoreResult
{
FindingId = input.FindingId,
Score = 0,
Bucket = ScoreBucket.Watchlist,
Inputs = new EvidenceInputValues(
clampedInput.Rch, clampedInput.Rts, clampedInput.Bkp,
clampedInput.Xpl, clampedInput.Src, clampedInput.Mit),
Weights = weights,
Breakdown = breakdown,
Flags = flags,
Explanations = explanations,
Caps = AppliedGuardrails.None(0),
PolicyDigest = policy.ComputeDigest(),
CalculatedAt = _timeProvider.GetUtcNow()
}.WithComputedDigest();
}
/// <summary>
/// Checks if a VEX source is authoritative based on trusted keys or in-project sources.
/// </summary>
private static bool IsAuthoritativeVexSource(string? vexSource, IReadOnlyList<string> trustedKeys)
{
if (string.IsNullOrEmpty(vexSource))
return false;
// Check against trusted keys
if (trustedKeys.Count > 0)
{
foreach (var key in trustedKeys)
{
if (string.Equals(vexSource, key, StringComparison.OrdinalIgnoreCase))
return true;
// Support key prefix matching (e.g., "vendor:*" matches "vendor:acme")
if (key.EndsWith('*') &&
vexSource.StartsWith(key[..^1], StringComparison.OrdinalIgnoreCase))
return true;
}
}
// In-project VEX is always authoritative
if (vexSource.StartsWith(".vex/", StringComparison.OrdinalIgnoreCase) ||
vexSource.StartsWith("in-project:", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
/// <summary>
/// Calculates advisory formula breakdown.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-007)
/// </summary>
private static IReadOnlyList<DimensionContribution> CalculateAdvisoryBreakdown(
EvidenceWeightedScoreInput input,
EvidenceWeights weights,
double normalizedCvss,
double normalizedExploitMaturity)
{
return
[
new DimensionContribution
{
Dimension = "CVSS Base",
Symbol = "CVS",
InputValue = normalizedCvss,
Weight = weights.Cvss,
Contribution = weights.Cvss * normalizedCvss,
IsSubtractive = false
},
new DimensionContribution
{
Dimension = "EPSS",
Symbol = "EPS",
InputValue = input.EpssScore,
Weight = weights.Epss,
Contribution = weights.Epss * input.EpssScore,
IsSubtractive = false
},
new DimensionContribution
{
Dimension = "Reachability",
Symbol = "RCH",
InputValue = input.Rch,
Weight = weights.Reachability,
Contribution = weights.Reachability * input.Rch,
IsSubtractive = false
},
new DimensionContribution
{
Dimension = "Exploit Maturity",
Symbol = "XPL",
InputValue = normalizedExploitMaturity,
Weight = weights.ExploitMaturity,
Contribution = weights.ExploitMaturity * normalizedExploitMaturity,
IsSubtractive = false
},
new DimensionContribution
{
Dimension = "Patch Proof",
Symbol = "PPF",
InputValue = input.PatchProofConfidence,
Weight = weights.PatchProof,
Contribution = -weights.PatchProof * input.PatchProofConfidence, // Negative because subtractive
IsSubtractive = true
}
];
}
/// <summary>
/// Generates flags for advisory formula.
/// </summary>
private static IReadOnlyList<string> GenerateAdvisoryFlags(
EvidenceWeightedScoreInput input,
AppliedGuardrails guardrails,
double normalizedExploitMaturity)
{
var flags = new List<string>();
// High CVSS flag
if (input.CvssBase >= 7.0)
flags.Add("high-cvss");
// High EPSS flag
if (input.EpssScore >= 0.5)
flags.Add("high-epss");
// KEV/active exploitation flag
if (input.ExploitMaturity == ExploitMaturityLevel.High)
flags.Add("active-exploitation");
else if (normalizedExploitMaturity >= 0.6)
flags.Add("known-exploit");
// Reachable flag
if (input.Rch >= 0.7)
flags.Add("reachable");
// Patch proof flag
if (input.PatchProofConfidence >= 0.8)
flags.Add("patch-verified");
// Speculative flag
if (guardrails.SpeculativeCap || (input.Rch == 0 && input.Rts == 0))
flags.Add("speculative");
// VEX status flags
if (string.Equals(input.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase))
flags.Add("vendor-na");
return flags;
}
/// <summary>
/// Generates explanations for advisory formula.
/// </summary>
private static IReadOnlyList<string> GenerateAdvisoryExplanations(
EvidenceWeightedScoreInput input,
IReadOnlyList<DimensionContribution> breakdown,
AppliedGuardrails guardrails)
{
var explanations = new List<string>();
// Sort by contribution magnitude (excluding subtractive)
var topContributors = breakdown
.Where(d => d.Contribution > 0)
.OrderByDescending(d => d.Contribution)
.Take(2)
.ToList();
foreach (var contributor in topContributors)
{
var level = contributor.InputValue switch
{
>= 0.8 => "critical",
>= 0.6 => "high",
>= 0.4 => "medium",
>= 0.2 => "low",
_ => "minimal"
};
explanations.Add($"{contributor.Dimension}: {level} ({contributor.InputValue:P0})");
}
// Patch proof explanation
if (input.PatchProofConfidence > 0)
{
var reductionDesc = input.PatchProofConfidence switch
{
>= 0.9 => "strongly reduces risk",
>= 0.7 => "reduces risk",
>= 0.5 => "partially reduces risk",
_ => "minor risk reduction"
};
explanations.Add($"Patch proof confidence ({input.PatchProofConfidence:P0}) {reductionDesc}");
}
// Guardrail explanations
if (guardrails.SpeculativeCap)
explanations.Add($"Speculative cap applied: no reachability or runtime evidence (capped at {guardrails.AdjustedScore})");
if (guardrails.NotAffectedCap)
explanations.Add($"Not-affected cap applied: vendor confirms not affected (capped at {guardrails.AdjustedScore})");
if (guardrails.RuntimeFloor)
explanations.Add($"Runtime floor applied: strong live signal (floor at {guardrails.AdjustedScore})");
// CVSS details if available
if (input.CvssDetails is not null)
{
explanations.Add($"CVSS {input.CvssDetails.Version}: {input.CvssBase:F1} ({input.CvssDetails.Vector})");
}
// Exploit maturity explanation
if (input.ExploitMaturity != ExploitMaturityLevel.Unknown && input.ExploitMaturity != ExploitMaturityLevel.None)
{
var maturityDesc = input.ExploitMaturity switch
{
ExploitMaturityLevel.High => "actively exploited (KEV listed)",
ExploitMaturityLevel.Functional => "functional exploit available",
ExploitMaturityLevel.ProofOfConcept => "proof-of-concept exists",
_ => "unknown maturity"
};
explanations.Add($"Exploit maturity: {maturityDesc}");
}
return explanations;
}
/// <summary>
@@ -407,7 +834,7 @@ public sealed class EvidenceWeightedScoreCalculator : IEvidenceWeightedScoreCalc
Caps = guardrails ?? AppliedGuardrails.None(score),
PolicyDigest = policy.ComputeDigest(),
CalculatedAt = _timeProvider.GetUtcNow()
};
}.WithComputedDigest();
}
private static (int finalScore, AppliedGuardrails guardrails) ApplyGuardrails(

View File

@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion
namespace StellaOps.Signals.EvidenceWeightedScore;
@@ -12,6 +13,55 @@ public sealed record EvidenceWeightedScoreInput
/// <summary>Finding identifier (CVE@PURL format or similar).</summary>
public required string FindingId { get; init; }
#region Advisory Formula Dimensions (SPRINT-029)
/// <summary>
/// CVSS base score [0, 10], normalized internally to [0, 1].
/// Advisory formula weight: 0.25
/// </summary>
public double CvssBase { get; init; }
/// <summary>CVSS version used (3.0, 3.1, 4.0).</summary>
public string? CvssVersion { get; init; }
/// <summary>Optional detailed CVSS metrics for explainability.</summary>
public CvssMetrics? CvssDetails { get; init; }
/// <summary>
/// EPSS probability [0, 1]. Probability of exploitation in next 30 days.
/// Advisory formula weight: 0.30
/// </summary>
public double EpssScore { get; init; }
/// <summary>EPSS percentile [0, 100] for context.</summary>
public double EpssPercentile { get; init; }
/// <summary>Source of EPSS data.</summary>
public string? EpssSource { get; init; }
/// <summary>
/// Exploit maturity level.
/// Advisory formula weight: 0.10
/// </summary>
public ExploitMaturityLevel ExploitMaturity { get; init; } = ExploitMaturityLevel.Unknown;
/// <summary>Source of maturity assessment (KEV, NVD, manual).</summary>
public string? ExploitMaturitySource { get; init; }
/// <summary>
/// Patch proof confidence [0, 1] from binary verifier.
/// Advisory formula: SUBTRACTIVE with weight 0.15
/// Higher confidence = more risk reduction.
/// </summary>
public double PatchProofConfidence { get; init; }
/// <summary>Details about the patch proof verification.</summary>
public PatchProofDetails? PatchProofDetails { get; init; }
#endregion
#region Legacy Dimensions (backward compatibility)
/// <summary>Reachability confidence [0, 1]. Higher = more reachable.</summary>
public required double Rch { get; init; }
@@ -30,9 +80,14 @@ public sealed record EvidenceWeightedScoreInput
/// <summary>Mitigation effectiveness [0, 1]. Higher = stronger mitigations.</summary>
public required double Mit { get; init; }
#endregion
/// <summary>VEX status for backport guardrail evaluation (e.g., "not_affected", "affected", "fixed").</summary>
public string? VexStatus { get; init; }
/// <summary>Source of the VEX statement for authorization check.</summary>
public string? VexSource { get; init; }
/// <summary>
/// Anchor metadata for the primary VEX/advisory evidence.
/// Used by attested-reduction scoring profile for precedence determination.
@@ -58,7 +113,7 @@ public sealed record EvidenceWeightedScoreInput
public MitigationInput? MitigationDetails { get; init; }
/// <summary>
/// Validates all dimension values are within [0, 1] range.
/// Validates all dimension values are within valid ranges.
/// </summary>
/// <returns>List of validation errors, empty if valid.</returns>
public IReadOnlyList<string> Validate()
@@ -68,6 +123,12 @@ public sealed record EvidenceWeightedScoreInput
if (string.IsNullOrWhiteSpace(FindingId))
errors.Add("FindingId is required");
// Advisory formula dimensions
ValidateCvssBase(errors);
ValidateDimension(nameof(EpssScore), EpssScore, errors);
ValidateDimension(nameof(PatchProofConfidence), PatchProofConfidence, errors);
// Legacy dimensions
ValidateDimension(nameof(Rch), Rch, errors);
ValidateDimension(nameof(Rts), Rts, errors);
ValidateDimension(nameof(Bkp), Bkp, errors);
@@ -79,13 +140,18 @@ public sealed record EvidenceWeightedScoreInput
}
/// <summary>
/// Creates a clamped version of this input with all values in [0, 1].
/// Creates a clamped version of this input with all values in valid ranges.
/// </summary>
/// <returns>New input with clamped values.</returns>
public EvidenceWeightedScoreInput Clamp()
{
return this with
{
// Advisory dimensions
CvssBase = ClampCvss(CvssBase),
EpssScore = ClampValue(EpssScore),
PatchProofConfidence = ClampValue(PatchProofConfidence),
// Legacy dimensions
Rch = ClampValue(Rch),
Rts = ClampValue(Rts),
Bkp = ClampValue(Bkp),
@@ -95,6 +161,25 @@ public sealed record EvidenceWeightedScoreInput
};
}
/// <summary>
/// Gets the normalized CVSS base score [0, 1].
/// Normalizer: cvss_normalized = CvssBase / 10.0
/// </summary>
public double GetNormalizedCvss() => ClampValue(CvssBase / 10.0);
/// <summary>
/// Gets the normalized exploit maturity score [0, 1].
/// </summary>
public double GetNormalizedExploitMaturity() => NormalizeExploitMaturity(ExploitMaturity);
private void ValidateCvssBase(List<string> errors)
{
if (double.IsNaN(CvssBase) || double.IsInfinity(CvssBase))
errors.Add($"CvssBase must be a valid number, got {CvssBase}");
else if (CvssBase < 0.0 || CvssBase > 10.0)
errors.Add($"CvssBase must be in range [0, 10], got {CvssBase}");
}
private static void ValidateDimension(string name, double value, List<string> errors)
{
if (double.IsNaN(value) || double.IsInfinity(value))
@@ -111,4 +196,169 @@ public sealed record EvidenceWeightedScoreInput
return 1.0;
return Math.Clamp(value, 0.0, 1.0);
}
private static double ClampCvss(double value)
{
if (double.IsNaN(value) || double.IsNegativeInfinity(value))
return 0.0;
if (double.IsPositiveInfinity(value))
return 10.0;
return Math.Clamp(value, 0.0, 10.0);
}
/// <summary>
/// Normalizes exploit maturity level to [0, 1] value.
/// </summary>
public static double NormalizeExploitMaturity(ExploitMaturityLevel level) => level switch
{
ExploitMaturityLevel.None => 0.0,
ExploitMaturityLevel.ProofOfConcept => 0.6,
ExploitMaturityLevel.Functional => 0.8,
ExploitMaturityLevel.High => 1.0,
_ => 0.0 // Unknown defaults to None
};
}
/// <summary>
/// Exploit maturity level for advisory formula scoring.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-002)
/// </summary>
public enum ExploitMaturityLevel
{
/// <summary>Unknown maturity, treated as None for scoring.</summary>
Unknown = 0,
/// <summary>No known exploitation (0.0).</summary>
None = 1,
/// <summary>Proof of concept exists (0.6).</summary>
ProofOfConcept = 2,
/// <summary>Functional exploit available (0.8).</summary>
Functional = 3,
/// <summary>Active exploitation / KEV listed (1.0).</summary>
High = 4
}
/// <summary>
/// Detailed CVSS metrics for explainability.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-001)
/// </summary>
public sealed record CvssMetrics
{
/// <summary>CVSS version (3.0, 3.1, 4.0).</summary>
public required string Version { get; init; }
/// <summary>Full CVSS vector string.</summary>
public required string Vector { get; init; }
/// <summary>Base score [0, 10].</summary>
public required double BaseScore { get; init; }
/// <summary>Temporal score [0, 10] if available.</summary>
public double? TemporalScore { get; init; }
/// <summary>Environmental score [0, 10] if available.</summary>
public double? EnvironmentalScore { get; init; }
/// <summary>Attack Vector (AV): Network, Adjacent, Local, Physical.</summary>
public string? AttackVector { get; init; }
/// <summary>Attack Complexity (AC): Low, High.</summary>
public string? AttackComplexity { get; init; }
/// <summary>Privileges Required (PR): None, Low, High.</summary>
public string? PrivilegesRequired { get; init; }
/// <summary>User Interaction (UI): None, Required.</summary>
public string? UserInteraction { get; init; }
/// <summary>Scope (S): Unchanged, Changed.</summary>
public string? Scope { get; init; }
/// <summary>Confidentiality Impact (C): None, Low, High.</summary>
public string? ConfidentialityImpact { get; init; }
/// <summary>Integrity Impact (I): None, Low, High.</summary>
public string? IntegrityImpact { get; init; }
/// <summary>Availability Impact (A): None, Low, High.</summary>
public string? AvailabilityImpact { get; init; }
/// <summary>Source of CVSS data (NVD, vendor, manual).</summary>
public string? Source { get; init; }
/// <summary>Timestamp when CVSS was retrieved.</summary>
public DateTimeOffset? Timestamp { get; init; }
}
/// <summary>
/// Details about patch proof verification.
/// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion (TASK-029-003)
/// </summary>
public sealed record PatchProofDetails
{
/// <summary>Binary diff signature match confidence [0, 1].</summary>
public double DeltaSigConfidence { get; init; }
/// <summary>Vendor VEX "fixed" claim present.</summary>
public bool VendorFixedClaim { get; init; }
/// <summary>Commit reference to patch.</summary>
public string? PatchCommitRef { get; init; }
/// <summary>Verification method used (delta-sig, vex-claim, commit-match).</summary>
public string? VerificationMethod { get; init; }
/// <summary>Source of the patch proof.</summary>
public string? Source { get; init; }
/// <summary>Timestamp of verification.</summary>
public DateTimeOffset? VerifiedAt { get; init; }
/// <summary>
/// Computes the overall patch proof confidence based on available evidence.
/// </summary>
public double ComputeConfidence()
{
// VendorFixedClaim + high DeltaSigConfidence -> 1.0
if (VendorFixedClaim && DeltaSigConfidence >= 0.9)
return 1.0;
// Only VendorFixedClaim -> 0.7
if (VendorFixedClaim && DeltaSigConfidence < 0.5)
return 0.7;
// Only DeltaSigConfidence -> confidence value
if (!VendorFixedClaim)
return DeltaSigConfidence;
// Combination: weighted average biased toward higher value
return Math.Max(DeltaSigConfidence, 0.7 + 0.3 * DeltaSigConfidence);
}
/// <summary>
/// Generates a human-readable explanation.
/// </summary>
public string GetExplanation()
{
var parts = new List<string>();
if (VendorFixedClaim)
parts.Add("vendor VEX claims fixed");
if (DeltaSigConfidence > 0)
parts.Add($"binary delta-sig confidence {DeltaSigConfidence:P0}");
if (!string.IsNullOrEmpty(PatchCommitRef))
parts.Add($"patch commit {PatchCommitRef[..Math.Min(8, PatchCommitRef.Length)]}");
if (!string.IsNullOrEmpty(VerificationMethod))
parts.Add($"method: {VerificationMethod}");
return parts.Count > 0
? string.Join("; ", parts)
: "no patch proof";
}
}

View File

@@ -14,6 +14,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Canonicalization/StellaOps.Canonicalization.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />