sprints work

This commit is contained in:
StellaOps Bot
2025-12-24 21:46:08 +02:00
parent 43e2af88f6
commit b9f71fc7e9
161 changed files with 29566 additions and 527 deletions

View File

@@ -0,0 +1,130 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Evidence tier for backport detection.
/// </summary>
public enum BackportEvidenceTier
{
/// <summary>No backport evidence.</summary>
None = 0,
/// <summary>Heuristic detection (changelog mention, commit patterns).</summary>
Heuristic = 1,
/// <summary>Patch-graph signature match.</summary>
PatchSignature = 2,
/// <summary>Binary-level diff confirmation.</summary>
BinaryDiff = 3,
/// <summary>Vendor-issued VEX statement.</summary>
VendorVex = 4,
/// <summary>Cryptographically signed proof (DSSE attestation).</summary>
SignedProof = 5
}
/// <summary>
/// Backport detection status.
/// </summary>
public enum BackportStatus
{
/// <summary>Vulnerability status unknown.</summary>
Unknown = 0,
/// <summary>Confirmed affected.</summary>
Affected = 1,
/// <summary>Confirmed not affected (e.g., backported, never included).</summary>
NotAffected = 2,
/// <summary>Fixed in this version.</summary>
Fixed = 3,
/// <summary>Under investigation.</summary>
UnderInvestigation = 4
}
/// <summary>
/// Detailed backport input for explanation generation.
/// </summary>
public sealed record BackportInput
{
/// <summary>Evidence tier for the backport detection.</summary>
public required BackportEvidenceTier EvidenceTier { get; init; }
/// <summary>Unique proof identifier for verification.</summary>
public string? ProofId { get; init; }
/// <summary>Backport detection status.</summary>
public required BackportStatus Status { get; init; }
/// <summary>Confidence in the backport detection [0, 1].</summary>
public required double Confidence { get; init; }
/// <summary>Source of backport evidence (e.g., "distro-changelog", "vendor-vex", "binary-diff").</summary>
public string? EvidenceSource { get; init; }
/// <summary>Evidence timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EvidenceTimestamp { get; init; }
/// <summary>Upstream fix commit (if known).</summary>
public string? UpstreamFixCommit { get; init; }
/// <summary>Backport commit in distribution (if known).</summary>
public string? BackportCommit { get; init; }
/// <summary>Distribution/vendor that issued the backport.</summary>
public string? Distributor { get; init; }
/// <summary>
/// Validates the backport input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Confidence < 0.0 || Confidence > 1.0)
errors.Add($"Confidence must be in range [0, 1], got {Confidence}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the backport evidence.
/// </summary>
public string GetExplanation()
{
if (EvidenceTier == BackportEvidenceTier.None)
return "No backport evidence";
var statusDesc = Status switch
{
BackportStatus.Unknown => "status unknown",
BackportStatus.Affected => "confirmed affected",
BackportStatus.NotAffected => "confirmed not affected",
BackportStatus.Fixed => "fixed",
BackportStatus.UnderInvestigation => "under investigation",
_ => $"unknown status ({Status})"
};
var tierDesc = EvidenceTier switch
{
BackportEvidenceTier.Heuristic => "heuristic",
BackportEvidenceTier.PatchSignature => "patch-signature",
BackportEvidenceTier.BinaryDiff => "binary-diff",
BackportEvidenceTier.VendorVex => "vendor VEX",
BackportEvidenceTier.SignedProof => "signed proof",
_ => $"unknown tier ({EvidenceTier})"
};
var distributorInfo = !string.IsNullOrEmpty(Distributor)
? $" from {Distributor}"
: "";
return $"{statusDesc} ({tierDesc}{distributorInfo}, {Confidence:P0} confidence)";
}
}

View File

@@ -0,0 +1,325 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using System.Security.Cryptography;
using System.Text;
using System.Text.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.
/// </summary>
public sealed record EvidenceWeights
{
/// <summary>Weight for reachability dimension [0, 1].</summary>
public required double Rch { get; init; }
/// <summary>Weight for runtime dimension [0, 1].</summary>
public required double Rts { get; init; }
/// <summary>Weight for backport dimension [0, 1].</summary>
public required double Bkp { get; init; }
/// <summary>Weight for exploit dimension [0, 1].</summary>
public required double Xpl { get; init; }
/// <summary>Weight for source trust dimension [0, 1].</summary>
public required double Src { get; init; }
/// <summary>Weight for mitigation dimension (subtractive) [0, 1].</summary>
public required double Mit { get; init; }
/// <summary>
/// Default weights as specified in the scoring model.
/// </summary>
public static EvidenceWeights Default => new()
{
Rch = 0.30,
Rts = 0.25,
Bkp = 0.15,
Xpl = 0.15,
Src = 0.10,
Mit = 0.10
};
/// <summary>
/// Validates all weight values.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
ValidateWeight(nameof(Rch), Rch, errors);
ValidateWeight(nameof(Rts), Rts, errors);
ValidateWeight(nameof(Bkp), Bkp, errors);
ValidateWeight(nameof(Xpl), Xpl, errors);
ValidateWeight(nameof(Src), Src, errors);
ValidateWeight(nameof(Mit), Mit, errors);
return errors;
}
/// <summary>
/// Gets the sum of additive weights (excludes MIT).
/// </summary>
public double AdditiveSum => Rch + Rts + Bkp + Xpl + Src;
/// <summary>
/// Returns normalized weights where additive weights sum to 1.0.
/// MIT is preserved as-is (subtractive).
/// </summary>
public EvidenceWeights Normalize()
{
var sum = AdditiveSum;
if (sum <= 0)
return Default;
return new EvidenceWeights
{
Rch = Rch / sum,
Rts = Rts / sum,
Bkp = Bkp / sum,
Xpl = Xpl / sum,
Src = Src / sum,
Mit = Mit // MIT is not normalized
};
}
private static void ValidateWeight(string name, double value, List<string> errors)
{
if (double.IsNaN(value) || double.IsInfinity(value))
errors.Add($"{name} must be a valid number, got {value}");
else if (value < 0.0 || value > 1.0)
errors.Add($"{name} must be in range [0, 1], got {value}");
}
}
/// <summary>
/// Guardrail configuration for score caps and floors.
/// </summary>
public sealed record GuardrailConfig
{
/// <summary>Not-affected cap configuration.</summary>
public NotAffectedCapConfig NotAffectedCap { get; init; } = NotAffectedCapConfig.Default;
/// <summary>Runtime floor configuration.</summary>
public RuntimeFloorConfig RuntimeFloor { get; init; } = RuntimeFloorConfig.Default;
/// <summary>Speculative cap configuration.</summary>
public SpeculativeCapConfig SpeculativeCap { get; init; } = SpeculativeCapConfig.Default;
/// <summary>Default guardrail configuration.</summary>
public static GuardrailConfig Default => new();
}
/// <summary>Configuration for not-affected cap guardrail.</summary>
public sealed record NotAffectedCapConfig
{
/// <summary>Whether this guardrail is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Maximum score when guardrail is triggered.</summary>
public int MaxScore { get; init; } = 15;
/// <summary>Minimum BKP value required to trigger.</summary>
public double RequiresBkpMin { get; init; } = 1.0;
/// <summary>Maximum RTS value allowed to trigger.</summary>
public double RequiresRtsMax { get; init; } = 0.6;
public static NotAffectedCapConfig Default => new();
}
/// <summary>Configuration for runtime floor guardrail.</summary>
public sealed record RuntimeFloorConfig
{
/// <summary>Whether this guardrail is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Minimum score when guardrail is triggered.</summary>
public int MinScore { get; init; } = 60;
/// <summary>Minimum RTS value required to trigger.</summary>
public double RequiresRtsMin { get; init; } = 0.8;
public static RuntimeFloorConfig Default => new();
}
/// <summary>Configuration for speculative cap guardrail.</summary>
public sealed record SpeculativeCapConfig
{
/// <summary>Whether this guardrail is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Maximum score when guardrail is triggered.</summary>
public int MaxScore { get; init; } = 45;
/// <summary>Maximum RCH value allowed to trigger (must be at or below).</summary>
public double RequiresRchMax { get; init; } = 0.0;
/// <summary>Maximum RTS value allowed to trigger (must be at or below).</summary>
public double RequiresRtsMax { get; init; } = 0.0;
public static SpeculativeCapConfig Default => new();
}
/// <summary>
/// Score bucket threshold configuration.
/// </summary>
public sealed record BucketThresholds
{
/// <summary>Minimum score for ActNow bucket.</summary>
public int ActNowMin { get; init; } = 90;
/// <summary>Minimum score for ScheduleNext bucket.</summary>
public int ScheduleNextMin { get; init; } = 70;
/// <summary>Minimum score for Investigate bucket.</summary>
public int InvestigateMin { get; init; } = 40;
/// <summary>Below InvestigateMin is Watchlist.</summary>
public static BucketThresholds Default => new();
}
/// <summary>
/// Complete evidence weight policy with version tracking.
/// </summary>
public sealed record EvidenceWeightPolicy
{
/// <summary>Policy schema version (e.g., "ews.v1").</summary>
public required string Version { get; init; }
/// <summary>Policy profile name (e.g., "production", "development").</summary>
public required string Profile { get; init; }
/// <summary>Dimension weights.</summary>
public required EvidenceWeights Weights { get; init; }
/// <summary>Guardrail configuration.</summary>
public GuardrailConfig Guardrails { get; init; } = GuardrailConfig.Default;
/// <summary>Bucket thresholds.</summary>
public BucketThresholds Buckets { get; init; } = BucketThresholds.Default;
/// <summary>Optional tenant ID for multi-tenant scenarios.</summary>
public string? TenantId { get; init; }
/// <summary>Policy creation timestamp (UTC ISO-8601).</summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Default production policy.
/// </summary>
public static EvidenceWeightPolicy DefaultProduction => new()
{
Version = "ews.v1",
Profile = "production",
Weights = EvidenceWeights.Default
};
private string? _cachedDigest;
/// <summary>
/// Computes a deterministic digest of this policy for versioning.
/// Uses canonical JSON serialization → SHA256.
/// </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);
return _cachedDigest;
}
/// <summary>
/// Gets the canonical JSON representation for hashing.
/// Uses deterministic property ordering and formatting.
/// </summary>
public string GetCanonicalJson()
{
// Use a deterministic structure for hashing
var canonical = new
{
version = Version,
profile = Profile,
weights = new
{
rch = Weights.Rch,
rts = Weights.Rts,
bkp = Weights.Bkp,
xpl = Weights.Xpl,
src = Weights.Src,
mit = Weights.Mit
},
guardrails = new
{
not_affected_cap = new
{
enabled = Guardrails.NotAffectedCap.Enabled,
max_score = Guardrails.NotAffectedCap.MaxScore,
requires_bkp_min = Guardrails.NotAffectedCap.RequiresBkpMin,
requires_rts_max = Guardrails.NotAffectedCap.RequiresRtsMax
},
runtime_floor = new
{
enabled = Guardrails.RuntimeFloor.Enabled,
min_score = Guardrails.RuntimeFloor.MinScore,
requires_rts_min = Guardrails.RuntimeFloor.RequiresRtsMin
},
speculative_cap = new
{
enabled = Guardrails.SpeculativeCap.Enabled,
max_score = Guardrails.SpeculativeCap.MaxScore,
requires_rch_max = Guardrails.SpeculativeCap.RequiresRchMax,
requires_rts_max = Guardrails.SpeculativeCap.RequiresRtsMax
}
},
buckets = new
{
act_now_min = Buckets.ActNowMin,
schedule_next_min = Buckets.ScheduleNextMin,
investigate_min = Buckets.InvestigateMin
}
};
return JsonSerializer.Serialize(canonical, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
}
/// <summary>
/// Validates the policy configuration.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Version))
errors.Add("Version is required");
if (string.IsNullOrWhiteSpace(Profile))
errors.Add("Profile is required");
errors.AddRange(Weights.Validate());
// Validate bucket ordering
if (Buckets.ActNowMin <= Buckets.ScheduleNextMin)
errors.Add("ActNowMin must be greater than ScheduleNextMin");
if (Buckets.ScheduleNextMin <= Buckets.InvestigateMin)
errors.Add("ScheduleNextMin must be greater than InvestigateMin");
if (Buckets.InvestigateMin < 0 || Buckets.ActNowMin > 100)
errors.Add("Bucket thresholds must be in range [0, 100]");
return errors;
}
}

View File

