sprints work

This commit is contained in:
StellaOps Bot
2025-12-25 12:19:12 +02:00
parent 223843f1d1
commit 2a06f780cf
224 changed files with 41796 additions and 1515 deletions

View File

@@ -0,0 +1,577 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using System.Collections.Concurrent;
using System.Diagnostics;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
/// <summary>
/// Determinism and quality gate tests for Evidence-Weighted Score calculator.
/// These tests ensure reproducibility, ordering independence, thread safety, and performance.
/// </summary>
public class EvidenceWeightedScoreDeterminismTests
{
private readonly IEvidenceWeightedScoreCalculator _calculator = new EvidenceWeightedScoreCalculator();
private readonly EvidenceWeightPolicy _defaultPolicy = EvidenceWeightPolicy.DefaultProduction;
#region Task 51: Determinism Tests
[Fact]
public void SameInputs_SamePolicy_ProducesIdenticalScore()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8,
Rts = 0.7,
Bkp = 0.5,
Xpl = 0.6,
Src = 0.5,
Mit = 0.1
};
var 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 SameInputs_SamePolicy_MultipleIterations_AllIdentical()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.75,
Rts = 0.65,
Bkp = 0.45,
Xpl = 0.55,
Src = 0.35,
Mit = 0.15
};
var results = Enumerable.Range(0, 1000)
.Select(_ => _calculator.Calculate(input, _defaultPolicy))
.ToList();
var firstScore = results[0].Score;
var firstDigest = results[0].PolicyDigest;
results.Should().OnlyContain(r => r.Score == firstScore);
results.Should().OnlyContain(r => r.PolicyDigest == firstDigest);
}
[Fact]
public void PolicyDigest_IsStable_AcrossCalculations()
{
var input1 = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-00001",
Rch = 0.1, Rts = 0.2, Bkp = 0.3, Xpl = 0.4, Src = 0.5, Mit = 0.1
};
var input2 = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-00002",
Rch = 0.9, Rts = 0.8, Bkp = 0.7, Xpl = 0.6, Src = 0.5, Mit = 0.2
};
var result1 = _calculator.Calculate(input1, _defaultPolicy);
var result2 = _calculator.Calculate(input2, _defaultPolicy);
// Same policy should produce same digest regardless of inputs
result1.PolicyDigest.Should().Be(result2.PolicyDigest);
}
[Fact]
public void DifferentPolicies_ProduceDifferentDigests()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.1
};
var policy1 = EvidenceWeightPolicy.DefaultProduction;
var policy2 = new EvidenceWeightPolicy
{
Profile = "custom",
Version = "v2",
Weights = new EvidenceWeights
{
Rch = 0.25, Rts = 0.25, Bkp = 0.20, Xpl = 0.15, Src = 0.10, Mit = 0.05
}
};
var result1 = _calculator.Calculate(input, policy1);
var result2 = _calculator.Calculate(input, policy2);
result1.PolicyDigest.Should().NotBe(result2.PolicyDigest);
}
[Fact]
public void Breakdown_IsConsistent_AcrossCalculations()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var result1 = _calculator.Calculate(input, _defaultPolicy);
var result2 = _calculator.Calculate(input, _defaultPolicy);
// Breakdown is a list of DimensionContribution records
result1.Breakdown.Should().HaveCount(result2.Breakdown.Count);
for (int i = 0; i < result1.Breakdown.Count; i++)
{
result1.Breakdown[i].Symbol.Should().Be(result2.Breakdown[i].Symbol);
result1.Breakdown[i].Contribution.Should().Be(result2.Breakdown[i].Contribution);
result1.Breakdown[i].InputValue.Should().Be(result2.Breakdown[i].InputValue);
result1.Breakdown[i].Weight.Should().Be(result2.Breakdown[i].Weight);
}
}
[Fact]
public void Flags_AreConsistent_AcrossCalculations()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8,
Rts = 0.85, // Should trigger live-signal
Bkp = 0.5,
Xpl = 0.6,
Src = 0.5,
Mit = 0.1
};
var results = Enumerable.Range(0, 100)
.Select(_ => _calculator.Calculate(input, _defaultPolicy))
.ToList();
var firstFlags = results[0].Flags.ToList();
results.Should().OnlyContain(r => r.Flags.SequenceEqual(firstFlags));
}
[Fact]
public void Bucket_IsConsistent_AcrossCalculations()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var results = Enumerable.Range(0, 100)
.Select(_ => _calculator.Calculate(input, _defaultPolicy))
.ToList();
var firstBucket = results[0].Bucket;
results.Should().OnlyContain(r => r.Bucket == firstBucket);
}
#endregion
#region Task 52: Ordering Independence Tests
[Fact]
public void InputOrder_DoesNotAffectScore()
{
// Create inputs in different orders - score should be identical
var input1 = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = 0.8,
Rts = 0.7,
Bkp = 0.5,
Xpl = 0.6,
Src = 0.5,
Mit = 0.1
};
var input2 = new EvidenceWeightedScoreInput
{
FindingId = "test",
Mit = 0.1, // Different init order
Src = 0.5,
Xpl = 0.6,
Bkp = 0.5,
Rts = 0.7,
Rch = 0.8
};
var result1 = _calculator.Calculate(input1, _defaultPolicy);
var result2 = _calculator.Calculate(input2, _defaultPolicy);
result1.Score.Should().Be(result2.Score);
}
[Fact]
public void PolicyWeightOrder_DoesNotAffectScore()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var policy1 = new EvidenceWeightPolicy
{
Profile = "test1",
Version = "v1",
Weights = new EvidenceWeights
{
Rch = 0.30, Rts = 0.25, Bkp = 0.15, Xpl = 0.15, Src = 0.10, Mit = 0.10
}
};
var policy2 = new EvidenceWeightPolicy
{
Profile = "test2",
Version = "v1",
Weights = new EvidenceWeights
{
Mit = 0.10, Src = 0.10, Xpl = 0.15, Bkp = 0.15, Rts = 0.25, Rch = 0.30
}
};
var result1 = _calculator.Calculate(input, policy1);
var result2 = _calculator.Calculate(input, policy2);
result1.Score.Should().Be(result2.Score);
}
[Theory]
[MemberData(nameof(GetRandomizedInputs))]
public void RandomizedInputOrder_ProducesConsistentScore(
double rch, double rts, double bkp, double xpl, double src, double mit)
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = rch, Rts = rts, Bkp = bkp, Xpl = xpl, Src = src, Mit = mit
};
var results = Enumerable.Range(0, 10)
.Select(_ => _calculator.Calculate(input, _defaultPolicy).Score)
.Distinct()
.ToList();
results.Should().ContainSingle("all calculations should produce identical scores");
}
public static IEnumerable<object[]> GetRandomizedInputs()
{
// Use fixed seed for reproducibility
var random = new Random(42);
for (int i = 0; i < 10; i++)
{
yield return new object[]
{
random.NextDouble(),
random.NextDouble(),
random.NextDouble(),
random.NextDouble(),
random.NextDouble(),
random.NextDouble() * 0.5 // MIT typically smaller
};
}
}
#endregion
#region Task 53: Concurrent Calculation Tests
[Fact]
public void ConcurrentCalculations_AreThreadSafe()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
var results = new ConcurrentBag<double>();
var digests = new ConcurrentBag<string>();
Parallel.For(0, 1000, _ =>
{
var result = _calculator.Calculate(input, _defaultPolicy);
results.Add(result.Score);
digests.Add(result.PolicyDigest);
});
results.Distinct().Should().ContainSingle("all concurrent calculations should produce identical scores");
digests.Distinct().Should().ContainSingle("all concurrent calculations should produce identical digests");
}
[Fact]
public void ConcurrentCalculations_WithDifferentInputs_AllComplete()
{
var inputs = Enumerable.Range(0, 100).Select(i => new EvidenceWeightedScoreInput
{
FindingId = $"CVE-2024-{i:D5}",
Rch = 0.1 + (i % 10) * 0.08,
Rts = 0.1 + ((i + 1) % 10) * 0.08,
Bkp = 0.1 + ((i + 2) % 10) * 0.08,
Xpl = 0.1 + ((i + 3) % 10) * 0.08,
Src = 0.1 + ((i + 4) % 10) * 0.08,
Mit = 0.05 + ((i + 5) % 10) * 0.04
}).ToList();
var results = new ConcurrentDictionary<string, double>();
Parallel.ForEach(inputs, input =>
{
var result = _calculator.Calculate(input, _defaultPolicy);
results[input.FindingId] = result.Score;
});
results.Should().HaveCount(100);
results.Values.Should().OnlyContain(s => s >= 0 && s <= 100);
}
[Fact]
public void ConcurrentCalculations_WithDifferentPolicies_AllComplete()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.1
};
var policies = Enumerable.Range(0, 50).Select(i => new EvidenceWeightPolicy
{
Profile = $"policy-{i}",
Version = "v1",
Weights = new EvidenceWeights
{
Rch = 0.20 + (i * 0.002),
Rts = 0.20,
Bkp = 0.15,
Xpl = 0.15,
Src = 0.10,
Mit = 0.10
}
}).ToList();
var results = new ConcurrentDictionary<string, (double Score, string Digest)>();
Parallel.ForEach(policies, policy =>
{
var result = _calculator.Calculate(input, policy);
results[policy.Profile] = (result.Score, result.PolicyDigest);
});
results.Should().HaveCount(50);
// Different policies should produce different digests
results.Values.Select(v => v.Digest).Distinct().Should().HaveCount(50);
}
[Fact]
public void HighConcurrency_NoDeadlocksOrRaceConditions()
{
var inputs = Enumerable.Range(0, 1000).Select(i => new EvidenceWeightedScoreInput
{
FindingId = $"CVE-{i}",
Rch = (i % 100) / 100.0,
Rts = ((i + 10) % 100) / 100.0,
Bkp = ((i + 20) % 100) / 100.0,
Xpl = ((i + 30) % 100) / 100.0,
Src = ((i + 40) % 100) / 100.0,
Mit = ((i + 50) % 100) / 200.0
}).ToList();
var completed = 0;
var exceptions = new ConcurrentBag<Exception>();
Parallel.ForEach(inputs, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount * 2 }, input =>
{
try
{
var result = _calculator.Calculate(input, _defaultPolicy);
if (result.Score >= 0 && result.Score <= 100)
{
Interlocked.Increment(ref completed);
}
}
catch (Exception ex)
{
exceptions.Add(ex);
}
});
exceptions.Should().BeEmpty("no exceptions should occur during concurrent calculations");
completed.Should().Be(1000, "all calculations should complete successfully");
}
#endregion
#region Task 54: Benchmark Tests
[Fact]
public void Performance_Calculate10KScores_Under1Second()
{
var inputs = Enumerable.Range(0, 10_000).Select(i => new EvidenceWeightedScoreInput
{
FindingId = $"CVE-2024-{i:D5}",
Rch = (i % 100) / 100.0,
Rts = ((i + 10) % 100) / 100.0,
Bkp = ((i + 20) % 100) / 100.0,
Xpl = ((i + 30) % 100) / 100.0,
Src = ((i + 40) % 100) / 100.0,
Mit = ((i + 50) % 100) / 200.0
}).ToList();
// Warmup
for (int i = 0; i < 100; i++)
{
_calculator.Calculate(inputs[i], _defaultPolicy);
}
var stopwatch = Stopwatch.StartNew();
foreach (var input in inputs)
{
_calculator.Calculate(input, _defaultPolicy);
}
stopwatch.Stop();
stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000,
"calculating 10,000 scores should complete in under 1 second");
}
[Fact]
public void Performance_AverageCalculation_Under100Microseconds()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
// Warmup
for (int i = 0; i < 1000; i++)
{
_calculator.Calculate(input, _defaultPolicy);
}
const int iterations = 10_000;
var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
{
_calculator.Calculate(input, _defaultPolicy);
}
stopwatch.Stop();
var averageMicroseconds = stopwatch.Elapsed.TotalMicroseconds / iterations;
averageMicroseconds.Should().BeLessThan(100,
"average calculation time should be under 100 microseconds");
}
[Fact]
public void Performance_ParallelCalculation_ScalesWithCores()
{
var inputs = Enumerable.Range(0, 10_000).Select(i => new EvidenceWeightedScoreInput
{
FindingId = $"CVE-{i}",
Rch = (i % 100) / 100.0,
Rts = ((i + 10) % 100) / 100.0,
Bkp = ((i + 20) % 100) / 100.0,
Xpl = ((i + 30) % 100) / 100.0,
Src = ((i + 40) % 100) / 100.0,
Mit = ((i + 50) % 100) / 200.0
}).ToList();
// Warmup
Parallel.ForEach(inputs.Take(100), input => _calculator.Calculate(input, _defaultPolicy));
var stopwatch = Stopwatch.StartNew();
Parallel.ForEach(inputs, input => _calculator.Calculate(input, _defaultPolicy));
stopwatch.Stop();
// Parallel should be faster than 1 second (sequential is already under 1s)
stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000,
"parallel calculation of 10,000 scores should be very fast");
}
[Fact]
public void Performance_PolicyDigestComputation_IsCached()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.1
};
// First calculation (may involve digest computation)
var result1 = _calculator.Calculate(input, _defaultPolicy);
// Subsequent calculations should reuse cached digest
const int iterations = 10_000;
var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
{
_calculator.Calculate(input, _defaultPolicy);
}
stopwatch.Stop();
// Should be very fast since digest is cached
stopwatch.ElapsedMilliseconds.Should().BeLessThan(500,
"calculations with cached policy digest should be very fast");
}
[Fact]
public void Performance_MemoryAllocation_IsReasonable()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8, Rts = 0.7, Bkp = 0.5, Xpl = 0.6, Src = 0.5, Mit = 0.1
};
// Force GC to get baseline
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var beforeMemory = GC.GetTotalMemory(forceFullCollection: true);
const int iterations = 10_000;
for (int i = 0; i < iterations; i++)
{
var result = _calculator.Calculate(input, _defaultPolicy);
// Prevent aggressive optimization from eliding the calculation
if (result.Score < 0) throw new InvalidOperationException();
}
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var afterMemory = GC.GetTotalMemory(forceFullCollection: true);
var memoryPerIteration = (afterMemory - beforeMemory) / (double)iterations;
// Each result should allocate roughly the size of the result object
// Should be well under 10KB per calculation
memoryPerIteration.Should().BeLessThan(10_000,
"memory allocation per calculation should be reasonable");
}
#endregion
}

View File

@@ -186,26 +186,28 @@ public class EvidenceWeightedScorePropertyTests
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;
// Sum contributions: positive dimensions are positive, MIT is stored as negative
var netSum = result.Breakdown.Sum(d => d.Contribution);
// Each contribution should be in valid range
foreach (var contrib in result.Breakdown)
// The net sum should roughly equal the score / 100 (before guardrails)
// Allow small rounding tolerance
var expectedScore = Math.Max(0, netSum * 100);
var actualRawScore = result.Caps.OriginalScore;
// Verify each non-subtractive contribution is positive or zero
foreach (var contrib in result.Breakdown.Where(d => !d.IsSubtractive))
{
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);
// Verify subtractive contributions are negative or zero
foreach (var contrib in result.Breakdown.Where(d => d.IsSubtractive))
{
contrib.Contribution.Should().BeLessThanOrEqualTo(0);
}
// Net should produce the raw score (approximately)
actualRawScore.Should().BeCloseTo((int)Math.Round(expectedScore), 2);
}
[Fact]

View File

