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

View File

@@ -137,6 +137,54 @@ public class EvidenceWeightPolicyTests
json.Should().Contain("\"weights\"");
json.Should().Contain("\"guardrails\"");
}
[Fact]
public void GetCanonicalJson_IsDeterministic_100Iterations()
{
// TASK-028-002: Add determinism test - serialize 100x → all identical
var policy = EvidenceWeightPolicy.DefaultProduction;
var results = Enumerable.Range(0, 100)
.Select(_ => policy.GetCanonicalJson())
.Distinct()
.ToList();
results.Should().ContainSingle("All 100 serializations should produce identical output");
}
[Fact]
public void ComputeDigest_IsDeterministic_100Iterations()
{
// TASK-028-002: Add determinism test - hash 100x → all identical
var policy = EvidenceWeightPolicy.DefaultProduction;
var digests = Enumerable.Range(0, 100)
.Select(_ =>
{
// Create fresh policy instance each time to avoid caching
var freshPolicy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "production",
Weights = EvidenceWeights.Default
};
return freshPolicy.ComputeDigest();
})
.Distinct()
.ToList();
digests.Should().ContainSingle("All 100 digest computations should produce identical output");
}
[Fact]
public void ComputeDigest_ProducesHexString()
{
var policy = EvidenceWeightPolicy.DefaultProduction;
var digest = policy.ComputeDigest();
digest.Should().MatchRegex("^[0-9a-f]{64}$", "Digest should be 64-character lowercase hex string");
}
}
public class EvidenceWeightsTests

View File