@@ -0,0 +1,242 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Configuration options for evidence-weighted scoring.
/// </summary>
public sealed class EvidenceWeightPolicyOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "EvidenceWeightedScore";
/// <summary>
/// Default environment profile (e.g., "production", "development").
/// </summary>
public string DefaultEnvironment { get; set; } = "production";
/// <summary>
/// Path to the weight policy YAML file (optional, for file-based provider).
/// </summary>
public string? PolicyFilePath { get; set; }
/// <summary>
/// Whether to enable hot-reload for policy file changes.
/// </summary>
public bool EnableHotReload { get; set; } = true;
/// <summary>
/// Hot-reload polling interval in seconds.
/// </summary>
public int HotReloadIntervalSeconds { get; set; } = 30;
/// <summary>
/// Default weights for production environment.
/// </summary>
public WeightConfiguration ProductionWeights { get; set; } = new()
{
Rch = 0.35,
Rts = 0.30,
Bkp = 0.10,
Xpl = 0.15,
Src = 0.05,
Mit = 0.05
};
/// <summary>
/// Default weights for development environment.
/// </summary>
public WeightConfiguration DevelopmentWeights { get; set; } = new()
{
Rch = 0.20,
Rts = 0.15,
Bkp = 0.20,
Xpl = 0.20,
Src = 0.15,
Mit = 0.10
};
/// <summary>
/// Guardrail configuration.
/// </summary>
public GuardrailConfiguration Guardrails { get; set; } = new();
/// <summary>
/// Bucket threshold configuration.
/// </summary>
public BucketConfiguration Buckets { get; set; } = new();
}
/// <summary>
/// Weight configuration for an environment.
/// </summary>
public sealed class WeightConfiguration
{
public double Rch { get; set; } = 0.30;
public double Rts { get; set; } = 0.25;
public double Bkp { get; set; } = 0.15;
public double Xpl { get; set; } = 0.15;
public double Src { get; set; } = 0.10;
public double Mit { get; set; } = 0.10;
/// <summary>
/// Converts to EvidenceWeights record.
/// </summary>
public EvidenceWeights ToEvidenceWeights() => new()
{
Rch = Rch,
Rts = Rts,
Bkp = Bkp,
Xpl = Xpl,
Src = Src,
Mit = Mit
};
}
/// <summary>
/// Guardrail configuration options.
/// </summary>
public sealed class GuardrailConfiguration
{
public NotAffectedCapConfiguration NotAffectedCap { get; set; } = new();
public RuntimeFloorConfiguration RuntimeFloor { get; set; } = new();
public SpeculativeCapConfiguration SpeculativeCap { get; set; } = new();
/// <summary>
/// Converts to GuardrailConfig record.
/// </summary>
public GuardrailConfig ToGuardrailConfig() => new()
{
NotAffectedCap = NotAffectedCap.ToConfig(),
RuntimeFloor = RuntimeFloor.ToConfig(),
SpeculativeCap = SpeculativeCap.ToConfig()
};
}
public sealed class NotAffectedCapConfiguration
{
public bool Enabled { get; set; } = true;
public int MaxScore { get; set; } = 15;
public double RequiresBkpMin { get; set; } = 1.0;
public double RequiresRtsMax { get; set; } = 0.6;
public NotAffectedCapConfig ToConfig() => new()
{
Enabled = Enabled,
MaxScore = MaxScore,
RequiresBkpMin = RequiresBkpMin,
RequiresRtsMax = RequiresRtsMax
};
}
public sealed class RuntimeFloorConfiguration
{
public bool Enabled { get; set; } = true;
public int MinScore { get; set; } = 60;
public double RequiresRtsMin { get; set; } = 0.8;
public RuntimeFloorConfig ToConfig() => new()
{
Enabled = Enabled,
MinScore = MinScore,
RequiresRtsMin = RequiresRtsMin
};
}
public sealed class SpeculativeCapConfiguration
{
public bool Enabled { get; set; } = true;
public int MaxScore { get; set; } = 45;
public double RequiresRchMax { get; set; } = 0.0;
public double RequiresRtsMax { get; set; } = 0.0;
public SpeculativeCapConfig ToConfig() => new()
{
Enabled = Enabled,
MaxScore = MaxScore,
RequiresRchMax = RequiresRchMax,
RequiresRtsMax = RequiresRtsMax
};
}
/// <summary>
/// Bucket threshold configuration options.
/// </summary>
public sealed class BucketConfiguration
{
public int ActNowMin { get; set; } = 90;
public int ScheduleNextMin { get; set; } = 70;
public int InvestigateMin { get; set; } = 40;
/// <summary>
/// Converts to BucketThresholds record.
/// </summary>
public BucketThresholds ToBucketThresholds() => new()
{
ActNowMin = ActNowMin,
ScheduleNextMin = ScheduleNextMin,
InvestigateMin = InvestigateMin
};
}
/// <summary>
/// Policy provider backed by IOptions configuration.
/// </summary>
public sealed class OptionsEvidenceWeightPolicyProvider : IEvidenceWeightPolicyProvider
{
private readonly IOptionsMonitor<EvidenceWeightPolicyOptions> _options;
public OptionsEvidenceWeightPolicyProvider(IOptionsMonitor<EvidenceWeightPolicyOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public Task<EvidenceWeightPolicy> GetPolicyAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
// Options provider doesn't support per-tenant policies
// Fall back to environment-based defaults
return GetDefaultPolicyAsync(environment, cancellationToken);
}
public Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
string environment,
CancellationToken cancellationToken = default)
{
var options = _options.CurrentValue;
var weights = environment.Equals("production", StringComparison.OrdinalIgnoreCase)
? options.ProductionWeights.ToEvidenceWeights()
: environment.Equals("development", StringComparison.OrdinalIgnoreCase)
? options.DevelopmentWeights.ToEvidenceWeights()
: EvidenceWeights.Default;
var policy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = environment,
Weights = weights,
Guardrails = options.Guardrails.ToGuardrailConfig(),
Buckets = options.Buckets.ToBucketThresholds()
};
return Task.FromResult(policy);
}
public Task<bool> PolicyExistsAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
// Options-based provider always has a policy for any environment
return Task.FromResult(true);
}
}

View File

@@ -0,0 +1,437 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Score bucket for quick triage categorization.
/// </summary>
public enum ScoreBucket
{
/// <summary>90-100: Act now - strong evidence of exploitable risk; immediate action required.</summary>
ActNow = 0,
/// <summary>70-89: Likely real; schedule for next sprint.</summary>
ScheduleNext = 1,
/// <summary>40-69: Moderate evidence; investigate when touching component.</summary>
Investigate = 2,
/// <summary>0-39: Low/insufficient evidence; watchlist.</summary>
Watchlist = 3
}
/// <summary>
/// Record of applied guardrails during score calculation.
/// </summary>
public sealed record AppliedGuardrails
{
/// <summary>Whether the speculative cap was applied.</summary>
public bool SpeculativeCap { get; init; }
/// <summary>Whether the not-affected cap was applied.</summary>
public bool NotAffectedCap { get; init; }
/// <summary>Whether the runtime floor was applied.</summary>
public bool RuntimeFloor { get; init; }
/// <summary>Original score before guardrails.</summary>
public int OriginalScore { get; init; }
/// <summary>Score after guardrails.</summary>
public int AdjustedScore { get; init; }
/// <summary>No guardrails applied.</summary>
public static AppliedGuardrails None(int score) => new()
{
SpeculativeCap = false,
NotAffectedCap = false,
RuntimeFloor = false,
OriginalScore = score,
AdjustedScore = score
};
/// <summary>Check if any guardrail was applied.</summary>
public bool AnyApplied => SpeculativeCap || NotAffectedCap || RuntimeFloor;
}
/// <summary>
/// Per-dimension contribution to the final score.
/// </summary>
public sealed record DimensionContribution
{
/// <summary>Dimension name (e.g., "Reachability", "Runtime").</summary>
public required string Dimension { get; init; }
/// <summary>Symbol (RCH, RTS, BKP, XPL, SRC, MIT).</summary>
public required string Symbol { get; init; }
/// <summary>Normalized input value [0, 1].</summary>
public required double InputValue { get; init; }
/// <summary>Weight applied.</summary>
public required double Weight { get; init; }
/// <summary>Contribution to raw score (weight * input, or negative for MIT).</summary>
public required double Contribution { get; init; }
/// <summary>Whether this is a subtractive dimension (like MIT).</summary>
public bool IsSubtractive { get; init; }
}
/// <summary>
/// Normalized input values echoed in result.
/// </summary>
public sealed record EvidenceInputValues(
double Rch, double Rts, double Bkp,
double Xpl, double Src, double Mit);
/// <summary>
/// Result of evidence-weighted score calculation.
/// </summary>
public sealed record EvidenceWeightedScoreResult
{
/// <summary>Finding identifier.</summary>
public required string FindingId { get; init; }
/// <summary>Final score [0, 100]. Higher = more evidence of real risk.</summary>
public required int Score { get; init; }
/// <summary>Score bucket for quick triage.</summary>
public required ScoreBucket Bucket { get; init; }
/// <summary>Normalized input values used.</summary>
public required EvidenceInputValues Inputs { get; init; }
/// <summary>Weight values used.</summary>
public required EvidenceWeights Weights { get; init; }
/// <summary>Per-dimension score contributions (breakdown).</summary>
public required IReadOnlyList<DimensionContribution> Breakdown { get; init; }
/// <summary>Active flags for badges (e.g., "live-signal", "proven-path", "vendor-na", "speculative").</summary>
public required IReadOnlyList<string> Flags { get; init; }
/// <summary>Human-readable explanations of top contributing factors.</summary>
public required IReadOnlyList<string> Explanations { get; init; }
/// <summary>Applied guardrails (caps/floors).</summary>
public required AppliedGuardrails Caps { get; init; }
/// <summary>Policy digest for determinism verification.</summary>
public required string PolicyDigest { get; init; }
/// <summary>Calculation timestamp (UTC ISO-8601).</summary>
public required DateTimeOffset CalculatedAt { get; init; }
}
/// <summary>
/// Interface for evidence-weighted score calculation.
/// </summary>
public interface IEvidenceWeightedScoreCalculator
{
/// <summary>
/// Calculates the evidence-weighted score for a finding.
/// </summary>
/// <param name="input">Normalized input values.</param>
/// <param name="policy">Weight policy to apply.</param>
/// <returns>Calculation result with score, breakdown, and explanations.</returns>
EvidenceWeightedScoreResult Calculate(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy);
}
/// <summary>
/// Evidence-weighted score calculator implementation.
/// Formula: Score = clamp01(W_rch*RCH + W_rts*RTS + W_bkp*BKP + W_xpl*XPL + W_src*SRC - W_mit*MIT) * 100
/// </summary>
public sealed class EvidenceWeightedScoreCalculator : IEvidenceWeightedScoreCalculator
{
private readonly TimeProvider _timeProvider;
public EvidenceWeightedScoreCalculator() : this(TimeProvider.System)
{
}
public EvidenceWeightedScoreCalculator(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public EvidenceWeightedScoreResult Calculate(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(policy);
// Clamp input values to ensure they're in valid range
var clampedInput = input.Clamp();
var weights = policy.Weights;
// Calculate raw score using formula
var rawScore =
weights.Rch * clampedInput.Rch +
weights.Rts * clampedInput.Rts +
weights.Bkp * clampedInput.Bkp +
weights.Xpl * clampedInput.Xpl +
weights.Src * clampedInput.Src -
weights.Mit * clampedInput.Mit; // MIT is 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 breakdown
var breakdown = CalculateBreakdown(clampedInput, weights);
// Generate flags
var flags = GenerateFlags(clampedInput, guardrails);
// Generate explanations
var explanations = GenerateExplanations(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()
};
}
private static (int finalScore, AppliedGuardrails guardrails) ApplyGuardrails(
int score,
EvidenceWeightedScoreInput input,
GuardrailConfig config)
{
var originalScore = score;
var speculativeCap = false;
var notAffectedCap = false;
var runtimeFloor = false;
// Order matters: caps before floors
// 1. Speculative cap: if RCH=0 + RTS=0 → cap at configured max (default 45)
if (config.SpeculativeCap.Enabled &&
input.Rch <= config.SpeculativeCap.RequiresRchMax &&
input.Rts <= config.SpeculativeCap.RequiresRtsMax)
{
if (score > config.SpeculativeCap.MaxScore)
{
score = config.SpeculativeCap.MaxScore;
speculativeCap = true;
}
}
// 2. Not-affected cap: if BKP>=1 + not_affected + RTS<0.6 → cap at configured max (default 15)
if (config.NotAffectedCap.Enabled &&
input.Bkp >= config.NotAffectedCap.RequiresBkpMin &&
input.Rts < config.NotAffectedCap.RequiresRtsMax &&
string.Equals(input.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase))
{
if (score > config.NotAffectedCap.MaxScore)
{
score = config.NotAffectedCap.MaxScore;
notAffectedCap = true;
}
}
// 3. Runtime floor: if RTS >= 0.8 → floor at configured min (default 60)
if (config.RuntimeFloor.Enabled &&
input.Rts >= config.RuntimeFloor.RequiresRtsMin)
{
if (score < config.RuntimeFloor.MinScore)
{
score = config.RuntimeFloor.MinScore;
runtimeFloor = true;
}
}
return (score, new AppliedGuardrails
{
SpeculativeCap = speculativeCap,
NotAffectedCap = notAffectedCap,
RuntimeFloor = runtimeFloor,
OriginalScore = originalScore,
AdjustedScore = score
});
}
private static IReadOnlyList<DimensionContribution> CalculateBreakdown(
EvidenceWeightedScoreInput input,
EvidenceWeights weights)
{
return
[
new DimensionContribution
{
Dimension = "Reachability",
Symbol = "RCH",
InputValue = input.Rch,
Weight = weights.Rch,
Contribution = weights.Rch * input.Rch
},
new DimensionContribution
{
Dimension = "Runtime",
Symbol = "RTS",
InputValue = input.Rts,
Weight = weights.Rts,
Contribution = weights.Rts * input.Rts
},
new DimensionContribution
{
Dimension = "Backport",
Symbol = "BKP",
InputValue = input.Bkp,
Weight = weights.Bkp,
Contribution = weights.Bkp * input.Bkp
},
new DimensionContribution
{
Dimension = "Exploit",
Symbol = "XPL",
InputValue = input.Xpl,
Weight = weights.Xpl,
Contribution = weights.Xpl * input.Xpl
},
new DimensionContribution
{
Dimension = "Source Trust",
Symbol = "SRC",
InputValue = input.Src,
Weight = weights.Src,
Contribution = weights.Src * input.Src
},
new DimensionContribution
{
Dimension = "Mitigations",
Symbol = "MIT",
InputValue = input.Mit,
Weight = weights.Mit,
Contribution = -weights.Mit * input.Mit, // Negative because subtractive
IsSubtractive = true
}
];
}
private static IReadOnlyList<string> GenerateFlags(
EvidenceWeightedScoreInput input,
AppliedGuardrails guardrails)
{
var flags = new List<string>();
// Live signal flag
if (input.Rts >= 0.6)
flags.Add("live-signal");
// Proven path flag
if (input.Rch >= 0.7 && input.Rts >= 0.5)
flags.Add("proven-path");
// Vendor not-affected flag
if (guardrails.NotAffectedCap ||
string.Equals(input.VexStatus, "not_affected", StringComparison.OrdinalIgnoreCase))
flags.Add("vendor-na");
// Speculative flag
if (guardrails.SpeculativeCap || (input.Rch == 0 && input.Rts == 0))
flags.Add("speculative");
// High exploit probability
if (input.Xpl >= 0.5)
flags.Add("high-epss");
// Strong mitigations
if (input.Mit >= 0.7)
flags.Add("well-mitigated");
return flags;
}
private static IReadOnlyList<string> GenerateExplanations(
EvidenceWeightedScoreInput input,
IReadOnlyList<DimensionContribution> breakdown,
AppliedGuardrails guardrails)
{
var explanations = new List<string>();
// Sort by contribution magnitude (excluding MIT which is negative)
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 => "very high",
>= 0.6 => "high",
>= 0.4 => "moderate",
>= 0.2 => "low",
_ => "minimal"
};
explanations.Add($"{contributor.Dimension}: {level} ({contributor.InputValue:P0})");
}
// Add 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})");
// Add mitigation note if significant
if (input.Mit >= 0.5)
{
explanations.Add($"Mitigations reduce effective risk ({input.Mit:P0} effectiveness)");
}
// Add detailed explanations from input if available
if (input.ReachabilityDetails is not null)
explanations.Add($"Reachability: {input.ReachabilityDetails.GetExplanation()}");
if (input.RuntimeDetails is not null)
explanations.Add($"Runtime: {input.RuntimeDetails.GetExplanation()}");
if (input.BackportDetails is not null)
explanations.Add($"Backport: {input.BackportDetails.GetExplanation()}");
if (input.ExploitDetails is not null)
explanations.Add($"Exploit: {input.ExploitDetails.GetExplanation()}");
return explanations;
}
/// <summary>
/// Determines the score bucket based on thresholds.
/// </summary>
public static ScoreBucket GetBucket(int score, BucketThresholds thresholds)
{
return score >= thresholds.ActNowMin ? ScoreBucket.ActNow
: score >= thresholds.ScheduleNextMin ? ScoreBucket.ScheduleNext
: score >= thresholds.InvestigateMin ? ScoreBucket.Investigate
: ScoreBucket.Watchlist;
}
}