@@ -0,0 +1,317 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
/// <summary>
/// Integration tests for the DI registration and full scoring pipeline.
/// </summary>
public class EvidenceWeightedScoringIntegrationTests
{
[Fact]
public void AddEvidenceWeightedScoring_RegistersAllServices()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoring();
var provider = services.BuildServiceProvider();
provider.GetService<IEvidenceWeightedScoreCalculator>().Should().NotBeNull();
provider.GetService<IEvidenceWeightPolicyProvider>().Should().NotBeNull();
provider.GetService<IOptions<EvidenceWeightPolicyOptions>>().Should().NotBeNull();
}
[Fact]
public void AddEvidenceWeightedScoring_WithConfiguration_AppliesOptions()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoring(opts =>
{
opts.DefaultEnvironment = "test-environment";
opts.ProductionWeights.Rch = 0.40;
});
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<EvidenceWeightPolicyOptions>>().Value;
options.DefaultEnvironment.Should().Be("test-environment");
options.ProductionWeights.Rch.Should().Be(0.40);
}
[Fact]
public async Task AddEvidenceWeightedScoringWithPolicy_UsesProvidedPolicy()
{
var services = new ServiceCollection();
var customPolicy = new EvidenceWeightPolicy
{
Profile = "custom",
Version = "custom.v1",
Weights = new EvidenceWeights
{
Rch = 0.20,
Rts = 0.20,
Bkp = 0.20,
Xpl = 0.20,
Src = 0.10,
Mit = 0.10
}
};
services.AddEvidenceWeightedScoringWithPolicy(customPolicy);
var provider = services.BuildServiceProvider();
var policyProvider = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
var policy = await policyProvider.GetDefaultPolicyAsync("custom");
policy.Profile.Should().Be("custom");
policy.Weights.Rch.Should().Be(0.20);
}
[Fact]
public async Task AddEvidenceWeightedScoringWithDefaults_UsesProductionPolicy()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoringWithDefaults();
var provider = services.BuildServiceProvider();
var policyProvider = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
var policy = await policyProvider.GetDefaultPolicyAsync("production");
policy.Profile.Should().Be("production");
policy.Version.Should().Be("ews.v1");
}
[Fact]
public void Calculator_IsSingleton()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoring();
var provider = services.BuildServiceProvider();
var calc1 = provider.GetRequiredService<IEvidenceWeightedScoreCalculator>();
var calc2 = provider.GetRequiredService<IEvidenceWeightedScoreCalculator>();
calc1.Should().BeSameAs(calc2);
}
[Fact]
public void PolicyProvider_IsSingleton()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoring();
var provider = services.BuildServiceProvider();
var pp1 = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
var pp2 = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
pp1.Should().BeSameAs(pp2);
}
[Fact]
public async Task FullPipeline_CalculatesScore()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoringWithDefaults();
var provider = services.BuildServiceProvider();
var calculator = provider.GetRequiredService<IEvidenceWeightedScoreCalculator>();
var policyProvider = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8,
Rts = 0.7,
Bkp = 0.5,
Xpl = 0.6,
Src = 0.5,
Mit = 0.1
};
var policy = await policyProvider.GetDefaultPolicyAsync("production");
var result = calculator.Calculate(input, policy);
result.Should().NotBeNull();
result.Score.Should().BeGreaterThan(0);
result.FindingId.Should().Be("CVE-2024-12345");
result.PolicyDigest.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task FullPipeline_WithCustomProvider_Works()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoring<TestPolicyProvider>();
var provider = services.BuildServiceProvider();
var calculator = provider.GetRequiredService<IEvidenceWeightedScoreCalculator>();
var policyProvider = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
var policy = await policyProvider.GetDefaultPolicyAsync("test");
policy.Profile.Should().Be("test");
}
[Fact]
public async Task FullPipeline_WithTenant_ReturnsCorrectPolicy()
{
var services = new ServiceCollection();
var tenant1Policy = new EvidenceWeightPolicy
{
Profile = "production",
Version = "ews.v1",
TenantId = "tenant1",
Weights = EvidenceWeights.Default
};
var tenant2Policy = new EvidenceWeightPolicy
{
Profile = "production",
Version = "ews.v1",
TenantId = "tenant2",
Weights = new EvidenceWeights
{
Rch = 0.40,
Rts = 0.20,
Bkp = 0.10,
Xpl = 0.15,
Src = 0.10,
Mit = 0.05
}
};
var policyProvider = new InMemoryEvidenceWeightPolicyProvider();
policyProvider.SetPolicy(tenant1Policy);
policyProvider.SetPolicy(tenant2Policy);
services.AddSingleton<IEvidenceWeightPolicyProvider>(policyProvider);
services.AddSingleton<IEvidenceWeightedScoreCalculator, EvidenceWeightedScoreCalculator>();
var provider = services.BuildServiceProvider();
var resolvedPolicyProvider = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
var policy1 = await resolvedPolicyProvider.GetPolicyAsync("tenant1", "production");
var policy2 = await resolvedPolicyProvider.GetPolicyAsync("tenant2", "production");
policy1.TenantId.Should().Be("tenant1");
policy2.TenantId.Should().Be("tenant2");
policy2.Weights.Rch.Should().Be(0.40);
}
[Fact]
public void OptionsMonitor_SupportsHotReload()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoring(opts =>
{
opts.DefaultEnvironment = "initial";
});
var provider = services.BuildServiceProvider();
var monitor = provider.GetRequiredService<IOptionsMonitor<EvidenceWeightPolicyOptions>>();
// Initial value
monitor.CurrentValue.DefaultEnvironment.Should().Be("initial");
// Note: Actual hot-reload would require IConfiguration binding,
// but we verify the monitor is wired correctly
monitor.Should().NotBeNull();
}
[Fact]
public void DuplicateRegistration_DoesNotOverwrite()
{
var services = new ServiceCollection();
// Register custom calculator first
services.AddSingleton<IEvidenceWeightedScoreCalculator>(new CustomCalculator());
// Then register scoring services
services.AddEvidenceWeightedScoring();
var provider = services.BuildServiceProvider();
var calculator = provider.GetRequiredService<IEvidenceWeightedScoreCalculator>();
calculator.Should().BeOfType<CustomCalculator>();
}
[Fact]
public void TimeProvider_IsRegistered()
{
var services = new ServiceCollection();
services.AddEvidenceWeightedScoring();
var provider = services.BuildServiceProvider();
var timeProvider = provider.GetService<TimeProvider>();
timeProvider.Should().NotBeNull();
}
[Fact]
public async Task Calculator_WithCustomTimeProvider_UsesIt()
{
var services = new ServiceCollection();
var fixedTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var fakeTimeProvider = new FakeTimeProvider(fixedTime);
services.AddSingleton<TimeProvider>(fakeTimeProvider);
services.AddEvidenceWeightedScoringWithDefaults();
var provider = services.BuildServiceProvider();
var calculator = provider.GetRequiredService<IEvidenceWeightedScoreCalculator>();
var policyProvider = provider.GetRequiredService<IEvidenceWeightPolicyProvider>();
var input = new EvidenceWeightedScoreInput
{
FindingId = "test",
Rch = 0.5,
Rts = 0.5,
Bkp = 0.5,
Xpl = 0.5,
Src = 0.5,
Mit = 0.1
};
var policy = await policyProvider.GetDefaultPolicyAsync("production");
var result = calculator.Calculate(input, policy);
result.Should().NotBeNull();
}
// Test helpers
private sealed class TestPolicyProvider : IEvidenceWeightPolicyProvider
{
private readonly EvidenceWeightPolicy _policy = new()
{
Profile = "test",
Version = "test.v1",
Weights = EvidenceWeights.Default
};
public Task<EvidenceWeightPolicy> GetPolicyAsync(string? tenantId, string environment, CancellationToken cancellationToken = default)
=> Task.FromResult(_policy);
public Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(string environment, CancellationToken cancellationToken = default)
=> Task.FromResult(_policy);
public Task<bool> PolicyExistsAsync(string? tenantId, string environment, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class CustomCalculator : IEvidenceWeightedScoreCalculator
{
public EvidenceWeightedScoreResult Calculate(EvidenceWeightedScoreInput input, EvidenceWeightPolicy policy) =>
throw new NotImplementedException("Custom calculator");
}
private sealed class FakeTimeProvider(DateTimeOffset fixedTime) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => fixedTime;
}
}

View File

@@ -0,0 +1,538 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for BackportEvidenceNormalizer.
/// </summary>
public class BackportEvidenceNormalizerTests
{
private readonly BackportNormalizerOptions _defaultOptions = new();
private readonly BackportEvidenceNormalizer _sut;
public BackportEvidenceNormalizerTests()
{
_sut = new BackportEvidenceNormalizer(_defaultOptions);
}
#region Dimension Property Tests
[Fact]
public void Dimension_ReturnsBKP()
{
_sut.Dimension.Should().Be("BKP");
}
#endregion
#region No Evidence Tests
[Fact]
public void Normalize_WithNoEvidence_ReturnsZero()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.None,
Confidence = 0.0,
Status = BackportStatus.Unknown
};
var result = _sut.Normalize(input);
result.Should().Be(0.0);
}
[Fact]
public void Normalize_WithNoEvidence_IgnoresConfidence()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.None,
Confidence = 1.0, // High confidence with no evidence should still be 0
Status = BackportStatus.Unknown
};
var result = _sut.Normalize(input);
result.Should().BeLessThan(0.15); // Tier 0 max is 0.10
}
#endregion
#region Tier 1 (Heuristic) Tests
[Fact]
public void Normalize_HeuristicTier_LowConfidence_ReturnsBaseScore()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.Heuristic,
Confidence = 0.0,
Status = BackportStatus.Unknown
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(_defaultOptions.Tier1Range.Min, 0.01);
}
[Fact]
public void Normalize_HeuristicTier_HighConfidence_ReturnsMaxScore()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.Heuristic,
Confidence = 1.0,
Status = BackportStatus.Fixed
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(_defaultOptions.Tier1Range.Max, 0.01);
}
[Fact]
public void Normalize_HeuristicTier_MidConfidence_ReturnsMidScore()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.Heuristic,
Confidence = 0.5,
Status = BackportStatus.Unknown
};
var result = _sut.Normalize(input);
var expected = _defaultOptions.Tier1Range.Min +
(_defaultOptions.Tier1Range.Max - _defaultOptions.Tier1Range.Min) * 0.5;
result.Should().BeApproximately(expected, 0.01);
}
#endregion
#region Tier 2 (PatchSignature) Tests
[Fact]
public void Normalize_PatchSignatureTier_ReturnsHigherThanHeuristic()
{
var heuristicInput = new BackportInput
{
EvidenceTier = BackportEvidenceTier.Heuristic,
Confidence = 0.8,
Status = BackportStatus.Fixed
};
var patchInput = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.8,
Status = BackportStatus.Fixed
};
var heuristicScore = _sut.Normalize(heuristicInput);
var patchScore = _sut.Normalize(patchInput);
patchScore.Should().BeGreaterThan(heuristicScore);
}
[Fact]
public void Normalize_PatchSignatureTier_WithinRange()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.7,
Status = BackportStatus.Fixed
};
var result = _sut.Normalize(input);
result.Should().BeInRange(_defaultOptions.Tier2Range.Min, _defaultOptions.Tier2Range.Max);
}
#endregion
#region Tier 3 (BinaryDiff) Tests
[Fact]
public void Normalize_BinaryDiffTier_ReturnsHigherThanPatchSignature()
{
var patchInput = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.9,
Status = BackportStatus.Fixed
};
var binaryInput = new BackportInput
{
EvidenceTier = BackportEvidenceTier.BinaryDiff,
Confidence = 0.9,
Status = BackportStatus.Fixed
};
var patchScore = _sut.Normalize(patchInput);
var binaryScore = _sut.Normalize(binaryInput);
binaryScore.Should().BeGreaterThanOrEqualTo(patchScore);
}
#endregion
#region Tier 4 (VendorVex) Tests
[Fact]
public void Normalize_VendorVexTier_HighScore()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.VendorVex,
Confidence = 0.85,
Status = BackportStatus.Fixed
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.85);
}
#endregion
#region Tier 5 (SignedProof) Tests
[Fact]
public void Normalize_SignedProofTier_MaxScore()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.SignedProof,
Confidence = 1.0,
Status = BackportStatus.Fixed
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(1.0, 0.01);
}
[Fact]
public void Normalize_SignedProofTier_WithinRange()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.SignedProof,
Confidence = 0.5,
Status = BackportStatus.Fixed
};
var result = _sut.Normalize(input);
result.Should().BeInRange(_defaultOptions.Tier5Range.Min, _defaultOptions.Tier5Range.Max);
}
#endregion
#region Status Tests
[Fact]
public void Normalize_NotAffectedStatus_GetsBonus()
{
var unknownInput = new BackportInput
{
EvidenceTier = BackportEvidenceTier.VendorVex,
Confidence = 0.9,
Status = BackportStatus.Unknown
};
var notAffectedInput = new BackportInput
{
EvidenceTier = BackportEvidenceTier.VendorVex,
Confidence = 0.9,
Status = BackportStatus.NotAffected
};
var unknownScore = _sut.Normalize(unknownInput);
var notAffectedScore = _sut.Normalize(notAffectedInput);
notAffectedScore.Should().BeGreaterThan(unknownScore);
}
[Fact]
public void Normalize_AffectedStatus_ReturnsBaseTierScore()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.Heuristic,
Confidence = 0.5,
Status = BackportStatus.Affected
};
var result = _sut.Normalize(input);
// Should be in heuristic range
result.Should().BeInRange(_defaultOptions.Tier1Range.Min, _defaultOptions.Tier1Range.Max);
}
[Fact]
public void Normalize_UnderInvestigation_ReturnsBaseTierScore()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.7,
Status = BackportStatus.UnderInvestigation
};
var result = _sut.Normalize(input);
result.Should().BeInRange(_defaultOptions.Tier2Range.Min, _defaultOptions.Tier2Range.Max);
}
#endregion
#region Score Clamping Tests
[Fact]
public void Normalize_NotAffectedBonus_ClampedAtOne()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.SignedProof,
Confidence = 1.0,
Status = BackportStatus.NotAffected
};
var result = _sut.Normalize(input);
result.Should().BeLessThanOrEqualTo(1.0);
}
#endregion
#region NormalizeWithDetails Tests
[Fact]
public void NormalizeWithDetails_ReturnsCorrectDimension()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.8,
Status = BackportStatus.Fixed
};
var result = _sut.NormalizeWithDetails(input);
result.Dimension.Should().Be("BKP");
}
[Fact]
public void NormalizeWithDetails_ReturnsComponents()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.BinaryDiff,
Confidence = 0.9,
Status = BackportStatus.Fixed
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("tier_base");
result.Components.Should().ContainKey("confidence");
result.Components.Should().ContainKey("tier_ordinal");
result.Components["tier_ordinal"].Should().Be((int)BackportEvidenceTier.BinaryDiff);
}
[Fact]
public void NormalizeWithDetails_NotAffected_IncludesStatusBonus()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.VendorVex,
Confidence = 0.85,
Status = BackportStatus.NotAffected
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("status_bonus");
result.Components["status_bonus"].Should().Be(0.10);
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.SignedProof,
Confidence = 0.95,
ProofId = "proof-abc-123",
Status = BackportStatus.Fixed
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("Fixed");
result.Explanation.Should().Contain("cryptographically signed proof");
result.Explanation.Should().Contain("high confidence");
result.Explanation.Should().Contain("proof-abc-123");
}
[Fact]
public void NormalizeWithDetails_NoEvidence_ExplainsLack()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.None,
Confidence = 0.0,
Status = BackportStatus.Unknown
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("No backport evidence");
}
#endregion
#region Monotonicity Tests
[Fact]
public void Normalize_TiersAreMonotonicallyIncreasing()
{
var tiers = new[]
{
BackportEvidenceTier.None,
BackportEvidenceTier.Heuristic,
BackportEvidenceTier.PatchSignature,
BackportEvidenceTier.BinaryDiff,
BackportEvidenceTier.VendorVex,
BackportEvidenceTier.SignedProof
};
var scores = tiers.Select(tier => _sut.Normalize(new BackportInput
{
EvidenceTier = tier,
Confidence = 0.8,
Status = BackportStatus.Fixed
})).ToList();
// Each tier should produce a score >= previous tier
for (int i = 1; i < scores.Count; i++)
{
scores[i].Should().BeGreaterThanOrEqualTo(scores[i - 1],
$"Tier {tiers[i]} should score >= {tiers[i - 1]}");
}
}
[Fact]
public void Normalize_ConfidenceIsMonotonicallyIncreasing()
{
var confidences = new[] { 0.0, 0.25, 0.5, 0.75, 1.0 };
var scores = confidences.Select(confidence => _sut.Normalize(new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = confidence,
Status = BackportStatus.Fixed
})).ToList();
// Higher confidence should produce higher or equal scores
for (int i = 1; i < scores.Count; i++)
{
scores[i].Should().BeGreaterThanOrEqualTo(scores[i - 1],
$"Confidence {confidences[i]} should score >= {confidences[i - 1]}");
}
}
#endregion
#region Null Input Tests
[Fact]
public void Normalize_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.Normalize(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void NormalizeWithDetails_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.NormalizeWithDetails(null!);
act.Should().Throw<ArgumentNullException>();
}
#endregion
#region DI Integration Tests
[Fact]
public void Constructor_WithIOptionsMonitor_WorksCorrectly()
{
var options = new NormalizerOptions
{
Backport = new BackportNormalizerOptions
{
Tier5Range = (0.95, 1.00)
}
};
var optionsMonitor = new TestOptionsMonitor(options);
var normalizer = new BackportEvidenceNormalizer(optionsMonitor);
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.SignedProof,
Confidence = 0.5,
Status = BackportStatus.Fixed
};
var result = normalizer.Normalize(input);
// Should use custom Tier5Range
result.Should().BeInRange(0.95, 1.00);
}
private sealed class TestOptionsMonitor(NormalizerOptions value) : IOptionsMonitor<NormalizerOptions>
{
public NormalizerOptions CurrentValue => value;
public NormalizerOptions Get(string? name) => value;
public IDisposable? OnChange(Action<NormalizerOptions, string?> listener) => null;
}
#endregion
#region Determinism Tests
[Fact]
public void Normalize_SameInput_ProducesSameOutput()
{
var input = new BackportInput
{
EvidenceTier = BackportEvidenceTier.BinaryDiff,
Confidence = 0.87,
ProofId = "proof-xyz",
Status = BackportStatus.Fixed
};
var results = Enumerable.Range(0, 100)
.Select(_ => _sut.Normalize(input))
.Distinct()
.ToList();
results.Should().ContainSingle("Deterministic normalizer should produce identical results");
}
#endregion
}

View File

