more features checks. setup improvements
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Confidence.Configuration;
|
||||
using StellaOps.Policy.Confidence.Services;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Scoring.Engines;
|
||||
using StellaOps.Policy.Engine.Scoring;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Unknowns.Configuration;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deep verification tests for the declarative multi-modal policy engine feature.
|
||||
/// Covers end-to-end DSL compilation + evaluation, scoring engine factory,
|
||||
/// multi-gate integration, and deterministic evaluation.
|
||||
/// </summary>
|
||||
public sealed class DeclarativeMultiModalPolicyEngineDeepTests
|
||||
{
|
||||
private static readonly string MultiGatePolicy = """
|
||||
policy "Multi-Gate Production" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Multi-modal policy with CVSS, VEX, and severity gates"
|
||||
tags = ["production","multi-gate"]
|
||||
}
|
||||
|
||||
rule block_critical priority 100 {
|
||||
when severity.normalized >= "Critical"
|
||||
then status := "blocked"
|
||||
because "Critical findings must be fixed."
|
||||
}
|
||||
|
||||
rule escalate_high_internet priority 90 {
|
||||
when severity.normalized == "High"
|
||||
and env.exposure == "internet"
|
||||
then escalate to severity_band("Critical")
|
||||
because "High on internet-exposed asset escalates."
|
||||
}
|
||||
|
||||
rule accept_vex_not_affected priority 80 {
|
||||
when vex.any(status in ["not_affected","fixed"])
|
||||
and vex.justification in ["component_not_present","vulnerable_code_not_present"]
|
||||
then status := vex.status
|
||||
annotate winning_statement := vex.latest().statementId
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule warn_medium priority 50 {
|
||||
when severity.normalized == "Medium"
|
||||
then warn message "Medium severity finding needs review."
|
||||
because "Medium findings require attention."
|
||||
}
|
||||
|
||||
rule allow_low priority 10 {
|
||||
when severity.normalized <= "Low"
|
||||
then status := "affected"
|
||||
because "Low severity accepted."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private readonly PolicyCompiler _compiler = new();
|
||||
private readonly PolicyEvaluationService _evaluationService = new();
|
||||
|
||||
#region End-to-End DSL Compilation + Evaluation
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void CompileAndEvaluate_CriticalSeverity_BlocksWithCorrectRule()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var context = CreateContext(severity: "Critical", exposure: "internal");
|
||||
|
||||
var result = _evaluationService.Evaluate(document, context);
|
||||
|
||||
result.Matched.Should().BeTrue();
|
||||
result.RuleName.Should().Be("block_critical");
|
||||
result.Status.Should().Be("blocked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void CompileAndEvaluate_HighInternet_EscalatesToCritical()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var context = CreateContext(severity: "High", exposure: "internet");
|
||||
|
||||
var result = _evaluationService.Evaluate(document, context);
|
||||
|
||||
result.Matched.Should().BeTrue();
|
||||
result.RuleName.Should().Be("escalate_high_internet");
|
||||
result.Severity.Should().Be("Critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void CompileAndEvaluate_VexNotAffected_SetsStatusAndAnnotation()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var statements = ImmutableArray.Create(
|
||||
new PolicyEvaluationVexStatement("not_affected", "component_not_present", "stmt-vex-001"));
|
||||
// Use "High" + "internal" so no lower-priority rule matches first.
|
||||
// Rules are evaluated in ascending priority order; warn_medium (50)
|
||||
// would fire before accept_vex_not_affected (80) with "Medium" severity.
|
||||
var context = CreateContext("High", "internal") with
|
||||
{
|
||||
Vex = new PolicyEvaluationVexEvidence(statements)
|
||||
};
|
||||
|
||||
var result = _evaluationService.Evaluate(document, context);
|
||||
|
||||
result.Matched.Should().BeTrue();
|
||||
result.RuleName.Should().Be("accept_vex_not_affected");
|
||||
result.Status.Should().Be("not_affected");
|
||||
result.Annotations.Should().ContainKey("winning_statement");
|
||||
result.Annotations["winning_statement"].Should().Be("stmt-vex-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void CompileAndEvaluate_MediumSeverity_EmitsWarning()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var context = CreateContext(severity: "Medium", exposure: "internal");
|
||||
|
||||
var result = _evaluationService.Evaluate(document, context);
|
||||
|
||||
result.Matched.Should().BeTrue();
|
||||
result.RuleName.Should().Be("warn_medium");
|
||||
result.Status.Should().Be("warned");
|
||||
result.Warnings.Should().Contain(w => w.Contains("Medium severity"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void CompileAndEvaluate_LowSeverity_Allows()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var context = CreateContext(severity: "Low", exposure: "internal");
|
||||
|
||||
var result = _evaluationService.Evaluate(document, context);
|
||||
|
||||
result.Matched.Should().BeTrue();
|
||||
result.RuleName.Should().Be("allow_low");
|
||||
result.Status.Should().Be("affected");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy DSL Compilation Verification
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Compile_MultiGatePolicy_ParsesAllRulesAndMetadata()
|
||||
{
|
||||
var result = _compiler.Compile(MultiGatePolicy);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Document!.Name.Should().Be("Multi-Gate Production");
|
||||
result.Document.Syntax.Should().Be("stella-dsl@1");
|
||||
result.Document.Rules.Should().HaveCountGreaterThanOrEqualTo(5);
|
||||
result.Document.Metadata.Should().ContainKey("description");
|
||||
result.Checksum.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Compile_InvalidPolicy_ReturnsDiagnostics()
|
||||
{
|
||||
var invalid = """
|
||||
policy "broken" syntax "stella-dsl@1" {
|
||||
rule missing_when priority 1 {
|
||||
then status := "blocked"
|
||||
because "missing when clause"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _compiler.Compile(invalid);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Compile_SameSource_ProducesSameChecksum()
|
||||
{
|
||||
var result1 = _compiler.Compile(MultiGatePolicy);
|
||||
var result2 = _compiler.Compile(MultiGatePolicy);
|
||||
|
||||
result1.Checksum.Should().Be(result2.Checksum);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Priority Ordering
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Evaluate_RulesExecuteInPriorityOrder_HighestFirst()
|
||||
{
|
||||
// Critical matches block_critical (priority 100) before warn_medium (priority 50)
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var context = CreateContext(severity: "Critical", exposure: "internet");
|
||||
|
||||
var result = _evaluationService.Evaluate(document, context);
|
||||
|
||||
// block_critical (100) should fire before escalate_high_internet (90) because
|
||||
// severity >= Critical matches first
|
||||
result.RuleName.Should().Be("block_critical");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exception Handling Integration
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Evaluate_WithSuppressException_SuppressesBlockedFinding()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var effect = new PolicyExceptionEffect(
|
||||
Id: "suppress-critical",
|
||||
Name: "Emergency Break Glass",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "secops",
|
||||
MaxDurationDays: 7,
|
||||
Description: null);
|
||||
var scope = PolicyEvaluationExceptionScope.Create(ruleNames: new[] { "block_critical" });
|
||||
var instance = new PolicyEvaluationExceptionInstance(
|
||||
Id: "exc-deep-001",
|
||||
EffectId: effect.Id,
|
||||
Scope: scope,
|
||||
CreatedAt: new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
var exceptions = new PolicyEvaluationExceptions(
|
||||
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
|
||||
ImmutableArray.Create(instance));
|
||||
var context = CreateContext("Critical", "internal", exceptions);
|
||||
|
||||
var result = _evaluationService.Evaluate(document, context);
|
||||
|
||||
result.Matched.Should().BeTrue();
|
||||
result.Status.Should().Be("suppressed");
|
||||
result.AppliedException.Should().NotBeNull();
|
||||
result.AppliedException!.ExceptionId.Should().Be("exc-deep-001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scoring Engine Integration
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void SimpleScoringEngine_Profile_ReturnsSimple()
|
||||
{
|
||||
var freshnessCalc = new EvidenceFreshnessCalculator();
|
||||
var engine = new SimpleScoringEngine(freshnessCalc, NullLogger<SimpleScoringEngine>.Instance);
|
||||
|
||||
engine.Profile.Should().Be(ScoringProfile.Simple);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void AdvancedScoringEngine_Profile_ReturnsAdvanced()
|
||||
{
|
||||
var freshnessCalc = new EvidenceFreshnessCalculator();
|
||||
var engine = new AdvancedScoringEngine(freshnessCalc, NullLogger<AdvancedScoringEngine>.Instance);
|
||||
|
||||
engine.Profile.Should().Be(ScoringProfile.Advanced);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknown Budget Integration
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Evaluate_UnknownBudgetExceeded_BlocksEvaluation()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var budgetService = CreateBudgetService(totalLimit: 0, action: BudgetAction.Block);
|
||||
var evaluator = new PolicyEvaluator(budgetService: budgetService);
|
||||
|
||||
var context = new PolicyEvaluationContext(
|
||||
new PolicyEvaluationSeverity("High"),
|
||||
new PolicyEvaluationEnvironment(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["name"] = "prod"
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
|
||||
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
|
||||
PolicyEvaluationVexEvidence.Empty,
|
||||
PolicyEvaluationSbom.Empty,
|
||||
PolicyEvaluationExceptions.Empty,
|
||||
ImmutableArray.Create(CreateUnknown()),
|
||||
ImmutableArray<ExceptionObject>.Empty,
|
||||
PolicyEvaluationReachability.Unknown,
|
||||
PolicyEvaluationEntropy.Unknown);
|
||||
|
||||
var result = evaluator.Evaluate(new PolicyEvaluationRequest(document, context));
|
||||
|
||||
result.Status.Should().Be("blocked");
|
||||
result.FailureReason.Should().Be(PolicyFailureReason.UnknownBudgetExceeded);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Evaluate_100Iterations_ProducesIdenticalResults()
|
||||
{
|
||||
var document = CompilePolicy(MultiGatePolicy);
|
||||
var context = CreateContext(severity: "High", exposure: "internet");
|
||||
|
||||
var first = _evaluationService.Evaluate(document, context);
|
||||
|
||||
for (var i = 1; i < 100; i++)
|
||||
{
|
||||
var current = _evaluationService.Evaluate(document, context);
|
||||
current.RuleName.Should().Be(first.RuleName, $"iteration {i}");
|
||||
current.Status.Should().Be(first.Status, $"iteration {i}");
|
||||
current.Severity.Should().Be(first.Severity, $"iteration {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Unit")]
|
||||
public void Compile_100Iterations_ProducesIdenticalChecksum()
|
||||
{
|
||||
var first = _compiler.Compile(MultiGatePolicy);
|
||||
|
||||
for (var i = 1; i < 100; i++)
|
||||
{
|
||||
var current = _compiler.Compile(MultiGatePolicy);
|
||||
current.Checksum.Should().Be(first.Checksum, $"iteration {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private PolicyIrDocument CompilePolicy(string source)
|
||||
{
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue(
|
||||
string.Join("; ", result.Diagnostics.Select(d => $"{d.Severity}:{d.Code}:{d.Message}")));
|
||||
return (PolicyIrDocument)result.Document!;
|
||||
}
|
||||
|
||||
private static PolicyEvaluationContext CreateContext(
|
||||
string severity,
|
||||
string exposure,
|
||||
PolicyEvaluationExceptions? exceptions = null)
|
||||
{
|
||||
return new PolicyEvaluationContext(
|
||||
new PolicyEvaluationSeverity(severity),
|
||||
new PolicyEvaluationEnvironment(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exposure"] = exposure
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
|
||||
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
|
||||
PolicyEvaluationVexEvidence.Empty,
|
||||
PolicyEvaluationSbom.Empty,
|
||||
exceptions ?? PolicyEvaluationExceptions.Empty,
|
||||
ImmutableArray<Unknown>.Empty,
|
||||
ImmutableArray<ExceptionObject>.Empty,
|
||||
PolicyEvaluationReachability.Unknown,
|
||||
PolicyEvaluationEntropy.Unknown);
|
||||
}
|
||||
|
||||
private static UnknownBudgetService CreateBudgetService(int totalLimit, BudgetAction action)
|
||||
{
|
||||
var options = new UnknownBudgetOptions
|
||||
{
|
||||
Budgets = new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["prod"] = new UnknownBudget
|
||||
{
|
||||
Environment = "prod",
|
||||
TotalLimit = totalLimit,
|
||||
Action = action
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new UnknownBudgetService(
|
||||
new TestOptionsMonitor<UnknownBudgetOptions>(options),
|
||||
NullLogger<UnknownBudgetService>.Instance);
|
||||
}
|
||||
|
||||
private static Unknown CreateUnknown()
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
return new Unknown
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
PackageId = "pkg:npm/lodash",
|
||||
PackageVersion = "4.17.21",
|
||||
Band = UnknownBand.Hot,
|
||||
Score = 80m,
|
||||
UncertaintyFactor = 0.5m,
|
||||
ExploitPressure = 0.7m,
|
||||
ReasonCode = UnknownReasonCode.Reachability,
|
||||
FirstSeenAt = timestamp,
|
||||
LastEvaluatedAt = timestamp,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T>(T current) : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _current = current;
|
||||
public T CurrentValue => _current;
|
||||
public T Get(string? name) => _current;
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NoopDisposable.Instance;
|
||||
}
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public static readonly NoopDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.DeterminismGuard;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.DeterminismGuard;
|
||||
|
||||
/// <summary>
|
||||
/// Deep verification tests for determinism guards covering pattern detection gaps,
|
||||
/// ValidateContext, FailOnSeverity threshold, GuardedPolicyEvaluatorBuilder,
|
||||
/// floating-point/unstable-iteration warnings, socket detection, and scope lifecycle.
|
||||
/// </summary>
|
||||
public sealed class DeterminismGuardDeepTests
|
||||
{
|
||||
#region Additional Pattern Detection
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsDateTimeOffsetNow()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var now = DateTimeOffset.Now;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "DateTimeOffset.Now" &&
|
||||
v.Category == DeterminismViolationCategory.WallClock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsDateTimeOffsetUtcNow()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var now = DateTimeOffset.UtcNow;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "DateTimeOffset.UtcNow" &&
|
||||
v.Category == DeterminismViolationCategory.WallClock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsCryptoRandom()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var bytes = RandomNumberGenerator.GetBytes(32);";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "RandomNumberGenerator" &&
|
||||
v.Category == DeterminismViolationCategory.RandomNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsSocketClasses()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = """
|
||||
var tcp = new TcpClient("localhost", 80);
|
||||
var udp = new UdpClient(9090);
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
""";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().HaveCount(3);
|
||||
result.Violations.Should().OnlyContain(v =>
|
||||
v.Category == DeterminismViolationCategory.NetworkAccess &&
|
||||
v.Severity == DeterminismViolationSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsWebClient()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "using var client = new WebClient();";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "WebClient" &&
|
||||
v.Category == DeterminismViolationCategory.NetworkAccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsEnvironmentMachineName()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var name = Environment.MachineName;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "Environment.MachineName" &&
|
||||
v.Category == DeterminismViolationCategory.EnvironmentAccess &&
|
||||
v.Severity == DeterminismViolationSeverity.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsFloatingPointComparison()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "double score == 7.5;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.Category == DeterminismViolationCategory.FloatingPointHazard &&
|
||||
v.Severity == DeterminismViolationSeverity.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsDictionaryIteration()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "foreach (var item in myDictionary)";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.Category == DeterminismViolationCategory.UnstableIteration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsHashSetIteration()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "foreach (var item in myHashSet)";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.Category == DeterminismViolationCategory.UnstableIteration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_MultipleViolationCategories_ReportsAll()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = """
|
||||
var now = DateTime.Now;
|
||||
var rng = new Random();
|
||||
var id = Guid.NewGuid();
|
||||
private readonly HttpClient _client = new();
|
||||
""";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().HaveCountGreaterThanOrEqualTo(4);
|
||||
result.Violations.Select(v => v.Category).Distinct()
|
||||
.Should().Contain(DeterminismViolationCategory.WallClock)
|
||||
.And.Contain(DeterminismViolationCategory.RandomNumber)
|
||||
.And.Contain(DeterminismViolationCategory.GuidGeneration)
|
||||
.And.Contain(DeterminismViolationCategory.NetworkAccess);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ValidateContext Tests
|
||||
|
||||
[Fact]
|
||||
public void ValidateContext_NullContext_DetectsViolation()
|
||||
{
|
||||
var guard = new DeterminismGuardService();
|
||||
|
||||
var result = guard.ValidateContext<object>(null!, "TestContext");
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.Category == DeterminismViolationCategory.Other &&
|
||||
v.ViolationType == "NullContext" &&
|
||||
v.Message.Contains("TestContext"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateContext_ValidContext_Passes()
|
||||
{
|
||||
var guard = new DeterminismGuardService();
|
||||
|
||||
var result = guard.ValidateContext(new { Score = 7.5 }, "ScoringContext");
|
||||
|
||||
result.Passed.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateContext_EnforcementDisabled_NullContextPassesButReportsViolation()
|
||||
{
|
||||
var options = new DeterminismGuardOptions { EnforcementEnabled = false };
|
||||
var guard = new DeterminismGuardService(options);
|
||||
|
||||
var result = guard.ValidateContext<object>(null!, "TestContext");
|
||||
|
||||
result.Passed.Should().BeTrue(); // Enforcement disabled = always passes
|
||||
result.Violations.Should().NotBeEmpty(); // But still reports violations
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FailOnSeverity Threshold Tests
|
||||
|
||||
[Fact]
|
||||
public void FailOnSeverity_Error_WarningViolationsDoNotCauseFailure()
|
||||
{
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = true,
|
||||
FailOnSeverity = DeterminismViolationSeverity.Error
|
||||
};
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
// Environment.MachineName is a Warning-level violation
|
||||
var source = "var name = Environment.MachineName;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", options);
|
||||
|
||||
result.Passed.Should().BeTrue(); // Warning < Error threshold
|
||||
result.Violations.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailOnSeverity_Error_ErrorViolationsCauseFailure()
|
||||
{
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = true,
|
||||
FailOnSeverity = DeterminismViolationSeverity.Error
|
||||
};
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var now = DateTime.Now;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", options);
|
||||
|
||||
result.Passed.Should().BeFalse(); // Error >= Error threshold
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailOnSeverity_Critical_ErrorViolationsDoNotCauseFailure()
|
||||
{
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = true,
|
||||
FailOnSeverity = DeterminismViolationSeverity.Critical
|
||||
};
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
// DateTime.Now is Error severity
|
||||
var source = "var now = DateTime.Now;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", options);
|
||||
|
||||
result.Passed.Should().BeTrue(); // Error < Critical threshold
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailOnSeverity_Critical_CriticalViolationsCauseFailure()
|
||||
{
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = true,
|
||||
FailOnSeverity = DeterminismViolationSeverity.Critical
|
||||
};
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
// HttpClient is Critical severity
|
||||
var source = "private readonly HttpClient _client = new();";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", options);
|
||||
|
||||
result.Passed.Should().BeFalse(); // Critical >= Critical threshold
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GuardedPolicyEvaluatorBuilder Tests
|
||||
|
||||
[Fact]
|
||||
public void Builder_CreateDevelopment_HasNoEnforcement()
|
||||
{
|
||||
var evaluator = GuardedPolicyEvaluatorBuilder.CreateDevelopment();
|
||||
|
||||
// Development mode: no enforcement, so reporting a critical violation should not throw
|
||||
var result = evaluator.Evaluate("dev-scope", DateTimeOffset.UtcNow, scope =>
|
||||
{
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.NetworkAccess,
|
||||
ViolationType = "HttpClient",
|
||||
Message = "Dev mode test",
|
||||
Severity = DeterminismViolationSeverity.Critical
|
||||
});
|
||||
return "ok";
|
||||
});
|
||||
|
||||
result.Succeeded.Should().BeTrue(); // Enforcement disabled in dev mode
|
||||
result.Result.Should().Be("ok");
|
||||
result.HasViolations.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CreateProduction_HasEnforcement()
|
||||
{
|
||||
var evaluator = GuardedPolicyEvaluatorBuilder.CreateProduction();
|
||||
|
||||
var result = evaluator.Evaluate("prod-scope", DateTimeOffset.UtcNow, scope =>
|
||||
{
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "DateTime.Now",
|
||||
Message = "Wall clock in prod",
|
||||
Severity = DeterminismViolationSeverity.Error
|
||||
});
|
||||
return "should not return";
|
||||
});
|
||||
|
||||
result.Succeeded.Should().BeFalse();
|
||||
result.WasBlocked.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CustomConfiguration_AppliesCorrectly()
|
||||
{
|
||||
var evaluator = new GuardedPolicyEvaluatorBuilder()
|
||||
.WithEnforcement(true)
|
||||
.FailOnSeverity(DeterminismViolationSeverity.Critical)
|
||||
.WithRuntimeMonitoring(true)
|
||||
.ExcludePatterns("test_", "spec_")
|
||||
.Build();
|
||||
|
||||
// Error-level violations should pass since FailOnSeverity is Critical
|
||||
var result = evaluator.Evaluate("custom-scope", DateTimeOffset.UtcNow, scope =>
|
||||
{
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "DateTime.Now",
|
||||
Message = "Error-level warning",
|
||||
Severity = DeterminismViolationSeverity.Error
|
||||
});
|
||||
return 42;
|
||||
});
|
||||
|
||||
result.Succeeded.Should().BeTrue();
|
||||
result.Result.Should().Be(42);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scope Lifecycle Tests
|
||||
|
||||
[Fact]
|
||||
public void Scope_Complete_CountsBySeverity()
|
||||
{
|
||||
var guard = new DeterminismGuardService(DeterminismGuardOptions.Development);
|
||||
using var scope = guard.CreateScope("lifecycle-test", DateTimeOffset.UtcNow);
|
||||
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "Test1",
|
||||
Message = "Warning 1",
|
||||
Severity = DeterminismViolationSeverity.Warning
|
||||
});
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.RandomNumber,
|
||||
ViolationType = "Test2",
|
||||
Message = "Warning 2",
|
||||
Severity = DeterminismViolationSeverity.Warning
|
||||
});
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.NetworkAccess,
|
||||
ViolationType = "Test3",
|
||||
Message = "Error 1",
|
||||
Severity = DeterminismViolationSeverity.Error
|
||||
});
|
||||
|
||||
var result = scope.Complete();
|
||||
|
||||
result.Violations.Should().HaveCount(3);
|
||||
result.CountBySeverity[DeterminismViolationSeverity.Warning].Should().Be(2);
|
||||
result.CountBySeverity[DeterminismViolationSeverity.Error].Should().Be(1);
|
||||
result.AnalysisDurationMs.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scope_ScopeId_IsPreserved()
|
||||
{
|
||||
var guard = new DeterminismGuardService();
|
||||
using var scope = guard.CreateScope("my-scope-id", DateTimeOffset.UtcNow);
|
||||
|
||||
scope.ScopeId.Should().Be("my-scope-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scope_NullScopeId_ThrowsArgumentNullException()
|
||||
{
|
||||
var guard = new DeterminismGuardService();
|
||||
|
||||
FluentActions.Invoking(() => guard.CreateScope(null!, DateTimeOffset.UtcNow))
|
||||
.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeterministicTimeProvider Tests
|
||||
|
||||
[Fact]
|
||||
public void DeterministicTimeProvider_MultipleCallsReturnSameValue()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2026, 2, 12, 10, 0, 0, TimeSpan.Zero);
|
||||
var provider = new DeterministicTimeProvider(fixedTime);
|
||||
|
||||
// 100 calls should all return the same value
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
provider.GetUtcNow().Should().Be(fixedTime);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GuardedEvaluationResult Properties
|
||||
|
||||
[Fact]
|
||||
public void GuardedEvaluationResult_ViolationCountBySeverity_Works()
|
||||
{
|
||||
var evaluator = new GuardedPolicyEvaluator(DeterminismGuardOptions.Development);
|
||||
|
||||
var result = evaluator.Evaluate("count-test", DateTimeOffset.UtcNow, scope =>
|
||||
{
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "T1",
|
||||
Message = "W1",
|
||||
Severity = DeterminismViolationSeverity.Warning
|
||||
});
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "T2",
|
||||
Message = "E1",
|
||||
Severity = DeterminismViolationSeverity.Error
|
||||
});
|
||||
return "done";
|
||||
});
|
||||
|
||||
result.ViolationCountBySeverity.Should().ContainKey(DeterminismViolationSeverity.Warning);
|
||||
result.ViolationCountBySeverity[DeterminismViolationSeverity.Warning].Should().Be(1);
|
||||
result.ViolationCountBySeverity[DeterminismViolationSeverity.Error].Should().Be(1);
|
||||
result.HasViolations.Should().BeTrue();
|
||||
result.WasBlocked.Should().BeFalse();
|
||||
result.ScopeId.Should().Be("count-test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_UnexpectedException_RecordsAsCriticalViolation()
|
||||
{
|
||||
var evaluator = new GuardedPolicyEvaluator();
|
||||
|
||||
var result = evaluator.Evaluate<string>("exception-test", DateTimeOffset.UtcNow, scope =>
|
||||
{
|
||||
throw new InvalidOperationException("Test exception");
|
||||
});
|
||||
|
||||
result.Succeeded.Should().BeFalse();
|
||||
result.Exception.Should().NotBeNull();
|
||||
result.Exception.Should().BeOfType<InvalidOperationException>();
|
||||
result.BlockingViolation.Should().NotBeNull();
|
||||
result.BlockingViolation!.ViolationType.Should().Be("EvaluationException");
|
||||
result.BlockingViolation.Severity.Should().Be(DeterminismViolationSeverity.Critical);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeterminismAnalysisResult.Pass Factory
|
||||
|
||||
[Fact]
|
||||
public void DeterminismAnalysisResult_Pass_CreatesCleanResult()
|
||||
{
|
||||
var result = DeterminismAnalysisResult.Pass(42, true);
|
||||
|
||||
result.Passed.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
result.CountBySeverity.Should().BeEmpty();
|
||||
result.AnalysisDurationMs.Should().Be(42);
|
||||
result.EnforcementEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Violation Remediation Messages
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_ViolationsIncludeRemediation()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var now = DateTime.Now;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle()
|
||||
.Which.Remediation.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_FileReadViolation_HasCriticalSeverity()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var text = File.ReadAllText(\"config.json\");";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.Category == DeterminismViolationCategory.FileSystemAccess &&
|
||||
v.Severity == DeterminismViolationSeverity.Critical);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Deep verification tests for CVE-aware release policy gates covering
|
||||
/// VexTrust integration in PolicyGateEvaluator, Contested lattice suggestions,
|
||||
/// RU lattice with justification, DriftGateEvaluator (KEV, EPSS, CVSS, custom),
|
||||
/// and StabilityDampingGate (hysteresis, upgrade bypass, pruning).
|
||||
/// </summary>
|
||||
public sealed class CveAwareReleasePolicyGatesDeepTests
|
||||
{
|
||||
#region PolicyGateEvaluator with VexTrust enabled
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_VexTrustEnabled_LowScore_Blocks()
|
||||
{
|
||||
var options = CreatePolicyGateOptions(vexTrustEnabled: true);
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
|
||||
request = request with
|
||||
{
|
||||
VexTrustScore = 0.30m, // Below default threshold
|
||||
VexSignatureVerified = true,
|
||||
Environment = "production"
|
||||
};
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("VexTrust");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_VexTrustEnabled_HighScore_Allows()
|
||||
{
|
||||
var options = CreatePolicyGateOptions(vexTrustEnabled: true);
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
|
||||
request = request with
|
||||
{
|
||||
VexTrustScore = 0.90m,
|
||||
VexSignatureVerified = true,
|
||||
Environment = "production"
|
||||
};
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_VexTrustEnabled_UnverifiedSignature_Blocks()
|
||||
{
|
||||
var options = CreatePolicyGateOptions(vexTrustEnabled: true);
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
|
||||
request = request with
|
||||
{
|
||||
VexTrustScore = 0.95m,
|
||||
VexSignatureVerified = false, // Production requires verification
|
||||
Environment = "production"
|
||||
};
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("VexTrust");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_VexTrustEnabled_MissingScore_Warns()
|
||||
{
|
||||
var options = CreatePolicyGateOptions(vexTrustEnabled: true);
|
||||
// Default MissingTrustBehavior is Warn in PolicyGateOptions
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
|
||||
request = request with
|
||||
{
|
||||
VexTrustScore = null,
|
||||
Environment = "production"
|
||||
};
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Missing trust data should warn (not block) since the gate evaluates before uncertainty
|
||||
decision.Decision.Should().BeOneOf(PolicyGateDecisionType.Warn, PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Contested Lattice State Suggestions
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_ContestedLattice_SuggestsTriageResolution()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "X", uncertaintyTier: "T4");
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("LatticeState");
|
||||
decision.Suggestion.Should().Contain("triage");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_CRLattice_SuggestsSubmitEvidence()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "CR", uncertaintyTier: "T4");
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
(decision.Suggestion!.Contains("runtime probe evidence") || decision.Suggestion.Contains("unreachability")).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RU Lattice with Justification
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_RULattice_WithJustification_AllowsWithWarning()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "RU",
|
||||
justification: "Verified dead code via manual analysis of runtime traces");
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// RU with justification should pass the lattice gate (PassWithNote -> Warn)
|
||||
decision.Decision.Should().BeOneOf(PolicyGateDecisionType.Warn, PolicyGateDecisionType.Allow);
|
||||
decision.BlockedBy.Should().NotBe("LatticeState");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_RULattice_WithoutJustification_Blocks()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "RU");
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("LatticeState");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fixed and UnderInvestigation Status Paths
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_Fixed_AllowsWithAnyLatticeState()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
foreach (var state in new[] { "U", "SR", "SU", "RO", "RU", "CR", "CU", "X" })
|
||||
{
|
||||
var request = CreateGateRequest("fixed", latticeState: state);
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow,
|
||||
$"Fixed status should be allowed with lattice state {state}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_UnderInvestigation_NoEvidenceRequired()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("under_investigation", latticeState: "U",
|
||||
graphHash: null, pathLength: null);
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Override with Justification
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_Override_WithValidJustification_BypassesBlock()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "SR");
|
||||
request = request with
|
||||
{
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "Manual review confirmed dead code path in production environment"
|
||||
};
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Warn);
|
||||
decision.Advisory.Should().Contain("Override accepted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_Override_WithShortJustification_DoesNotBypass()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "SR");
|
||||
request = request with
|
||||
{
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "short" // < 20 chars
|
||||
};
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gate Short-Circuit Behavior
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_EvidenceBlock_ShortCircuitsBeforeLattice()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "CU",
|
||||
uncertaintyTier: "T4", graphHash: null); // Missing graph hash blocks
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("EvidenceCompleteness");
|
||||
// LatticeState gate should NOT appear in gate results since it short-circuited
|
||||
decision.Gates.Should().HaveCount(1);
|
||||
decision.Gates[0].Name.Should().Be("EvidenceCompleteness");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyGate_100Iterations_DeterministicDecision()
|
||||
{
|
||||
var options = CreatePolicyGateOptions();
|
||||
var evaluator = CreateEvaluator(options);
|
||||
|
||||
var request = CreateGateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
|
||||
var reference = await evaluator.EvaluateAsync(request);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
decision.Decision.Should().Be(reference.Decision);
|
||||
decision.BlockedBy.Should().Be(reference.BlockedBy);
|
||||
decision.Gates.Length.Should().Be(reference.Gates.Length);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DriftGateEvaluator Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_KevReachable_Blocks()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(blockOnKev: true);
|
||||
var request = CreateDriftRequest(hasKev: true, deltaReachable: 1);
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("KevReachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_KevButNoNewReachable_Passes()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(blockOnKev: true);
|
||||
var request = CreateDriftRequest(hasKev: true, deltaReachable: 0);
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_HighCvss_Blocks()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(cvssThreshold: 9.0);
|
||||
var request = CreateDriftRequest(maxCvss: 9.5, deltaReachable: 2);
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("CvssThreshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_HighEpss_Blocks()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(epssThreshold: 0.5);
|
||||
var request = CreateDriftRequest(maxEpss: 0.75, deltaReachable: 1);
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("EpssThreshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_AffectedReachable_Blocks()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(blockOnAffectedReachable: true);
|
||||
var request = CreateDriftRequest(deltaReachable: 3,
|
||||
vexStatuses: new[] { "affected" });
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("AffectedReachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_NoMaterialDrift_Allows()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(blockOnKev: true, cvssThreshold: 7.0);
|
||||
var request = CreateDriftRequest(hasMaterialDrift: false);
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Allow);
|
||||
decision.Advisory.Should().Contain("No material drift");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_Disabled_AllowsEverything()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(enabled: false, blockOnKev: true);
|
||||
var request = CreateDriftRequest(hasKev: true, deltaReachable: 5);
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriftGate_Override_BypassesBlock()
|
||||
{
|
||||
var evaluator = CreateDriftGateEvaluator(blockOnKev: true);
|
||||
var request = CreateDriftRequest(hasKev: true, deltaReachable: 1);
|
||||
request = request with
|
||||
{
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "Accepted risk per security review #SR-2025-042"
|
||||
};
|
||||
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
decision.Decision.Should().Be(DriftGateDecisionType.Warn);
|
||||
decision.Advisory.Should().Contain("Override accepted");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StabilityDampingGate Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StabilityDamping_FirstVerdict_Surfaces()
|
||||
{
|
||||
var gate = CreateStabilityDampingGate();
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = "artifact:CVE-2025-001",
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.85,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.Reason.Should().Contain("new verdict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StabilityDamping_SameStatus_SmallDelta_Suppressed()
|
||||
{
|
||||
var gate = CreateStabilityDampingGate();
|
||||
var key = "artifact:CVE-2025-002";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Record initial state
|
||||
await gate.RecordStateAsync(key, new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.80,
|
||||
Timestamp = now
|
||||
});
|
||||
|
||||
// Propose same status with small confidence change
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = key,
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.82, // Small delta < threshold
|
||||
Timestamp = now.AddMinutes(5)
|
||||
}
|
||||
};
|
||||
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
decision.ShouldSurface.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StabilityDamping_Disabled_AlwaysSurfaces()
|
||||
{
|
||||
var gate = CreateStabilityDampingGate(enabled: false);
|
||||
var request = new StabilityDampingRequest
|
||||
{
|
||||
Key = "test:key",
|
||||
ProposedState = new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.5,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var decision = await gate.EvaluateAsync(request);
|
||||
|
||||
decision.ShouldSurface.Should().BeTrue();
|
||||
decision.Reason.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StabilityDamping_PruneHistory_RemovesOldRecords()
|
||||
{
|
||||
var gate = CreateStabilityDampingGate();
|
||||
|
||||
// Record old state
|
||||
await gate.RecordStateAsync("old:key", new VerdictState
|
||||
{
|
||||
Status = "affected",
|
||||
Confidence = 0.8,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-60) // Very old
|
||||
});
|
||||
|
||||
var pruned = await gate.PruneHistoryAsync();
|
||||
|
||||
pruned.Should().BeGreaterThanOrEqualTo(0); // Depends on retention config
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static PolicyGateEvaluator CreateEvaluator(PolicyGateOptions options)
|
||||
{
|
||||
return new PolicyGateEvaluator(
|
||||
new TestOptionsMonitor<PolicyGateOptions>(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGateEvaluator>.Instance);
|
||||
}
|
||||
|
||||
private static PolicyGateOptions CreatePolicyGateOptions(bool vexTrustEnabled = false)
|
||||
{
|
||||
var options = new PolicyGateOptions();
|
||||
options.VexTrust.Enabled = vexTrustEnabled;
|
||||
if (vexTrustEnabled)
|
||||
{
|
||||
options.VexTrust.ApplyToStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "not_affected", "fixed" };
|
||||
options.VexTrust.MissingTrustBehavior = MissingTrustBehavior.Warn;
|
||||
options.VexTrust.Thresholds["production"] = new VexTrustThresholds
|
||||
{
|
||||
MinCompositeScore = 0.80m,
|
||||
RequireIssuerVerified = true,
|
||||
AcceptableFreshness = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "fresh" },
|
||||
FailureAction = FailureAction.Block
|
||||
};
|
||||
options.VexTrust.Thresholds["default"] = new VexTrustThresholds
|
||||
{
|
||||
MinCompositeScore = 0.60m,
|
||||
RequireIssuerVerified = false,
|
||||
AcceptableFreshness = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "fresh", "stale" },
|
||||
FailureAction = FailureAction.Warn
|
||||
};
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private static PolicyGateRequest CreateGateRequest(
|
||||
string status,
|
||||
string? latticeState = null,
|
||||
string? uncertaintyTier = null,
|
||||
string? graphHash = "blake3:abc123",
|
||||
int? pathLength = -1,
|
||||
bool hasRuntimeEvidence = false,
|
||||
string? justification = null)
|
||||
{
|
||||
return new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-12345",
|
||||
Purl = "pkg:maven/com.example/foo@1.0.0",
|
||||
RequestedStatus = status,
|
||||
LatticeState = latticeState,
|
||||
UncertaintyTier = uncertaintyTier,
|
||||
GraphHash = graphHash,
|
||||
PathLength = pathLength,
|
||||
HasRuntimeEvidence = hasRuntimeEvidence,
|
||||
Justification = justification,
|
||||
Confidence = 0.95,
|
||||
RiskScore = 0.3
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftGateEvaluator CreateDriftGateEvaluator(
|
||||
bool enabled = true,
|
||||
bool blockOnKev = false,
|
||||
bool blockOnAffectedReachable = false,
|
||||
double? cvssThreshold = null,
|
||||
double? epssThreshold = null)
|
||||
{
|
||||
var options = new DriftGateOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
BlockOnKev = blockOnKev,
|
||||
BlockOnAffectedReachable = blockOnAffectedReachable,
|
||||
CvssBlockThreshold = cvssThreshold,
|
||||
EpssBlockThreshold = epssThreshold
|
||||
};
|
||||
|
||||
return new DriftGateEvaluator(
|
||||
new TestOptionsMonitor<DriftGateOptions>(options),
|
||||
TimeProvider.System,
|
||||
new TestGuidProvider(),
|
||||
NullLogger<DriftGateEvaluator>.Instance);
|
||||
}
|
||||
|
||||
private static DriftGateRequest CreateDriftRequest(
|
||||
bool hasMaterialDrift = true,
|
||||
bool hasKev = false,
|
||||
int deltaReachable = 0,
|
||||
double? maxCvss = null,
|
||||
double? maxEpss = null,
|
||||
string[]? vexStatuses = null)
|
||||
{
|
||||
// HasMaterialDrift is computed: DeltaReachable > 0 || DeltaUnreachable > 0
|
||||
// When hasMaterialDrift=false, ensure both are 0
|
||||
// When hasMaterialDrift=true but deltaReachable=0, use DeltaUnreachable=1 to trigger material drift
|
||||
var deltaUnreachable = (!hasMaterialDrift || deltaReachable > 0) ? 0 : 1;
|
||||
var effectiveDeltaReachable = hasMaterialDrift ? deltaReachable : 0;
|
||||
|
||||
return new DriftGateRequest
|
||||
{
|
||||
Context = new DriftGateContext
|
||||
{
|
||||
HasKevReachable = hasKev,
|
||||
DeltaReachable = effectiveDeltaReachable,
|
||||
DeltaUnreachable = deltaUnreachable,
|
||||
MaxCvss = maxCvss,
|
||||
MaxEpss = maxEpss,
|
||||
NewlyReachableVexStatuses = vexStatuses ?? Array.Empty<string>()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static StabilityDampingGate CreateStabilityDampingGate(bool enabled = true)
|
||||
{
|
||||
var options = new StabilityDampingOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MinDurationBeforeChange = TimeSpan.FromHours(24),
|
||||
MinConfidenceDeltaPercent = 0.10,
|
||||
OnlyDampDowngrades = false,
|
||||
DampedStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "affected", "not_affected" },
|
||||
HistoryRetention = TimeSpan.FromDays(30)
|
||||
};
|
||||
|
||||
return new StabilityDampingGate(
|
||||
new TestOptionsMonitor<StabilityDampingOptions>(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<StabilityDampingGate>.Instance);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
public TestOptionsMonitor(T value) => _value = value;
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class TestGuidProvider : StellaOps.Determinism.IGuidProvider
|
||||
{
|
||||
public Guid NewGuid() => new("11111111-2222-3333-4444-555555555555");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user