View File

@@ -0,0 +1,108 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Normalized inputs for evidence-weighted score calculation.
/// All primary dimension values are [0, 1] where higher = stronger evidence.
/// </summary>
public sealed record EvidenceWeightedScoreInput
{
/// <summary>Finding identifier (CVE@PURL format or similar).</summary>
public required string FindingId { get; init; }
/// <summary>Reachability confidence [0, 1]. Higher = more reachable.</summary>
public required double Rch { get; init; }
/// <summary>Runtime signal strength [0, 1]. Higher = stronger live signal.</summary>
public required double Rts { get; init; }
/// <summary>Backport evidence [0, 1]. Higher = stronger patch proof.</summary>
public required double Bkp { get; init; }
/// <summary>Exploit likelihood [0, 1]. Higher = more likely to be exploited.</summary>
public required double Xpl { get; init; }
/// <summary>Source trust [0, 1]. Higher = more trustworthy source.</summary>
public required double Src { get; init; }
/// <summary>Mitigation effectiveness [0, 1]. Higher = stronger mitigations.</summary>
public required double Mit { get; init; }
/// <summary>VEX status for backport guardrail evaluation (e.g., "not_affected", "affected", "fixed").</summary>
public string? VexStatus { get; init; }
/// <summary>Detailed inputs for explanation generation (reachability).</summary>
public ReachabilityInput? ReachabilityDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (runtime).</summary>
public RuntimeInput? RuntimeDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (backport).</summary>
public BackportInput? BackportDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (exploit).</summary>
public ExploitInput? ExploitDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (source trust).</summary>
public SourceTrustInput? SourceTrustDetails { get; init; }
/// <summary>Detailed inputs for explanation generation (mitigations).</summary>
public MitigationInput? MitigationDetails { get; init; }
/// <summary>
/// Validates all dimension values are within [0, 1] range.
/// </summary>
/// <returns>List of validation errors, empty if valid.</returns>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(FindingId))
errors.Add("FindingId is required");
ValidateDimension(nameof(Rch), Rch, errors);
ValidateDimension(nameof(Rts), Rts, errors);
ValidateDimension(nameof(Bkp), Bkp, errors);
ValidateDimension(nameof(Xpl), Xpl, errors);
ValidateDimension(nameof(Src), Src, errors);
ValidateDimension(nameof(Mit), Mit, errors);
return errors;
}
/// <summary>
/// Creates a clamped version of this input with all values in [0, 1].
/// </summary>
/// <returns>New input with clamped values.</returns>
public EvidenceWeightedScoreInput Clamp()
{
return this with
{
Rch = ClampValue(Rch),
Rts = ClampValue(Rts),
Bkp = ClampValue(Bkp),
Xpl = ClampValue(Xpl),
Src = ClampValue(Src),
Mit = ClampValue(Mit)
};
}
private static void ValidateDimension(string name, double value, List<string> errors)
{
if (double.IsNaN(value) || double.IsInfinity(value))
errors.Add($"{name} must be a valid number, got {value}");
else if (value < 0.0 || value > 1.0)
errors.Add($"{name} must be in range [0, 1], got {value}");
}
private static double ClampValue(double value)
{
if (double.IsNaN(value) || double.IsNegativeInfinity(value))
return 0.0;
if (double.IsPositiveInfinity(value))
return 1.0;
return Math.Clamp(value, 0.0, 1.0);
}
}

View File

@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Known Exploited Vulnerabilities (KEV) status.
/// </summary>
public enum KevStatus
{
/// <summary>Not in KEV catalog.</summary>
NotInKev = 0,
/// <summary>In KEV catalog, actively exploited.</summary>
InKev = 1,
/// <summary>Removed from KEV (remediated widely or false positive).</summary>
RemovedFromKev = 2
}
/// <summary>
/// Detailed exploit likelihood input for explanation generation.
/// </summary>
public sealed record ExploitInput
{
/// <summary>EPSS score [0, 1]. Probability of exploitation in the next 30 days.</summary>
public required double EpssScore { get; init; }
/// <summary>EPSS percentile [0, 100]. Relative rank among all CVEs.</summary>
public required double EpssPercentile { get; init; }
/// <summary>Known Exploited Vulnerabilities (KEV) catalog status.</summary>
public required KevStatus KevStatus { get; init; }
/// <summary>Date added to KEV (if applicable).</summary>
public DateTimeOffset? KevAddedDate { get; init; }
/// <summary>KEV due date for remediation (if applicable).</summary>
public DateTimeOffset? KevDueDate { get; init; }
/// <summary>Whether public exploit code is available.</summary>
public bool PublicExploitAvailable { get; init; }
/// <summary>Exploit maturity (e.g., "poc", "functional", "weaponized").</summary>
public string? ExploitMaturity { get; init; }
/// <summary>Source of EPSS data (e.g., "first.org", "stellaops-cache").</summary>
public string? EpssSource { get; init; }
/// <summary>EPSS model version.</summary>
public string? EpssModelVersion { get; init; }
/// <summary>EPSS score timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EpssTimestamp { get; init; }
/// <summary>
/// Validates the exploit input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (EpssScore < 0.0 || EpssScore > 1.0)
errors.Add($"EpssScore must be in range [0, 1], got {EpssScore}");
if (EpssPercentile < 0.0 || EpssPercentile > 100.0)
errors.Add($"EpssPercentile must be in range [0, 100], got {EpssPercentile}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the exploit evidence.
/// </summary>
public string GetExplanation()
{
var parts = new List<string>();
// EPSS info
var epssDesc = EpssScore switch
{
>= 0.7 => $"Very high EPSS ({EpssScore:P1}, top {100 - EpssPercentile:F0}%)",
>= 0.4 => $"High EPSS ({EpssScore:P1}, top {100 - EpssPercentile:F0}%)",
>= 0.1 => $"Moderate EPSS ({EpssScore:P1})",
_ => $"Low EPSS ({EpssScore:P1})"
};
parts.Add(epssDesc);
// KEV info
if (KevStatus == KevStatus.InKev)
{
var kevInfo = "in KEV catalog";
if (KevAddedDate.HasValue)
kevInfo += $" (added {KevAddedDate.Value:yyyy-MM-dd})";
parts.Add(kevInfo);
}
// Public exploit
if (PublicExploitAvailable)
{
var maturityInfo = !string.IsNullOrEmpty(ExploitMaturity)
? $"public exploit ({ExploitMaturity})"
: "public exploit available";
parts.Add(maturityInfo);
}
return string.Join("; ", parts);
}
}

View File

@@ -0,0 +1,166 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Provider for evidence weight policies.
/// Supports multi-tenant and multi-environment scenarios.
/// </summary>
public interface IEvidenceWeightPolicyProvider
{
/// <summary>
/// Gets the weight policy for the specified tenant and environment.
/// </summary>
/// <param name="tenantId">Optional tenant identifier. Null for default/global policy.</param>
/// <param name="environment">Environment name (e.g., "production", "development").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The applicable weight policy.</returns>
Task<EvidenceWeightPolicy> GetPolicyAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the default policy for the specified environment.
/// </summary>
Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
string environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a specific policy exists.
/// </summary>
Task<bool> PolicyExistsAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory policy provider for testing and development.
/// </summary>
public sealed class InMemoryEvidenceWeightPolicyProvider : IEvidenceWeightPolicyProvider
{
private readonly Dictionary<string, EvidenceWeightPolicy> _policies = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <summary>
/// Adds or updates a policy.
/// </summary>
public void SetPolicy(EvidenceWeightPolicy policy)
{
var key = GetPolicyKey(policy.TenantId, policy.Profile);
lock (_lock)
{
_policies[key] = policy;
}
}
/// <summary>
/// Removes a policy.
/// </summary>
public bool RemovePolicy(string? tenantId, string environment)
{
var key = GetPolicyKey(tenantId, environment);
lock (_lock)
{
return _policies.Remove(key);
}
}
/// <summary>
/// Clears all policies.
/// </summary>
public void Clear()
{
lock (_lock)
{
_policies.Clear();
}
}
public Task<EvidenceWeightPolicy> GetPolicyAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
// Try tenant-specific first, then fall back to global
var tenantKey = GetPolicyKey(tenantId, environment);
var globalKey = GetPolicyKey(null, environment);
lock (_lock)
{
if (_policies.TryGetValue(tenantKey, out var tenantPolicy))
return Task.FromResult(tenantPolicy);
if (_policies.TryGetValue(globalKey, out var globalPolicy))
return Task.FromResult(globalPolicy);
}
// Return default if nothing found
return Task.FromResult(CreateDefaultPolicy(environment));
}
public Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
string environment,
CancellationToken cancellationToken = default)
{
return GetPolicyAsync(null, environment, cancellationToken);
}
public Task<bool> PolicyExistsAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var key = GetPolicyKey(tenantId, environment);
lock (_lock)
{
return Task.FromResult(_policies.ContainsKey(key));
}
}
private static string GetPolicyKey(string? tenantId, string environment)
{
return string.IsNullOrEmpty(tenantId)
? $"__global__:{environment}"
: $"{tenantId}:{environment}";
}
private static EvidenceWeightPolicy CreateDefaultPolicy(string environment)
{
var weights = environment.Equals("production", StringComparison.OrdinalIgnoreCase)
? new EvidenceWeights
{
Rch = 0.35,
Rts = 0.30,
Bkp = 0.10,
Xpl = 0.15,
Src = 0.05,
Mit = 0.05
}
: environment.Equals("development", StringComparison.OrdinalIgnoreCase)
? new EvidenceWeights
{
Rch = 0.20,
Rts = 0.15,
Bkp = 0.20,
Xpl = 0.20,
Src = 0.15,
Mit = 0.10
}
: EvidenceWeights.Default;
return new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = environment,
Weights = weights
};
}
}