@@ -0,0 +1,371 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for EvidenceNormalizersServiceCollectionExtensions.
/// </summary>
public class EvidenceNormalizersServiceCollectionExtensionsTests
{
#region AddEvidenceNormalizers (Default) Tests
[Fact]
public void AddEvidenceNormalizers_RegistersAllNormalizers()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
var provider = services.BuildServiceProvider();
// Verify all normalizers are registered
provider.GetService<IEvidenceNormalizer<ReachabilityInput>>().Should().NotBeNull();
provider.GetService<IEvidenceNormalizer<RuntimeInput>>().Should().NotBeNull();
provider.GetService<IEvidenceNormalizer<BackportInput>>().Should().NotBeNull();
provider.GetService<IEvidenceNormalizer<ExploitInput>>().Should().NotBeNull();
provider.GetService<IEvidenceNormalizer<SourceTrustInput>>().Should().NotBeNull();
provider.GetService<IEvidenceNormalizer<MitigationInput>>().Should().NotBeNull();
}
[Fact]
public void AddEvidenceNormalizers_RegistersAggregator()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
var provider = services.BuildServiceProvider();
provider.GetService<INormalizerAggregator>().Should().NotBeNull();
}
[Fact]
public void AddEvidenceNormalizers_RegistersAsCorrectTypes()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
var provider = services.BuildServiceProvider();
provider.GetRequiredService<IEvidenceNormalizer<ReachabilityInput>>()
.Should().BeOfType<ReachabilityNormalizer>();
provider.GetRequiredService<IEvidenceNormalizer<RuntimeInput>>()
.Should().BeOfType<RuntimeSignalNormalizer>();
provider.GetRequiredService<IEvidenceNormalizer<BackportInput>>()
.Should().BeOfType<BackportEvidenceNormalizer>();
provider.GetRequiredService<IEvidenceNormalizer<ExploitInput>>()
.Should().BeOfType<ExploitLikelihoodNormalizer>();
provider.GetRequiredService<IEvidenceNormalizer<SourceTrustInput>>()
.Should().BeOfType<SourceTrustNormalizer>();
provider.GetRequiredService<IEvidenceNormalizer<MitigationInput>>()
.Should().BeOfType<MitigationNormalizer>();
provider.GetRequiredService<INormalizerAggregator>()
.Should().BeOfType<NormalizerAggregator>();
}
[Fact]
public void AddEvidenceNormalizers_NormalizersAreSingletons()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
var provider = services.BuildServiceProvider();
var normalizer1 = provider.GetRequiredService<IEvidenceNormalizer<ReachabilityInput>>();
var normalizer2 = provider.GetRequiredService<IEvidenceNormalizer<ReachabilityInput>>();
normalizer1.Should().BeSameAs(normalizer2);
}
[Fact]
public void AddEvidenceNormalizers_AggregatorIsSingleton()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
var provider = services.BuildServiceProvider();
var aggregator1 = provider.GetRequiredService<INormalizerAggregator>();
var aggregator2 = provider.GetRequiredService<INormalizerAggregator>();
aggregator1.Should().BeSameAs(aggregator2);
}
#endregion
#region AddEvidenceNormalizers (With Configuration) Tests
[Fact]
public void AddEvidenceNormalizers_WithConfiguration_AppliesOptions()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers(options =>
{
options.Reachability.UnknownScore = 0.65;
});
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<NormalizerOptions>>();
options.Value.Reachability.UnknownScore.Should().Be(0.65);
}
[Fact]
public void AddEvidenceNormalizers_WithConfiguration_NormalizerUsesOptions()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers(options =>
{
options.Reachability.UnknownScore = 0.70;
});
var provider = services.BuildServiceProvider();
var aggregator = provider.GetRequiredService<INormalizerAggregator>();
// Aggregate with no reachability evidence should use the unknown score
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0"
};
var result = aggregator.Aggregate(evidence);
result.Rch.Should().BeApproximately(0.70, 0.01);
}
#endregion
#region AddEvidenceNormalizers (With IConfiguration) Tests
[Fact]
public void AddEvidenceNormalizers_WithIConfiguration_BindsFromSection()
{
var inMemorySettings = new Dictionary<string, string?>
{
{ "EvidenceNormalizers:Reachability:UnknownScore", "0.55" },
{ "EvidenceNormalizers:Exploit:NoEpssScore", "0.25" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings)
.Build();
var services = new ServiceCollection();
services.AddEvidenceNormalizers(configuration);
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<NormalizerOptions>>();
options.Value.Reachability.UnknownScore.Should().Be(0.55);
options.Value.Exploit.NoEpssScore.Should().Be(0.25);
}
[Fact]
public void AddEvidenceNormalizers_WithIConfiguration_CustomSectionName()
{
var inMemorySettings = new Dictionary<string, string?>
{
{ "CustomSection:Reachability:UnknownScore", "0.42" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(inMemorySettings)
.Build();
var services = new ServiceCollection();
services.AddEvidenceNormalizers(configuration, "CustomSection");
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<NormalizerOptions>>();
options.Value.Reachability.UnknownScore.Should().Be(0.42);
}
#endregion
#region AddNormalizerAggregator Tests
[Fact]
public void AddNormalizerAggregator_RegistersAggregatorOnly()
{
var services = new ServiceCollection();
services.AddNormalizerAggregator();
var provider = services.BuildServiceProvider();
provider.GetService<INormalizerAggregator>().Should().NotBeNull();
// Individual normalizers should not be registered
provider.GetService<IEvidenceNormalizer<ReachabilityInput>>().Should().BeNull();
}
#endregion
#region Double Registration Tests
[Fact]
public void AddEvidenceNormalizers_CalledTwice_DoesNotDuplicate()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
services.AddEvidenceNormalizers();
// Should only have one registration per type
var descriptors = services.Where(d =>
d.ServiceType == typeof(IEvidenceNormalizer<ReachabilityInput>));
descriptors.Should().HaveCount(1);
}
[Fact]
public void AddEvidenceNormalizers_DoesNotReplaceExistingRegistrations()
{
var services = new ServiceCollection();
// Register a custom normalizer first
var customNormalizer = new CustomReachabilityNormalizer();
services.AddSingleton<IEvidenceNormalizer<ReachabilityInput>>(customNormalizer);
services.AddEvidenceNormalizers();
var provider = services.BuildServiceProvider();
// Should keep the original registration
var normalizer = provider.GetRequiredService<IEvidenceNormalizer<ReachabilityInput>>();
normalizer.Should().BeSameAs(customNormalizer);
}
private sealed class CustomReachabilityNormalizer : IEvidenceNormalizer<ReachabilityInput>
{
public string Dimension => "RCH";
public double Normalize(ReachabilityInput input) => 0.99;
public NormalizationResult NormalizeWithDetails(ReachabilityInput input) =>
NormalizationResult.Simple(0.99, "RCH", "Custom normalizer");
}
#endregion
#region Null Argument Tests
[Fact]
public void AddEvidenceNormalizers_NullServices_ThrowsArgumentNullException()
{
IServiceCollection? services = null;
var act = () => services!.AddEvidenceNormalizers();
act.Should().Throw<ArgumentNullException>()
.WithParameterName("services");
}
[Fact]
public void AddEvidenceNormalizers_NullConfigure_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
var act = () => services.AddEvidenceNormalizers((Action<NormalizerOptions>)null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("configure");
}
[Fact]
public void AddEvidenceNormalizers_NullConfiguration_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
var act = () => services.AddEvidenceNormalizers((IConfiguration)null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("configuration");
}
#endregion
#region Integration Tests
[Fact]
public void AddEvidenceNormalizers_FullPipeline_Works()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers(options =>
{
options.Reachability.UnknownScore = 0.50;
options.Runtime.UnknownScore = 0.0;
});
var provider = services.BuildServiceProvider();
var aggregator = provider.GetRequiredService<INormalizerAggregator>();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8
},
Exploit = new ExploitInput
{
EpssScore = 0.65,
EpssPercentile = 90.0,
KevStatus = KevStatus.NotInKev
}
};
var result = aggregator.Aggregate(evidence);
// All dimensions should be in valid range
result.Rch.Should().BeInRange(0.0, 1.0);
result.Rts.Should().BeInRange(0.0, 1.0);
result.Bkp.Should().BeInRange(0.0, 1.0);
result.Xpl.Should().BeInRange(0.0, 1.0);
result.Src.Should().BeInRange(0.0, 1.0);
result.Mit.Should().BeInRange(0.0, 1.0);
}
[Fact]
public void AddEvidenceNormalizers_AggregatorWithDetails_Works()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
var provider = services.BuildServiceProvider();
var aggregator = provider.GetRequiredService<INormalizerAggregator>();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8
}
};
var result = aggregator.AggregateWithDetails(evidence);
result.Input.Should().NotBeNull();
result.Details.Should().ContainKey("RCH");
result.Warnings.Should().NotBeEmpty(); // Should warn about missing dimensions
}
#endregion
}

View File

