sprints work
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictBudgetCheckTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0006_budget_threshold_attestation
|
||||
// Tasks: BUDGET-8200-011, BUDGET-8200-012, BUDGET-8200-013
|
||||
// Description: Unit tests for budget check attestation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Attestation;
|
||||
|
||||
public class VerdictBudgetCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerdictBudgetCheck_WithAllFields_CreatesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var config = new VerdictBudgetConfig(
|
||||
maxUnknownCount: 10,
|
||||
maxCumulativeUncertainty: 2.5,
|
||||
action: "warn",
|
||||
reasonLimits: new Dictionary<string, int> { ["Reachability"] = 5 });
|
||||
|
||||
var actualCounts = new VerdictBudgetActualCounts(
|
||||
total: 3,
|
||||
cumulativeUncertainty: 1.2,
|
||||
byReason: new Dictionary<string, int> { ["Reachability"] = 2 });
|
||||
|
||||
var configHash = VerdictBudgetCheck.ComputeConfigHash(config);
|
||||
|
||||
// Act
|
||||
var budgetCheck = new VerdictBudgetCheck(
|
||||
environment: "production",
|
||||
config: config,
|
||||
actualCounts: actualCounts,
|
||||
result: "pass",
|
||||
configHash: configHash,
|
||||
evaluatedAt: DateTimeOffset.UtcNow,
|
||||
violations: null);
|
||||
|
||||
// Assert
|
||||
budgetCheck.Environment.Should().Be("production");
|
||||
budgetCheck.Config.MaxUnknownCount.Should().Be(10);
|
||||
budgetCheck.ActualCounts.Total.Should().Be(3);
|
||||
budgetCheck.Result.Should().Be("pass");
|
||||
budgetCheck.ConfigHash.Should().StartWith("sha256:");
|
||||
budgetCheck.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictBudgetCheck_WithViolations_IncludesAllViolations()
|
||||
{
|
||||
// Arrange
|
||||
var config = new VerdictBudgetConfig(5, 2.0, "fail");
|
||||
var actualCounts = new VerdictBudgetActualCounts(10, 3.0);
|
||||
var violations = new[]
|
||||
{
|
||||
new VerdictBudgetViolation("total", 5, 10),
|
||||
new VerdictBudgetViolation("reason", 3, 5, "Reachability")
|
||||
};
|
||||
|
||||
// Act
|
||||
var budgetCheck = new VerdictBudgetCheck(
|
||||
"staging",
|
||||
config,
|
||||
actualCounts,
|
||||
"fail",
|
||||
VerdictBudgetCheck.ComputeConfigHash(config),
|
||||
DateTimeOffset.UtcNow,
|
||||
violations);
|
||||
|
||||
// Assert
|
||||
budgetCheck.Violations.Should().HaveCount(2);
|
||||
budgetCheck.Violations[0].Type.Should().Be("reason"); // Sorted
|
||||
budgetCheck.Violations[1].Type.Should().Be("total");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeConfigHash_SameConfig_ProducesSameHash()
|
||||
{
|
||||
// Arrange
|
||||
var config1 = new VerdictBudgetConfig(10, 2.5, "warn",
|
||||
new Dictionary<string, int> { ["Reachability"] = 5 });
|
||||
var config2 = new VerdictBudgetConfig(10, 2.5, "warn",
|
||||
new Dictionary<string, int> { ["Reachability"] = 5 });
|
||||
|
||||
// Act
|
||||
var hash1 = VerdictBudgetCheck.ComputeConfigHash(config1);
|
||||
var hash2 = VerdictBudgetCheck.ComputeConfigHash(config2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeConfigHash_DifferentConfig_ProducesDifferentHash()
|
||||
{
|
||||
// Arrange
|
||||
var config1 = new VerdictBudgetConfig(10, 2.5, "warn");
|
||||
var config2 = new VerdictBudgetConfig(20, 2.5, "warn");
|
||||
|
||||
// Act
|
||||
var hash1 = VerdictBudgetCheck.ComputeConfigHash(config1);
|
||||
var hash2 = VerdictBudgetCheck.ComputeConfigHash(config2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeConfigHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var config = new VerdictBudgetConfig(10, 2.5, "warn",
|
||||
new Dictionary<string, int>
|
||||
{
|
||||
["Reachability"] = 5,
|
||||
["Identity"] = 3,
|
||||
["Provenance"] = 2
|
||||
});
|
||||
|
||||
// Act - compute multiple times
|
||||
var hashes = Enumerable.Range(0, 10)
|
||||
.Select(_ => VerdictBudgetCheck.ComputeConfigHash(config))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
hashes.Should().HaveCount(1, "same config should always produce same hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictBudgetConfig_NormalizesReasonLimits()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new Dictionary<string, int>
|
||||
{
|
||||
[" Reachability "] = 5,
|
||||
[" Identity "] = 3,
|
||||
[""] = 0 // Should be filtered out
|
||||
};
|
||||
|
||||
// Act
|
||||
var config = new VerdictBudgetConfig(10, 2.5, "warn", limits);
|
||||
|
||||
// Assert
|
||||
config.ReasonLimits.Should().ContainKey("Reachability");
|
||||
config.ReasonLimits.Should().ContainKey("Identity");
|
||||
config.ReasonLimits.Should().NotContainKey("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictBudgetActualCounts_NormalizesByReason()
|
||||
{
|
||||
// Arrange
|
||||
var byReason = new Dictionary<string, int>
|
||||
{
|
||||
[" Reachability "] = 5,
|
||||
[" Identity "] = 3
|
||||
};
|
||||
|
||||
// Act
|
||||
var counts = new VerdictBudgetActualCounts(8, 2.0, byReason);
|
||||
|
||||
// Assert
|
||||
counts.ByReason.Should().ContainKey("Reachability");
|
||||
counts.ByReason.Should().ContainKey("Identity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictBudgetViolation_WithReason_IncludesReason()
|
||||
{
|
||||
// Act
|
||||
var violation = new VerdictBudgetViolation("reason", 5, 10, "Reachability");
|
||||
|
||||
// Assert
|
||||
violation.Type.Should().Be("reason");
|
||||
violation.Limit.Should().Be(5);
|
||||
violation.Actual.Should().Be(10);
|
||||
violation.Reason.Should().Be("Reachability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictBudgetViolation_WithoutReason_HasNullReason()
|
||||
{
|
||||
// Act
|
||||
var violation = new VerdictBudgetViolation("total", 5, 10);
|
||||
|
||||
// Assert
|
||||
violation.Reason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentEnvironments_ProduceDifferentBudgetChecks()
|
||||
{
|
||||
// Arrange
|
||||
var config = new VerdictBudgetConfig(10, 2.5, "warn");
|
||||
var actualCounts = new VerdictBudgetActualCounts(3, 1.2);
|
||||
var configHash = VerdictBudgetCheck.ComputeConfigHash(config);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act
|
||||
var prodCheck = new VerdictBudgetCheck("production", config, actualCounts, "pass", configHash, now);
|
||||
var devCheck = new VerdictBudgetCheck("development", config, actualCounts, "pass", configHash, now);
|
||||
|
||||
// Assert
|
||||
prodCheck.Environment.Should().Be("production");
|
||||
devCheck.Environment.Should().Be("development");
|
||||
prodCheck.ConfigHash.Should().Be(devCheck.ConfigHash, "same config should have same hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictPredicate_IncludesBudgetCheck()
|
||||
{
|
||||
// Arrange
|
||||
var config = new VerdictBudgetConfig(10, 2.5, "warn");
|
||||
var actualCounts = new VerdictBudgetActualCounts(3, 1.2);
|
||||
var budgetCheck = new VerdictBudgetCheck(
|
||||
"production",
|
||||
config,
|
||||
actualCounts,
|
||||
"pass",
|
||||
VerdictBudgetCheck.ComputeConfigHash(config),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var predicate = new VerdictPredicate(
|
||||
tenantId: "tenant-1",
|
||||
policyId: "policy-1",
|
||||
policyVersion: 1,
|
||||
runId: "run-1",
|
||||
findingId: "finding-1",
|
||||
evaluatedAt: DateTimeOffset.UtcNow,
|
||||
verdict: new VerdictInfo("passed", "low", 25.0),
|
||||
budgetCheck: budgetCheck);
|
||||
|
||||
// Assert
|
||||
predicate.BudgetCheck.Should().NotBeNull();
|
||||
predicate.BudgetCheck!.Environment.Should().Be("production");
|
||||
predicate.BudgetCheck!.Result.Should().Be("pass");
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine;
|
||||
using StellaOps.DeltaVerdict;
|
||||
using StellaOps.Excititor.Core.Vex;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Policy.Unknowns;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictSummaryTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
|
||||
// Task: PINT-8200-024
|
||||
// Description: Unit tests for VerdictSummary extension methods
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Evaluation;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="VerdictSummary"/> and <see cref="VerdictSummaryExtensions"/>.
|
||||
/// </summary>
|
||||
public class VerdictSummaryTests
|
||||
{
|
||||
#region ToSummary Tests
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_WithFullEws_ReturnsCompleteSummary()
|
||||
{
|
||||
// Arrange
|
||||
var ews = CreateEwsResult(
|
||||
score: 85,
|
||||
bucket: ScoreBucket.ScheduleNext,
|
||||
flags: ["kev", "reachable"],
|
||||
explanations: ["High EPSS score", "Confirmed reachable"]);
|
||||
var result = CreatePolicyResult(
|
||||
status: "affected",
|
||||
severity: "High",
|
||||
matched: true,
|
||||
ruleName: "block-kev",
|
||||
ews: ews);
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
summary.Status.Should().Be("affected");
|
||||
summary.Severity.Should().Be("High");
|
||||
summary.RuleMatched.Should().BeTrue();
|
||||
summary.RuleName.Should().Be("block-kev");
|
||||
summary.ScoreBucket.Should().Be("ScheduleNext");
|
||||
summary.Score.Should().Be(85);
|
||||
summary.Flags.Should().BeEquivalentTo(["kev", "reachable"]);
|
||||
summary.Explanations.Should().BeEquivalentTo(["High EPSS score", "Confirmed reachable"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_WithoutEws_ReturnsPartialSummary()
|
||||
{
|
||||
// Arrange
|
||||
var result = CreatePolicyResult(
|
||||
status: "not_affected",
|
||||
severity: "Medium",
|
||||
matched: false,
|
||||
ews: null);
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
summary.Status.Should().Be("not_affected");
|
||||
summary.Severity.Should().Be("Medium");
|
||||
summary.RuleMatched.Should().BeFalse();
|
||||
summary.ScoreBucket.Should().BeNull();
|
||||
summary.Score.Should().BeNull();
|
||||
summary.TopFactors.Should().BeEmpty();
|
||||
summary.Flags.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_ExtractsTopFactorsOrderedByContribution()
|
||||
{
|
||||
// Arrange
|
||||
var breakdown = new List<DimensionContribution>
|
||||
{
|
||||
CreateContribution("Runtime", "RTS", 0.8, 20, 16.0),
|
||||
CreateContribution("Reachability", "RCH", 0.9, 25, 22.5),
|
||||
CreateContribution("Exploit", "XPL", 0.5, 15, 7.5),
|
||||
CreateContribution("Mitigation", "MIT", 0.3, 10, -3.0, isSubtractive: true),
|
||||
};
|
||||
var ews = CreateEwsResultWithBreakdown(85, ScoreBucket.ScheduleNext, breakdown);
|
||||
var result = CreatePolicyResult(ews: ews);
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
summary.TopFactors.Should().HaveCount(4);
|
||||
summary.TopFactors[0].Symbol.Should().Be("RCH"); // 22.5 contribution
|
||||
summary.TopFactors[1].Symbol.Should().Be("RTS"); // 16.0 contribution
|
||||
summary.TopFactors[2].Symbol.Should().Be("XPL"); // 7.5 contribution
|
||||
summary.TopFactors[3].Symbol.Should().Be("MIT"); // -3.0 (abs = 3.0)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_LimitsTopFactorsToFive()
|
||||
{
|
||||
// Arrange
|
||||
var breakdown = new List<DimensionContribution>
|
||||
{
|
||||
CreateContribution("Reachability", "RCH", 0.9, 25, 22.5),
|
||||
CreateContribution("Runtime", "RTS", 0.8, 20, 16.0),
|
||||
CreateContribution("Exploit", "XPL", 0.5, 15, 7.5),
|
||||
CreateContribution("Source", "SRC", 0.4, 10, 4.0),
|
||||
CreateContribution("Backport", "BKP", 0.3, 10, 3.0),
|
||||
CreateContribution("Mitigation", "MIT", 0.2, 5, -1.0, isSubtractive: true),
|
||||
};
|
||||
var ews = CreateEwsResultWithBreakdown(85, ScoreBucket.ScheduleNext, breakdown);
|
||||
var result = CreatePolicyResult(ews: ews);
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
summary.TopFactors.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_IncludesGuardrailsApplied()
|
||||
{
|
||||
// Arrange
|
||||
var ews = CreateEwsResult(
|
||||
score: 65,
|
||||
bucket: ScoreBucket.Investigate,
|
||||
guardrails: new AppliedGuardrails
|
||||
{
|
||||
SpeculativeCap = true,
|
||||
OriginalScore = 85,
|
||||
AdjustedScore = 65
|
||||
});
|
||||
var result = CreatePolicyResult(ews: ews);
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
summary.GuardrailsApplied.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_IncludesExceptionApplied()
|
||||
{
|
||||
// Arrange
|
||||
var result = CreatePolicyResult(
|
||||
exception: new PolicyExceptionApplication(
|
||||
ExceptionId: "EXC-001",
|
||||
EffectId: "effect-001",
|
||||
EffectType: PolicyExceptionEffectType.Suppress,
|
||||
OriginalStatus: "affected",
|
||||
OriginalSeverity: "high",
|
||||
AppliedStatus: "not_affected",
|
||||
AppliedSeverity: null,
|
||||
Metadata: ImmutableDictionary<string, string>.Empty));
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
summary.ExceptionApplied.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_IncludesLegacyConfidence()
|
||||
{
|
||||
// Arrange - Value=0.75 gives Tier=High
|
||||
var confidence = new ConfidenceScore
|
||||
{
|
||||
Value = 0.75m,
|
||||
Factors = [],
|
||||
Explanation = "Medium confidence test"
|
||||
};
|
||||
var result = CreatePolicyResult(confidence: confidence);
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
summary.ConfidenceScore.Should().Be(0.75m);
|
||||
summary.ConfidenceBand.Should().Be("High");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToMinimalSummary Tests
|
||||
|
||||
[Fact]
|
||||
public void ToMinimalSummary_IncludesOnlyEssentialFields()
|
||||
{
|
||||
// Arrange
|
||||
var ews = CreateEwsResult(
|
||||
score: 92,
|
||||
bucket: ScoreBucket.ActNow,
|
||||
flags: ["live-signal", "kev"],
|
||||
explanations: ["Runtime exploitation detected"]);
|
||||
var result = CreatePolicyResult(
|
||||
status: "affected",
|
||||
severity: "Critical",
|
||||
matched: true,
|
||||
ruleName: "block-live-signal",
|
||||
ews: ews);
|
||||
|
||||
// Act
|
||||
var summary = result.ToMinimalSummary();
|
||||
|
||||
// Assert
|
||||
summary.Status.Should().Be("affected");
|
||||
summary.Severity.Should().Be("Critical");
|
||||
summary.RuleMatched.Should().BeTrue();
|
||||
summary.RuleName.Should().Be("block-live-signal");
|
||||
summary.ScoreBucket.Should().Be("ActNow");
|
||||
summary.Score.Should().Be(92);
|
||||
// Minimal summary should NOT include top factors, flags, explanations
|
||||
summary.TopFactors.Should().BeEmpty();
|
||||
summary.Flags.Should().BeEmpty();
|
||||
summary.Explanations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPrimaryFactor Tests
|
||||
|
||||
[Fact]
|
||||
public void GetPrimaryFactor_ReturnsHighestContributor()
|
||||
{
|
||||
// Arrange
|
||||
var breakdown = new List<DimensionContribution>
|
||||
{
|
||||
CreateContribution("Runtime", "RTS", 0.8, 20, 16.0),
|
||||
CreateContribution("Reachability", "RCH", 0.9, 25, 22.5),
|
||||
CreateContribution("Exploit", "XPL", 0.5, 15, 7.5),
|
||||
};
|
||||
var ews = CreateEwsResultWithBreakdown(85, ScoreBucket.ScheduleNext, breakdown);
|
||||
|
||||
// Act
|
||||
var primary = ews.GetPrimaryFactor();
|
||||
|
||||
// Assert
|
||||
primary.Should().NotBeNull();
|
||||
primary!.Symbol.Should().Be("RCH");
|
||||
primary.Contribution.Should().Be(22.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPrimaryFactor_WithNullEws_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
EvidenceWeightedScoreResult? ews = null;
|
||||
|
||||
// Act
|
||||
var primary = ews.GetPrimaryFactor();
|
||||
|
||||
// Assert
|
||||
primary.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPrimaryFactor_WithEmptyBreakdown_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var ews = CreateEwsResultWithBreakdown(50, ScoreBucket.Investigate, []);
|
||||
|
||||
// Act
|
||||
var primary = ews.GetPrimaryFactor();
|
||||
|
||||
// Assert
|
||||
primary.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FormatTriageLine Tests
|
||||
|
||||
[Fact]
|
||||
public void FormatTriageLine_IncludesAllComponents()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Score = 92,
|
||||
ScoreBucket = "ActNow",
|
||||
TopFactors =
|
||||
[
|
||||
new VerdictFactor { Dimension = "Reachability", Symbol = "RCH", Contribution = 25, Weight = 25, InputValue = 1.0 },
|
||||
new VerdictFactor { Dimension = "Runtime", Symbol = "RTS", Contribution = 20, Weight = 20, InputValue = 1.0 },
|
||||
new VerdictFactor { Dimension = "Exploit", Symbol = "XPL", Contribution = 15, Weight = 15, InputValue = 1.0 },
|
||||
],
|
||||
Flags = ["live-signal", "kev"],
|
||||
};
|
||||
|
||||
// Act
|
||||
var line = summary.FormatTriageLine("CVE-2024-1234");
|
||||
|
||||
// Assert
|
||||
line.Should().Contain("[ActNow 92]");
|
||||
line.Should().Contain("CVE-2024-1234:");
|
||||
line.Should().Contain("RCH(+25)");
|
||||
line.Should().Contain("RTS(+20)");
|
||||
line.Should().Contain("XPL(+15)");
|
||||
line.Should().Contain("| live-signal, kev");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatTriageLine_HandlesNegativeContributions()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Score = 45,
|
||||
ScoreBucket = "Investigate",
|
||||
TopFactors =
|
||||
[
|
||||
new VerdictFactor { Dimension = "Mitigation", Symbol = "MIT", Contribution = -15, Weight = 15, InputValue = 1.0, IsSubtractive = true },
|
||||
],
|
||||
};
|
||||
|
||||
// Act
|
||||
var line = summary.FormatTriageLine();
|
||||
|
||||
// Assert
|
||||
line.Should().Contain("MIT(-15)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatTriageLine_WithoutScore_OmitsScoreSection()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
};
|
||||
|
||||
// Act
|
||||
var line = summary.FormatTriageLine();
|
||||
|
||||
// Assert
|
||||
line.Should().NotContain("[");
|
||||
line.Should().NotContain("]");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetBucketExplanation Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBucketExplanation_ActNow_ReturnsUrgentMessage()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Score = 95,
|
||||
ScoreBucket = "ActNow",
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = summary.GetBucketExplanation();
|
||||
|
||||
// Assert
|
||||
explanation.Should().Contain("95/100");
|
||||
explanation.Should().Contain("Strong evidence");
|
||||
explanation.Should().Contain("Immediate action");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBucketExplanation_WithKevFlag_MentionsKev()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Score = 85,
|
||||
ScoreBucket = "ScheduleNext",
|
||||
Flags = ["kev"],
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = summary.GetBucketExplanation();
|
||||
|
||||
// Assert
|
||||
explanation.Should().Contain("Known Exploited Vulnerability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBucketExplanation_WithLiveSignal_ShowsAlert()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Score = 92,
|
||||
ScoreBucket = "ActNow",
|
||||
Flags = ["live-signal"],
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = summary.GetBucketExplanation();
|
||||
|
||||
// Assert
|
||||
explanation.Should().Contain("ALERT");
|
||||
explanation.Should().Contain("Live exploitation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBucketExplanation_WithVendorNa_MentionsVendorConfirmation()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "not_affected",
|
||||
Score = 15,
|
||||
ScoreBucket = "Watchlist",
|
||||
Flags = ["vendor-na"],
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = summary.GetBucketExplanation();
|
||||
|
||||
// Assert
|
||||
explanation.Should().Contain("Vendor has confirmed not affected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBucketExplanation_WithPrimaryReachabilityFactor_MentionsReachability()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
Score = 75,
|
||||
ScoreBucket = "ScheduleNext",
|
||||
TopFactors =
|
||||
[
|
||||
new VerdictFactor { Dimension = "Reachability", Symbol = "RCH", Contribution = 25, Weight = 25, InputValue = 1.0 },
|
||||
],
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = summary.GetBucketExplanation();
|
||||
|
||||
// Assert
|
||||
explanation.Should().Contain("Reachability analysis is the primary driver");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBucketExplanation_WithoutScore_ReturnsNotAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new VerdictSummary
|
||||
{
|
||||
Status = "affected",
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = summary.GetBucketExplanation();
|
||||
|
||||
// Assert
|
||||
explanation.Should().Be("No evidence-weighted score available.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Null Safety Tests
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_NullResult_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
PolicyEvaluationResult? result = null;
|
||||
|
||||
// Act & Assert
|
||||
var action = () => result!.ToSummary();
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToMinimalSummary_NullResult_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
PolicyEvaluationResult? result = null;
|
||||
|
||||
// Act & Assert
|
||||
var action = () => result!.ToMinimalSummary();
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatTriageLine_NullSummary_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
VerdictSummary? summary = null;
|
||||
|
||||
// Act & Assert
|
||||
var action = () => summary!.FormatTriageLine();
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBucketExplanation_NullSummary_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
VerdictSummary? summary = null;
|
||||
|
||||
// Act & Assert
|
||||
var action = () => summary!.GetBucketExplanation();
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static PolicyEvaluationResult CreatePolicyResult(
|
||||
string status = "affected",
|
||||
string? severity = null,
|
||||
bool matched = false,
|
||||
string? ruleName = null,
|
||||
int? priority = null,
|
||||
EvidenceWeightedScoreResult? ews = null,
|
||||
ConfidenceScore? confidence = null,
|
||||
PolicyExceptionApplication? exception = null)
|
||||
{
|
||||
return new PolicyEvaluationResult(
|
||||
Matched: matched,
|
||||
Status: status,
|
||||
Severity: severity,
|
||||
RuleName: ruleName,
|
||||
Priority: priority,
|
||||
Annotations: ImmutableDictionary<string, string>.Empty,
|
||||
Warnings: ImmutableArray<string>.Empty,
|
||||
AppliedException: exception,
|
||||
Confidence: confidence,
|
||||
EvidenceWeightedScore: ews);
|
||||
}
|
||||
|
||||
private static EvidenceWeightedScoreResult CreateEwsResult(
|
||||
int score = 50,
|
||||
ScoreBucket bucket = ScoreBucket.Investigate,
|
||||
IEnumerable<string>? flags = null,
|
||||
IEnumerable<string>? explanations = null,
|
||||
AppliedGuardrails? guardrails = null)
|
||||
{
|
||||
return new EvidenceWeightedScoreResult
|
||||
{
|
||||
FindingId = "test-finding-001",
|
||||
Score = score,
|
||||
Bucket = bucket,
|
||||
Inputs = new EvidenceInputValues(0.5, 0.5, 0.5, 0.5, 0.5, 0.5),
|
||||
Weights = EvidenceWeights.Default,
|
||||
Breakdown = [],
|
||||
Flags = flags?.ToList() ?? [],
|
||||
Explanations = explanations?.ToList() ?? [],
|
||||
Caps = guardrails ?? AppliedGuardrails.None(score),
|
||||
PolicyDigest = "sha256:abc123",
|
||||
CalculatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceWeightedScoreResult CreateEwsResultWithBreakdown(
|
||||
int score,
|
||||
ScoreBucket bucket,
|
||||
IReadOnlyList<DimensionContribution> breakdown)
|
||||
{
|
||||
return new EvidenceWeightedScoreResult
|
||||
{
|
||||
FindingId = "test-finding-001",
|
||||
Score = score,
|
||||
Bucket = bucket,
|
||||
Inputs = new EvidenceInputValues(0.5, 0.5, 0.5, 0.5, 0.5, 0.5),
|
||||
Weights = EvidenceWeights.Default,
|
||||
Breakdown = breakdown,
|
||||
Flags = [],
|
||||
Explanations = [],
|
||||
Caps = AppliedGuardrails.None(score),
|
||||
PolicyDigest = "sha256:abc123",
|
||||
CalculatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
private static DimensionContribution CreateContribution(
|
||||
string dimension,
|
||||
string symbol,
|
||||
double inputValue,
|
||||
double weight,
|
||||
double contribution,
|
||||
bool isSubtractive = false)
|
||||
{
|
||||
return new DimensionContribution
|
||||
{
|
||||
Dimension = dimension,
|
||||
Symbol = symbol,
|
||||
InputValue = inputValue,
|
||||
Weight = weight,
|
||||
Contribution = contribution,
|
||||
IsSubtractive = isSubtractive,
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Lattice;
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025 StellaOps
|
||||
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
|
||||
// Task: PINT-8200-008 - Unit tests for enricher invocation, context population, caching
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Scoring.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Scoring.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for EvidenceWeightedScoreEnricher.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "8200.0012.0003")]
|
||||
public sealed class EvidenceWeightedScoreEnricherTests
|
||||
{
|
||||
private readonly TestNormalizerAggregator _aggregator;
|
||||
private readonly EvidenceWeightedScoreCalculator _calculator;
|
||||
private readonly TestPolicyProvider _policyProvider;
|
||||
|
||||
public EvidenceWeightedScoreEnricherTests()
|
||||
{
|
||||
_aggregator = new TestNormalizerAggregator();
|
||||
_calculator = new EvidenceWeightedScoreCalculator();
|
||||
_policyProvider = new TestPolicyProvider();
|
||||
}
|
||||
|
||||
#region Feature Flag Tests
|
||||
|
||||
[Fact(DisplayName = "Enrich returns skipped when feature disabled")]
|
||||
public void Enrich_WhenDisabled_ReturnsSkipped()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: false);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Score.Should().BeNull();
|
||||
result.FindingId.Should().Be("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Enrich calculates score when feature enabled")]
|
||||
public void Enrich_WhenEnabled_CalculatesScore()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Score.Should().NotBeNull();
|
||||
result.FindingId.Should().Be("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
result.FromCache.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "IsEnabled reflects options")]
|
||||
public void IsEnabled_ReflectsOptions()
|
||||
{
|
||||
// Arrange
|
||||
var enabledOptions = CreateOptions(enabled: true);
|
||||
var disabledOptions = CreateOptions(enabled: false);
|
||||
var enabledEnricher = CreateEnricher(enabledOptions);
|
||||
var disabledEnricher = CreateEnricher(disabledOptions);
|
||||
|
||||
// Assert
|
||||
enabledEnricher.IsEnabled.Should().BeTrue();
|
||||
disabledEnricher.IsEnabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Caching Tests
|
||||
|
||||
[Fact(DisplayName = "Enrich caches result when caching enabled")]
|
||||
public void Enrich_WhenCachingEnabled_CachesResult()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true, enableCaching: true);
|
||||
var cache = new InMemoryScoreEnrichmentCache();
|
||||
var enricher = CreateEnricher(options, cache);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result1 = enricher.Enrich(evidence);
|
||||
var result2 = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result1.FromCache.Should().BeFalse();
|
||||
result2.FromCache.Should().BeTrue();
|
||||
cache.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Enrich does not cache when caching disabled")]
|
||||
public void Enrich_WhenCachingDisabled_DoesNotCache()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true, enableCaching: false);
|
||||
var cache = new InMemoryScoreEnrichmentCache();
|
||||
var enricher = CreateEnricher(options, cache);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result1 = enricher.Enrich(evidence);
|
||||
var result2 = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result1.FromCache.Should().BeFalse();
|
||||
result2.FromCache.Should().BeFalse();
|
||||
cache.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Cache respects max size limit")]
|
||||
public void Cache_RespectsMaxSizeLimit()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true, enableCaching: true, maxCachedScores: 2);
|
||||
var cache = new InMemoryScoreEnrichmentCache();
|
||||
var enricher = CreateEnricher(options, cache);
|
||||
|
||||
// Act - add 3 items
|
||||
enricher.Enrich(CreateEvidence("finding-1"));
|
||||
enricher.Enrich(CreateEvidence("finding-2"));
|
||||
enricher.Enrich(CreateEvidence("finding-3"));
|
||||
|
||||
// Assert - cache should stop at max (third item not cached)
|
||||
cache.Count.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Calculation Tests
|
||||
|
||||
[Fact(DisplayName = "Enrich produces valid score range")]
|
||||
public void Enrich_ProducesValidScoreRange()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().NotBeNull();
|
||||
result.Score!.Score.Should().BeInRange(0, 100);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Enrich with high evidence produces high score")]
|
||||
public void Enrich_HighEvidence_ProducesHighScore()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateHighEvidenceData("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().NotBeNull();
|
||||
result.Score!.Score.Should().BeGreaterThanOrEqualTo(70);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Enrich with low evidence produces low score")]
|
||||
public void Enrich_LowEvidence_ProducesLowScore()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateLowEvidenceData("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().NotBeNull();
|
||||
result.Score!.Score.Should().BeLessThanOrEqualTo(50);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Enrich records calculation duration")]
|
||||
public void Enrich_RecordsCalculationDuration()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true, enableCaching: false);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.CalculationDuration.Should().NotBeNull();
|
||||
result.CalculationDuration!.Value.Should().BeGreaterThan(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Async Tests
|
||||
|
||||
[Fact(DisplayName = "EnrichAsync returns same result as sync")]
|
||||
public async Task EnrichAsync_ReturnsSameResultAsSync()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true, enableCaching: false);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var syncResult = enricher.Enrich(evidence);
|
||||
var asyncResult = await enricher.EnrichAsync(evidence);
|
||||
|
||||
// Assert
|
||||
asyncResult.IsSuccess.Should().Be(syncResult.IsSuccess);
|
||||
asyncResult.Score?.Score.Should().Be(syncResult.Score?.Score);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "EnrichBatchAsync processes all items")]
|
||||
public async Task EnrichBatchAsync_ProcessesAllItems()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidenceList = new[]
|
||||
{
|
||||
CreateEvidence("finding-1"),
|
||||
CreateEvidence("finding-2"),
|
||||
CreateEvidence("finding-3")
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = new List<ScoreEnrichmentResult>();
|
||||
await foreach (var result in enricher.EnrichBatchAsync(evidenceList))
|
||||
{
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results.Should().OnlyContain(r => r.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "EnrichBatchAsync respects cancellation")]
|
||||
public async Task EnrichBatchAsync_RespectsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true, enableCaching: false);
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidenceList = Enumerable.Range(1, 100)
|
||||
.Select(i => CreateEvidence($"finding-{i}"))
|
||||
.ToList();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel(); // Cancel immediately
|
||||
|
||||
// Act
|
||||
var results = new List<ScoreEnrichmentResult>();
|
||||
await foreach (var result in enricher.EnrichBatchAsync(evidenceList, cts.Token))
|
||||
{
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Override Tests
|
||||
|
||||
[Fact(DisplayName = "Enrich applies weight overrides")]
|
||||
public void Enrich_AppliesWeightOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
options.Weights = new EvidenceWeightsConfiguration
|
||||
{
|
||||
Rch = 0.5,
|
||||
Rts = 0.3,
|
||||
Bkp = 0.1,
|
||||
Xpl = 0.05,
|
||||
Src = 0.05,
|
||||
Mit = 0.1
|
||||
};
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert - score calculation should use custom weights
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Score.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Enrich applies bucket threshold overrides")]
|
||||
public void Enrich_AppliesBucketThresholdOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
options.BucketThresholds = new BucketThresholdsConfiguration
|
||||
{
|
||||
ActNowMin = 95,
|
||||
ScheduleNextMin = 80,
|
||||
InvestigateMin = 50
|
||||
};
|
||||
var enricher = CreateEnricher(options);
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Score.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact(DisplayName = "Enrich handles aggregator exception gracefully")]
|
||||
public void Enrich_HandleAggregatorException_Gracefully()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(enabled: true);
|
||||
var failingAggregator = new FailingNormalizerAggregator();
|
||||
var enricher = new EvidenceWeightedScoreEnricher(
|
||||
failingAggregator,
|
||||
_calculator,
|
||||
_policyProvider,
|
||||
CreateOptionsMonitor(options));
|
||||
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().NotBeNullOrEmpty();
|
||||
result.Score.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private EvidenceWeightedScoreEnricher CreateEnricher(
|
||||
PolicyEvidenceWeightedScoreOptions options,
|
||||
IScoreEnrichmentCache? cache = null)
|
||||
{
|
||||
return new EvidenceWeightedScoreEnricher(
|
||||
_aggregator,
|
||||
_calculator,
|
||||
_policyProvider,
|
||||
CreateOptionsMonitor(options),
|
||||
logger: null,
|
||||
cache: cache);
|
||||
}
|
||||
|
||||
private static PolicyEvidenceWeightedScoreOptions CreateOptions(
|
||||
bool enabled = false,
|
||||
bool enableCaching = true,
|
||||
int maxCachedScores = 10_000)
|
||||
{
|
||||
return new PolicyEvidenceWeightedScoreOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
EnableCaching = enableCaching,
|
||||
MaxCachedScoresPerContext = maxCachedScores
|
||||
};
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<PolicyEvidenceWeightedScoreOptions> CreateOptionsMonitor(
|
||||
PolicyEvidenceWeightedScoreOptions options)
|
||||
{
|
||||
return new StaticOptionsMonitor<PolicyEvidenceWeightedScoreOptions>(options);
|
||||
}
|
||||
|
||||
private static FindingEvidence CreateEvidence(string findingId)
|
||||
{
|
||||
return new FindingEvidence
|
||||
{
|
||||
FindingId = findingId
|
||||
};
|
||||
}
|
||||
|
||||
private static FindingEvidence CreateHighEvidenceData(string findingId)
|
||||
{
|
||||
return new FindingEvidence
|
||||
{
|
||||
FindingId = findingId,
|
||||
Reachability = new ReachabilityInput
|
||||
{
|
||||
State = ReachabilityState.DynamicReachable,
|
||||
Confidence = 0.95
|
||||
},
|
||||
Runtime = new RuntimeInput
|
||||
{
|
||||
Posture = RuntimePosture.ActiveTracing,
|
||||
ObservationCount = 10,
|
||||
RecencyFactor = 0.95
|
||||
},
|
||||
Exploit = new ExploitInput
|
||||
{
|
||||
EpssScore = 0.85,
|
||||
EpssPercentile = 95,
|
||||
KevStatus = KevStatus.InKev,
|
||||
PublicExploitAvailable = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FindingEvidence CreateLowEvidenceData(string findingId)
|
||||
{
|
||||
return new FindingEvidence
|
||||
{
|
||||
FindingId = findingId,
|
||||
Reachability = new ReachabilityInput
|
||||
{
|
||||
State = ReachabilityState.Unknown,
|
||||
Confidence = 0.1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Doubles
|
||||
|
||||
private sealed class TestNormalizerAggregator : INormalizerAggregator
|
||||
{
|
||||
public Task<EvidenceWeightedScoreInput> AggregateAsync(
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Aggregate(new FindingEvidence { FindingId = findingId }));
|
||||
}
|
||||
|
||||
public EvidenceWeightedScoreInput Aggregate(FindingEvidence evidence)
|
||||
{
|
||||
// Simple aggregation - use defaults for missing evidence
|
||||
var rch = evidence.Reachability is not null
|
||||
? (evidence.Reachability.Confidence * MapReachabilityState(evidence.Reachability.State))
|
||||
: 0.3; // Default
|
||||
|
||||
var rts = evidence.Runtime is not null
|
||||
? 0.7 * (evidence.Runtime.ObservationCount > 0 ? 1.0 : 0.5)
|
||||
: 0.0;
|
||||
|
||||
var xpl = evidence.Exploit is not null
|
||||
? (evidence.Exploit.EpssScore +
|
||||
(evidence.Exploit.KevStatus == KevStatus.InKev ? 0.3 : 0.0) +
|
||||
(evidence.Exploit.PublicExploitAvailable ? 0.2 : 0.0)) / 1.5
|
||||
: 0.0;
|
||||
|
||||
return new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = evidence.FindingId,
|
||||
Rch = Math.Clamp(rch, 0, 1),
|
||||
Rts = Math.Clamp(rts, 0, 1),
|
||||
Bkp = 0.0,
|
||||
Xpl = Math.Clamp(xpl, 0, 1),
|
||||
Src = 0.5,
|
||||
Mit = 0.0
|
||||
};
|
||||
}
|
||||
|
||||
public AggregationResult AggregateWithDetails(FindingEvidence evidence)
|
||||
{
|
||||
return new AggregationResult
|
||||
{
|
||||
Input = Aggregate(evidence),
|
||||
Details = new Dictionary<string, NormalizationResult>()
|
||||
};
|
||||
}
|
||||
|
||||
private static double MapReachabilityState(ReachabilityState state) => state switch
|
||||
{
|
||||
ReachabilityState.LiveExploitPath => 1.0,
|
||||
ReachabilityState.DynamicReachable => 0.9,
|
||||
ReachabilityState.StaticReachable => 0.7,
|
||||
ReachabilityState.PotentiallyReachable => 0.4,
|
||||
ReachabilityState.NotReachable => 0.1,
|
||||
_ => 0.3
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FailingNormalizerAggregator : INormalizerAggregator
|
||||
{
|
||||
public Task<EvidenceWeightedScoreInput> AggregateAsync(
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated aggregator failure");
|
||||
}
|
||||
|
||||
public EvidenceWeightedScoreInput Aggregate(FindingEvidence evidence)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated aggregator failure");
|
||||
}
|
||||
|
||||
public AggregationResult AggregateWithDetails(FindingEvidence evidence)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated aggregator failure");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestPolicyProvider : IEvidenceWeightPolicyProvider
|
||||
{
|
||||
public EvidenceWeightPolicy Policy { get; set; } = EvidenceWeightPolicy.DefaultProduction;
|
||||
|
||||
public Task<EvidenceWeightPolicy> GetPolicyAsync(
|
||||
string? tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Policy);
|
||||
}
|
||||
|
||||
public Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(EvidenceWeightPolicy.DefaultProduction);
|
||||
}
|
||||
|
||||
public Task<bool> PolicyExistsAsync(
|
||||
string? tenantId,
|
||||
string environment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
where T : class
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
|
||||
public T Get(string? name) => _value;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user