View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Type of mitigation control.
/// </summary>
public enum MitigationType
{
/// <summary>Unknown mitigation type.</summary>
Unknown = 0,
/// <summary>Network-level control (WAF, firewall rules).</summary>
NetworkControl = 1,
/// <summary>Runtime feature flag (code disabled).</summary>
FeatureFlag = 2,
/// <summary>Seccomp/AppArmor/SELinux policy.</summary>
SecurityPolicy = 3,
/// <summary>Sandbox/container isolation.</summary>
Isolation = 4,
/// <summary>Rate limiting or input validation.</summary>
InputValidation = 5,
/// <summary>Authentication/authorization requirement.</summary>
AuthRequired = 6,
/// <summary>Virtual patching (IDS/IPS rule).</summary>
VirtualPatch = 7,
/// <summary>Complete removal of vulnerable component.</summary>
ComponentRemoval = 8
}
/// <summary>
/// Active mitigation control.
/// </summary>
public sealed record ActiveMitigation
{
/// <summary>Mitigation type.</summary>
public required MitigationType Type { get; init; }
/// <summary>Mitigation identifier or name.</summary>
public string? Name { get; init; }
/// <summary>Effectiveness of this mitigation [0, 1].</summary>
public required double Effectiveness { get; init; }
/// <summary>Whether the mitigation has been verified active.</summary>
public bool Verified { get; init; }
/// <summary>Source of mitigation evidence.</summary>
public string? EvidenceSource { get; init; }
/// <summary>
/// Validates the mitigation.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Effectiveness < 0.0 || Effectiveness > 1.0)
errors.Add($"Effectiveness must be in range [0, 1], got {Effectiveness}");
return errors;
}
}
/// <summary>
/// Detailed mitigation input for explanation generation.
/// </summary>
public sealed record MitigationInput
{
/// <summary>List of active mitigations.</summary>
public required IReadOnlyList<ActiveMitigation> ActiveMitigations { get; init; }
/// <summary>Combined effectiveness score [0, 1] (pre-computed or from formula).</summary>
public required double CombinedEffectiveness { get; init; }
/// <summary>Whether mitigations have been verified in runtime.</summary>
public bool RuntimeVerified { get; init; }
/// <summary>Evidence timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EvidenceTimestamp { get; init; }
/// <summary>Source of mitigation assessment.</summary>
public string? AssessmentSource { get; init; }
/// <summary>
/// Validates the mitigation input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (CombinedEffectiveness < 0.0 || CombinedEffectiveness > 1.0)
errors.Add($"CombinedEffectiveness must be in range [0, 1], got {CombinedEffectiveness}");
foreach (var mitigation in ActiveMitigations)
{
var mitigationErrors = mitigation.Validate();
errors.AddRange(mitigationErrors);
}
return errors;
}
/// <summary>
/// Calculates combined effectiveness using diminishing returns formula.
/// Each additional mitigation has decreasing marginal effectiveness.
/// </summary>
/// <returns>Combined effectiveness [0, 1].</returns>
public static double CalculateCombinedEffectiveness(IReadOnlyList<ActiveMitigation> mitigations)
{
if (mitigations.Count == 0)
return 0.0;
// Sort by effectiveness descending for stable ordering
var sorted = mitigations
.OrderByDescending(m => m.Effectiveness)
.ThenBy(m => m.Name ?? "", StringComparer.Ordinal)
.ToList();
// Diminishing returns: combined = 1 - Π(1 - e_i)
// Each mitigation reduces remaining risk multiplicatively
var remainingRisk = 1.0;
foreach (var mitigation in sorted)
{
remainingRisk *= (1.0 - mitigation.Effectiveness);
}
return Math.Clamp(1.0 - remainingRisk, 0.0, 1.0);
}
/// <summary>
/// Generates a human-readable explanation of the mitigations.
/// </summary>
public string GetExplanation()
{
if (ActiveMitigations.Count == 0)
return "No active mitigations";
var verifiedCount = ActiveMitigations.Count(m => m.Verified);
var totalCount = ActiveMitigations.Count;
var typeGroups = ActiveMitigations
.GroupBy(m => m.Type)
.Select(g => GetMitigationTypeDescription(g.Key))
.Distinct()
.Take(3);
var typeSummary = string.Join(", ", typeGroups);
var verificationInfo = RuntimeVerified
? " (runtime verified)"
: verifiedCount > 0
? $" ({verifiedCount}/{totalCount} verified)"
: "";
return $"{totalCount} active mitigation(s): {typeSummary}, {CombinedEffectiveness:P0} combined effectiveness{verificationInfo}";
}
private static string GetMitigationTypeDescription(MitigationType type)
{
return type switch
{
MitigationType.NetworkControl => "network control",
MitigationType.FeatureFlag => "feature flag",
MitigationType.SecurityPolicy => "security policy",
MitigationType.Isolation => "isolation",
MitigationType.InputValidation => "input validation",
MitigationType.AuthRequired => "auth required",
MitigationType.VirtualPatch => "virtual patch",
MitigationType.ComponentRemoval => "component removed",
_ => "unknown"
};
}
}

View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Reachability state from static/dynamic analysis.
/// </summary>
public enum ReachabilityState
{
/// <summary>No reachability data available.</summary>
Unknown = 0,
/// <summary>Definitely not reachable.</summary>
NotReachable = 1,
/// <summary>Potentially reachable (conservative analysis).</summary>
PotentiallyReachable = 2,
/// <summary>Confirmed reachable via static analysis.</summary>
StaticReachable = 3,
/// <summary>Confirmed reachable via dynamic analysis.</summary>
DynamicReachable = 4,
/// <summary>Live exploit path observed.</summary>
LiveExploitPath = 5
}
/// <summary>
/// Detailed reachability input for explanation generation.
/// </summary>
public sealed record ReachabilityInput
{
/// <summary>Current reachability state.</summary>
public required ReachabilityState State { get; init; }
/// <summary>Confidence score [0, 1] from the analysis.</summary>
public required double Confidence { get; init; }
/// <summary>Number of hops from entry point to vulnerable sink (0 = direct).</summary>
public int HopCount { get; init; }
/// <summary>Whether analysis includes inter-procedural flow.</summary>
public bool HasInterproceduralFlow { get; init; }
/// <summary>Whether analysis includes taint tracking.</summary>
public bool HasTaintTracking { get; init; }
/// <summary>Whether analysis includes data-flow sensitivity.</summary>
public bool HasDataFlowSensitivity { get; init; }
/// <summary>Analysis method used (e.g., "call-graph", "taint-tracking", "symbolic-execution").</summary>
public string? AnalysisMethod { get; init; }
/// <summary>Source of reachability evidence (e.g., "codeql", "semgrep", "stellaops-native").</summary>
public string? EvidenceSource { get; init; }
/// <summary>Evidence timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? EvidenceTimestamp { get; init; }
/// <summary>
/// Validates the reachability input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (Confidence < 0.0 || Confidence > 1.0)
errors.Add($"Confidence must be in range [0, 1], got {Confidence}");
if (HopCount < 0)
errors.Add($"HopCount must be non-negative, got {HopCount}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the reachability evidence.
/// </summary>
public string GetExplanation()
{
var stateDesc = State switch
{
ReachabilityState.Unknown => "No reachability data available",
ReachabilityState.NotReachable => "Confirmed not reachable",
ReachabilityState.PotentiallyReachable => "Potentially reachable",
ReachabilityState.StaticReachable => "Statically reachable",
ReachabilityState.DynamicReachable => "Dynamically confirmed reachable",
ReachabilityState.LiveExploitPath => "Live exploit path observed",
_ => $"Unknown state ({State})"
};
var hopInfo = HopCount switch
{
0 => "direct path",
1 => "1 hop away",
_ => $"{HopCount} hops away"
};
var analysisFlags = new List<string>();
if (HasInterproceduralFlow) analysisFlags.Add("interprocedural");
if (HasTaintTracking) analysisFlags.Add("taint-tracked");
if (HasDataFlowSensitivity) analysisFlags.Add("data-flow");
var analysis = analysisFlags.Count > 0
? $" ({string.Join(", ", analysisFlags)})"
: "";
return $"{stateDesc}, {hopInfo}, {Confidence:P0} confidence{analysis}";
}
}

View File

@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// Runtime observation posture.
/// </summary>
public enum RuntimePosture
{
/// <summary>No runtime observation.</summary>
None = 0,
/// <summary>Passive monitoring (logs, metrics).</summary>
Passive = 1,
/// <summary>Active tracing (syscalls, ETW, dtrace).</summary>
ActiveTracing = 2,
/// <summary>eBPF-based deep observation.</summary>
EbpfDeep = 3,
/// <summary>Full coverage instrumentation.</summary>
FullInstrumentation = 4
}
/// <summary>
/// Detailed runtime signal input for explanation generation.
/// </summary>
public sealed record RuntimeInput
{
/// <summary>Current observation posture.</summary>
public required RuntimePosture Posture { get; init; }
/// <summary>Number of code path observations.</summary>
public required int ObservationCount { get; init; }
/// <summary>Most recent observation timestamp (UTC ISO-8601).</summary>
public DateTimeOffset? LastObservation { get; init; }
/// <summary>Observation recency factor [0, 1]. 1 = within last 24h, decays over time.</summary>
public required double RecencyFactor { get; init; }
/// <summary>Observed session digests (for cross-session correlation).</summary>
public IReadOnlyList<string>? SessionDigests { get; init; }
/// <summary>Whether the vulnerable code path was directly observed.</summary>
public bool DirectPathObserved { get; init; }
/// <summary>Whether the observation was in production traffic.</summary>
public bool IsProductionTraffic { get; init; }
/// <summary>Source of runtime evidence (e.g., "ebpf-sensor", "dyld-trace", "etw-provider").</summary>
public string? EvidenceSource { get; init; }
/// <summary>Correlation ID linking to runtime evidence.</summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Validates the runtime input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (ObservationCount < 0)
errors.Add($"ObservationCount must be non-negative, got {ObservationCount}");
if (RecencyFactor < 0.0 || RecencyFactor > 1.0)
errors.Add($"RecencyFactor must be in range [0, 1], got {RecencyFactor}");
return errors;
}
/// <summary>
/// Generates a human-readable explanation of the runtime evidence.
/// </summary>
public string GetExplanation()
{
if (Posture == RuntimePosture.None || ObservationCount == 0)
return "No runtime observations";
var postureDesc = Posture switch
{
RuntimePosture.Passive => "passive monitoring",
RuntimePosture.ActiveTracing => "active tracing",
RuntimePosture.EbpfDeep => "eBPF deep observation",
RuntimePosture.FullInstrumentation => "full instrumentation",
_ => $"unknown posture ({Posture})"
};
var pathInfo = DirectPathObserved
? "vulnerable path directly observed"
: "related code executed";
var trafficInfo = IsProductionTraffic
? " in production"
: "";
var recencyInfo = RecencyFactor switch
{
>= 0.9 => " (recent)",
>= 0.5 => " (moderate age)",
_ => " (old)"
};
return $"{ObservationCount} observations via {postureDesc}, {pathInfo}{trafficInfo}{recencyInfo}";
}
}

