Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Snapshots/PolicyEvaluationTraceSnapshotTests.cs
2026-01-22 19:08:46 +02:00

536 lines
19 KiB
C#

// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2025 StellaOps Contributors
using FluentAssertions;
using StellaOps.TestKit.Assertions;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Snapshots;
/// <summary>
/// Snapshot tests for policy evaluation trace summaries.
/// Ensures evaluation traces have stable structure for debugging and auditing.
/// </summary>
public sealed class PolicyEvaluationTraceSnapshotTests
{
private static readonly DateTimeOffset FrozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
/// <summary>
/// Verifies that a simple evaluation trace produces stable structure.
/// </summary>
[Fact]
public void SimpleEvaluationTrace_ProducesStableStructure()
{
// Arrange
var trace = CreateSimpleEvaluationTrace();
// Act
SnapshotAssert.MatchesSnapshot(trace, "SimpleEvaluationTrace");
}
/// <summary>
/// Verifies that an evaluation trace with multiple rule evaluations produces stable structure.
/// </summary>
[Fact]
public void MultiRuleEvaluationTrace_ProducesStableStructure()
{
// Arrange
var trace = CreateMultiRuleEvaluationTrace();
// Act
SnapshotAssert.MatchesSnapshot(trace, "MultiRuleEvaluationTrace");
}
/// <summary>
/// Verifies that an evaluation trace with VEX resolution produces stable structure.
/// </summary>
[Fact]
public void VexResolutionTrace_ProducesStableStructure()
{
// Arrange
var trace = CreateVexResolutionTrace();
// Act
SnapshotAssert.MatchesSnapshot(trace, "VexResolutionTrace");
}
/// <summary>
/// Verifies that an evaluation trace with profile application produces stable structure.
/// </summary>
[Fact]
public void ProfileApplicationTrace_ProducesStableStructure()
{
// Arrange
var trace = CreateProfileApplicationTrace();
// Act
SnapshotAssert.MatchesSnapshot(trace, "ProfileApplicationTrace");
}
/// <summary>
/// Verifies that an evaluation trace with severity escalation produces stable structure.
/// </summary>
[Fact]
public void SeverityEscalationTrace_ProducesStableStructure()
{
// Arrange
var trace = CreateSeverityEscalationTrace();
// Act
SnapshotAssert.MatchesSnapshot(trace, "SeverityEscalationTrace");
}
/// <summary>
/// Verifies that evaluation trace steps are ordered by priority.
/// </summary>
[Fact]
public void EvaluationTrace_StepsOrderedByPriority()
{
// Arrange
var trace = CreateMultiRuleEvaluationTrace();
// Assert
var priorities = trace.Steps.Select(s => s.Priority).ToList();
priorities.Should().BeInDescendingOrder("Steps should be ordered by priority (highest first)");
}
/// <summary>
/// Verifies that evaluation trace includes timing information.
/// </summary>
[Fact]
public void EvaluationTrace_IncludesTimingInformation()
{
// Arrange
var trace = CreateSimpleEvaluationTrace();
// Assert
trace.StartedAt.Should().Be(FrozenTime);
trace.CompletedAt.Should().BeAfter(trace.StartedAt);
trace.DurationMs.Should().BeGreaterThanOrEqualTo(0);
trace.Steps.Should().AllSatisfy(s => s.DurationMs.Should().BeGreaterThanOrEqualTo(0));
}
#region Trace Factories
private static PolicyEvaluationTrace CreateSimpleEvaluationTrace()
{
return new PolicyEvaluationTrace
{
TraceId = "TRACE-2025-001",
PolicyId = "POL-PROD-001",
PolicyName = "Production Baseline Policy",
EvaluationContext = new EvaluationContext
{
DigestEvaluated = "sha256:abc123def456",
TenantId = "TENANT-001",
Environment = "production",
Exposure = "internet"
},
StartedAt = FrozenTime,
CompletedAt = FrozenTime.AddMilliseconds(42),
DurationMs = 42,
Outcome = "Pass",
Steps =
[
new EvaluationStep
{
StepNumber = 1,
RuleName = "block_critical",
Priority = 5,
Phase = EvaluationPhase.RuleMatch,
Condition = "severity.normalized >= \"Critical\"",
ConditionResult = false,
Action = null,
Explanation = "No critical vulnerabilities found in scan",
DurationMs = 15
},
new EvaluationStep
{
StepNumber = 2,
RuleName = "allow_low_severity",
Priority = 1,
Phase = EvaluationPhase.RuleMatch,
Condition = "severity.normalized <= \"Low\"",
ConditionResult = true,
Action = "status := \"allowed\"",
Explanation = "All findings are Low severity or below",
DurationMs = 12
}
],
FinalStatus = "allowed",
MatchedRuleCount = 1,
TotalRuleCount = 2
};
}
private static PolicyEvaluationTrace CreateMultiRuleEvaluationTrace()
{
return new PolicyEvaluationTrace
{
TraceId = "TRACE-2025-002",
PolicyId = "POL-COMPLEX-001",
PolicyName = "Complex Multi-Rule Policy",
EvaluationContext = new EvaluationContext
{
DigestEvaluated = "sha256:xyz789",
TenantId = "TENANT-002",
Environment = "staging",
Exposure = "internal"
},
StartedAt = FrozenTime,
CompletedAt = FrozenTime.AddMilliseconds(89),
DurationMs = 89,
Outcome = "Warn",
Steps =
[
new EvaluationStep
{
StepNumber = 1,
RuleName = "block_critical",
Priority = 5,
Phase = EvaluationPhase.RuleMatch,
Condition = "severity.normalized >= \"Critical\"",
ConditionResult = false,
Action = null,
Explanation = "No critical vulnerabilities",
DurationMs = 10
},
new EvaluationStep
{
StepNumber = 2,
RuleName = "escalate_high_internet",
Priority = 4,
Phase = EvaluationPhase.RuleMatch,
Condition = "severity.normalized == \"High\" and env.exposure == \"internet\"",
ConditionResult = false,
Action = null,
Explanation = "Not internet-exposed, skipping escalation",
DurationMs = 8
},
new EvaluationStep
{
StepNumber = 3,
RuleName = "block_ruby_dev",
Priority = 4,
Phase = EvaluationPhase.RuleMatch,
Condition = "sbom.any_component(ruby.group(\"development\"))",
ConditionResult = false,
Action = null,
Explanation = "No development-only Ruby gems",
DurationMs = 12
},
new EvaluationStep
{
StepNumber = 4,
RuleName = "require_vex_justification",
Priority = 3,
Phase = EvaluationPhase.RuleMatch,
Condition = "vex.any(status in [\"not_affected\",\"fixed\"])",
ConditionResult = true,
Action = "status := vex.status",
Explanation = "VEX statement found: not_affected (component_not_present)",
DurationMs = 25
},
new EvaluationStep
{
StepNumber = 5,
RuleName = "warn_eol_runtime",
Priority = 1,
Phase = EvaluationPhase.RuleMatch,
Condition = "severity.normalized <= \"Medium\" and sbom.has_tag(\"runtime:eol\")",
ConditionResult = true,
Action = "warn message \"Runtime marked as EOL; upgrade recommended.\"",
Explanation = "EOL runtime detected: python3.9",
DurationMs = 15
}
],
FinalStatus = "warning",
MatchedRuleCount = 2,
TotalRuleCount = 5
};
}
private static PolicyEvaluationTrace CreateVexResolutionTrace()
{
return new PolicyEvaluationTrace
{
TraceId = "TRACE-2025-003",
PolicyId = "POL-VEX-001",
PolicyName = "VEX-Aware Policy",
EvaluationContext = new EvaluationContext
{
DigestEvaluated = "sha256:vex123",
TenantId = "TENANT-001",
Environment = "production",
Exposure = "internet"
},
StartedAt = FrozenTime,
CompletedAt = FrozenTime.AddMilliseconds(67),
DurationMs = 67,
Outcome = "Pass",
Steps =
[
new EvaluationStep
{
StepNumber = 1,
RuleName = "vex_merge_resolution",
Priority = 10,
Phase = EvaluationPhase.VexMerge,
Condition = "vex.statements.count > 1",
ConditionResult = true,
Action = null,
Explanation = "Multiple VEX statements found; merging via K4 lattice",
DurationMs = 20,
VexMergeDetail = new VexMergeDetail
{
VulnerabilityId = "CVE-2024-0001",
StatementCount = 3,
Sources =
[
new VexSourceInfo { Source = "vendor", Status = "not_affected", Trust = 1.0m },
new VexSourceInfo { Source = "maintainer", Status = "affected", Trust = 0.9m },
new VexSourceInfo { Source = "scanner", Status = "unknown", Trust = 0.5m }
],
WinningSource = "vendor",
WinningStatus = "not_affected",
ResolutionReason = "TrustWeight",
ConflictsResolved = 2
}
},
new EvaluationStep
{
StepNumber = 2,
RuleName = "require_vex_justification",
Priority = 3,
Phase = EvaluationPhase.RuleMatch,
Condition = "vex.justification in [\"component_not_present\",\"vulnerable_code_not_present\"]",
ConditionResult = true,
Action = "status := vex.status",
Explanation = "VEX justification accepted: component_not_present",
DurationMs = 12
}
],
FinalStatus = "allowed",
MatchedRuleCount = 2,
TotalRuleCount = 2
};
}
private static PolicyEvaluationTrace CreateProfileApplicationTrace()
{
return new PolicyEvaluationTrace
{
TraceId = "TRACE-2025-004",
PolicyId = "POL-PROFILE-001",
PolicyName = "Profile-Based Policy",
EvaluationContext = new EvaluationContext
{
DigestEvaluated = "sha256:profile123",
TenantId = "TENANT-003",
Environment = "production",
Exposure = "internet"
},
StartedAt = FrozenTime,
CompletedAt = FrozenTime.AddMilliseconds(55),
DurationMs = 55,
Outcome = "Pass",
Steps =
[
new EvaluationStep
{
StepNumber = 1,
RuleName = "profile_severity",
Priority = 100,
Phase = EvaluationPhase.ProfileApplication,
Condition = "profile.severity.enabled",
ConditionResult = true,
Action = null,
Explanation = "Applying severity profile adjustments",
DurationMs = 18,
ProfileApplicationDetail = new ProfileApplicationDetail
{
ProfileName = "severity",
Adjustments =
[
new ProfileAdjustment
{
Finding = "CVE-2024-0001",
OriginalSeverity = "High",
AdjustedSeverity = "Critical",
Reason = "GHSA source weight +0.5, internet exposure +0.5"
}
]
}
},
new EvaluationStep
{
StepNumber = 2,
RuleName = "block_critical",
Priority = 5,
Phase = EvaluationPhase.RuleMatch,
Condition = "severity.normalized >= \"Critical\"",
ConditionResult = false,
Action = null,
Explanation = "Post-profile: no critical vulnerabilities (VEX override applied)",
DurationMs = 10
}
],
FinalStatus = "allowed",
MatchedRuleCount = 1,
TotalRuleCount = 2
};
}
private static PolicyEvaluationTrace CreateSeverityEscalationTrace()
{
return new PolicyEvaluationTrace
{
TraceId = "TRACE-2025-005",
PolicyId = "POL-ESCALATE-001",
PolicyName = "Escalation Policy",
EvaluationContext = new EvaluationContext
{
DigestEvaluated = "sha256:escalate123",
TenantId = "TENANT-001",
Environment = "production",
Exposure = "internet"
},
StartedAt = FrozenTime,
CompletedAt = FrozenTime.AddMilliseconds(45),
DurationMs = 45,
Outcome = "Fail",
Steps =
[
new EvaluationStep
{
StepNumber = 1,
RuleName = "escalate_high_internet",
Priority = 4,
Phase = EvaluationPhase.SeverityEscalation,
Condition = "severity.normalized == \"High\" and env.exposure == \"internet\"",
ConditionResult = true,
Action = "escalate to severity_band(\"Critical\")",
Explanation = "High severity on internet-exposed asset escalated to Critical",
DurationMs = 15,
EscalationDetail = new EscalationDetail
{
Finding = "CVE-2024-0001",
OriginalSeverity = "High",
EscalatedSeverity = "Critical",
Reason = "Internet exposure triggers escalation per policy rule"
}
},
new EvaluationStep
{
StepNumber = 2,
RuleName = "block_critical",
Priority = 5,
Phase = EvaluationPhase.RuleMatch,
Condition = "severity.normalized >= \"Critical\"",
ConditionResult = true,
Action = "status := \"blocked\"",
Explanation = "Critical severity (post-escalation) triggers block",
DurationMs = 10
}
],
FinalStatus = "blocked",
MatchedRuleCount = 2,
TotalRuleCount = 2
};
}
#endregion
}
#region Trace Models
public sealed record PolicyEvaluationTrace
{
public required string TraceId { get; init; }
public required string PolicyId { get; init; }
public required string PolicyName { get; init; }
public required EvaluationContext EvaluationContext { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public required DateTimeOffset CompletedAt { get; init; }
public required int DurationMs { get; init; }
public required string Outcome { get; init; }
public required IReadOnlyList<EvaluationStep> Steps { get; init; }
public required string FinalStatus { get; init; }
public required int MatchedRuleCount { get; init; }
public required int TotalRuleCount { get; init; }
}
public sealed record EvaluationContext
{
public required string DigestEvaluated { get; init; }
public required string TenantId { get; init; }
public required string Environment { get; init; }
public required string Exposure { get; init; }
}
public sealed record EvaluationStep
{
public required int StepNumber { get; init; }
public required string RuleName { get; init; }
public required int Priority { get; init; }
public required EvaluationPhase Phase { get; init; }
public required string Condition { get; init; }
public required bool ConditionResult { get; init; }
public string? Action { get; init; }
public required string Explanation { get; init; }
public required int DurationMs { get; init; }
public VexMergeDetail? VexMergeDetail { get; init; }
public ProfileApplicationDetail? ProfileApplicationDetail { get; init; }
public EscalationDetail? EscalationDetail { get; init; }
}
public enum EvaluationPhase
{
ProfileApplication,
VexMerge,
SeverityEscalation,
RuleMatch
}
public sealed record VexMergeDetail
{
public required string VulnerabilityId { get; init; }
public required int StatementCount { get; init; }
public required IReadOnlyList<VexSourceInfo> Sources { get; init; }
public required string WinningSource { get; init; }
public required string WinningStatus { get; init; }
public required string ResolutionReason { get; init; }
public required int ConflictsResolved { get; init; }
}
public sealed record VexSourceInfo
{
public required string Source { get; init; }
public required string Status { get; init; }
public required decimal Trust { get; init; }
}
public sealed record ProfileApplicationDetail
{
public required string ProfileName { get; init; }
public required IReadOnlyList<ProfileAdjustment> Adjustments { get; init; }
}
public sealed record ProfileAdjustment
{
public required string Finding { get; init; }
public required string OriginalSeverity { get; init; }
public required string AdjustedSeverity { get; init; }
public required string Reason { get; init; }
}
public sealed record EscalationDetail
{
public required string Finding { get; init; }
public required string OriginalSeverity { get; init; }
public required string EscalatedSeverity { get; init; }
public required string Reason { get; init; }
}
#endregion