@@ -0,0 +1,497 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_029_LIB_scoring_dimensions_expansion
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
/// <summary>
/// Tests for the advisory formula scoring (SPRINT-029).
/// Formula: raw = 0.25*cvss + 0.30*epss + 0.20*reachability + 0.10*exploit_maturity - 0.15*patch_proof
/// </summary>
public class EvidenceWeightedScoreAdvisoryFormulaTests
{
private readonly EvidenceWeightedScoreCalculator _calculator = new();
private readonly EvidenceWeightPolicy _advisoryPolicy = EvidenceWeightPolicy.AdvisoryProduction;
#region TASK-029-001: CVSS Base Score Tests
[Theory]
[InlineData(0.0, 0.0)] // Min CVSS
[InlineData(5.0, 0.5)] // Mid CVSS
[InlineData(10.0, 1.0)] // Max CVSS
[InlineData(7.5, 0.75)] // High CVSS
public void CvssBase_NormalizesToZeroToOne(double cvssBase, double expectedNormalized)
{
var input = CreateAdvisoryInput(cvssBase: cvssBase);
var normalized = input.GetNormalizedCvss();
normalized.Should().BeApproximately(expectedNormalized, 0.001);
}
[Fact]
public void CvssBase_ClampsToValidRange()
{
var inputOver = CreateAdvisoryInput(cvssBase: 15.0);
var inputUnder = CreateAdvisoryInput(cvssBase: -5.0);
var clampedOver = inputOver.Clamp();
var clampedUnder = inputUnder.Clamp();
clampedOver.CvssBase.Should().Be(10.0);
clampedUnder.CvssBase.Should().Be(0.0);
}
[Fact]
public void CvssBase_ContributesToAdvisoryFormula()
{
var lowCvss = CreateAdvisoryInput(cvssBase: 3.0, epss: 0.5, reachability: 0.5);
var highCvss = CreateAdvisoryInput(cvssBase: 9.5, epss: 0.5, reachability: 0.5);
var resultLow = _calculator.Calculate(lowCvss, _advisoryPolicy);
var resultHigh = _calculator.Calculate(highCvss, _advisoryPolicy);
resultHigh.Score.Should().BeGreaterThan(resultLow.Score);
}
[Fact]
public void HighCvss_GeneratesFlag()
{
var input = CreateAdvisoryInput(cvssBase: 8.5);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Flags.Should().Contain("high-cvss");
}
#endregion
#region TASK-029-002: Exploit Maturity Tests
[Theory]
[InlineData(ExploitMaturityLevel.Unknown, 0.0)]
[InlineData(ExploitMaturityLevel.None, 0.0)]
[InlineData(ExploitMaturityLevel.ProofOfConcept, 0.6)]
[InlineData(ExploitMaturityLevel.Functional, 0.8)]
[InlineData(ExploitMaturityLevel.High, 1.0)]
public void ExploitMaturity_NormalizesCorrectly(ExploitMaturityLevel level, double expectedNormalized)
{
var normalized = EvidenceWeightedScoreInput.NormalizeExploitMaturity(level);
normalized.Should().BeApproximately(expectedNormalized, 0.001);
}
[Fact]
public void ExploitMaturity_ContributesToAdvisoryFormula()
{
var noExploit = CreateAdvisoryInput(cvssBase: 7.0, exploitMaturity: ExploitMaturityLevel.None);
var highExploit = CreateAdvisoryInput(cvssBase: 7.0, exploitMaturity: ExploitMaturityLevel.High);
var resultNone = _calculator.Calculate(noExploit, _advisoryPolicy);
var resultHigh = _calculator.Calculate(highExploit, _advisoryPolicy);
resultHigh.Score.Should().BeGreaterThan(resultNone.Score);
}
[Fact]
public void ActiveExploitation_GeneratesFlag()
{
var input = CreateAdvisoryInput(cvssBase: 7.0, exploitMaturity: ExploitMaturityLevel.High);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Flags.Should().Contain("active-exploitation");
}
[Fact]
public void KnownExploit_GeneratesFlag()
{
var input = CreateAdvisoryInput(cvssBase: 7.0, exploitMaturity: ExploitMaturityLevel.ProofOfConcept);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Flags.Should().Contain("known-exploit");
}
#endregion
#region TASK-029-003: Patch Proof Confidence Tests
[Fact]
public void PatchProofConfidence_IsSubtractive()
{
var noPatch = CreateAdvisoryInput(cvssBase: 8.0, epss: 0.6, patchProofConfidence: 0.0);
var withPatch = CreateAdvisoryInput(cvssBase: 8.0, epss: 0.6, patchProofConfidence: 1.0);
var resultNoPatch = _calculator.Calculate(noPatch, _advisoryPolicy);
var resultWithPatch = _calculator.Calculate(withPatch, _advisoryPolicy);
resultWithPatch.Score.Should().BeLessThan(resultNoPatch.Score);
}
[Fact]
public void PatchProofConfidence_ClampsToValidRange()
{
var inputOver = CreateAdvisoryInput(patchProofConfidence: 1.5);
var inputUnder = CreateAdvisoryInput(patchProofConfidence: -0.5);
var clampedOver = inputOver.Clamp();
var clampedUnder = inputUnder.Clamp();
clampedOver.PatchProofConfidence.Should().Be(1.0);
clampedUnder.PatchProofConfidence.Should().Be(0.0);
}
[Fact]
public void PatchVerified_GeneratesFlag()
{
var input = CreateAdvisoryInput(cvssBase: 7.0, patchProofConfidence: 0.9);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Flags.Should().Contain("patch-verified");
}
[Fact]
public void PatchProofDetails_ComputesConfidence()
{
var bothEvidence = new PatchProofDetails
{
VendorFixedClaim = true,
DeltaSigConfidence = 0.95
};
var vendorOnly = new PatchProofDetails
{
VendorFixedClaim = true,
DeltaSigConfidence = 0.3
};
var deltaOnly = new PatchProofDetails
{
VendorFixedClaim = false,
DeltaSigConfidence = 0.8
};
bothEvidence.ComputeConfidence().Should().Be(1.0);
vendorOnly.ComputeConfidence().Should().Be(0.7);
deltaOnly.ComputeConfidence().Should().Be(0.8);
}
#endregion
#region TASK-029-004 & 005: Advisory Formula Tests
[Fact]
public void AdvisoryFormula_UsesCorrectWeights()
{
var policy = EvidenceWeightPolicy.AdvisoryProduction;
policy.Weights.Cvss.Should().Be(0.25);
policy.Weights.Epss.Should().Be(0.30);
policy.Weights.Reachability.Should().Be(0.20);
policy.Weights.ExploitMaturity.Should().Be(0.10);
policy.Weights.PatchProof.Should().Be(0.15);
}
[Fact]
public void AdvisoryFormula_CalculatesCorrectly()
{
// Known inputs:
// CVSS: 8.0 -> normalized: 0.8
// EPSS: 0.5
// Reachability: 0.7
// Exploit Maturity: Functional -> 0.8
// Patch Proof: 0.0
// Expected: 0.25*0.8 + 0.30*0.5 + 0.20*0.7 + 0.10*0.8 - 0.15*0.0
// = 0.20 + 0.15 + 0.14 + 0.08 - 0.0 = 0.57 -> 57
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
CvssBase = 8.0,
EpssScore = 0.5,
ExploitMaturity = ExploitMaturityLevel.Functional,
PatchProofConfidence = 0.0,
// Legacy fields (required)
Rch = 0.7, // Used as reachability
Rts = 0.0,
Bkp = 0.0,
Xpl = 0.0,
Src = 0.0,
Mit = 0.0
};
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Score.Should().BeCloseTo(57, 2); // Allow small rounding difference
}
[Fact]
public void AdvisoryFormula_ReturnsAdvisoryBreakdown()
{
var input = CreateAdvisoryInput(cvssBase: 7.0, epss: 0.4, reachability: 0.6);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Breakdown.Should().HaveCount(5); // CVS, EPS, RCH, XPL, PPF
result.Breakdown.Should().Contain(d => d.Symbol == "CVS");
result.Breakdown.Should().Contain(d => d.Symbol == "EPS");
result.Breakdown.Should().Contain(d => d.Symbol == "RCH");
result.Breakdown.Should().Contain(d => d.Symbol == "XPL");
result.Breakdown.Should().Contain(d => d.Symbol == "PPF" && d.IsSubtractive);
}
[Fact]
public void LegacyFormula_UsesLegacyBreakdown()
{
var input = CreateAdvisoryInput(cvssBase: 7.0, epss: 0.4, reachability: 0.6);
var legacyPolicy = EvidenceWeightPolicy.DefaultProduction;
var result = _calculator.Calculate(input, legacyPolicy);
result.Breakdown.Should().HaveCount(6); // RCH, RTS, BKP, XPL, SRC, MIT
result.Breakdown.Should().Contain(d => d.Symbol == "RCH");
result.Breakdown.Should().Contain(d => d.Symbol == "MIT" && d.IsSubtractive);
}
#endregion
#region TASK-029-006: VEX Override Tests
[Fact]
public void VexOverride_AppliesWhenAuthoritativeNotAffected()
{
var input = CreateAdvisoryInput(
cvssBase: 9.0,
epss: 0.8,
reachability: 0.9,
vexStatus: "not_affected",
vexSource: ".vex/findings.json"
);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Score.Should().Be(0);
result.Flags.Should().Contain("vex-override");
result.Flags.Should().Contain("vendor-na");
result.Explanations.Should().Contain(e => e.Contains("Authoritative VEX"));
}
[Fact]
public void VexOverride_AppliesWhenAuthoritativeFixed()
{
var input = CreateAdvisoryInput(
cvssBase: 8.5,
epss: 0.7,
vexStatus: "fixed",
vexSource: "in-project:vex"
);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Score.Should().Be(0);
result.Flags.Should().Contain("vex-override");
}
[Fact]
public void VexOverride_AppliesWithTrustedKey()
{
var policyWithTrustedKeys = new EvidenceWeightPolicy
{
Version = "ews.v2",
Profile = "test",
Weights = EvidenceWeights.Advisory,
FormulaMode = FormulaMode.Advisory,
TrustedVexKeys = ["vendor:acme", "vendor:bigcorp"]
};
var input = CreateAdvisoryInput(
cvssBase: 9.0,
vexStatus: "not_affected",
vexSource: "vendor:acme"
);
var result = _calculator.Calculate(input, policyWithTrustedKeys);
result.Score.Should().Be(0);
result.Flags.Should().Contain("vex-override");
}
[Fact]
public void VexOverride_AppliesWithWildcardKey()
{
var policyWithWildcard = new EvidenceWeightPolicy
{
Version = "ews.v2",
Profile = "test",
Weights = EvidenceWeights.Advisory,
FormulaMode = FormulaMode.Advisory,
TrustedVexKeys = ["vendor:*"]
};
var input = CreateAdvisoryInput(
cvssBase: 8.0,
vexStatus: "fixed",
vexSource: "vendor:some-company"
);
var result = _calculator.Calculate(input, policyWithWildcard);
result.Score.Should().Be(0);
}
[Fact]
public void VexOverride_DoesNotApplyWithoutAuthoritativeSource()
{
var policyWithTrustedKeys = new EvidenceWeightPolicy
{
Version = "ews.v2",
Profile = "test",
Weights = EvidenceWeights.Advisory,
FormulaMode = FormulaMode.Advisory,
TrustedVexKeys = ["vendor:acme"]
};
var input = CreateAdvisoryInput(
cvssBase: 8.0,
vexStatus: "not_affected",
vexSource: "untrusted:random"
);
var result = _calculator.Calculate(input, policyWithTrustedKeys);
result.Score.Should().BeGreaterThan(0);
result.Flags.Should().NotContain("vex-override");
}
[Fact]
public void VexOverride_DoesNotApplyForAffectedStatus()
{
var input = CreateAdvisoryInput(
cvssBase: 8.0,
vexStatus: "affected",
vexSource: ".vex/findings.json"
);
var result = _calculator.Calculate(input, _advisoryPolicy);
result.Score.Should().BeGreaterThan(0);
result.Flags.Should().NotContain("vex-override");
}
#endregion
#region TASK-029-007: Breakdown Enhancement Tests
[Fact]
public void AdvisoryBreakdown_ContainsAllDimensions()
{
var input = CreateAdvisoryInput(
cvssBase: 7.0,
epss: 0.5,
reachability: 0.6,
exploitMaturity: ExploitMaturityLevel.Functional,
patchProofConfidence: 0.3
);
var result = _calculator.Calculate(input, _advisoryPolicy);
var breakdown = result.Breakdown;
breakdown.Should().HaveCount(5);
var cvs = breakdown.First(d => d.Symbol == "CVS");
cvs.Dimension.Should().Be("CVSS Base");
cvs.InputValue.Should().BeApproximately(0.7, 0.001); // 7.0/10
cvs.Weight.Should().Be(0.25);
cvs.Contribution.Should().BeApproximately(0.175, 0.01); // 0.7 * 0.25
cvs.IsSubtractive.Should().BeFalse();
var ppf = breakdown.First(d => d.Symbol == "PPF");
ppf.Dimension.Should().Be("Patch Proof");
ppf.IsSubtractive.Should().BeTrue();
ppf.Contribution.Should().BeLessThan(0); // Negative contribution
}
[Fact]
public void AdvisoryBreakdown_SumsToRawScore()
{
var input = CreateAdvisoryInput(
cvssBase: 7.0,
epss: 0.5,
reachability: 0.6,
exploitMaturity: ExploitMaturityLevel.ProofOfConcept,
patchProofConfidence: 0.2
);
var result = _calculator.Calculate(input, _advisoryPolicy);
var breakdownSum = result.Breakdown.Sum(d => d.Contribution);
var expectedRaw = result.Score / 100.0; // Approximate
breakdownSum.Should().BeApproximately(expectedRaw, 0.1); // Allow for rounding
}
#endregion
#region Determinism Tests
[Fact]
public void AdvisoryFormula_IsDeterministic()
{
var input = CreateAdvisoryInput(
cvssBase: 8.0,
epss: 0.6,
reachability: 0.7,
exploitMaturity: ExploitMaturityLevel.High,
patchProofConfidence: 0.3
);
var results = Enumerable.Range(0, 100)
.Select(_ => _calculator.Calculate(input, _advisoryPolicy))
.ToList();
var firstScore = results.First().Score;
results.Should().OnlyContain(r => r.Score == firstScore);
}
#endregion
#region Helper Methods
private static EvidenceWeightedScoreInput CreateAdvisoryInput(
double cvssBase = 5.0,
double epss = 0.3,
double reachability = 0.5,
ExploitMaturityLevel exploitMaturity = ExploitMaturityLevel.Unknown,
double patchProofConfidence = 0.0,
string? vexStatus = null,
string? vexSource = null,
string findingId = "test")
{
return new EvidenceWeightedScoreInput
{
FindingId = findingId,
// Advisory dimensions
CvssBase = cvssBase,
EpssScore = epss,
ExploitMaturity = exploitMaturity,
PatchProofConfidence = patchProofConfidence,
VexStatus = vexStatus,
VexSource = vexSource,
// Legacy dimensions (required fields, use reachability for Rch)
Rch = reachability,
Rts = 0.0,
Bkp = 0.0,
Xpl = epss, // Map EPSS to XPL for legacy
Src = 0.0,
Mit = 0.0
};
}
#endregion
}