View File

@@ -0,0 +1,148 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
namespace StellaOps.Signals.EvidenceWeightedScore;
/// <summary>
/// VEX/advisory issuer type.
/// </summary>
public enum IssuerType
{
/// <summary>Unknown or unverified source.</summary>
Unknown = 0,
/// <summary>Community/crowd-sourced advisory.</summary>
Community = 1,
/// <summary>Security researcher or organization.</summary>
SecurityResearcher = 2,
/// <summary>Linux distribution (Debian, RedHat, Ubuntu, etc.).</summary>
Distribution = 3,
/// <summary>Upstream project maintainer.</summary>
Upstream = 4,
/// <summary>Commercial software vendor.</summary>
Vendor = 5,
/// <summary>CVE Numbering Authority (CNA).</summary>
Cna = 6,
/// <summary>CISA or government agency.</summary>
GovernmentAgency = 7
}
/// <summary>
/// Detailed source trust input for explanation generation.
/// </summary>
public sealed record SourceTrustInput
{
/// <summary>Issuer type for the VEX/advisory.</summary>
public required IssuerType IssuerType { get; init; }
/// <summary>Issuer identifier (e.g., "debian-security", "redhat-psirt").</summary>
public string? IssuerId { get; init; }
/// <summary>Provenance trust factor [0, 1]. Higher = better attestation chain.</summary>
public required double ProvenanceTrust { get; init; }
/// <summary>Coverage completeness [0, 1]. Higher = more complete analysis.</summary>
public required double CoverageCompleteness { get; init; }
/// <summary>Replayability factor [0, 1]. Higher = more reproducible.</summary>
public required double Replayability { get; init; }
/// <summary>Whether the source is cryptographically attested (DSSE/in-toto).</summary>
public bool IsCryptographicallyAttested { get; init; }
/// <summary>Whether the source has been independently verified.</summary>
public bool IndependentlyVerified { get; init; }
/// <summary>Historical accuracy of this source [0, 1] (if known).</summary>
public double? HistoricalAccuracy { get; init; }
/// <summary>Number of corroborating sources.</summary>
public int CorroboratingSourceCount { get; init; }
/// <summary>
/// Validates the source trust input.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (ProvenanceTrust < 0.0 || ProvenanceTrust > 1.0)
errors.Add($"ProvenanceTrust must be in range [0, 1], got {ProvenanceTrust}");
if (CoverageCompleteness < 0.0 || CoverageCompleteness > 1.0)
errors.Add($"CoverageCompleteness must be in range [0, 1], got {CoverageCompleteness}");
if (Replayability < 0.0 || Replayability > 1.0)
errors.Add($"Replayability must be in range [0, 1], got {Replayability}");
if (HistoricalAccuracy.HasValue && (HistoricalAccuracy < 0.0 || HistoricalAccuracy > 1.0))
errors.Add($"HistoricalAccuracy must be in range [0, 1], got {HistoricalAccuracy}");
if (CorroboratingSourceCount < 0)
errors.Add($"CorroboratingSourceCount must be non-negative, got {CorroboratingSourceCount}");
return errors;
}
/// <summary>
/// Calculates the combined trust vector score [0, 1].
/// </summary>
public double GetCombinedTrustScore()
{
// Weighted combination: provenance most important, then coverage, then replayability
const double wProvenance = 0.5;
const double wCoverage = 0.3;
const double wReplay = 0.2;
return wProvenance * ProvenanceTrust +
wCoverage * CoverageCompleteness +
wReplay * Replayability;
}
/// <summary>
/// Generates a human-readable explanation of the source trust.
/// </summary>
public string GetExplanation()
{
var issuerDesc = IssuerType switch
{
IssuerType.Unknown => "unknown source",
IssuerType.Community => "community source",
IssuerType.SecurityResearcher => "security researcher",
IssuerType.Distribution => "distribution maintainer",
IssuerType.Upstream => "upstream project",
IssuerType.Vendor => "software vendor",
IssuerType.Cna => "CVE Numbering Authority",
IssuerType.GovernmentAgency => "government agency",
_ => $"unknown type ({IssuerType})"
};
var parts = new List<string> { issuerDesc };
if (IsCryptographicallyAttested)
parts.Add("cryptographically attested");
if (IndependentlyVerified)
parts.Add("independently verified");
if (CorroboratingSourceCount > 0)
parts.Add($"{CorroboratingSourceCount} corroborating source(s)");
var trustScore = GetCombinedTrustScore();
var trustLevel = trustScore switch
{
>= 0.8 => "high trust",
>= 0.5 => "moderate trust",
_ => "low trust"
};
parts.Add(trustLevel);
return string.Join(", ", parts);
}
}

View File

@@ -0,0 +1,445 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
public class ReachabilityInputTests
{
[Fact]
public void Validate_WithValidInput_ReturnsNoErrors()
{
var input = CreateValidInput();
var errors = input.Validate();
errors.Should().BeEmpty();
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.5)]
public void Validate_WithInvalidConfidence_ReturnsError(double confidence)
{
var input = CreateValidInput() with { Confidence = confidence };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("Confidence"));
}
[Fact]
public void Validate_WithNegativeHopCount_ReturnsError()
{
var input = CreateValidInput() with { HopCount = -1 };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("HopCount"));
}
[Theory]
[InlineData(ReachabilityState.Unknown, "No reachability data available")]
[InlineData(ReachabilityState.NotReachable, "Confirmed not reachable")]
[InlineData(ReachabilityState.StaticReachable, "Statically reachable")]
[InlineData(ReachabilityState.DynamicReachable, "Dynamically confirmed reachable")]
[InlineData(ReachabilityState.LiveExploitPath, "Live exploit path observed")]
public void GetExplanation_ReturnsCorrectStateDescription(ReachabilityState state, string expectedFragment)
{
var input = CreateValidInput() with { State = state };
var explanation = input.GetExplanation();
explanation.Should().Contain(expectedFragment);
}
[Theory]
[InlineData(0, "direct path")]
[InlineData(1, "1 hop away")]
[InlineData(5, "5 hops away")]
public void GetExplanation_IncludesHopInfo(int hopCount, string expectedFragment)
{
var input = CreateValidInput() with { HopCount = hopCount };
var explanation = input.GetExplanation();
explanation.Should().Contain(expectedFragment);
}
[Fact]
public void GetExplanation_IncludesAnalysisFlags()
{
var input = CreateValidInput() with
{
HasInterproceduralFlow = true,
HasTaintTracking = true,
HasDataFlowSensitivity = true
};
var explanation = input.GetExplanation();
explanation.Should().Contain("interprocedural");
explanation.Should().Contain("taint-tracked");
explanation.Should().Contain("data-flow");
}
private static ReachabilityInput CreateValidInput() => new()
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 2
};
}
public class RuntimeInputTests
{
[Fact]
public void Validate_WithValidInput_ReturnsNoErrors()
{
var input = CreateValidInput();
var errors = input.Validate();
errors.Should().BeEmpty();
}
[Fact]
public void Validate_WithNegativeObservationCount_ReturnsError()
{
var input = CreateValidInput() with { ObservationCount = -1 };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("ObservationCount"));
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.5)]
public void Validate_WithInvalidRecencyFactor_ReturnsError(double recency)
{
var input = CreateValidInput() with { RecencyFactor = recency };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("RecencyFactor"));
}
[Theory]
[InlineData(RuntimePosture.None, 0, "No runtime observations")]
[InlineData(RuntimePosture.EbpfDeep, 5, "eBPF deep observation")]
[InlineData(RuntimePosture.ActiveTracing, 10, "active tracing")]
public void GetExplanation_ReturnsCorrectDescription(RuntimePosture posture, int count, string expectedFragment)
{
var input = CreateValidInput() with { Posture = posture, ObservationCount = count };
var explanation = input.GetExplanation();
explanation.Should().Contain(expectedFragment);
}
[Fact]
public void GetExplanation_IncludesProductionInfo()
{
var input = CreateValidInput() with { IsProductionTraffic = true };
var explanation = input.GetExplanation();
explanation.Should().Contain("in production");
}
[Fact]
public void GetExplanation_IncludesDirectPathInfo()
{
var input = CreateValidInput() with { DirectPathObserved = true };
var explanation = input.GetExplanation();
explanation.Should().Contain("vulnerable path directly observed");
}
private static RuntimeInput CreateValidInput() => new()
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 5,
RecencyFactor = 0.9
};
}
public class BackportInputTests
{
[Fact]
public void Validate_WithValidInput_ReturnsNoErrors()
{
var input = CreateValidInput();
var errors = input.Validate();
errors.Should().BeEmpty();
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.5)]
public void Validate_WithInvalidConfidence_ReturnsError(double confidence)
{
var input = CreateValidInput() with { Confidence = confidence };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("Confidence"));
}
[Theory]
[InlineData(BackportStatus.NotAffected, "confirmed not affected")]
[InlineData(BackportStatus.Affected, "confirmed affected")]
[InlineData(BackportStatus.Fixed, "fixed")]
public void GetExplanation_ReturnsCorrectStatusDescription(BackportStatus status, string expectedFragment)
{
var input = CreateValidInput() with { Status = status };
var explanation = input.GetExplanation();
explanation.Should().Contain(expectedFragment);
}
[Theory]
[InlineData(BackportEvidenceTier.VendorVex, "vendor VEX")]
[InlineData(BackportEvidenceTier.SignedProof, "signed proof")]
[InlineData(BackportEvidenceTier.BinaryDiff, "binary-diff")]
public void GetExplanation_ReturnsCorrectTierDescription(BackportEvidenceTier tier, string expectedFragment)
{
var input = CreateValidInput() with { EvidenceTier = tier };
var explanation = input.GetExplanation();
explanation.Should().Contain(expectedFragment);
}
[Fact]
public void GetExplanation_IncludesDistributor()
{
var input = CreateValidInput() with { Distributor = "debian-security" };
var explanation = input.GetExplanation();
explanation.Should().Contain("debian-security");
}
private static BackportInput CreateValidInput() => new()
{
EvidenceTier = BackportEvidenceTier.VendorVex,
Status = BackportStatus.NotAffected,
Confidence = 0.95
};
}
public class ExploitInputTests
{
[Fact]
public void Validate_WithValidInput_ReturnsNoErrors()
{
var input = CreateValidInput();
var errors = input.Validate();
errors.Should().BeEmpty();
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.5)]
public void Validate_WithInvalidEpssScore_ReturnsError(double score)
{
var input = CreateValidInput() with { EpssScore = score };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("EpssScore"));
}
[Theory]
[InlineData(-1.0)]
[InlineData(101.0)]
public void Validate_WithInvalidEpssPercentile_ReturnsError(double percentile)
{
var input = CreateValidInput() with { EpssPercentile = percentile };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("EpssPercentile"));
}
[Theory]
[InlineData(0.8, "Very high EPSS")]
[InlineData(0.5, "High EPSS")]
[InlineData(0.15, "Moderate EPSS")]
[InlineData(0.05, "Low EPSS")]
public void GetExplanation_ReturnsCorrectEpssDescription(double score, string expectedFragment)
{
var input = CreateValidInput() with { EpssScore = score };
var explanation = input.GetExplanation();
explanation.Should().Contain(expectedFragment);
}
[Fact]
public void GetExplanation_IncludesKevStatus()
{
var input = CreateValidInput() with
{
KevStatus = KevStatus.InKev,
KevAddedDate = DateTimeOffset.Parse("2024-01-15T00:00:00Z")
};
var explanation = input.GetExplanation();
explanation.Should().Contain("in KEV catalog");
explanation.Should().Contain("2024-01-15");
}
[Fact]
public void GetExplanation_IncludesPublicExploit()
{
var input = CreateValidInput() with
{
PublicExploitAvailable = true,
ExploitMaturity = "weaponized"
};
var explanation = input.GetExplanation();
explanation.Should().Contain("public exploit");
explanation.Should().Contain("weaponized");
}
private static ExploitInput CreateValidInput() => new()
{
EpssScore = 0.3,
EpssPercentile = 85.0,
KevStatus = KevStatus.NotInKev
};
}
public class SourceTrustInputTests
{
[Fact]
public void Validate_WithValidInput_ReturnsNoErrors()
{
var input = CreateValidInput();
var errors = input.Validate();
errors.Should().BeEmpty();
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.5)]
public void Validate_WithInvalidTrustFactors_ReturnsErrors(double value)
{
var input = CreateValidInput() with
{
ProvenanceTrust = value,
CoverageCompleteness = value,
Replayability = value
};
var errors = input.Validate();
errors.Should().HaveCount(3);
}
[Theory]
[InlineData(IssuerType.Vendor, "software vendor")]
[InlineData(IssuerType.Distribution, "distribution maintainer")]
[InlineData(IssuerType.GovernmentAgency, "government agency")]
public void GetExplanation_ReturnsCorrectIssuerDescription(IssuerType issuer, string expectedFragment)
{
var input = CreateValidInput() with { IssuerType = issuer };
var explanation = input.GetExplanation();
explanation.Should().Contain(expectedFragment);
}
[Fact]
public void GetCombinedTrustScore_CalculatesWeightedAverage()
{
var input = new SourceTrustInput
{
IssuerType = IssuerType.Vendor,
ProvenanceTrust = 1.0,
CoverageCompleteness = 1.0,
Replayability = 1.0
};
var score = input.GetCombinedTrustScore();
score.Should().Be(1.0); // All weights sum to 1
}
[Fact]
public void GetExplanation_IncludesAttestationInfo()
{
var input = CreateValidInput() with
{
IsCryptographicallyAttested = true,
IndependentlyVerified = true,
CorroboratingSourceCount = 3
};
var explanation = input.GetExplanation();
explanation.Should().Contain("cryptographically attested");
explanation.Should().Contain("independently verified");
explanation.Should().Contain("3 corroborating");
}
private static SourceTrustInput CreateValidInput() => new()
{
IssuerType = IssuerType.Vendor,
ProvenanceTrust = 0.9,
CoverageCompleteness = 0.8,
Replayability = 0.7
};
}
public class MitigationInputTests
{
[Fact]
public void Validate_WithValidInput_ReturnsNoErrors()
{
var input = CreateValidInput();
var errors = input.Validate();
errors.Should().BeEmpty();
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.5)]
public void Validate_WithInvalidCombinedEffectiveness_ReturnsError(double value)
{
var input = CreateValidInput() with { CombinedEffectiveness = value };
var errors = input.Validate();
errors.Should().ContainSingle(e => e.Contains("CombinedEffectiveness"));
}
[Fact]
public void CalculateCombinedEffectiveness_WithNoMitigations_ReturnsZero()
{
var effectiveness = MitigationInput.CalculateCombinedEffectiveness([]);
effectiveness.Should().Be(0.0);
}
[Fact]
public void CalculateCombinedEffectiveness_WithSingleMitigation_ReturnsMitigationEffectiveness()
{
var mitigations = new[]
{
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.8 }
};
var effectiveness = MitigationInput.CalculateCombinedEffectiveness(mitigations);
effectiveness.Should().BeApproximately(0.8, 0.001);
}
[Fact]
public void CalculateCombinedEffectiveness_WithMultipleMitigations_UsesDiminishingReturns()
{
var mitigations = new[]
{
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.5 },
new ActiveMitigation { Type = MitigationType.NetworkControl, Effectiveness = 0.5 }
};
// Combined = 1 - (1-0.5)(1-0.5) = 1 - 0.25 = 0.75
var effectiveness = MitigationInput.CalculateCombinedEffectiveness(mitigations);
effectiveness.Should().BeApproximately(0.75, 0.001);
}
[Fact]
public void GetExplanation_WithNoMitigations_ReturnsNoneMessage()
{
var input = new MitigationInput
{
ActiveMitigations = [],
CombinedEffectiveness = 0.0
};
var explanation = input.GetExplanation();
explanation.Should().Contain("No active mitigations");
}
[Fact]
public void GetExplanation_IncludesMitigationSummary()
{
var input = CreateValidInput();
var explanation = input.GetExplanation();
explanation.Should().Contain("2 active mitigation(s)");
explanation.Should().Contain("feature flag");
}
private static MitigationInput CreateValidInput() => new()
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Name = "disable-feature-x", Effectiveness = 0.7, Verified = true },
new ActiveMitigation { Type = MitigationType.NetworkControl, Name = "waf-rule-123", Effectiveness = 0.5 }
],
CombinedEffectiveness = 0.85,
RuntimeVerified = true
};
}