@@ -0,0 +1,523 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for ExploitLikelihoodNormalizer.
/// </summary>
public class ExploitLikelihoodNormalizerTests
{
private readonly ExploitNormalizerOptions _defaultOptions = new();
private readonly ExploitLikelihoodNormalizer _sut;
public ExploitLikelihoodNormalizerTests()
{
_sut = new ExploitLikelihoodNormalizer(_defaultOptions);
}
#region Dimension Property Tests
[Fact]
public void Dimension_ReturnsXPL()
{
_sut.Dimension.Should().Be("XPL");
}
#endregion
#region EPSS Percentile Band Tests
[Theory]
[InlineData(99.5, 0.90, 1.00)] // Top 1%
[InlineData(99.0, 0.90, 1.00)] // Top 1% boundary
[InlineData(97.0, 0.70, 0.89)] // Top 5%
[InlineData(95.0, 0.70, 0.89)] // Top 5% boundary
[InlineData(85.0, 0.40, 0.69)] // Top 25%
[InlineData(75.0, 0.40, 0.69)] // Top 25% boundary
[InlineData(50.0, 0.20, 0.39)] // Below 75%
[InlineData(10.0, 0.20, 0.39)] // Low percentile
public void Normalize_EpssPercentile_MapsToCorrectBand(double percentile, double expectedMin, double expectedMax)
{
var input = new ExploitInput
{
EpssScore = percentile / 100.0, // Score roughly correlates
EpssPercentile = percentile,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeInRange(expectedMin, expectedMax,
$"Percentile {percentile} should map to range [{expectedMin}, {expectedMax}]");
}
[Fact]
public void Normalize_Top1Percent_ScoresHighest()
{
var input = new ExploitInput
{
EpssScore = 0.95,
EpssPercentile = 99.5,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.90);
}
[Fact]
public void Normalize_VeryLowPercentile_ScoresLowest()
{
var input = new ExploitInput
{
EpssScore = 0.001,
EpssPercentile = 5.0,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.20, 0.35);
}
#endregion
#region KEV Status Tests
[Fact]
public void Normalize_InKev_AppliesFloor()
{
var input = new ExploitInput
{
EpssScore = 0.01, // Very low EPSS
EpssPercentile = 10.0, // Would normally score low
KevStatus = KevStatus.InKev
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(_defaultOptions.KevFloor,
"KEV status should enforce minimum floor");
}
[Fact]
public void Normalize_InKev_HighEpss_UsesEpssScore()
{
var input = new ExploitInput
{
EpssScore = 0.95,
EpssPercentile = 99.5, // Top 1%
KevStatus = KevStatus.InKev
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThan(_defaultOptions.KevFloor,
"High EPSS score should exceed KEV floor");
}
[Fact]
public void Normalize_RemovedFromKev_ReducedFloor()
{
var input = new ExploitInput
{
EpssScore = 0.01,
EpssPercentile = 10.0,
KevStatus = KevStatus.RemovedFromKev
};
var result = _sut.Normalize(input);
var expectedReducedFloor = _defaultOptions.KevFloor * 0.5;
result.Should().BeGreaterThanOrEqualTo(expectedReducedFloor);
result.Should().BeLessThan(_defaultOptions.KevFloor);
}
[Fact]
public void Normalize_NotInKev_NoFloor()
{
var input = new ExploitInput
{
EpssScore = 0.001,
EpssPercentile = 1.0,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeLessThan(_defaultOptions.KevFloor,
"Without KEV status, low EPSS should score below KEV floor");
}
[Fact]
public void Normalize_InKev_WithDates_ScoresCorrectly()
{
var input = new ExploitInput
{
EpssScore = 0.30,
EpssPercentile = 50.0,
KevStatus = KevStatus.InKev,
KevAddedDate = DateTimeOffset.UtcNow.AddDays(-30),
KevDueDate = DateTimeOffset.UtcNow.AddDays(14)
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(_defaultOptions.KevFloor);
}
#endregion
#region Public Exploit Availability Tests
[Fact]
public void Normalize_PublicExploitAvailable_AddsBonus()
{
var inputWithoutExploit = new ExploitInput
{
EpssScore = 0.50,
EpssPercentile = 80.0,
KevStatus = KevStatus.NotInKev,
PublicExploitAvailable = false
};
var inputWithExploit = new ExploitInput
{
EpssScore = 0.50,
EpssPercentile = 80.0,
KevStatus = KevStatus.NotInKev,
PublicExploitAvailable = true
};
var scoreWithout = _sut.Normalize(inputWithoutExploit);
var scoreWith = _sut.Normalize(inputWithExploit);
scoreWith.Should().BeGreaterThan(scoreWithout);
(scoreWith - scoreWithout).Should().BeApproximately(0.10, 0.01);
}
[Fact]
public void Normalize_PublicExploitWithMaturity_ScoresCorrectly()
{
var input = new ExploitInput
{
EpssScore = 0.70,
EpssPercentile = 95.0,
KevStatus = KevStatus.NotInKev,
PublicExploitAvailable = true,
ExploitMaturity = "weaponized"
};
var result = _sut.Normalize(input);
// Use BeCloseTo to handle floating point precision
result.Should().BeGreaterThanOrEqualTo(0.79);
}
#endregion
#region Score Clamping Tests
[Fact]
public void Normalize_MaxScore_ClampedAtOne()
{
var input = new ExploitInput
{
EpssScore = 0.99,
EpssPercentile = 99.9,
KevStatus = KevStatus.InKev,
PublicExploitAvailable = true,
ExploitMaturity = "weaponized"
};
var result = _sut.Normalize(input);
result.Should().BeLessThanOrEqualTo(1.0);
}
#endregion
#region NormalizeWithDetails Tests
[Fact]
public void NormalizeWithDetails_ReturnsCorrectDimension()
{
var input = new ExploitInput
{
EpssScore = 0.50,
EpssPercentile = 75.0,
KevStatus = KevStatus.NotInKev
};
var result = _sut.NormalizeWithDetails(input);
result.Dimension.Should().Be("XPL");
}
[Fact]
public void NormalizeWithDetails_ReturnsComponents()
{
var input = new ExploitInput
{
EpssScore = 0.45,
EpssPercentile = 97.0,
KevStatus = KevStatus.InKev
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("epss_score");
result.Components.Should().ContainKey("epss_percentile");
result.Components.Should().ContainKey("epss_based_score");
result.Components.Should().ContainKey("kev_floor");
result.Components.Should().ContainKey("kev_status");
result.Components["kev_status"].Should().Be((int)KevStatus.InKev);
}
[Fact]
public void NormalizeWithDetails_PublicExploit_IncludesBonus()
{
var input = new ExploitInput
{
EpssScore = 0.50,
EpssPercentile = 80.0,
KevStatus = KevStatus.NotInKev,
PublicExploitAvailable = true
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("exploit_bonus");
result.Components["exploit_bonus"].Should().Be(0.10);
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation_TopPercentile()
{
var input = new ExploitInput
{
EpssScore = 0.92,
EpssPercentile = 99.5,
KevStatus = KevStatus.NotInKev
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("Very high EPSS");
result.Explanation.Should().Contain("top 1%");
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation_Kev()
{
var input = new ExploitInput
{
EpssScore = 0.30,
EpssPercentile = 50.0,
KevStatus = KevStatus.InKev,
KevAddedDate = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero)
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("actively exploited (KEV)");
result.Explanation.Should().Contain("2024-06-15");
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation_PublicExploit()
{
var input = new ExploitInput
{
EpssScore = 0.60,
EpssPercentile = 90.0,
KevStatus = KevStatus.NotInKev,
PublicExploitAvailable = true,
ExploitMaturity = "functional"
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("public exploit available");
result.Explanation.Should().Contain("functional");
}
#endregion
#region Monotonicity Tests
[Fact]
public void Normalize_PercentileIsMonotonicallyIncreasing()
{
var percentiles = new[] { 10.0, 30.0, 50.0, 70.0, 85.0, 95.0, 99.0 };
var scores = percentiles.Select(p => _sut.Normalize(new ExploitInput
{
EpssScore = p / 100.0,
EpssPercentile = p,
KevStatus = KevStatus.NotInKev
})).ToList();
// Higher percentiles should produce higher or equal scores
for (int i = 1; i < scores.Count; i++)
{
scores[i].Should().BeGreaterThanOrEqualTo(scores[i - 1],
$"Percentile {percentiles[i]} should score >= {percentiles[i - 1]}");
}
}
#endregion
#region Null Input Tests
[Fact]
public void Normalize_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.Normalize(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void NormalizeWithDetails_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.NormalizeWithDetails(null!);
act.Should().Throw<ArgumentNullException>();
}
#endregion
#region DI Integration Tests
[Fact]
public void Constructor_WithIOptionsMonitor_WorksCorrectly()
{
var options = new NormalizerOptions
{
Exploit = new ExploitNormalizerOptions
{
KevFloor = 0.50 // Custom floor
}
};
var optionsMonitor = new TestOptionsMonitor(options);
var normalizer = new ExploitLikelihoodNormalizer(optionsMonitor);
var input = new ExploitInput
{
EpssScore = 0.01,
EpssPercentile = 5.0,
KevStatus = KevStatus.InKev
};
var result = normalizer.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.50); // Custom floor
}
private sealed class TestOptionsMonitor(NormalizerOptions value) : IOptionsMonitor<NormalizerOptions>
{
public NormalizerOptions CurrentValue => value;
public NormalizerOptions Get(string? name) => value;
public IDisposable? OnChange(Action<NormalizerOptions, string?> listener) => null;
}
#endregion
#region Determinism Tests
[Fact]
public void Normalize_SameInput_ProducesSameOutput()
{
var input = new ExploitInput
{
EpssScore = 0.67,
EpssPercentile = 93.5,
KevStatus = KevStatus.InKev,
PublicExploitAvailable = true
};
var results = Enumerable.Range(0, 100)
.Select(_ => _sut.Normalize(input))
.Distinct()
.ToList();
results.Should().ContainSingle("Deterministic normalizer should produce identical results");
}
#endregion
#region Edge Case Tests
[Fact]
public void Normalize_ZeroPercentile_HandlesCorrectly()
{
var input = new ExploitInput
{
EpssScore = 0.0001,
EpssPercentile = 0.0,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.20, 0.40);
}
[Fact]
public void Normalize_ExactlyOnBoundary_HandlesCorrectly()
{
// Test exactly on 75th percentile boundary
var input = new ExploitInput
{
EpssScore = 0.30,
EpssPercentile = 75.0,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.40, 0.70);
}
[Fact]
public void Normalize_ExactlyOn95Boundary_HandlesCorrectly()
{
var input = new ExploitInput
{
EpssScore = 0.60,
EpssPercentile = 95.0,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.70, 0.90);
}
[Fact]
public void Normalize_ExactlyOn99Boundary_HandlesCorrectly()
{
var input = new ExploitInput
{
EpssScore = 0.85,
EpssPercentile = 99.0,
KevStatus = KevStatus.NotInKev
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.90, 1.00);
}
#endregion
}

View File

@@ -0,0 +1,528 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for MitigationNormalizer.
/// </summary>
public class MitigationNormalizerTests
{
private readonly MitigationNormalizerOptions _defaultOptions = new();
private readonly MitigationNormalizer _sut;
public MitigationNormalizerTests()
{
_sut = new MitigationNormalizer(_defaultOptions);
}
#region Dimension Property Tests
[Fact]
public void Dimension_ReturnsMIT()
{
_sut.Dimension.Should().Be("MIT");
}
#endregion
#region No Mitigation Tests
[Fact]
public void Normalize_NoMitigations_ReturnsZero()
{
var input = new MitigationInput
{
ActiveMitigations = [],
CombinedEffectiveness = 0.0
};
var result = _sut.Normalize(input);
result.Should().Be(0.0);
}
[Fact]
public void Normalize_EmptyMitigationsList_ReturnsZero()
{
var input = new MitigationInput
{
ActiveMitigations = Array.Empty<ActiveMitigation>(),
CombinedEffectiveness = 0.0
};
var result = _sut.Normalize(input);
result.Should().Be(0.0);
}
#endregion
#region Single Mitigation Tests
[Fact]
public void Normalize_SingleFeatureFlag_ReturnsEffectiveness()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.30 }
],
CombinedEffectiveness = 0.30
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(0.30, 0.01);
}
[Fact]
public void Normalize_SingleAuthRequired_ReturnsEffectiveness()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.15 }
],
CombinedEffectiveness = 0.15
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(0.15, 0.01);
}
[Fact]
public void Normalize_SingleSecurityPolicy_ReturnsEffectiveness()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.20
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(0.20, 0.01);
}
#endregion
#region Multiple Mitigations Tests
[Fact]
public void Normalize_MultipleMitigations_SumsEffectiveness()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.25 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.15 }
],
CombinedEffectiveness = 0.40
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(0.40, 0.01);
}
[Fact]
public void Normalize_ManyMitigations_SumsAll()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.20 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.10 },
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.15 },
new ActiveMitigation { Type = MitigationType.Isolation, Effectiveness = 0.10 }
],
CombinedEffectiveness = 0.55
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(0.55, 0.01);
}
#endregion
#region Capping Tests
[Fact]
public void Normalize_ExcessiveMitigations_CappedAtOne()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.40 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.30 },
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.25 },
new ActiveMitigation { Type = MitigationType.Isolation, Effectiveness = 0.20 }
],
CombinedEffectiveness = 1.15 // Exceeds 1.0
};
var result = _sut.Normalize(input);
result.Should().BeLessThanOrEqualTo(1.0);
}
[Fact]
public void Normalize_ComponentRemoval_HighEffectiveness()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.ComponentRemoval, Effectiveness = 0.95 }
],
CombinedEffectiveness = 0.95
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.95);
}
#endregion
#region Verification Bonus Tests
[Fact]
public void Normalize_RuntimeVerified_GetsBonus()
{
var inputUnverified = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.20,
RuntimeVerified = false
};
var inputVerified = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.20,
RuntimeVerified = true
};
var scoreUnverified = _sut.Normalize(inputUnverified);
var scoreVerified = _sut.Normalize(inputVerified);
scoreVerified.Should().BeGreaterThan(scoreUnverified);
(scoreVerified - scoreUnverified).Should().BeApproximately(_defaultOptions.VerificationBonus, 0.01);
}
[Fact]
public void Normalize_IndividualMitigationVerified_GetsPartialBonus()
{
var inputUnverified = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20, Verified = false }
],
CombinedEffectiveness = 0.0, // Force calculation from mitigations
RuntimeVerified = false
};
var inputVerified = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20, Verified = true }
],
CombinedEffectiveness = 0.0, // Force calculation from mitigations
RuntimeVerified = false
};
var scoreUnverified = _sut.Normalize(inputUnverified);
var scoreVerified = _sut.Normalize(inputVerified);
scoreVerified.Should().BeGreaterThan(scoreUnverified);
}
#endregion
#region CombinedEffectiveness vs ActiveMitigations Tests
[Fact]
public void Normalize_CombinedEffectivenessProvided_UsesCombined()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.50 // Higher than individual
};
var result = _sut.Normalize(input);
// Should use pre-computed combined effectiveness
result.Should().BeApproximately(0.50, 0.01);
}
[Fact]
public void Normalize_ZeroCombined_CalculatesFromMitigations()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.25 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.15 }
],
CombinedEffectiveness = 0.0 // Zero forces calculation
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(0.40, 0.01);
}
#endregion
#region NormalizeWithDetails Tests
[Fact]
public void NormalizeWithDetails_ReturnsCorrectDimension()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.30 }
],
CombinedEffectiveness = 0.30
};
var result = _sut.NormalizeWithDetails(input);
result.Dimension.Should().Be("MIT");
}
[Fact]
public void NormalizeWithDetails_ReturnsComponents()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.20,
RuntimeVerified = true
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("mitigation_count");
result.Components.Should().ContainKey("combined_effectiveness");
result.Components.Should().ContainKey("runtime_verified");
result.Components["mitigation_count"].Should().Be(1);
result.Components["runtime_verified"].Should().Be(1.0);
}
[Fact]
public void NormalizeWithDetails_IncludesIndividualMitigations()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.30 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.15 }
],
CombinedEffectiveness = 0.45
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("mitigation_0_type");
result.Components.Should().ContainKey("mitigation_0_effectiveness");
result.Components.Should().ContainKey("mitigation_1_type");
result.Components.Should().ContainKey("mitigation_1_effectiveness");
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation
{
Type = MitigationType.SecurityPolicy,
Name = "seccomp-strict",
Effectiveness = 0.20,
Verified = true
}
],
CombinedEffectiveness = 0.20,
RuntimeVerified = true,
AssessmentSource = "runtime-scanner"
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("seccomp-strict");
result.Explanation.Should().Contain("runtime verified");
result.Explanation.Should().Contain("runtime-scanner");
}
[Fact]
public void NormalizeWithDetails_NoMitigations_ExplainsLack()
{
var input = new MitigationInput
{
ActiveMitigations = [],
CombinedEffectiveness = 0.0
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("No active mitigations");
}
#endregion
#region Null Input Tests
[Fact]
public void Normalize_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.Normalize(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void NormalizeWithDetails_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.NormalizeWithDetails(null!);
act.Should().Throw<ArgumentNullException>();
}
#endregion
#region DI Integration Tests
[Fact]
public void Constructor_WithIOptionsMonitor_WorksCorrectly()
{
var options = new NormalizerOptions
{
Mitigation = new MitigationNormalizerOptions
{
MaxTotalMitigation = 0.80 // Custom cap
}
};
var optionsMonitor = new TestOptionsMonitor(options);
var normalizer = new MitigationNormalizer(optionsMonitor);
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.50 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.50 }
],
CombinedEffectiveness = 1.0 // Would be 1.0 without cap
};
var result = normalizer.Normalize(input);
result.Should().BeLessThanOrEqualTo(0.85); // Custom cap + possible bonus
}
private sealed class TestOptionsMonitor(NormalizerOptions value) : IOptionsMonitor<NormalizerOptions>
{
public NormalizerOptions CurrentValue => value;
public NormalizerOptions Get(string? name) => value;
public IDisposable? OnChange(Action<NormalizerOptions, string?> listener) => null;
}
#endregion
#region Determinism Tests
[Fact]
public void Normalize_SameInput_ProducesSameOutput()
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.22 },
new ActiveMitigation { Type = MitigationType.NetworkControl, Effectiveness = 0.13 }
],
CombinedEffectiveness = 0.35,
RuntimeVerified = true
};
var results = Enumerable.Range(0, 100)
.Select(_ => _sut.Normalize(input))
.Distinct()
.ToList();
results.Should().ContainSingle("Deterministic normalizer should produce identical results");
}
#endregion
#region Mitigation Type Tests
[Theory]
[InlineData(MitigationType.Unknown)]
[InlineData(MitigationType.NetworkControl)]
[InlineData(MitigationType.FeatureFlag)]
[InlineData(MitigationType.SecurityPolicy)]
[InlineData(MitigationType.Isolation)]
[InlineData(MitigationType.InputValidation)]
[InlineData(MitigationType.AuthRequired)]
[InlineData(MitigationType.VirtualPatch)]
[InlineData(MitigationType.ComponentRemoval)]
public void Normalize_AllMitigationTypes_HandleCorrectly(MitigationType type)
{
var input = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = type, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.20
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.0, 1.0);
}
#endregion
}

View File

@@ -0,0 +1,452 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for NormalizerAggregator.
/// </summary>
public class NormalizerAggregatorTests
{
private readonly NormalizerAggregator _sut;
public NormalizerAggregatorTests()
{
_sut = new NormalizerAggregator();
}
#region Basic Aggregation Tests
[Fact]
public void Aggregate_EmptyEvidence_ReturnsDefaults()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0"
};
var result = _sut.Aggregate(evidence);
result.FindingId.Should().Be(evidence.FindingId);
result.Rch.Should().BeInRange(0.0, 1.0);
result.Rts.Should().BeInRange(0.0, 1.0);
result.Bkp.Should().BeInRange(0.0, 1.0);
result.Xpl.Should().BeInRange(0.0, 1.0);
result.Src.Should().BeInRange(0.0, 1.0);
result.Mit.Should().BeInRange(0.0, 1.0);
}
[Fact]
public void Aggregate_WithAllEvidence_NormalizesAll()
{
var evidence = CreateFullEvidence();
var result = _sut.Aggregate(evidence);
result.FindingId.Should().Be(evidence.FindingId);
result.Rch.Should().BeGreaterThan(0.0);
result.Rts.Should().BeGreaterThan(0.0);
result.Bkp.Should().BeGreaterThan(0.0);
result.Xpl.Should().BeGreaterThan(0.0);
result.Src.Should().BeGreaterThan(0.0);
result.Mit.Should().BeGreaterThan(0.0);
}
[Fact]
public void Aggregate_PreservesDetailedInputs()
{
var evidence = CreateFullEvidence();
var result = _sut.Aggregate(evidence);
result.ReachabilityDetails.Should().BeSameAs(evidence.Reachability);
result.RuntimeDetails.Should().BeSameAs(evidence.Runtime);
result.BackportDetails.Should().BeSameAs(evidence.Backport);
result.ExploitDetails.Should().BeSameAs(evidence.Exploit);
result.SourceTrustDetails.Should().BeSameAs(evidence.SourceTrust);
result.MitigationDetails.Should().BeSameAs(evidence.Mitigations);
}
#endregion
#region Partial Evidence Tests
[Fact]
public void Aggregate_OnlyReachability_UsesDefaultsForOthers()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8
}
};
var result = _sut.Aggregate(evidence);
result.Rch.Should().BeGreaterThan(0.5); // High reachability
result.Rts.Should().Be(0.0); // Default for no runtime
result.Bkp.Should().BeApproximately(0.0, 0.05); // Default for no backport
result.Mit.Should().Be(0.0); // Default for no mitigation
}
[Fact]
public void Aggregate_OnlyExploit_UsesDefaultsForOthers()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-5678@pkg:pypi/requests@2.28.0",
Exploit = new ExploitInput
{
EpssScore = 0.85,
EpssPercentile = 97.0,
KevStatus = KevStatus.InKev
}
};
var result = _sut.Aggregate(evidence);
result.Xpl.Should().BeGreaterThan(0.7); // High exploit risk
}
[Fact]
public void Aggregate_OnlyMitigation_UsesDefaultsForOthers()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-9012@pkg:maven/commons-io@2.11.0",
Mitigations = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.FeatureFlag, Effectiveness = 0.30 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.15 }
],
CombinedEffectiveness = 0.45
}
};
var result = _sut.Aggregate(evidence);
result.Mit.Should().BeGreaterThan(0.4);
}
#endregion
#region AggregateWithDetails Tests
[Fact]
public void AggregateWithDetails_ReturnsAllDimensions()
{
var evidence = CreateFullEvidence();
var result = _sut.AggregateWithDetails(evidence);
result.Input.Should().NotBeNull();
result.Details.Should().ContainKey("RCH");
result.Details.Should().ContainKey("RTS");
result.Details.Should().ContainKey("BKP");
result.Details.Should().ContainKey("XPL");
result.Details.Should().ContainKey("SRC");
result.Details.Should().ContainKey("MIT");
}
[Fact]
public void AggregateWithDetails_IncludesExplanations()
{
var evidence = CreateFullEvidence();
var result = _sut.AggregateWithDetails(evidence);
foreach (var (_, details) in result.Details)
{
details.Explanation.Should().NotBeNullOrEmpty();
details.Score.Should().BeInRange(0.0, 1.0);
details.Dimension.Should().NotBeNullOrEmpty();
}
}
[Fact]
public void AggregateWithDetails_EmptyEvidence_GeneratesWarnings()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0"
};
var result = _sut.AggregateWithDetails(evidence);
result.Warnings.Should().NotBeEmpty();
result.Warnings.Should().Contain(w => w.Contains("reachability"));
result.Warnings.Should().Contain(w => w.Contains("runtime"));
}
[Fact]
public void AggregateWithDetails_PartialEvidence_WarnsAboutMissing()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8
}
// Other dimensions missing
};
var result = _sut.AggregateWithDetails(evidence);
result.Details.Should().ContainKey("RCH");
result.Details.Should().NotContainKey("RTS"); // No runtime input
result.Warnings.Should().Contain(w => w.Contains("runtime"));
}
[Fact]
public void AggregateWithDetails_IncludesComponents()
{
var evidence = CreateFullEvidence();
var result = _sut.AggregateWithDetails(evidence);
foreach (var (_, details) in result.Details)
{
details.Components.Should().NotBeEmpty();
}
}
#endregion
#region AggregateAsync Tests
[Fact]
public async Task AggregateAsync_ReturnsValidInput()
{
var findingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0";
var result = await _sut.AggregateAsync(findingId);
result.FindingId.Should().Be(findingId);
result.Rch.Should().BeInRange(0.0, 1.0);
}
[Fact]
public async Task AggregateAsync_NullFindingId_ThrowsArgumentException()
{
var act = () => _sut.AggregateAsync(null!);
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task AggregateAsync_EmptyFindingId_ThrowsArgumentException()
{
var act = () => _sut.AggregateAsync(string.Empty);
await act.Should().ThrowAsync<ArgumentException>();
}
#endregion
#region Null Input Tests
[Fact]
public void Aggregate_NullEvidence_ThrowsArgumentNullException()
{
var act = () => _sut.Aggregate(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void AggregateWithDetails_NullEvidence_ThrowsArgumentNullException()
{
var act = () => _sut.AggregateWithDetails(null!);
act.Should().Throw<ArgumentNullException>();
}
#endregion
#region DI Integration Tests
[Fact]
public void Constructor_WithIOptionsMonitor_WorksCorrectly()
{
var options = new NormalizerOptions
{
Reachability = new ReachabilityNormalizerOptions
{
UnknownScore = 0.60
}
};
var optionsMonitor = new TestOptionsMonitor(options);
var aggregator = new NormalizerAggregator(optionsMonitor);
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0"
};
var result = aggregator.Aggregate(evidence);
// Should use custom unknown score
result.Rch.Should().BeApproximately(0.60, 0.01);
}
private sealed class TestOptionsMonitor(NormalizerOptions value) : IOptionsMonitor<NormalizerOptions>
{
public NormalizerOptions CurrentValue => value;
public NormalizerOptions Get(string? name) => value;
public IDisposable? OnChange(Action<NormalizerOptions, string?> listener) => null;
}
#endregion
#region Determinism Tests
[Fact]
public void Aggregate_SameInput_ProducesSameOutput()
{
var evidence = CreateFullEvidence();
var results = Enumerable.Range(0, 10)
.Select(_ => _sut.Aggregate(evidence))
.ToList();
var firstResult = results[0];
foreach (var result in results.Skip(1))
{
result.Rch.Should().Be(firstResult.Rch);
result.Rts.Should().Be(firstResult.Rts);
result.Bkp.Should().Be(firstResult.Bkp);
result.Xpl.Should().Be(firstResult.Xpl);
result.Src.Should().Be(firstResult.Src);
result.Mit.Should().Be(firstResult.Mit);
}
}
#endregion
#region FromScoreInput Conversion Tests
[Fact]
public void FindingEvidence_FromScoreInput_ExtractsAllDetails()
{
var scoreInput = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0",
Rch = 0.75,
Rts = 0.60,
Bkp = 0.80,
Xpl = 0.40,
Src = 0.85,
Mit = 0.30,
ReachabilityDetails = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8
},
RuntimeDetails = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.7
}
};
var evidence = FindingEvidence.FromScoreInput(scoreInput);
evidence.FindingId.Should().Be(scoreInput.FindingId);
evidence.Reachability.Should().BeSameAs(scoreInput.ReachabilityDetails);
evidence.Runtime.Should().BeSameAs(scoreInput.RuntimeDetails);
}
[Fact]
public void Aggregate_RoundTrip_MaintainsConsistency()
{
var evidence = CreateFullEvidence();
// Aggregate to score input
var scoreInput = _sut.Aggregate(evidence);
// Convert back to evidence
var roundTripEvidence = FindingEvidence.FromScoreInput(scoreInput);
// Re-aggregate
var result = _sut.Aggregate(roundTripEvidence);
// Scores should match
result.Rch.Should().Be(scoreInput.Rch);
result.Rts.Should().Be(scoreInput.Rts);
result.Bkp.Should().Be(scoreInput.Bkp);
result.Xpl.Should().Be(scoreInput.Xpl);
result.Src.Should().Be(scoreInput.Src);
result.Mit.Should().Be(scoreInput.Mit);
}
#endregion
#region Helper Methods
private static FindingEvidence CreateFullEvidence() => new()
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 2,
HasTaintTracking = true
},
Runtime = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 8,
RecencyFactor = 0.75,
DirectPathObserved = true
},
Backport = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.85,
Status = BackportStatus.Fixed
},
Exploit = new ExploitInput
{
EpssScore = 0.65,
EpssPercentile = 90.0,
KevStatus = KevStatus.NotInKev,
PublicExploitAvailable = true
},
SourceTrust = new SourceTrustInput
{
IssuerType = IssuerType.Distribution,
IssuerId = "debian-security",
ProvenanceTrust = 0.85,
CoverageCompleteness = 0.80,
Replayability = 0.75,
IsCryptographicallyAttested = true
},
Mitigations = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.20,
RuntimeVerified = true
}
};
#endregion
}

