sprints work
This commit is contained in:
@@ -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