536 lines
19 KiB
C#
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
|