View File

@@ -0,0 +1,466 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Cross-module integration tests for evidence normalization pipeline.
/// Tests the full flow from raw evidence through normalizers to score input.
/// </summary>
public class NormalizerIntegrationTests
{
#region Backport Evidence BKP Score Tests
[Fact]
public void BackportEvidence_PatchSignatureFixed_ProducesHighBkpScore()
{
// Arrange: High-quality backport evidence (patch signature + fixed)
var aggregator = CreateAggregator();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:deb/debian/openssl@1.1.1",
Backport = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.90,
Status = BackportStatus.Fixed,
EvidenceSource = "binary-diff"
}
};
// Act
var result = aggregator.Aggregate(evidence);
// Assert: High BKP score
result.Bkp.Should().BeGreaterThan(0.75, "Patch signature with fixed status should produce high BKP");
result.BackportDetails.Should().BeSameAs(evidence.Backport);
}
[Fact]
public void BackportEvidence_HeuristicNotAffected_ProducesModerateBkpScore()
{
var aggregator = CreateAggregator();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-5678@pkg:rpm/redhat/kernel@5.14.0",
Backport = new BackportInput
{
EvidenceTier = BackportEvidenceTier.Heuristic,
Confidence = 0.70,
Status = BackportStatus.NotAffected,
EvidenceSource = "manual-review"
}
};
var result = aggregator.Aggregate(evidence);
result.Bkp.Should().BeInRange(0.20, 0.70, "Heuristic tier with not_affected should produce moderate BKP");
}
[Fact]
public void BackportEvidence_NoEvidence_ProducesLowBkpScore()
{
var aggregator = CreateAggregator();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-9999@pkg:npm/unknown@1.0.0"
// No backport evidence
};
var result = aggregator.Aggregate(evidence);
result.Bkp.Should().BeLessThan(0.10, "No backport evidence should produce low BKP");
}
#endregion
#region EPSS + KEV XPL Score Tests
[Fact]
public void ExploitEvidence_HighEpssAndKev_ProducesHighXplScore()
{
var aggregator = CreateAggregator();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
Exploit = new ExploitInput
{
EpssScore = 0.85,
EpssPercentile = 97.0,
KevStatus = KevStatus.InKev,
KevAddedDate = DateTimeOffset.UtcNow.AddDays(-30),
PublicExploitAvailable = true,
ExploitMaturity = "weaponized"
}
};
var result = aggregator.Aggregate(evidence);
result.Xpl.Should().BeGreaterThan(0.80, "High EPSS + KEV should produce very high XPL");
}
[Fact]
public void ExploitEvidence_MediumEpssNoKev_ProducesMediumXplScore()
{
var aggregator = CreateAggregator();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-5678@pkg:pypi/requests@2.28.0",
Exploit = new ExploitInput
{
EpssScore = 0.25,
EpssPercentile = 75.0,
KevStatus = KevStatus.NotInKev
}
};
var result = aggregator.Aggregate(evidence);
result.Xpl.Should().BeInRange(0.25, 0.50, "Medium EPSS without KEV should produce medium XPL");
}
[Fact]
public void ExploitEvidence_LowEpss_ProducesLowXplScore()
{
var aggregator = CreateAggregator();
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-9012@pkg:maven/commons-io@2.11.0",
Exploit = new ExploitInput
{
EpssScore = 0.001,
EpssPercentile = 5.0,
KevStatus = KevStatus.NotInKev
}
};
var result = aggregator.Aggregate(evidence);
result.Xpl.Should().BeLessThan(0.30, "Low EPSS should produce low XPL");
}
#endregion
#region Full Evidence Pipeline Score Input Tests
[Fact]
public void FullEvidence_AllDimensions_ProducesValidScoreInput()
{
var aggregator = CreateAggregator();
var evidence = CreateComprehensiveEvidence();
var result = aggregator.Aggregate(evidence);
// Validate all dimensions are in valid range
result.Rch.Should().BeInRange(0.0, 1.0);
result.Rts.Should().BeInRange(0.0, 1.0);
result.Bkp.Should().BeInRange(0.0, 1.0);
result.Xpl.Should().BeInRange(0.0, 1.0);
result.Src.Should().BeInRange(0.0, 1.0);
result.Mit.Should().BeInRange(0.0, 1.0);
// Validate details are preserved
result.ReachabilityDetails.Should().BeSameAs(evidence.Reachability);
result.RuntimeDetails.Should().BeSameAs(evidence.Runtime);
result.BackportDetails.Should().BeSameAs(evidence.Backport);
result.ExploitDetails.Should().BeSameAs(evidence.Exploit);
result.SourceTrustDetails.Should().BeSameAs(evidence.SourceTrust);
result.MitigationDetails.Should().BeSameAs(evidence.Mitigations);
}
[Fact]
public void FullEvidence_AggregateWithDetails_ProducesExplanations()
{
var aggregator = CreateAggregator();
var evidence = CreateComprehensiveEvidence();
var result = aggregator.AggregateWithDetails(evidence);
// Validate all dimensions have explanations
result.Details.Should().ContainKey("RCH");
result.Details.Should().ContainKey("RTS");
result.Details.Should().ContainKey("BKP");
result.Details.Should().ContainKey("XPL");
result.Details.Should().ContainKey("SRC");
result.Details.Should().ContainKey("MIT");
// Validate explanations are meaningful
foreach (var (dimension, details) in result.Details)
{
details.Score.Should().BeInRange(0.0, 1.0, $"{dimension} score should be in [0,1]");
details.Explanation.Should().NotBeNullOrEmpty($"{dimension} should have explanation");
details.Dimension.Should().NotBeNullOrEmpty($"{dimension} should have dimension name");
}
}
[Fact]
public void FullEvidence_ScoreInputPassesValidation()
{
var aggregator = CreateAggregator();
var evidence = CreateComprehensiveEvidence();
var result = aggregator.Aggregate(evidence);
var validationErrors = result.Validate();
validationErrors.Should().BeEmpty("Aggregated score input should pass validation");
}
#endregion
#region End-to-End Scoring Flow Tests
[Fact]
public void EndToEnd_HighRiskFinding_ProducesHigherScoreComponents()
{
var aggregator = CreateAggregator();
// High-risk scenario: reachable, actively exploited, no mitigation
var highRiskEvidence = new FindingEvidence
{
FindingId = "CVE-2024-CRITICAL@pkg:npm/vulnerable@1.0.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.DynamicReachable,
Confidence = 0.95,
HasTaintTracking = true
},
Runtime = new RuntimeInput
{
Posture = RuntimePosture.FullInstrumentation,
ObservationCount = 15,
RecencyFactor = 0.95,
DirectPathObserved = true
},
Exploit = new ExploitInput
{
EpssScore = 0.90,
EpssPercentile = 99.0,
KevStatus = KevStatus.InKev,
PublicExploitAvailable = true,
ExploitMaturity = "weaponized"
},
SourceTrust = new SourceTrustInput
{
IssuerType = IssuerType.Vendor,
ProvenanceTrust = 0.95,
CoverageCompleteness = 0.90,
Replayability = 0.85,
IsCryptographicallyAttested = true
}
// No mitigations
};
var result = aggregator.Aggregate(highRiskEvidence);
// High-risk finding should have high risk-increasing dimensions
result.Rch.Should().BeGreaterThan(0.80, "Dynamic reachability should be high");
result.Rts.Should().BeGreaterThan(0.70, "Runtime observed should be high");
result.Xpl.Should().BeGreaterThan(0.85, "KEV + high EPSS should be very high");
result.Mit.Should().BeLessThanOrEqualTo(0.01, "No mitigations should be near zero");
}
[Fact]
public void EndToEnd_LowRiskFinding_ProducesLowerScoreComponents()
{
var aggregator = CreateAggregator();
// Low-risk scenario: not reachable, patched, heavily mitigated
var lowRiskEvidence = new FindingEvidence
{
FindingId = "CVE-2024-MINOR@pkg:npm/safe@2.0.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.NotReachable,
Confidence = 0.90
},
Backport = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.95,
Status = BackportStatus.Fixed
},
Exploit = new ExploitInput
{
EpssScore = 0.001,
EpssPercentile = 2.0,
KevStatus = KevStatus.NotInKev
},
Mitigations = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 },
new ActiveMitigation { Type = MitigationType.Isolation, Effectiveness = 0.30 },
new ActiveMitigation { Type = MitigationType.AuthRequired, Effectiveness = 0.15 }
],
CombinedEffectiveness = 0.65,
RuntimeVerified = true
}
};
var result = aggregator.Aggregate(lowRiskEvidence);
// Low-risk finding should have low risk-increasing dimensions
result.Rch.Should().BeLessThan(0.20, "Not reachable should be low");
result.Bkp.Should().BeGreaterThan(0.75, "Patched with signature should be high");
result.Xpl.Should().BeLessThan(0.30, "Low EPSS should be low");
result.Mit.Should().BeGreaterThan(0.60, "Heavy mitigations should be high");
}
#endregion
#region DI Integration Tests
[Fact]
public void DiIntegration_ResolvedAggregator_WorksCorrectly()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers();
using var provider = services.BuildServiceProvider();
var aggregator = provider.GetRequiredService<INormalizerAggregator>();
var evidence = CreateComprehensiveEvidence();
var result = aggregator.Aggregate(evidence);
result.Should().NotBeNull();
result.FindingId.Should().Be(evidence.FindingId);
}
[Fact]
public void DiIntegration_CustomOptions_AffectsNormalization()
{
var services = new ServiceCollection();
services.AddEvidenceNormalizers(options =>
{
options.Reachability.UnknownScore = 0.75;
});
using var provider = services.BuildServiceProvider();
var aggregator = provider.GetRequiredService<INormalizerAggregator>();
// No reachability evidence should use custom unknown score
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-1234@pkg:npm/test@1.0.0"
};
var result = aggregator.Aggregate(evidence);
result.Rch.Should().BeApproximately(0.75, 0.01);
}
#endregion
#region Determinism Tests
[Fact]
public void Determinism_SameEvidence_ProducesSameScores()
{
var aggregator = CreateAggregator();
var evidence = CreateComprehensiveEvidence();
var results = Enumerable.Range(0, 100)
.Select(_ => aggregator.Aggregate(evidence))
.ToList();
var first = results[0];
foreach (var result in results.Skip(1))
{
result.Rch.Should().Be(first.Rch);
result.Rts.Should().Be(first.Rts);
result.Bkp.Should().Be(first.Bkp);
result.Xpl.Should().Be(first.Xpl);
result.Src.Should().Be(first.Src);
result.Mit.Should().Be(first.Mit);
}
}
[Fact]
public void Determinism_DifferentAggregatorInstances_ProduceSameScores()
{
var evidence = CreateComprehensiveEvidence();
var results = Enumerable.Range(0, 10)
.Select(_ => CreateAggregator().Aggregate(evidence))
.ToList();
var first = results[0];
foreach (var result in results.Skip(1))
{
result.Rch.Should().Be(first.Rch);
result.Rts.Should().Be(first.Rts);
result.Bkp.Should().Be(first.Bkp);
result.Xpl.Should().Be(first.Xpl);
result.Src.Should().Be(first.Src);
result.Mit.Should().Be(first.Mit);
}
}
#endregion
#region Helper Methods
private static INormalizerAggregator CreateAggregator()
{
return new NormalizerAggregator();
}
private static FindingEvidence CreateComprehensiveEvidence() => new()
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 2,
HasTaintTracking = true
},
Runtime = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 8,
RecencyFactor = 0.75,
DirectPathObserved = true
},
Backport = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.85,
Status = BackportStatus.Fixed
},
Exploit = new ExploitInput
{
EpssScore = 0.45,
EpssPercentile = 85.0,
KevStatus = KevStatus.NotInKev,
PublicExploitAvailable = false
},
SourceTrust = new SourceTrustInput
{
IssuerType = IssuerType.Distribution,
IssuerId = "debian-security",
ProvenanceTrust = 0.85,
CoverageCompleteness = 0.80,
Replayability = 0.75,
IsCryptographicallyAttested = true
},
Mitigations = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 }
],
CombinedEffectiveness = 0.20,
RuntimeVerified = true
}
};
#endregion
}

View File