View File

@@ -0,0 +1,345 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
public class EvidenceWeightPolicyTests
{
[Fact]
public void DefaultProduction_HasValidDefaults()
{
var policy = EvidenceWeightPolicy.DefaultProduction;
policy.Version.Should().Be("ews.v1");
policy.Profile.Should().Be("production");
policy.Weights.Should().NotBeNull();
policy.Validate().Should().BeEmpty();
}
[Fact]
public void Validate_WithValidPolicy_ReturnsNoErrors()
{
var policy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "test",
Weights = EvidenceWeights.Default
};
var errors = policy.Validate();
errors.Should().BeEmpty();
}
[Fact]
public void Validate_WithMissingVersion_ReturnsError()
{
var policy = new EvidenceWeightPolicy
{
Version = "",
Profile = "test",
Weights = EvidenceWeights.Default
};
var errors = policy.Validate();
errors.Should().ContainSingle(e => e.Contains("Version"));
}
[Fact]
public void Validate_WithMissingProfile_ReturnsError()
{
var policy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "",
Weights = EvidenceWeights.Default
};
var errors = policy.Validate();
errors.Should().ContainSingle(e => e.Contains("Profile"));
}
[Fact]
public void Validate_WithInvalidBucketOrdering_ReturnsError()
{
var policy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "test",
Weights = EvidenceWeights.Default,
Buckets = new BucketThresholds
{
ActNowMin = 50,
ScheduleNextMin = 70, // Invalid: should be less than ActNowMin
InvestigateMin = 40
}
};
var errors = policy.Validate();
errors.Should().Contain(e => e.Contains("ActNowMin") && e.Contains("ScheduleNextMin"));
}
[Fact]
public void ComputeDigest_IsDeterministic()
{
var policy1 = EvidenceWeightPolicy.DefaultProduction;
var policy2 = EvidenceWeightPolicy.DefaultProduction;
var digest1 = policy1.ComputeDigest();
var digest2 = policy2.ComputeDigest();
digest1.Should().Be(digest2);
}
[Fact]
public void ComputeDigest_IsCached()
{
var policy = EvidenceWeightPolicy.DefaultProduction;
var digest1 = policy.ComputeDigest();
var digest2 = policy.ComputeDigest();
digest1.Should().BeSameAs(digest2);
}
[Fact]
public void ComputeDigest_DiffersForDifferentWeights()
{
var policy1 = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "test",
Weights = new EvidenceWeights { Rch = 0.5, Rts = 0.2, Bkp = 0.1, Xpl = 0.1, Src = 0.05, Mit = 0.05 }
};
var policy2 = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "test",
Weights = new EvidenceWeights { Rch = 0.3, Rts = 0.3, Bkp = 0.15, Xpl = 0.15, Src = 0.05, Mit = 0.05 }
};
policy1.ComputeDigest().Should().NotBe(policy2.ComputeDigest());
}
[Fact]
public void GetCanonicalJson_IsValid()
{
var policy = EvidenceWeightPolicy.DefaultProduction;
var json = policy.GetCanonicalJson();
json.Should().NotBeNullOrEmpty();
json.Should().Contain("\"version\"");
json.Should().Contain("\"weights\"");
json.Should().Contain("\"guardrails\"");
}
}
public class EvidenceWeightsTests
{
[Fact]
public void Default_HasCorrectValues()
{
var weights = EvidenceWeights.Default;
weights.Rch.Should().Be(0.30);
weights.Rts.Should().Be(0.25);
weights.Bkp.Should().Be(0.15);
weights.Xpl.Should().Be(0.15);
weights.Src.Should().Be(0.10);
weights.Mit.Should().Be(0.10);
}
[Fact]
public void Default_AdditiveSumIsOne()
{
var weights = EvidenceWeights.Default;
// Sum of additive weights (excludes MIT)
weights.AdditiveSum.Should().BeApproximately(0.95, 0.001);
}
[Fact]
public void Normalize_SumsAdditiveToOne()
{
var weights = new EvidenceWeights
{
Rch = 0.5,
Rts = 0.3,
Bkp = 0.2,
Xpl = 0.1,
Src = 0.1,
Mit = 0.1
};
var normalized = weights.Normalize();
normalized.AdditiveSum.Should().BeApproximately(1.0, 0.001);
}
[Fact]
public void Normalize_PreservesMitWeight()
{
var weights = new EvidenceWeights
{
Rch = 0.5,
Rts = 0.3,
Bkp = 0.2,
Xpl = 0.1,
Src = 0.1,
Mit = 0.15
};
var normalized = weights.Normalize();
normalized.Mit.Should().Be(0.15);
}
[Fact]
public void Validate_WithValidWeights_ReturnsNoErrors()
{
var weights = EvidenceWeights.Default;
var errors = weights.Validate();
errors.Should().BeEmpty();
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.5)]
[InlineData(double.NaN)]
public void Validate_WithInvalidWeight_ReturnsError(double value)
{
var weights = EvidenceWeights.Default with { Rch = value };
var errors = weights.Validate();
errors.Should().NotBeEmpty();
}
}
public class InMemoryEvidenceWeightPolicyProviderTests
{
[Fact]
public async Task GetPolicyAsync_WithNoStoredPolicy_ReturnsDefault()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
var policy = await provider.GetPolicyAsync(null, "production");
policy.Should().NotBeNull();
policy.Profile.Should().Be("production");
}
[Fact]
public async Task GetPolicyAsync_WithStoredPolicy_ReturnsStored()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
var customPolicy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "production",
Weights = new EvidenceWeights { Rch = 0.5, Rts = 0.2, Bkp = 0.1, Xpl = 0.1, Src = 0.05, Mit = 0.05 }
};
provider.SetPolicy(customPolicy);
var policy = await provider.GetPolicyAsync(null, "production");
policy.Weights.Rch.Should().Be(0.5);
}
[Fact]
public async Task GetPolicyAsync_WithTenantPolicy_ReturnsTenantSpecific()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
var tenantPolicy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "production",
TenantId = "tenant-123",
Weights = new EvidenceWeights { Rch = 0.6, Rts = 0.2, Bkp = 0.1, Xpl = 0.05, Src = 0.025, Mit = 0.025 }
};
provider.SetPolicy(tenantPolicy);
var policy = await provider.GetPolicyAsync("tenant-123", "production");
policy.Weights.Rch.Should().Be(0.6);
}
[Fact]
public async Task GetPolicyAsync_WithTenantFallsBackToGlobal()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
var globalPolicy = new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "production",
Weights = new EvidenceWeights { Rch = 0.4, Rts = 0.3, Bkp = 0.1, Xpl = 0.1, Src = 0.05, Mit = 0.05 }
};
provider.SetPolicy(globalPolicy);
var policy = await provider.GetPolicyAsync("unknown-tenant", "production");
policy.Weights.Rch.Should().Be(0.4);
}
[Fact]
public async Task PolicyExistsAsync_WithStoredPolicy_ReturnsTrue()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
provider.SetPolicy(EvidenceWeightPolicy.DefaultProduction);
var exists = await provider.PolicyExistsAsync(null, "production");
exists.Should().BeTrue();
}
[Fact]
public async Task PolicyExistsAsync_WithNoPolicy_ReturnsFalse()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
var exists = await provider.PolicyExistsAsync("tenant-xyz", "staging");
exists.Should().BeFalse();
}
[Fact]
public void RemovePolicy_RemovesStoredPolicy()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
provider.SetPolicy(EvidenceWeightPolicy.DefaultProduction);
var removed = provider.RemovePolicy(null, "production");
removed.Should().BeTrue();
}
[Fact]
public void Clear_RemovesAllPolicies()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
provider.SetPolicy(new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "production",
Weights = EvidenceWeights.Default
});
provider.SetPolicy(new EvidenceWeightPolicy
{
Version = "ews.v1",
Profile = "development",
Weights = EvidenceWeights.Default
});
provider.Clear();
provider.PolicyExistsAsync(null, "production").Result.Should().BeFalse();
provider.PolicyExistsAsync(null, "development").Result.Should().BeFalse();
}
}

View File