View File

@@ -415,6 +415,193 @@ public class EvidenceWeightedScoreDeterminismTests
#endregion
#region Task TASK-028-003: Canonical Digest Tests
[Fact]
public void Result_HasCanonicalDigest_AfterCalculation()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var result = _calculator.Calculate(input, _defaultPolicy);
result.CanonicalDigest.Should().NotBeNullOrEmpty();
result.CanonicalDigest.Should().MatchRegex("^[0-9a-f]{64}$",
"Digest should be 64-character lowercase hex string");
}
[Fact]
public void Result_GetCanonicalJson_ProducesValidJson()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var result = _calculator.Calculate(input, _defaultPolicy);
var canonicalJson = result.GetCanonicalJson();
canonicalJson.Should().NotBeNullOrEmpty();
canonicalJson.Should().Contain("\"finding_id\"");
canonicalJson.Should().Contain("\"score\"");
canonicalJson.Should().Contain("\"bucket\"");
canonicalJson.Should().Contain("\"inputs\"");
canonicalJson.Should().Contain("\"weights\"");
canonicalJson.Should().Contain("\"breakdown\"");
canonicalJson.Should().Contain("\"flags\"");
canonicalJson.Should().Contain("\"caps\"");
canonicalJson.Should().Contain("\"policy_digest\"");
canonicalJson.Should().Contain("\"calculated_at\"");
// Should NOT contain the canonical_digest itself (avoids circular reference)
canonicalJson.Should().NotContain("\"canonical_digest\"");
}
[Fact]
public void Result_ComputeDigest_ProducesHexString()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var result = _calculator.Calculate(input, _defaultPolicy);
var digest = result.ComputeDigest();
digest.Should().MatchRegex("^[0-9a-f]{64}$",
"Digest should be 64-character lowercase hex string");
}
[Fact]
public void Result_ComputeDigest_IsDeterministic_100Iterations()
{
// TASK-028-003: Add determinism test - hash 100x → all identical
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
// Create fresh results each time to avoid caching effects
var digests = Enumerable.Range(0, 100)
.Select(_ =>
{
var result = _calculator.Calculate(input, _defaultPolicy);
return result.ComputeDigest();
})
.Distinct()
.ToList();
digests.Should().ContainSingle("All 100 digest computations should produce identical output");
}
[Fact]
public void Result_GetCanonicalJson_IsDeterministic_100Iterations()
{
// TASK-028-003: Add determinism test - serialize 100x → all identical
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var results = Enumerable.Range(0, 100)
.Select(_ =>
{
var result = _calculator.Calculate(input, _defaultPolicy);
return result.GetCanonicalJson();
})
.Distinct()
.ToList();
results.Should().ContainSingle("All 100 serializations should produce identical output");
}
[Fact]
public void Result_DifferentInputs_ProduceDifferentDigests()
{
var input1 = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-00001",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var input2 = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-00002",
Rch = 0.3, Rts = 0.4, Bkp = 0.2, Xpl = 0.1, Src = 0.2, Mit = 0.05
};
var result1 = _calculator.Calculate(input1, _defaultPolicy);
var result2 = _calculator.Calculate(input2, _defaultPolicy);
result1.CanonicalDigest.Should().NotBe(result2.CanonicalDigest);
}
[Fact]
public void Result_SameInputsDifferentPolicies_ProduceDifferentDigests()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.1
};
var policy2 = new EvidenceWeightPolicy
{
Profile = "custom",
Version = "v2",
Weights = new EvidenceWeights
{
Rch = 0.25, Rts = 0.25, Bkp = 0.20, Xpl = 0.15, Src = 0.10, Mit = 0.05
}
};
var result1 = _calculator.Calculate(input, _defaultPolicy);
var result2 = _calculator.Calculate(input, policy2);
// Results should differ because policy digests differ
result1.PolicyDigest.Should().NotBe(result2.PolicyDigest);
result1.CanonicalDigest.Should().NotBe(result2.CanonicalDigest);
}
[Fact]
public void Result_WithComputedDigest_MatchesComputeDigest()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var result = _calculator.Calculate(input, _defaultPolicy);
// The CanonicalDigest property should match ComputeDigest()
result.CanonicalDigest.Should().Be(result.ComputeDigest());
}
[Fact]
public void Result_CanonicalJson_ExcludesCanonicalDigest()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var result = _calculator.Calculate(input, _defaultPolicy);
var json = result.GetCanonicalJson();
// Should not include canonical_digest to avoid circular dependency
json.Should().NotContain("canonical_digest");
}
#endregion
#region Task 54: Benchmark Tests
[Fact]