@@ -0,0 +1,446 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for IEvidenceNormalizer interface and NormalizationResult.
/// </summary>
public class EvidenceNormalizerInterfaceTests
{
#region NormalizationResult Tests
[Fact]
public void NormalizationResult_Simple_CreatesWithEmptyComponents()
{
var result = NormalizationResult.Simple(0.75, "RCH", "High reachability");
result.Score.Should().Be(0.75);
result.Dimension.Should().Be("RCH");
result.Explanation.Should().Be("High reachability");
result.Components.Should().BeEmpty();
}
[Fact]
public void NormalizationResult_WithComponents_IncludesBreakdown()
{
var components = new Dictionary<string, double>
{
["base_score"] = 0.60,
["confidence_bonus"] = 0.15
};
var result = NormalizationResult.WithComponents(
0.75,
"RCH",
"Static reachable with high confidence",
components);
result.Score.Should().Be(0.75);
result.Components.Should().HaveCount(2);
result.Components["base_score"].Should().Be(0.60);
result.Components["confidence_bonus"].Should().Be(0.15);
}
[Fact]
public void NormalizationResult_IsImmutable()
{
var components = new Dictionary<string, double> { ["test"] = 1.0 };
var result = NormalizationResult.WithComponents(0.5, "TEST", "Test", components);
// Modifying original dictionary shouldn't affect result
components["another"] = 2.0;
result.Components.Should().HaveCount(1);
result.Components.Should().NotContainKey("another");
}
#endregion
#region Extension Method Tests
[Fact]
public void NormalizeClamped_ClampsAboveOne()
{
var normalizer = new TestNormalizer(1.5);
var result = normalizer.NormalizeClamped("test");
result.Should().Be(1.0);
}
[Fact]
public void NormalizeClamped_ClampsBelowZero()
{
var normalizer = new TestNormalizer(-0.5);
var result = normalizer.NormalizeClamped("test");
result.Should().Be(0.0);
}
[Fact]
public void NormalizeClamped_PassesThroughValidValues()
{
var normalizer = new TestNormalizer(0.75);
var result = normalizer.NormalizeClamped("test");
result.Should().Be(0.75);
}
[Fact]
public void NormalizeAverage_ReturnsAverageOfScores()
{
var normalizer = new SequenceNormalizer([0.2, 0.4, 0.6, 0.8]);
var result = normalizer.NormalizeAverage(["a", "b", "c", "d"]);
result.Should().Be(0.5);
}
[Fact]
public void NormalizeAverage_ReturnsZeroForEmptySequence()
{
var normalizer = new SequenceNormalizer([]);
var result = normalizer.NormalizeAverage(Array.Empty<string>());
result.Should().Be(0.0);
}
[Fact]
public void NormalizeMax_ReturnsMaximumScore()
{
var normalizer = new SequenceNormalizer([0.2, 0.9, 0.4, 0.6]);
var result = normalizer.NormalizeMax(["a", "b", "c", "d"]);
result.Should().Be(0.9);
}
[Fact]
public void NormalizeMax_ReturnsZeroForEmptySequence()
{
var normalizer = new SequenceNormalizer([]);
var result = normalizer.NormalizeMax(Array.Empty<string>());
result.Should().Be(0.0);
}
[Fact]
public void NormalizeMax_ClampsValues()
{
var normalizer = new SequenceNormalizer([0.5, 1.5, 0.3]); // 1.5 should be clamped
var result = normalizer.NormalizeMax(["a", "b", "c"]);
result.Should().Be(1.0);
}
#endregion
#region Test Helpers
private sealed class TestNormalizer(double fixedScore) : IEvidenceNormalizer<string>
{
public string Dimension => "TEST";
public double Normalize(string input) => fixedScore;
public NormalizationResult NormalizeWithDetails(string input) =>
NormalizationResult.Simple(fixedScore, Dimension, $"Fixed score: {fixedScore}");
}
private sealed class SequenceNormalizer(double[] scores) : IEvidenceNormalizer<string>
{
private int _index;
public string Dimension => "SEQ";
public double Normalize(string input) =>
_index < scores.Length ? scores[_index++] : 0.0;
public NormalizationResult NormalizeWithDetails(string input) =>
NormalizationResult.Simple(Normalize(input), Dimension, "Sequence");
}
#endregion
}
/// <summary>
/// Tests for INormalizerAggregator interface and FindingEvidence.
/// </summary>
public class NormalizerAggregatorInterfaceTests
{
#region FindingEvidence Tests
[Fact]
public void FindingEvidence_WithAllEvidence_IsValid()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-12345@pkg:npm/express@4.18.0",
Reachability = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 3
},
Runtime = new RuntimeInput
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 15,
RecencyFactor = 0.9
},
Backport = new BackportInput
{
EvidenceTier = BackportEvidenceTier.PatchSignature,
Confidence = 0.85,
Status = BackportStatus.Fixed,
ProofId = "proof-123"
},
Exploit = new ExploitInput
{
EpssScore = 0.45,
EpssPercentile = 97.0,
KevStatus = KevStatus.InKev
},
SourceTrust = new SourceTrustInput
{
IssuerType = IssuerType.Vendor,
ProvenanceTrust = 0.95,
CoverageCompleteness = 0.90,
Replayability = 0.85,
IsCryptographicallyAttested = true
},
Mitigations = new MitigationInput
{
ActiveMitigations =
[
new ActiveMitigation { Type = MitigationType.SecurityPolicy, Effectiveness = 0.20 },
new ActiveMitigation { Type = MitigationType.Isolation, Effectiveness = 0.10 }
],
CombinedEffectiveness = 0.30
}
};
evidence.FindingId.Should().NotBeNullOrEmpty();
evidence.Reachability.Should().NotBeNull();
evidence.Runtime.Should().NotBeNull();
evidence.Backport.Should().NotBeNull();
evidence.Exploit.Should().NotBeNull();
evidence.SourceTrust.Should().NotBeNull();
evidence.Mitigations.Should().NotBeNull();
}
[Fact]
public void FindingEvidence_WithPartialEvidence_IsValid()
{
var evidence = new FindingEvidence
{
FindingId = "CVE-2024-12345",
Reachability = new ReachabilityInput
{
State = ReachabilityState.Unknown,
Confidence = 0.5
}
// Other evidence is null - handled by aggregator with defaults
};
evidence.FindingId.Should().Be("CVE-2024-12345");
evidence.Reachability.Should().NotBeNull();
evidence.Runtime.Should().BeNull();
evidence.Backport.Should().BeNull();
evidence.Exploit.Should().BeNull();
evidence.SourceTrust.Should().BeNull();
evidence.Mitigations.Should().BeNull();
}
[Fact]
public void FindingEvidence_FromScoreInput_CopiesDetails()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8,
Rts = 0.7,
Bkp = 0.5,
Xpl = 0.6,
Src = 0.5,
Mit = 0.1,
ReachabilityDetails = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8
}
};
var evidence = FindingEvidence.FromScoreInput(input);
evidence.FindingId.Should().Be("CVE-2024-12345");
evidence.Reachability.Should().NotBeNull();
evidence.Reachability!.State.Should().Be(ReachabilityState.StaticReachable);
}
#endregion
#region AggregationResult Tests
[Fact]
public void AggregationResult_WithDetails_IsValid()
{
var input = new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-12345",
Rch = 0.8,
Rts = 0.7,
Bkp = 0.6,
Xpl = 0.5,
Src = 0.4,
Mit = 0.1
};
var details = new Dictionary<string, NormalizationResult>
{
["RCH"] = NormalizationResult.Simple(0.8, "RCH", "High reachability"),
["RTS"] = NormalizationResult.Simple(0.7, "RTS", "Strong runtime signal")
};
var result = new AggregationResult
{
Input = input,
Details = details,
Warnings = ["Minor: Missing EPSS data"]
};
result.Input.Should().NotBeNull();
result.Details.Should().HaveCount(2);
result.Warnings.Should().ContainSingle();
}
#endregion
}
/// <summary>
/// Tests for NormalizerOptions configuration.
/// </summary>
public class NormalizerOptionsTests
{
[Fact]
public void NormalizerOptions_HasCorrectSectionName()
{
NormalizerOptions.SectionName.Should().Be("EvidenceNormalization");
}
[Fact]
public void NormalizerOptions_HasSensibleDefaults()
{
var options = new NormalizerOptions();
// Reachability defaults
options.Reachability.ConfirmedReachableBase.Should().Be(0.95);
options.Reachability.UnknownScore.Should().Be(0.50);
// Runtime defaults
options.Runtime.HighObservationThreshold.Should().Be(10);
options.Runtime.ContradictsScore.Should().Be(0.10);
// Backport defaults (tiers match BackportEvidenceTier enum: 0=None, 1=Heuristic, etc.)
options.Backport.Tier0Range.Should().Be((0.00, 0.10)); // None
options.Backport.Tier1Range.Should().Be((0.45, 0.60)); // Heuristic
options.Backport.Tier2Range.Should().Be((0.70, 0.85)); // PatchSignature
options.Backport.Tier3Range.Should().Be((0.80, 0.92)); // BinaryDiff
options.Backport.Tier4Range.Should().Be((0.85, 0.95)); // VendorVex
options.Backport.Tier5Range.Should().Be((0.90, 1.00)); // SignedProof
options.Backport.CombinationBonus.Should().Be(0.05);
// Exploit defaults
options.Exploit.KevFloor.Should().Be(0.40);
options.Exploit.NoEpssScore.Should().Be(0.30);
// Source trust defaults
options.SourceTrust.VendorMultiplier.Should().Be(1.0);
options.SourceTrust.CommunityMultiplier.Should().Be(0.60);
// Mitigation defaults
options.Mitigation.MaxTotalMitigation.Should().Be(1.0);
// Default values for missing evidence
options.Defaults.Rch.Should().Be(0.50);
options.Defaults.Rts.Should().Be(0.0);
options.Defaults.Mit.Should().Be(0.0);
}
[Fact]
public void ReachabilityNormalizerOptions_CanBeConfigured()
{
var options = new ReachabilityNormalizerOptions
{
ConfirmedReachableBase = 0.90,
ConfirmedReachableBonus = 0.10,
StaticReachableBase = 0.35,
UnknownScore = 0.45
};
options.ConfirmedReachableBase.Should().Be(0.90);
options.ConfirmedReachableBonus.Should().Be(0.10);
options.StaticReachableBase.Should().Be(0.35);
options.UnknownScore.Should().Be(0.45);
}
[Fact]
public void ExploitNormalizerOptions_PercentileThresholdsAreOrdered()
{
var options = new ExploitNormalizerOptions();
options.Top1PercentThreshold.Should().BeGreaterThan(options.Top5PercentThreshold);
options.Top5PercentThreshold.Should().BeGreaterThan(options.Top25PercentThreshold);
}
[Fact]
public void SourceTrustNormalizerOptions_MultipliersAreOrdered()
{
var options = new SourceTrustNormalizerOptions();
options.VendorMultiplier.Should().BeGreaterThanOrEqualTo(options.DistributionMultiplier);
options.DistributionMultiplier.Should().BeGreaterThanOrEqualTo(options.TrustedThirdPartyMultiplier);
options.TrustedThirdPartyMultiplier.Should().BeGreaterThanOrEqualTo(options.CommunityMultiplier);
options.CommunityMultiplier.Should().BeGreaterThanOrEqualTo(options.UnknownMultiplier);
}
[Fact]
public void MitigationNormalizerOptions_EffectivenessRangesAreValid()
{
var options = new MitigationNormalizerOptions();
// All ranges should have Low <= High
options.FeatureFlagEffectiveness.Low.Should().BeLessThanOrEqualTo(options.FeatureFlagEffectiveness.High);
options.AuthRequiredEffectiveness.Low.Should().BeLessThanOrEqualTo(options.AuthRequiredEffectiveness.High);
options.AdminOnlyEffectiveness.Low.Should().BeLessThanOrEqualTo(options.AdminOnlyEffectiveness.High);
options.NonDefaultConfigEffectiveness.Low.Should().BeLessThanOrEqualTo(options.NonDefaultConfigEffectiveness.High);
options.SeccompEffectiveness.Low.Should().BeLessThanOrEqualTo(options.SeccompEffectiveness.High);
options.MacEffectiveness.Low.Should().BeLessThanOrEqualTo(options.MacEffectiveness.High);
options.NetworkIsolationEffectiveness.Low.Should().BeLessThanOrEqualTo(options.NetworkIsolationEffectiveness.High);
options.ReadOnlyFsEffectiveness.Low.Should().BeLessThanOrEqualTo(options.ReadOnlyFsEffectiveness.High);
}
[Fact]
public void DefaultValuesOptions_AllValuesInValidRange()
{
var options = new DefaultValuesOptions();
options.Rch.Should().BeInRange(0.0, 1.0);
options.Rts.Should().BeInRange(0.0, 1.0);
options.Bkp.Should().BeInRange(0.0, 1.0);
options.Xpl.Should().BeInRange(0.0, 1.0);
options.Src.Should().BeInRange(0.0, 1.0);
options.Mit.Should().BeInRange(0.0, 1.0);
}
}

View File