@@ -0,0 +1,358 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
public class EvidenceWeightedScoreCalculatorTests
{
private readonly EvidenceWeightedScoreCalculator _calculator = new();
private readonly EvidenceWeightPolicy _defaultPolicy = EvidenceWeightPolicy.DefaultProduction;
[Fact]
public void Calculate_WithAllZeros_ReturnsZeroScore()
{
var input = CreateInput(0, 0, 0, 0, 0, 0);
var result = _calculator.Calculate(input, _defaultPolicy);
result.Score.Should().Be(0);
result.Bucket.Should().Be(ScoreBucket.Watchlist);
}
[Fact]
public void Calculate_WithAllOnes_ReturnsNearMaxScore()
{
var input = CreateInput(1, 1, 1, 1, 1, 0); // MIT=0 to get max
var result = _calculator.Calculate(input, _defaultPolicy);
// Without MIT, sum of weights = 0.95 (default) → 95%
result.Score.Should().BeGreaterOrEqualTo(90);
result.Bucket.Should().Be(ScoreBucket.ActNow);
}
[Fact]
public void Calculate_WithHighMit_ReducesScore()
{
var inputNoMit = CreateInput(0.8, 0.8, 0.5, 0.5, 0.5, 0);
var inputWithMit = CreateInput(0.8, 0.8, 0.5, 0.5, 0.5, 1.0);
var resultNoMit = _calculator.Calculate(inputNoMit, _defaultPolicy);
var resultWithMit = _calculator.Calculate(inputWithMit, _defaultPolicy);
resultWithMit.Score.Should().BeLessThan(resultNoMit.Score);
}
[Fact]
public void Calculate_ReturnsCorrectFindingId()
{
var input = CreateInput(0.5, 0.5, 0.5, 0.5, 0.5, 0.1, "CVE-2024-1234@pkg:npm/test@1.0.0");
var result = _calculator.Calculate(input, _defaultPolicy);
result.FindingId.Should().Be("CVE-2024-1234@pkg:npm/test@1.0.0");
}
[Fact]
public void Calculate_ReturnsCorrectInputsEcho()
{
var input = CreateInput(0.7, 0.6, 0.5, 0.4, 0.3, 0.2);
var result = _calculator.Calculate(input, _defaultPolicy);
result.Inputs.Rch.Should().Be(0.7);
result.Inputs.Rts.Should().Be(0.6);
result.Inputs.Bkp.Should().Be(0.5);
result.Inputs.Xpl.Should().Be(0.4);
result.Inputs.Src.Should().Be(0.3);
result.Inputs.Mit.Should().Be(0.2);
}
[Fact]
public void Calculate_ReturnsBreakdown()
{
var input = CreateInput(0.8, 0.6, 0.4, 0.3, 0.2, 0.1);
var result = _calculator.Calculate(input, _defaultPolicy);
result.Breakdown.Should().HaveCount(6);
result.Breakdown.Should().Contain(d => d.Symbol == "RCH");
result.Breakdown.Should().Contain(d => d.Symbol == "MIT" && d.IsSubtractive);
}
[Fact]
public void Calculate_ReturnsFlags()
{
var input = CreateInput(0.8, 0.7, 0.5, 0.6, 0.5, 0.1);
var result = _calculator.Calculate(input, _defaultPolicy);
result.Flags.Should().Contain("live-signal"); // RTS >= 0.6
result.Flags.Should().Contain("proven-path"); // RCH >= 0.7 && RTS >= 0.5
result.Flags.Should().Contain("high-epss"); // XPL >= 0.5
}
[Fact]
public void Calculate_ReturnsExplanations()
{
var input = CreateInput(0.9, 0.8, 0.5, 0.5, 0.5, 0.1);
var result = _calculator.Calculate(input, _defaultPolicy);
result.Explanations.Should().NotBeEmpty();
result.Explanations.Should().Contain(e => e.Contains("Reachability"));
}
[Fact]
public void Calculate_ReturnsPolicyDigest()
{
var input = CreateInput(0.5, 0.5, 0.5, 0.5, 0.5, 0.1);
var result = _calculator.Calculate(input, _defaultPolicy);
result.PolicyDigest.Should().NotBeNullOrEmpty();
result.PolicyDigest.Should().Be(_defaultPolicy.ComputeDigest());
}
[Fact]
public void Calculate_ReturnsTimestamp()
{
var input = CreateInput(0.5, 0.5, 0.5, 0.5, 0.5, 0.1);
var before = DateTimeOffset.UtcNow;
var result = _calculator.Calculate(input, _defaultPolicy);
result.CalculatedAt.Should().BeOnOrAfter(before);
}
[Fact]
public void Calculate_ClampsOutOfRangeInputs()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = 1.5, // Out of range
Rts = -0.3, // Out of range
Bkp = 0.5,
Xpl = 0.5,
Src = 0.5,
Mit = 0.1
};
var result = _calculator.Calculate(input, _defaultPolicy);
result.Inputs.Rch.Should().Be(1.0);
result.Inputs.Rts.Should().Be(0.0);
}
[Theory]
[InlineData(0, ScoreBucket.Watchlist)]
[InlineData(39, ScoreBucket.Watchlist)]
[InlineData(40, ScoreBucket.Investigate)]
[InlineData(69, ScoreBucket.Investigate)]
[InlineData(70, ScoreBucket.ScheduleNext)]
[InlineData(89, ScoreBucket.ScheduleNext)]
[InlineData(90, ScoreBucket.ActNow)]
[InlineData(100, ScoreBucket.ActNow)]
public void GetBucket_ReturnsCorrectBucket(int score, ScoreBucket expected)
{
var bucket = EvidenceWeightedScoreCalculator.GetBucket(score, BucketThresholds.Default);
bucket.Should().Be(expected);
}
// Guardrail Tests
[Fact]
public void Calculate_SpeculativeCapApplied_WhenNoReachabilityOrRuntime()
{
// Use high values for other dimensions to get a score > 45, but Rch=0 and Rts=0
// to trigger the speculative cap. We use a custom policy with very low Rch/Rts weight
// so other dimensions drive the score high enough to cap.
var policyWithLowRchRtsWeight = new EvidenceWeightPolicy
{
Profile = "test-speculative",
Version = "ews.v1",
Weights = new EvidenceWeights
{
Rch = 0.05, // Very low weight
Rts = 0.05, // Very low weight
Bkp = 0.30, // High weight
Xpl = 0.30, // High weight
Src = 0.20, // High weight
Mit = 0.05
}
};
// With Rch=0, Rts=0 but Bkp=1.0, Xpl=1.0, Src=1.0:
// Score = 0*0.05 + 0*0.05 + 1*0.30 + 1*0.30 + 1*0.20 - 0*0.05 = 0.80 * 100 = 80
// This should be capped to 45
var input = CreateInput(0, 0, 1.0, 1.0, 1.0, 0);
var result = _calculator.Calculate(input, policyWithLowRchRtsWeight);
result.Score.Should().Be(45);
result.Caps.SpeculativeCap.Should().BeTrue();
result.Flags.Should().Contain("speculative");
}
[Fact]
public void Calculate_NotAffectedCapApplied_WhenVendorSaysNotAffected()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = 0.8,
Rts = 0.3, // Below 0.6
Bkp = 1.0, // Vendor backport proof
Xpl = 0.5,
Src = 0.8,
Mit = 0,
VexStatus = "not_affected"
};
var result = _calculator.Calculate(input, _defaultPolicy);
result.Score.Should().BeLessOrEqualTo(15);
result.Caps.NotAffectedCap.Should().BeTrue();
result.Flags.Should().Contain("vendor-na");
}
[Fact]
public void Calculate_RuntimeFloorApplied_WhenStrongLiveSignal()
{
var input = CreateInput(0.1, 0.9, 0.1, 0.1, 0.1, 0.1);
var result = _calculator.Calculate(input, _defaultPolicy);
result.Score.Should().BeGreaterOrEqualTo(60);
result.Caps.RuntimeFloor.Should().BeTrue();
}
[Fact]
public void Calculate_GuardrailsAppliedInOrder_CapsBeforeFloors()
{
// Scenario: speculative cap should apply first, but runtime floor would override
var input = CreateInput(0, 0.85, 0.5, 0.5, 0.5, 0);
var result = _calculator.Calculate(input, _defaultPolicy);
// Since RTS >= 0.8, runtime floor should apply (floor at 60)
result.Score.Should().BeGreaterOrEqualTo(60);
result.Caps.RuntimeFloor.Should().BeTrue();
// Speculative cap shouldn't apply because RTS > 0
result.Caps.SpeculativeCap.Should().BeFalse();
}
[Fact]
public void Calculate_NoGuardrailsApplied_WhenNotTriggered()
{
var input = CreateInput(0.5, 0.5, 0.5, 0.5, 0.5, 0.1);
var result = _calculator.Calculate(input, _defaultPolicy);
result.Caps.AnyApplied.Should().BeFalse();
result.Caps.OriginalScore.Should().Be(result.Caps.AdjustedScore);
}
// Determinism Tests
[Fact]
public void Calculate_IsDeterministic_SameInputsSameResult()
{
var input = CreateInput(0.7, 0.6, 0.5, 0.4, 0.3, 0.2);
var result1 = _calculator.Calculate(input, _defaultPolicy);
var result2 = _calculator.Calculate(input, _defaultPolicy);
result1.Score.Should().Be(result2.Score);
result1.PolicyDigest.Should().Be(result2.PolicyDigest);
}
[Fact]
public void Calculate_IsDeterministic_WithDifferentCalculatorInstances()
{
var calc1 = new EvidenceWeightedScoreCalculator();
var calc2 = new EvidenceWeightedScoreCalculator();
var input = CreateInput(0.7, 0.6, 0.5, 0.4, 0.3, 0.2);
var result1 = calc1.Calculate(input, _defaultPolicy);
var result2 = calc2.Calculate(input, _defaultPolicy);
result1.Score.Should().Be(result2.Score);
}
// Edge Cases
[Fact]
public void Calculate_HandlesNullDetailInputs()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = 0.5,
Rts = 0.5,
Bkp = 0.5,
Xpl = 0.5,
Src = 0.5,
Mit = 0.1,
ReachabilityDetails = null,
RuntimeDetails = null,
BackportDetails = null,
ExploitDetails = null,
SourceTrustDetails = null,
MitigationDetails = null
};
var result = _calculator.Calculate(input, _defaultPolicy);
result.Should().NotBeNull();
result.Score.Should().BeGreaterOrEqualTo(0);
}
[Fact]
public void Calculate_WithDetailedInputs_IncludesThemInExplanations()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = 0.8,
Rts = 0.7,
Bkp = 0.5,
Xpl = 0.5,
Src = 0.5,
Mit = 0.1,
ReachabilityDetails = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 2
}
};
var result = _calculator.Calculate(input, _defaultPolicy);
result.Explanations.Should().Contain(e => e.Contains("Statically reachable"));
}
// Helper
private static EvidenceWeightedScoreInput CreateInput(
double rch, double rts, double bkp, double xpl, double src, double mit, string findingId = "test")
{
return new EvidenceWeightedScoreInput
{
FindingId = findingId,
Rch = rch,
Rts = rts,
Bkp = bkp,
Xpl = xpl,
Src = src,
Mit = mit
};
}
}

View File

@@ -0,0 +1,179 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
public class EvidenceWeightedScoreInputTests
{
[Fact]
public void Validate_WithValidInput_ReturnsNoErrors()
{
// Arrange
var input = CreateValidInput();
// Act
var errors = input.Validate();
// Assert
errors.Should().BeEmpty();
}
[Theory]
[InlineData(-0.1, "Rch")]
[InlineData(1.1, "Rch")]
[InlineData(double.NaN, "Rch")]
[InlineData(double.PositiveInfinity, "Rch")]
[InlineData(double.NegativeInfinity, "Rch")]
public void Validate_WithInvalidRch_ReturnsError(double value, string dimension)
{
// Arrange
var input = CreateValidInput() with { Rch = value };
// Act
var errors = input.Validate();
// Assert
errors.Should().ContainSingle(e => e.Contains(dimension));
}
[Theory]
[InlineData(-0.6)] // 0.5 + -0.6 = -0.1 (invalid)
[InlineData(0.6)] // 0.5 + 0.6 = 1.1 (invalid)
public void Validate_WithInvalidDimensions_ReturnsMultipleErrors(double offset)
{
// Arrange
var input = CreateValidInput() with
{
Rch = 0.5 + offset,
Rts = 0.5 + offset,
Bkp = 0.5 + offset
};
// Act
var errors = input.Validate();
// Assert
errors.Should().HaveCount(3);
}
[Fact]
public void Validate_WithEmptyFindingId_ReturnsError()
{
// Arrange
var input = CreateValidInput() with { FindingId = "" };
// Act
var errors = input.Validate();
// Assert
errors.Should().ContainSingle(e => e.Contains("FindingId"));
}
[Fact]
public void Clamp_WithOutOfRangeValues_ReturnsClampedInput()
{
// Arrange
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-1234@pkg:npm/test@1.0.0",
Rch = 1.5,
Rts = -0.3,
Bkp = 0.5,
Xpl = double.PositiveInfinity,
Src = double.NaN,
Mit = 2.0
};
// Act
var clamped = input.Clamp();
// Assert
clamped.Rch.Should().Be(1.0);
clamped.Rts.Should().Be(0.0);
clamped.Bkp.Should().Be(0.5);
clamped.Xpl.Should().Be(1.0);
clamped.Src.Should().Be(0.0);
clamped.Mit.Should().Be(1.0);
}
[Fact]
public void Clamp_PreservesValidValues()
{
// Arrange
var input = CreateValidInput();
// Act
var clamped = input.Clamp();
// Assert
clamped.Should().BeEquivalentTo(input);
}
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
[InlineData(1.0)]
public void Validate_WithBoundaryValues_ReturnsNoErrors(double value)
{
// Arrange
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-1234@pkg:npm/test@1.0.0",
Rch = value,
Rts = value,
Bkp = value,
Xpl = value,
Src = value,
Mit = value
};
// Act
var errors = input.Validate();
// Assert
errors.Should().BeEmpty();
}
[Fact]
public void Input_WithDetailedInputs_PreservesAllProperties()
{
// Arrange
var input = CreateValidInput() with
{
VexStatus = "not_affected",
ReachabilityDetails = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8
},
RuntimeDetails = new RuntimeInput
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 10,
RecencyFactor = 0.9
}
};
// Assert
input.VexStatus.Should().Be("not_affected");
input.ReachabilityDetails.Should().NotBeNull();
input.ReachabilityDetails!.State.Should().Be(ReachabilityState.StaticReachable);
input.RuntimeDetails.Should().NotBeNull();
input.RuntimeDetails!.Posture.Should().Be(RuntimePosture.EbpfDeep);
}
private static EvidenceWeightedScoreInput CreateValidInput() => new()
{
FindingId = "CVE-2024-1234@pkg:npm/test@1.0.0",
Rch = 0.7,
Rts = 0.5,
Bkp = 0.3,
Xpl = 0.4,
Src = 0.6,
Mit = 0.2
};
}

