sprints work
This commit is contained in:
@@ -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)";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user