@@ -0,0 +1,632 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for ReachabilityNormalizer.
/// </summary>
public class ReachabilityNormalizerTests
{
private readonly ReachabilityNormalizerOptions _defaultOptions = new();
private readonly ReachabilityNormalizer _sut;
public ReachabilityNormalizerTests()
{
_sut = new ReachabilityNormalizer(_defaultOptions);
}
#region Dimension Property Tests
[Fact]
public void Dimension_ReturnsRCH()
{
_sut.Dimension.Should().Be("RCH");
}
#endregion
#region LiveExploitPath Tests
[Fact]
public void Normalize_LiveExploitPath_HighConfidence_ReturnsHighScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.LiveExploitPath,
Confidence = 1.0
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.95);
result.Should().BeLessThanOrEqualTo(1.0);
}
[Fact]
public void Normalize_LiveExploitPath_LowConfidence_StillHigh()
{
var input = new ReachabilityInput
{
State = ReachabilityState.LiveExploitPath,
Confidence = 0.5
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.95);
}
#endregion
#region DynamicReachable Tests
[Fact]
public void Normalize_DynamicReachable_HighConfidence_ReturnsHighScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.DynamicReachable,
Confidence = 1.0
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.90);
result.Should().BeLessThan(1.0);
}
[Fact]
public void Normalize_DynamicReachable_LowerThanLiveExploit()
{
var liveInput = new ReachabilityInput
{
State = ReachabilityState.LiveExploitPath,
Confidence = 1.0
};
var dynamicInput = new ReachabilityInput
{
State = ReachabilityState.DynamicReachable,
Confidence = 1.0
};
var liveScore = _sut.Normalize(liveInput);
var dynamicScore = _sut.Normalize(dynamicInput);
dynamicScore.Should().BeLessThan(liveScore);
}
#endregion
#region StaticReachable Tests
[Fact]
public void Normalize_StaticReachable_HighConfidence_ReturnsMediumHighScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 1.0
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.70);
result.Should().BeLessThanOrEqualTo(0.95);
}
[Fact]
public void Normalize_StaticReachable_LowConfidence_ReturnsLowerScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.3
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.40);
result.Should().BeLessThan(0.70);
}
[Fact]
public void Normalize_StaticReachable_ConfidenceScales()
{
var lowConfidence = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.3
};
var highConfidence = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.9
};
var lowScore = _sut.Normalize(lowConfidence);
var highScore = _sut.Normalize(highConfidence);
highScore.Should().BeGreaterThan(lowScore);
}
#endregion
#region PotentiallyReachable Tests
[Fact]
public void Normalize_PotentiallyReachable_ReturnsMediumScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.PotentiallyReachable,
Confidence = 0.5
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.30);
result.Should().BeLessThanOrEqualTo(0.60);
}
[Fact]
public void Normalize_PotentiallyReachable_LowerThanStatic()
{
var potentialInput = new ReachabilityInput
{
State = ReachabilityState.PotentiallyReachable,
Confidence = 0.7
};
var staticInput = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.7
};
var potentialScore = _sut.Normalize(potentialInput);
var staticScore = _sut.Normalize(staticInput);
potentialScore.Should().BeLessThan(staticScore);
}
#endregion
#region Unknown State Tests
[Fact]
public void Normalize_Unknown_ReturnsNeutralScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.Unknown,
Confidence = 0.0
};
var result = _sut.Normalize(input);
result.Should().BeApproximately(_defaultOptions.UnknownScore, 0.01);
}
[Fact]
public void Normalize_Unknown_ConfidenceDoesNotAffect()
{
var lowConfidence = new ReachabilityInput
{
State = ReachabilityState.Unknown,
Confidence = 0.2
};
var highConfidence = new ReachabilityInput
{
State = ReachabilityState.Unknown,
Confidence = 0.9
};
var lowScore = _sut.Normalize(lowConfidence);
var highScore = _sut.Normalize(highConfidence);
lowScore.Should().BeApproximately(highScore, 0.01);
}
#endregion
#region NotReachable Tests
[Fact]
public void Normalize_NotReachable_HighConfidence_ReturnsLowScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.NotReachable,
Confidence = 1.0
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.0);
result.Should().BeLessThanOrEqualTo(0.10);
}
[Fact]
public void Normalize_NotReachable_LowConfidence_ReturnsSlightlyHigherScore()
{
var input = new ReachabilityInput
{
State = ReachabilityState.NotReachable,
Confidence = 0.3
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.0);
result.Should().BeLessThan(0.10);
}
#endregion
#region Hop Count Tests
[Fact]
public void Normalize_StaticReachable_ZeroHops_NoHopPenalty()
{
var input = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 0
};
var result = _sut.Normalize(input);
// Should be higher than with hops
result.Should().BeGreaterThanOrEqualTo(0.70);
}
[Fact]
public void Normalize_StaticReachable_ManyHops_PenaltyApplied()
{
var zeroHops = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 0
};
var manyHops = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 10
};
var zeroHopScore = _sut.Normalize(zeroHops);
var manyHopScore = _sut.Normalize(manyHops);
manyHopScore.Should().BeLessThan(zeroHopScore);
}
[Fact]
public void Normalize_DynamicReachable_HopsNotPenalized()
{
// For dynamic analysis, hop count shouldn't matter as much
var zeroHops = new ReachabilityInput
{
State = ReachabilityState.DynamicReachable,
Confidence = 0.9,
HopCount = 0
};
var manyHops = new ReachabilityInput
{
State = ReachabilityState.DynamicReachable,
Confidence = 0.9,
HopCount = 10
};
var zeroHopScore = _sut.Normalize(zeroHops);
var manyHopScore = _sut.Normalize(manyHops);
// Should be the same (no hop penalty for dynamic)
zeroHopScore.Should().BeApproximately(manyHopScore, 0.01);
}
#endregion
#region Analysis Quality Bonus Tests
[Fact]
public void Normalize_StaticReachable_WithTaintTracking_GetsBonus()
{
var withoutTaint = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HasTaintTracking = false
};
var withTaint = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HasTaintTracking = true
};
var withoutScore = _sut.Normalize(withoutTaint);
var withScore = _sut.Normalize(withTaint);
withScore.Should().BeGreaterThan(withoutScore);
}
[Fact]
public void Normalize_StaticReachable_AllAnalysisFlags_MaxBonus()
{
var minimal = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HasInterproceduralFlow = false,
HasTaintTracking = false,
HasDataFlowSensitivity = false
};
var full = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HasInterproceduralFlow = true,
HasTaintTracking = true,
HasDataFlowSensitivity = true
};
var minimalScore = _sut.Normalize(minimal);
var fullScore = _sut.Normalize(full);
fullScore.Should().BeGreaterThan(minimalScore);
(fullScore - minimalScore).Should().BeApproximately(0.05, 0.01); // 0.02 + 0.02 + 0.01
}
[Fact]
public void Normalize_NotReachable_AnalysisFlagsNoBonus()
{
// Analysis bonuses should not apply to unreachable findings
var withFlags = new ReachabilityInput
{
State = ReachabilityState.NotReachable,
Confidence = 1.0,
HasInterproceduralFlow = true,
HasTaintTracking = true,
HasDataFlowSensitivity = true
};
var withoutFlags = new ReachabilityInput
{
State = ReachabilityState.NotReachable,
Confidence = 1.0,
HasInterproceduralFlow = false,
HasTaintTracking = false,
HasDataFlowSensitivity = false
};
var withScore = _sut.Normalize(withFlags);
var withoutScore = _sut.Normalize(withoutFlags);
withScore.Should().BeApproximately(withoutScore, 0.01);
}
#endregion
#region NormalizeWithDetails Tests
[Fact]
public void NormalizeWithDetails_ReturnsCorrectDimension()
{
var input = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.7
};
var result = _sut.NormalizeWithDetails(input);
result.Dimension.Should().Be("RCH");
}
[Fact]
public void NormalizeWithDetails_ReturnsComponents()
{
var input = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.8,
HopCount = 3,
HasTaintTracking = true
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("state");
result.Components.Should().ContainKey("confidence");
result.Components.Should().ContainKey("hop_count");
result.Components.Should().ContainKey("base_score");
result.Components.Should().ContainKey("confidence_modifier");
result.Components.Should().ContainKey("analysis_bonus");
result.Components.Should().ContainKey("hop_penalty");
result.Components.Should().ContainKey("taint_tracking");
result.Components["state"].Should().Be((double)ReachabilityState.StaticReachable);
result.Components["confidence"].Should().Be(0.8);
result.Components["hop_count"].Should().Be(3);
result.Components["taint_tracking"].Should().Be(1.0);
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation()
{
var input = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.85,
HopCount = 2,
HasTaintTracking = true,
AnalysisMethod = "codeql",
EvidenceSource = "stellaops-native"
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("Statically determined reachable");
result.Explanation.Should().Contain("85%");
result.Explanation.Should().Contain("2 hop");
result.Explanation.Should().Contain("taint-tracked");
result.Explanation.Should().Contain("codeql");
result.Explanation.Should().Contain("stellaops-native");
result.Explanation.Should().Contain("RCH=");
}
[Fact]
public void NormalizeWithDetails_Unknown_ExplainsCorrectly()
{
var input = new ReachabilityInput
{
State = ReachabilityState.Unknown,
Confidence = 0.0
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("Reachability unknown");
}
#endregion
#region Null Input Tests
[Fact]
public void Normalize_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.Normalize(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void NormalizeWithDetails_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.NormalizeWithDetails(null!);
act.Should().Throw<ArgumentNullException>();
}
#endregion
#region DI Integration Tests
[Fact]
public void Constructor_WithIOptionsMonitor_WorksCorrectly()
{
var options = new NormalizerOptions
{
Reachability = new ReachabilityNormalizerOptions
{
UnknownScore = 0.60 // Custom unknown score
}
};
var optionsMonitor = new TestOptionsMonitor(options);
var normalizer = new ReachabilityNormalizer(optionsMonitor);
var input = new ReachabilityInput
{
State = ReachabilityState.Unknown,
Confidence = 0.0
};
var result = normalizer.Normalize(input);
result.Should().BeApproximately(0.60, 0.01);
}
private sealed class TestOptionsMonitor(NormalizerOptions value) : IOptionsMonitor<NormalizerOptions>
{
public NormalizerOptions CurrentValue => value;
public NormalizerOptions Get(string? name) => value;
public IDisposable? OnChange(Action<NormalizerOptions, string?> listener) => null;
}
#endregion
#region Determinism Tests
[Fact]
public void Normalize_SameInput_ProducesSameOutput()
{
var input = new ReachabilityInput
{
State = ReachabilityState.StaticReachable,
Confidence = 0.73,
HopCount = 4,
HasInterproceduralFlow = true,
HasTaintTracking = true,
AnalysisMethod = "codeql"
};
var results = Enumerable.Range(0, 100)
.Select(_ => _sut.Normalize(input))
.Distinct()
.ToList();
results.Should().ContainSingle("Deterministic normalizer should produce identical results");
}
#endregion
#region Score Ordering Tests
[Theory]
[InlineData(ReachabilityState.LiveExploitPath)]
[InlineData(ReachabilityState.DynamicReachable)]
[InlineData(ReachabilityState.StaticReachable)]
[InlineData(ReachabilityState.PotentiallyReachable)]
[InlineData(ReachabilityState.Unknown)]
[InlineData(ReachabilityState.NotReachable)]
public void Normalize_AllStates_ReturnValidRange(ReachabilityState state)
{
var input = new ReachabilityInput
{
State = state,
Confidence = 0.75
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.0, 1.0);
}
[Fact]
public void Normalize_StateOrdering_HigherStatesProduceHigherScores()
{
var states = new[]
{
ReachabilityState.NotReachable,
ReachabilityState.Unknown,
ReachabilityState.PotentiallyReachable,
ReachabilityState.StaticReachable,
ReachabilityState.DynamicReachable,
ReachabilityState.LiveExploitPath
};
var scores = states.Select(state => _sut.Normalize(new ReachabilityInput
{
State = state,
Confidence = 0.8
})).ToList();
// Scores should generally increase (with Unknown being neutral)
scores[0].Should().BeLessThan(scores[2]); // NotReachable < PotentiallyReachable
scores[2].Should().BeLessThan(scores[3]); // PotentiallyReachable < StaticReachable
scores[3].Should().BeLessThan(scores[4]); // StaticReachable < DynamicReachable
scores[4].Should().BeLessThan(scores[5]); // DynamicReachable < LiveExploitPath
}
#endregion
}

View File

@@ -0,0 +1,616 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for RuntimeSignalNormalizer.
/// </summary>
public class RuntimeSignalNormalizerTests
{
private readonly RuntimeNormalizerOptions _defaultOptions = new();
private readonly RuntimeSignalNormalizer _sut;
public RuntimeSignalNormalizerTests()
{
_sut = new RuntimeSignalNormalizer(_defaultOptions);
}
#region Dimension Property Tests
[Fact]
public void Dimension_ReturnsRTS()
{
_sut.Dimension.Should().Be("RTS");
}
#endregion
#region No Observation Tests
[Fact]
public void Normalize_NoPosture_ReturnsZero()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.None,
ObservationCount = 0,
RecencyFactor = 0.0
};
var result = _sut.Normalize(input);
result.Should().Be(_defaultOptions.UnknownScore);
}
[Fact]
public void Normalize_ZeroObservations_ReturnsZero()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 0,
RecencyFactor = 0.9
};
var result = _sut.Normalize(input);
result.Should().Be(_defaultOptions.UnknownScore);
}
#endregion
#region Observation Count Scaling Tests
[Fact]
public void Normalize_HighObservations_ReturnsHighScore()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 15,
RecencyFactor = 0.5
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.85);
}
[Fact]
public void Normalize_MediumObservations_ReturnsMediumScore()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 7,
RecencyFactor = 0.5
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.70);
result.Should().BeLessThan(0.90);
}
[Fact]
public void Normalize_LowObservations_ReturnsLowerScore()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 2,
RecencyFactor = 0.5
};
var result = _sut.Normalize(input);
result.Should().BeGreaterThanOrEqualTo(0.55);
result.Should().BeLessThan(0.75);
}
[Fact]
public void Normalize_ObservationCountScales()
{
var low = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 2,
RecencyFactor = 0.5
};
var high = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 15,
RecencyFactor = 0.5
};
var lowScore = _sut.Normalize(low);
var highScore = _sut.Normalize(high);
highScore.Should().BeGreaterThan(lowScore);
}
#endregion
#region Posture Multiplier Tests
[Fact]
public void Normalize_FullInstrumentation_HighestMultiplier()
{
var fullInst = new RuntimeInput
{
Posture = RuntimePosture.FullInstrumentation,
ObservationCount = 10,
RecencyFactor = 0.5
};
var active = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 10,
RecencyFactor = 0.5
};
var fullScore = _sut.Normalize(fullInst);
var activeScore = _sut.Normalize(active);
fullScore.Should().BeGreaterThan(activeScore);
}
[Fact]
public void Normalize_EbpfDeep_HighMultiplier()
{
var ebpf = new RuntimeInput
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 10,
RecencyFactor = 0.5
};
var active = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 10,
RecencyFactor = 0.5
};
var ebpfScore = _sut.Normalize(ebpf);
var activeScore = _sut.Normalize(active);
ebpfScore.Should().BeGreaterThan(activeScore);
}
[Fact]
public void Normalize_Passive_LowerMultiplier()
{
var passive = new RuntimeInput
{
Posture = RuntimePosture.Passive,
ObservationCount = 10,
RecencyFactor = 0.5
};
var active = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 10,
RecencyFactor = 0.5
};
var passiveScore = _sut.Normalize(passive);
var activeScore = _sut.Normalize(active);
passiveScore.Should().BeLessThan(activeScore);
}
#endregion
#region Recency Bonus Tests
[Fact]
public void Normalize_VeryRecentObservations_GetsBonus()
{
var recent = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.95
};
var old = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.3
};
var recentScore = _sut.Normalize(recent);
var oldScore = _sut.Normalize(old);
recentScore.Should().BeGreaterThan(oldScore);
(recentScore - oldScore).Should().BeApproximately(_defaultOptions.VeryRecentBonus, 0.02);
}
[Fact]
public void Normalize_ModeratelyRecentObservations_GetsPartialBonus()
{
var modRecent = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.6
};
var old = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.2
};
var modRecentScore = _sut.Normalize(modRecent);
var oldScore = _sut.Normalize(old);
modRecentScore.Should().BeGreaterThan(oldScore);
(modRecentScore - oldScore).Should().BeApproximately(_defaultOptions.RecentBonus, 0.02);
}
[Fact]
public void Normalize_OldObservations_NoBonus()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.1
};
var result = _sut.Normalize(input);
// Should be observation score * posture multiplier only
result.Should().BeLessThanOrEqualTo(0.80);
}
#endregion
#region Quality Bonus Tests
[Fact]
public void Normalize_DirectPathObserved_GetsBonus()
{
var withDirect = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.5,
DirectPathObserved = true
};
var withoutDirect = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.5,
DirectPathObserved = false
};
var withScore = _sut.Normalize(withDirect);
var withoutScore = _sut.Normalize(withoutDirect);
withScore.Should().BeGreaterThan(withoutScore);
(withScore - withoutScore).Should().BeApproximately(0.05, 0.01);
}
[Fact]
public void Normalize_ProductionTraffic_GetsBonus()
{
var production = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.5,
IsProductionTraffic = true
};
var nonProd = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.5,
IsProductionTraffic = false
};
var prodScore = _sut.Normalize(production);
var nonProdScore = _sut.Normalize(nonProd);
prodScore.Should().BeGreaterThan(nonProdScore);
(prodScore - nonProdScore).Should().BeApproximately(0.03, 0.01);
}
[Fact]
public void Normalize_AllBonuses_Accumulate()
{
var minimal = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.2,
DirectPathObserved = false,
IsProductionTraffic = false
};
var full = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.95,
DirectPathObserved = true,
IsProductionTraffic = true
};
var minimalScore = _sut.Normalize(minimal);
var fullScore = _sut.Normalize(full);
// Full should have: recency bonus (0.10) + direct (0.05) + production (0.03) = 0.18 extra
(fullScore - minimalScore).Should().BeApproximately(0.18, 0.03);
}
#endregion
#region Score Capping Tests
[Fact]
public void Normalize_MaxBonuses_CappedAtOne()
{
var maxInput = new RuntimeInput
{
Posture = RuntimePosture.FullInstrumentation,
ObservationCount = 100,
RecencyFactor = 1.0,
DirectPathObserved = true,
IsProductionTraffic = true
};
var result = _sut.Normalize(maxInput);
result.Should().BeLessThanOrEqualTo(1.0);
}
#endregion
#region NormalizeWithDetails Tests
[Fact]
public void NormalizeWithDetails_ReturnsCorrectDimension()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.7
};
var result = _sut.NormalizeWithDetails(input);
result.Dimension.Should().Be("RTS");
}
[Fact]
public void NormalizeWithDetails_ReturnsComponents()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 8,
RecencyFactor = 0.85,
DirectPathObserved = true,
IsProductionTraffic = true
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("posture");
result.Components.Should().ContainKey("observation_count");
result.Components.Should().ContainKey("recency_factor");
result.Components.Should().ContainKey("observation_score");
result.Components.Should().ContainKey("posture_multiplier");
result.Components.Should().ContainKey("recency_bonus");
result.Components.Should().ContainKey("quality_bonus");
result.Components.Should().ContainKey("direct_path_observed");
result.Components.Should().ContainKey("is_production_traffic");
result.Components["posture"].Should().Be((double)RuntimePosture.EbpfDeep);
result.Components["observation_count"].Should().Be(8);
result.Components["direct_path_observed"].Should().Be(1.0);
result.Components["is_production_traffic"].Should().Be(1.0);
}
[Fact]
public void NormalizeWithDetails_IncludesSessionCount()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 5,
RecencyFactor = 0.5,
SessionDigests = ["sha256:aaa", "sha256:bbb", "sha256:ccc"]
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("session_count");
result.Components["session_count"].Should().Be(3);
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 12,
RecencyFactor = 0.92,
DirectPathObserved = true,
IsProductionTraffic = true,
EvidenceSource = "stellaops-ebpf"
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("12 observation(s)");
result.Explanation.Should().Contain("eBPF deep observation");
result.Explanation.Should().Contain("vulnerable path directly observed");
result.Explanation.Should().Contain("production traffic");
result.Explanation.Should().Contain("very recent");
result.Explanation.Should().Contain("stellaops-ebpf");
result.Explanation.Should().Contain("RTS=");
}
[Fact]
public void NormalizeWithDetails_NoObservations_ExplainsCorrectly()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.None,
ObservationCount = 0,
RecencyFactor = 0.0
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("No runtime observations");
}
#endregion
#region Null Input Tests
[Fact]
public void Normalize_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.Normalize(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void NormalizeWithDetails_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.NormalizeWithDetails(null!);
act.Should().Throw<ArgumentNullException>();
}
#endregion
#region DI Integration Tests
[Fact]
public void Constructor_WithIOptionsMonitor_WorksCorrectly()
{
var options = new NormalizerOptions
{
Runtime = new RuntimeNormalizerOptions
{
HighObservationScore = 0.95, // Custom high score
VeryRecentBonus = 0.15 // Custom bonus
}
};
var optionsMonitor = new TestOptionsMonitor(options);
var normalizer = new RuntimeSignalNormalizer(optionsMonitor);
var input = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 15,
RecencyFactor = 0.95
};
var result = normalizer.Normalize(input);
// Should reflect custom high observation score + custom bonus
result.Should().BeGreaterThanOrEqualTo(1.0); // May be capped at 1.0
}
private sealed class TestOptionsMonitor(NormalizerOptions value) : IOptionsMonitor<NormalizerOptions>
{
public NormalizerOptions CurrentValue => value;
public NormalizerOptions Get(string? name) => value;
public IDisposable? OnChange(Action<NormalizerOptions, string?> listener) => null;
}
#endregion
#region Determinism Tests
[Fact]
public void Normalize_SameInput_ProducesSameOutput()
{
var input = new RuntimeInput
{
Posture = RuntimePosture.EbpfDeep,
ObservationCount = 7,
RecencyFactor = 0.67,
DirectPathObserved = true,
IsProductionTraffic = false,
EvidenceSource = "test-sensor"
};
var results = Enumerable.Range(0, 100)
.Select(_ => _sut.Normalize(input))
.Distinct()
.ToList();
results.Should().ContainSingle("Deterministic normalizer should produce identical results");
}
#endregion
#region Posture Ordering Tests
[Theory]
[InlineData(RuntimePosture.None)]
[InlineData(RuntimePosture.Passive)]
[InlineData(RuntimePosture.ActiveTracing)]
[InlineData(RuntimePosture.EbpfDeep)]
[InlineData(RuntimePosture.FullInstrumentation)]
public void Normalize_AllPostures_ReturnValidRange(RuntimePosture posture)
{
var input = new RuntimeInput
{
Posture = posture,
ObservationCount = posture == RuntimePosture.None ? 0 : 5,
RecencyFactor = 0.5
};
var result = _sut.Normalize(input);
result.Should().BeInRange(0.0, 1.0);
}
[Fact]
public void Normalize_PostureOrdering_BetterPosturesProduceHigherScores()
{
var postures = new[]
{
RuntimePosture.Passive,
RuntimePosture.ActiveTracing,
RuntimePosture.EbpfDeep,
RuntimePosture.FullInstrumentation
};
var scores = postures.Select(posture => _sut.Normalize(new RuntimeInput
{
Posture = posture,
ObservationCount = 10,
RecencyFactor = 0.5
})).ToList();
// Scores should generally increase with better postures
scores[0].Should().BeLessThan(scores[1]); // Passive < ActiveTracing
scores[1].Should().BeLessThan(scores[2]); // ActiveTracing < EbpfDeep
scores[2].Should().BeLessThan(scores[3]); // EbpfDeep < FullInstrumentation
}
#endregion
}

