product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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().BeGreaterOrEqualTo(0);
|
||||
trace.Steps.Should().AllSatisfy(s => s.DurationMs.Should().BeGreaterOrEqualTo(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 = "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 = 4,
|
||||
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
|
||||
},
|
||||
new EvaluationStep
|
||||
{
|
||||
StepNumber = 5,
|
||||
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
|
||||
}
|
||||
],
|
||||
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
|
||||
Reference in New Issue
Block a user