View File

@@ -0,0 +1,290 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
/// <summary>
/// Property-style tests for score calculation invariants using exhaustive sampling.
/// Uses deterministic sample sets rather than random generation for reproducibility.
/// </summary>
public class EvidenceWeightedScorePropertyTests
{
private static readonly EvidenceWeightedScoreCalculator Calculator = new();
private static readonly EvidenceWeightPolicy Policy = EvidenceWeightPolicy.DefaultProduction;
// Sample grid values for exhaustive testing
private static readonly double[] SampleValues = [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0];
public static IEnumerable<object[]> GetBoundaryTestCases()
{
foreach (var rch in SampleValues)
foreach (var xpl in SampleValues)
foreach (var mit in new[] { 0.0, 0.5, 1.0 })
{
yield return [rch, 0.5, 0.5, xpl, 0.5, mit];
}
}
public static IEnumerable<object[]> GetDeterminismTestCases()
{
yield return [0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
yield return [1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
yield return [0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
yield return [0.33, 0.66, 0.25, 0.75, 0.1, 0.9];
yield return [0.123, 0.456, 0.789, 0.012, 0.345, 0.678];
}
public static IEnumerable<object[]> GetMonotonicityTestCases()
{
// Pairs where (base, increment) for increasing input tests
foreach (var baseVal in new[] { 0.1, 0.3, 0.5, 0.7 })
foreach (var increment in new[] { 0.05, 0.1, 0.2 })
{
if (baseVal + increment <= 1.0)
{
yield return [baseVal, increment];
}
}
}
public static IEnumerable<object[]> GetMitigationMonotonicityTestCases()
{
foreach (var mit1 in new[] { 0.0, 0.2, 0.4 })
foreach (var mit2 in new[] { 0.5, 0.7, 0.9 })
{
if (mit1 < mit2)
{
yield return [mit1, mit2];
}
}
}
[Theory]
[MemberData(nameof(GetBoundaryTestCases))]
public void Score_IsAlwaysBetween0And100(double rch, double rts, double bkp, double xpl, double src, double mit)
{
var input = CreateInput(rch, rts, bkp, xpl, src, mit);
var result = Calculator.Calculate(input, Policy);
result.Score.Should().BeGreaterThanOrEqualTo(0);
result.Score.Should().BeLessThanOrEqualTo(100);
}
[Theory]
[MemberData(nameof(GetBoundaryTestCases))]
public void GuardrailsNeverProduceScoreOutsideBounds(double rch, double rts, double bkp, double xpl, double src, double mit)
{
var input = CreateInput(rch, rts, bkp, xpl, src, mit);
var result = Calculator.Calculate(input, Policy);
result.Caps.AdjustedScore.Should().BeGreaterThanOrEqualTo(0);
result.Caps.AdjustedScore.Should().BeLessThanOrEqualTo(100);
}
[Theory]
[MemberData(nameof(GetDeterminismTestCases))]
public void DeterminismProperty_SameInputsSameScore(double rch, double rts, double bkp, double xpl, double src, double mit)
{
var input1 = CreateInput(rch, rts, bkp, xpl, src, mit);
var input2 = CreateInput(rch, rts, bkp, xpl, src, mit);
var result1 = Calculator.Calculate(input1, Policy);
var result2 = Calculator.Calculate(input2, Policy);
result1.Score.Should().Be(result2.Score);
result1.PolicyDigest.Should().Be(result2.PolicyDigest);
}
[Fact]
public void DeterminismProperty_MultipleCalculationsProduceSameResult()
{
var input = CreateInput(0.7, 0.6, 0.5, 0.4, 0.3, 0.2);
var results = Enumerable.Range(0, 100)
.Select(_ => Calculator.Calculate(input, Policy))
.ToList();
var firstScore = results[0].Score;
results.Should().AllSatisfy(r => r.Score.Should().Be(firstScore));
}
[Theory]
[MemberData(nameof(GetMonotonicityTestCases))]
public void IncreasingInputs_IncreaseOrMaintainScore_WhenNoGuardrails(double baseValue, double increment)
{
// Use mid-range values that won't trigger guardrails
var input1 = CreateInput(baseValue, 0.5, 0.3, 0.3, 0.3, 0.1);
var input2 = CreateInput(baseValue + increment, 0.5, 0.3, 0.3, 0.3, 0.1);
var result1 = Calculator.Calculate(input1, Policy);
var result2 = Calculator.Calculate(input2, Policy);
// If no guardrails triggered on either, higher input should give >= score
if (!result1.Caps.AnyApplied && !result2.Caps.AnyApplied)
{
result2.Score.Should().BeGreaterThanOrEqualTo(result1.Score,
"increasing reachability input should increase or maintain score when no guardrails apply");
}
}
[Theory]
[MemberData(nameof(GetMitigationMonotonicityTestCases))]
public void IncreasingMit_DecreasesOrMaintainsScore(double mitLow, double mitHigh)
{
var inputLowMit = CreateInput(0.5, 0.5, 0.5, 0.5, 0.5, mitLow);
var inputHighMit = CreateInput(0.5, 0.5, 0.5, 0.5, 0.5, mitHigh);
var resultLowMit = Calculator.Calculate(inputLowMit, Policy);
var resultHighMit = Calculator.Calculate(inputHighMit, Policy);
resultHighMit.Score.Should().BeLessThanOrEqualTo(resultLowMit.Score,
"higher mitigation should result in lower or equal score");
}
[Theory]
[MemberData(nameof(GetBoundaryTestCases))]
public void BucketMatchesScore(double rch, double rts, double bkp, double xpl, double src, double mit)
{
var input = CreateInput(rch, rts, bkp, xpl, src, mit);
var result = Calculator.Calculate(input, Policy);
var expectedBucket = result.Score switch
{
>= 90 => ScoreBucket.ActNow,
>= 70 => ScoreBucket.ScheduleNext,
>= 40 => ScoreBucket.Investigate,
_ => ScoreBucket.Watchlist
};
result.Bucket.Should().Be(expectedBucket);
}
[Theory]
[MemberData(nameof(GetDeterminismTestCases))]
public void BreakdownHasCorrectDimensions(double rch, double rts, double bkp, double xpl, double src, double mit)
{
var input = CreateInput(rch, rts, bkp, xpl, src, mit);
var result = Calculator.Calculate(input, Policy);
result.Breakdown.Should().HaveCount(6);
result.Breakdown.Should().Contain(d => d.Symbol == "RCH");
result.Breakdown.Should().Contain(d => d.Symbol == "RTS");
result.Breakdown.Should().Contain(d => d.Symbol == "BKP");
result.Breakdown.Should().Contain(d => d.Symbol == "XPL");
result.Breakdown.Should().Contain(d => d.Symbol == "SRC");
result.Breakdown.Should().Contain(d => d.Symbol == "MIT" && d.IsSubtractive);
}
[Theory]
[MemberData(nameof(GetDeterminismTestCases))]
public void BreakdownContributionsSumApproximately(double rch, double rts, double bkp, double xpl, double src, double mit)
{
var input = CreateInput(rch, rts, bkp, xpl, src, mit);
var result = Calculator.Calculate(input, Policy);
var positiveSum = result.Breakdown
.Where(d => !d.IsSubtractive)
.Sum(d => d.Contribution);
var negativeSum = result.Breakdown
.Where(d => d.IsSubtractive)
.Sum(d => d.Contribution);
var netSum = positiveSum - negativeSum;
// Each contribution should be in valid range
foreach (var contrib in result.Breakdown)
{
contrib.Contribution.Should().BeGreaterThanOrEqualTo(0);
contrib.Contribution.Should().BeLessThanOrEqualTo(contrib.Weight * 1.01); // Allow small float tolerance
}
// Net should be non-negative and produce the score (approximately)
netSum.Should().BeGreaterThanOrEqualTo(0);
// The score should be approximately 100 * netSum (before guardrails)
var expectedRawScore = (int)Math.Round(netSum * 100);
result.Caps.OriginalScore.Should().BeCloseTo(expectedRawScore, 2);
}
[Fact]
public void AllZeroInputs_ProducesZeroScore()
{
var input = CreateInput(0, 0, 0, 0, 0, 0);
var result = Calculator.Calculate(input, Policy);
result.Score.Should().Be(0);
result.Bucket.Should().Be(ScoreBucket.Watchlist);
}
[Fact]
public void AllMaxInputs_WithZeroMitigation_ProducesHighScore()
{
var input = CreateInput(1.0, 1.0, 1.0, 1.0, 1.0, 0.0);
var result = Calculator.Calculate(input, Policy);
result.Score.Should().BeGreaterThan(80, "max positive inputs with no mitigation should produce high score");
}
[Fact]
public void MaxMitigation_SignificantlyReducesScore()
{
var inputNoMit = CreateInput(0.8, 0.8, 0.8, 0.8, 0.8, 0.0);
var inputMaxMit = CreateInput(0.8, 0.8, 0.8, 0.8, 0.8, 1.0);
var resultNoMit = Calculator.Calculate(inputNoMit, Policy);
var resultMaxMit = Calculator.Calculate(inputMaxMit, Policy);
var reduction = resultNoMit.Score - resultMaxMit.Score;
reduction.Should().BeGreaterThan(5, "max mitigation should significantly reduce score");
}
[Fact]
public void PolicyDigest_IsConsistentAcrossCalculations()
{
var input = CreateInput(0.5, 0.5, 0.5, 0.5, 0.5, 0.5);
var result1 = Calculator.Calculate(input, Policy);
var result2 = Calculator.Calculate(input, Policy);
result1.PolicyDigest.Should().Be(result2.PolicyDigest);
result1.PolicyDigest.Should().Be(Policy.ComputeDigest());
}
[Fact]
public void DifferentPolicies_ProduceDifferentDigests()
{
var policy2 = new EvidenceWeightPolicy
{
Profile = "different-policy",
Version = "ews.v2",
Weights = new EvidenceWeights
{
Rch = 0.40, // Different from default 0.30
Rts = 0.25,
Bkp = 0.15,
Xpl = 0.10, // Different from default 0.15
Src = 0.05, // Different from default 0.10
Mit = 0.05 // Different from default 0.10
}
};
Policy.ComputeDigest().Should().NotBe(policy2.ComputeDigest());
}
private static EvidenceWeightedScoreInput CreateInput(
double rch, double rts, double bkp, double xpl, double src, double mit)
{
return new EvidenceWeightedScoreInput
{
FindingId = "property-test",
Rch = rch,
Rts = rts,
Bkp = bkp,
Xpl = xpl,
Src = src,
Mit = mit
};
}
}

View File

@@ -15,6 +15,11 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<!-- FsCheck for property-based testing (EvidenceWeightedScore) -->
<PackageReference Include="FsCheck" Version="3.0.0-rc3" />
<PackageReference Include="FsCheck.Xunit" Version="3.0.0-rc3" />
<!-- Verify for snapshot testing (EvidenceWeightedScore) -->
<PackageReference Include="Verify.Xunit" Version="28.7.2" />
</ItemGroup>
<ItemGroup>