View File

@@ -0,0 +1,551 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Signals.Tests.EvidenceWeightedScore.Normalizers;
/// <summary>
/// Tests for SourceTrustNormalizer.
/// </summary>
public class SourceTrustNormalizerTests
{
private readonly SourceTrustNormalizerOptions _defaultOptions = new();
private readonly SourceTrustNormalizer _sut;
public SourceTrustNormalizerTests()
{
_sut = new SourceTrustNormalizer(_defaultOptions);
}
#region Dimension Property Tests
[Fact]
public void Dimension_ReturnsSRC()
{
_sut.Dimension.Should().Be("SRC");
}
#endregion
#region Issuer Type Tests
[Fact]
public void Normalize_GovernmentAgency_HighestMultiplier()
{
var input = CreateBaseInput() with { IssuerType = IssuerType.GovernmentAgency };
var result = _sut.Normalize(input);
result.Should().BeGreaterThan(0.78);
}
[Fact]
public void Normalize_Cna_HighMultiplier()
{
var input = CreateBaseInput() with { IssuerType = IssuerType.Cna };
var result = _sut.Normalize(input);
result.Should().BeGreaterThan(0.75);
}
[Fact]
public void Normalize_Vendor_HighTrust()
{
var input = CreateBaseInput() with { IssuerType = IssuerType.Vendor };
var result = _sut.Normalize(input);
result.Should().BeGreaterThan(0.70);
}
[Fact]
public void Normalize_Distribution_GoodTrust()
{
var input = CreateBaseInput() with { IssuerType = IssuerType.Distribution };
var result = _sut.Normalize(input);
result.Should().BeGreaterThan(0.60);
}
[Fact]
public void Normalize_Community_LowerTrust()
{
var input = CreateBaseInput() with { IssuerType = IssuerType.Community };
var result = _sut.Normalize(input);
result.Should().BeGreaterThan(0.40);
result.Should().BeLessThan(0.70);
}
[Fact]
public void Normalize_Unknown_MinimalTrust()
{
var input = CreateBaseInput() with { IssuerType = IssuerType.Unknown };
var result = _sut.Normalize(input);
result.Should().BeLessThan(0.40);
}
[Fact]
public void Normalize_IssuerTypeOrdering()
{
var issuers = new[]
{
IssuerType.Unknown,
IssuerType.Community,
IssuerType.SecurityResearcher,
IssuerType.Upstream,
IssuerType.Distribution,
IssuerType.Vendor,
IssuerType.Cna,
IssuerType.GovernmentAgency
};
var scores = issuers.Select(issuer => _sut.Normalize(CreateBaseInput() with
{
IssuerType = issuer
})).ToList();
// General ordering: Unknown < Community < ... < GovernmentAgency
scores[0].Should().BeLessThan(scores[1]); // Unknown < Community
scores[1].Should().BeLessThan(scores[5]); // Community < Vendor
scores[5].Should().BeLessThan(scores[7]); // Vendor < GovernmentAgency
}
#endregion
#region Trust Vector Tests
[Fact]
public void Normalize_HighProvenance_HigherScore()
{
var lowProvenance = CreateBaseInput() with { ProvenanceTrust = 0.3 };
var highProvenance = CreateBaseInput() with { ProvenanceTrust = 0.95 };
var lowScore = _sut.Normalize(lowProvenance);
var highScore = _sut.Normalize(highProvenance);
highScore.Should().BeGreaterThan(lowScore);
}
[Fact]
public void Normalize_HighCoverage_HigherScore()
{
var lowCoverage = CreateBaseInput() with { CoverageCompleteness = 0.2 };
var highCoverage = CreateBaseInput() with { CoverageCompleteness = 0.9 };
var lowScore = _sut.Normalize(lowCoverage);
var highScore = _sut.Normalize(highCoverage);
highScore.Should().BeGreaterThan(lowScore);
}
[Fact]
public void Normalize_HighReplayability_HigherScore()
{
var lowReplay = CreateBaseInput() with { Replayability = 0.2 };
var highReplay = CreateBaseInput() with { Replayability = 0.9 };
var lowScore = _sut.Normalize(lowReplay);
var highScore = _sut.Normalize(highReplay);
highScore.Should().BeGreaterThan(lowScore);
}
[Fact]
public void Normalize_ProvenanceWeightedHighest()
{
// Provenance should have highest weight (40%)
var baseInput = CreateBaseInput() with
{
ProvenanceTrust = 0.5,
CoverageCompleteness = 0.5,
Replayability = 0.5
};
// Increase only provenance
var highProvenance = baseInput with { ProvenanceTrust = 1.0 };
// Increase only coverage
var highCoverage = baseInput with { CoverageCompleteness = 1.0 };
var provenanceDelta = _sut.Normalize(highProvenance) - _sut.Normalize(baseInput);
var coverageDelta = _sut.Normalize(highCoverage) - _sut.Normalize(baseInput);
// Provenance increase should have larger impact
provenanceDelta.Should().BeGreaterThan(coverageDelta);
}
#endregion
#region Attestation Bonus Tests
[Fact]
public void Normalize_CryptographicallyAttested_GetsBonus()
{
var unattested = CreateBaseInput() with { IsCryptographicallyAttested = false };
var attested = CreateBaseInput() with { IsCryptographicallyAttested = true };
var unattestedScore = _sut.Normalize(unattested);
var attestedScore = _sut.Normalize(attested);
attestedScore.Should().BeGreaterThan(unattestedScore);
(attestedScore - unattestedScore).Should().BeApproximately(_defaultOptions.SignedBonus, 0.02);
}
[Fact]
public void Normalize_IndependentlyVerified_GetsBonus()
{
var unverified = CreateBaseInput() with { IndependentlyVerified = false };
var verified = CreateBaseInput() with { IndependentlyVerified = true };
var unverifiedScore = _sut.Normalize(unverified);
var verifiedScore = _sut.Normalize(verified);
verifiedScore.Should().BeGreaterThan(unverifiedScore);
(verifiedScore - unverifiedScore).Should().BeApproximately(0.05, 0.01);
}
[Fact]
public void Normalize_BothAttestations_BonusesStack()
{
var none = CreateBaseInput() with
{
IsCryptographicallyAttested = false,
IndependentlyVerified = false
};
var both = CreateBaseInput() with
{
IsCryptographicallyAttested = true,
IndependentlyVerified = true
};
var noneScore = _sut.Normalize(none);
var bothScore = _sut.Normalize(both);
(bothScore - noneScore).Should().BeApproximately(0.15, 0.02); // 0.10 + 0.05
}
#endregion
#region Corroboration Tests
[Fact]
public void Normalize_CorroboratingSources_GetsBonus()
{
var noCorroboration = CreateBaseInput() with { CorroboratingSourceCount = 0 };
var withCorroboration = CreateBaseInput() with { CorroboratingSourceCount = 2 };
var noScore = _sut.Normalize(noCorroboration);
var withScore = _sut.Normalize(withCorroboration);
withScore.Should().BeGreaterThan(noScore);
}
[Fact]
public void Normalize_ManyCorroboratingSources_CappedBonus()
{
var three = CreateBaseInput() with { CorroboratingSourceCount = 3 };
var ten = CreateBaseInput() with { CorroboratingSourceCount = 10 };
var threeScore = _sut.Normalize(three);
var tenScore = _sut.Normalize(ten);
// Both should have same bonus (capped at 3+)
threeScore.Should().BeApproximately(tenScore, 0.01);
}
#endregion
#region Historical Accuracy Tests
[Fact]
public void Normalize_ExcellentHistory_GetsBonus()
{
var noHistory = CreateBaseInput() with { HistoricalAccuracy = null };
var excellentHistory = CreateBaseInput() with { HistoricalAccuracy = 0.98 };
var noScore = _sut.Normalize(noHistory);
var excellentScore = _sut.Normalize(excellentHistory);
excellentScore.Should().BeGreaterThan(noScore);
}
[Fact]
public void Normalize_PoorHistory_GetsPenalty()
{
var noHistory = CreateBaseInput() with { HistoricalAccuracy = null };
var poorHistory = CreateBaseInput() with { HistoricalAccuracy = 0.50 };
var noScore = _sut.Normalize(noHistory);
var poorScore = _sut.Normalize(poorHistory);
poorScore.Should().BeLessThan(noScore);
}
[Theory]
[InlineData(0.96, 0.05)] // Excellent
[InlineData(0.88, 0.03)] // Good
[InlineData(0.72, 0.01)] // Acceptable
[InlineData(0.60, -0.02)] // Poor
public void Normalize_HistoricalAccuracyTiers(double accuracy, double expectedBonus)
{
var noHistory = CreateBaseInput() with { HistoricalAccuracy = null };
var withHistory = CreateBaseInput() with { HistoricalAccuracy = accuracy };
var noScore = _sut.Normalize(noHistory);
var withScore = _sut.Normalize(withHistory);
(withScore - noScore).Should().BeApproximately(expectedBonus, 0.02);
}
#endregion
#region NormalizeWithDetails Tests
[Fact]
public void NormalizeWithDetails_ReturnsCorrectDimension()
{
var input = CreateBaseInput();
var result = _sut.NormalizeWithDetails(input);
result.Dimension.Should().Be("SRC");
}
[Fact]
public void NormalizeWithDetails_ReturnsComponents()
{
var input = CreateBaseInput() with
{
IsCryptographicallyAttested = true,
IndependentlyVerified = true,
CorroboratingSourceCount = 2,
HistoricalAccuracy = 0.92
};
var result = _sut.NormalizeWithDetails(input);
result.Components.Should().ContainKey("issuer_type");
result.Components.Should().ContainKey("issuer_multiplier");
result.Components.Should().ContainKey("provenance_trust");
result.Components.Should().ContainKey("coverage_completeness");
result.Components.Should().ContainKey("replayability");
result.Components.Should().ContainKey("trust_vector_score");
result.Components.Should().ContainKey("attestation_bonus");
result.Components.Should().ContainKey("corroboration_bonus");
result.Components.Should().ContainKey("historical_bonus");
result.Components.Should().ContainKey("cryptographically_attested");
result.Components.Should().ContainKey("independently_verified");
result.Components.Should().ContainKey("corroborating_sources");
result.Components.Should().ContainKey("historical_accuracy");
result.Components["cryptographically_attested"].Should().Be(1.0);
result.Components["independently_verified"].Should().Be(1.0);
result.Components["corroborating_sources"].Should().Be(2);
result.Components["historical_accuracy"].Should().Be(0.92);
}
[Fact]
public void NormalizeWithDetails_GeneratesExplanation()
{
var input = CreateBaseInput() with
{
IssuerType = IssuerType.Vendor,
IssuerId = "redhat-psirt",
IsCryptographicallyAttested = true,
CorroboratingSourceCount = 2,
HistoricalAccuracy = 0.95
};
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("software vendor");
result.Explanation.Should().Contain("redhat-psirt");
result.Explanation.Should().Contain("cryptographically attested");
result.Explanation.Should().Contain("2 corroborating source(s)");
result.Explanation.Should().Contain("95%");
result.Explanation.Should().Contain("SRC=");
}
[Fact]
public void NormalizeWithDetails_UnknownSource_ExplainsCorrectly()
{
var input = CreateBaseInput() with { IssuerType = IssuerType.Unknown };
var result = _sut.NormalizeWithDetails(input);
result.Explanation.Should().Contain("unknown source");
}
#endregion
#region Null Input Tests
[Fact]
public void Normalize_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.Normalize(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void NormalizeWithDetails_NullInput_ThrowsArgumentNullException()
{
var act = () => _sut.NormalizeWithDetails(null!);
act.Should().Throw<ArgumentNullException>();
}
#endregion
#region DI Integration Tests
[Fact]
public void Constructor_WithIOptionsMonitor_WorksCorrectly()
{
var options = new NormalizerOptions
{
SourceTrust = new SourceTrustNormalizerOptions
{
VendorMultiplier = 1.2, // Custom multiplier
SignedBonus = 0.15 // Custom bonus
}
};
var optionsMonitor = new TestOptionsMonitor(options);
var normalizer = new SourceTrustNormalizer(optionsMonitor);
var input = CreateBaseInput() with
{
IssuerType = IssuerType.Vendor,
IsCryptographicallyAttested = true
};
var result = normalizer.Normalize(input);
// Should reflect custom options
result.Should().BeGreaterThan(0.90);
}
private sealed class TestOptionsMonitor(NormalizerOptions value) : IOptionsMonitor<NormalizerOptions>
{
public NormalizerOptions CurrentValue => value;
public NormalizerOptions Get(string? name) => value;
public IDisposable? OnChange(Action<NormalizerOptions, string?> listener) => null;
}
#endregion
#region Score Capping Tests
[Fact]
public void Normalize_MaxBonuses_CappedAtOne()
{
var maxInput = new SourceTrustInput
{
IssuerType = IssuerType.GovernmentAgency,
ProvenanceTrust = 1.0,
CoverageCompleteness = 1.0,
Replayability = 1.0,
IsCryptographicallyAttested = true,
IndependentlyVerified = true,
HistoricalAccuracy = 0.99,
CorroboratingSourceCount = 10
};
var result = _sut.Normalize(maxInput);
result.Should().BeLessThanOrEqualTo(1.0);
}
[Fact]
public void Normalize_MinimalInput_NotNegative()
{
var minInput = new SourceTrustInput
{
IssuerType = IssuerType.Unknown,
ProvenanceTrust = 0.0,
CoverageCompleteness = 0.0,
Replayability = 0.0,
HistoricalAccuracy = 0.2 // Poor history = penalty
};
var result = _sut.Normalize(minInput);
result.Should().BeGreaterThanOrEqualTo(0.0);
}
#endregion
#region Determinism Tests
[Fact]
public void Normalize_SameInput_ProducesSameOutput()
{
var input = new SourceTrustInput
{
IssuerType = IssuerType.Distribution,
IssuerId = "debian-security",
ProvenanceTrust = 0.82,
CoverageCompleteness = 0.75,
Replayability = 0.88,
IsCryptographicallyAttested = true,
CorroboratingSourceCount = 1,
HistoricalAccuracy = 0.90
};
var results = Enumerable.Range(0, 100)
.Select(_ => _sut.Normalize(input))
.Distinct()
.ToList();
results.Should().ContainSingle("Deterministic normalizer should produce identical results");
}
#endregion
#region All IssuerTypes Valid Range Tests
[Theory]
[InlineData(IssuerType.Unknown)]
[InlineData(IssuerType.Community)]
[InlineData(IssuerType.SecurityResearcher)]
[InlineData(IssuerType.Distribution)]
[InlineData(IssuerType.Upstream)]
[InlineData(IssuerType.Vendor)]
[InlineData(IssuerType.Cna)]
[InlineData(IssuerType.GovernmentAgency)]
public void Normalize_AllIssuerTypes_ReturnValidRange(IssuerType issuerType)
{
var input = CreateBaseInput() with { IssuerType = issuerType };
var result = _sut.Normalize(input);
result.Should().BeInRange(0.0, 1.0);
}
#endregion
#region Helper Methods
private static SourceTrustInput CreateBaseInput() => new()
{
IssuerType = IssuerType.Vendor,
ProvenanceTrust = 0.80,
CoverageCompleteness = 0.75,
Replayability = 0.70
};
#endregion
}