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:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user