This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,360 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Gates;
public class PolicyGateEvaluatorTests
{
private readonly PolicyGateEvaluator _evaluator;
private readonly PolicyGateOptions _options;
public PolicyGateEvaluatorTests()
{
_options = new PolicyGateOptions();
_evaluator = new PolicyGateEvaluator(
new OptionsMonitorWrapper(_options),
TimeProvider.System,
NullLogger<PolicyGateEvaluator>.Instance);
}
// Lattice State Gate Tests
[Fact]
public async Task NotAffected_WithCU_AllowsDecision()
{
var request = CreateRequest("not_affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
Assert.Null(decision.BlockedBy);
}
[Fact]
public async Task NotAffected_WithSU_AllowsWithWarning_WhenJustificationProvided()
{
var request = CreateRequest("not_affected", latticeState: "SU", justification: "Verified dead code");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.NotNull(decision.Advisory);
}
[Fact]
public async Task NotAffected_WithSU_Blocks_WhenNoJustification()
{
var request = CreateRequest("not_affected", latticeState: "SU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
}
[Fact]
public async Task NotAffected_WithSR_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "SR");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
Assert.Contains("SR", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithCR_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CR");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
}
[Fact]
public async Task NotAffected_WithContested_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "X");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
Assert.Contains("Contested", decision.BlockReason);
}
[Fact]
public async Task Affected_WithCR_Allows()
{
var request = CreateRequest("affected", latticeState: "CR");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
[Fact]
public async Task Affected_WithCU_WarnsOfFalsePositive()
{
var request = CreateRequest("affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.Contains("false positive", decision.Advisory, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task UnderInvestigation_AllowsAnyLatticeState()
{
var states = new[] { "U", "SR", "SU", "RO", "RU", "CR", "CU", "X" };
foreach (var state in states)
{
var request = CreateRequest("under_investigation", latticeState: state);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
}
// Uncertainty Tier Gate Tests
[Fact]
public async Task NotAffected_WithT1_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T1");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("UncertaintyTier", decision.BlockedBy);
Assert.Contains("T1", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithT2_Warns()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T2");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.NotNull(decision.Advisory);
}
[Fact]
public async Task NotAffected_WithT3_AllowsWithNote()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T3");
var decision = await _evaluator.EvaluateAsync(request);
// T3 results in a PassWithNote which becomes a Warn decision
Assert.True(decision.Decision == PolicyGateDecisionType.Allow || decision.Decision == PolicyGateDecisionType.Warn);
}
[Fact]
public async Task NotAffected_WithT4_Allows()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
[Fact]
public async Task Affected_WithT1_WarnsOfReviewRequired()
{
var request = CreateRequest("affected", latticeState: "CR", uncertaintyTier: "T1");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.Contains("Review required", decision.Advisory, StringComparison.OrdinalIgnoreCase);
}
// Evidence Completeness Gate Tests
[Fact]
public async Task NotAffected_WithoutGraphHash_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4", graphHash: null);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("EvidenceCompleteness", decision.BlockedBy);
Assert.Contains("graphHash", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithoutPathLength_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4", pathLength: null);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("EvidenceCompleteness", decision.BlockedBy);
Assert.Contains("pathLength", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithGraphHashAndPath_Allows()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4", graphHash: "blake3:abc", pathLength: -1);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
[Fact]
public async Task Affected_WithoutEvidence_Warns()
{
var request = CreateRequest("affected", latticeState: "CR", graphHash: null, hasRuntimeEvidence: false);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
}
// Override Tests
[Fact]
public async Task Override_WithJustification_BypassesBlock()
{
var request = CreateRequest("not_affected", latticeState: "SR");
request = request with
{
AllowOverride = true,
OverrideJustification = "Manual review confirmed dead code path in production"
};
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.Contains("Override accepted", decision.Advisory);
}
[Fact]
public async Task Override_WithoutJustification_DoesNotBypass()
{
var request = CreateRequest("not_affected", latticeState: "SR");
request = request with
{
AllowOverride = true,
OverrideJustification = "" // Empty justification
};
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
}
[Fact]
public async Task Override_WithShortJustification_DoesNotBypass()
{
var request = CreateRequest("not_affected", latticeState: "SR");
request = request with
{
AllowOverride = true,
OverrideJustification = "Too short" // Less than 20 characters
};
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
}
// Disabled Gates Tests
[Fact]
public async Task DisabledGates_AllowsEverything()
{
var options = new PolicyGateOptions { Enabled = false };
var evaluator = new PolicyGateEvaluator(
new OptionsMonitorWrapper(options),
TimeProvider.System,
NullLogger<PolicyGateEvaluator>.Instance);
var request = CreateRequest("not_affected", latticeState: "CR", uncertaintyTier: "T1");
var decision = await evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
Assert.Contains("disabled", decision.Advisory, StringComparison.OrdinalIgnoreCase);
}
// Decision Document Tests
[Fact]
public async Task Decision_ContainsGateId()
{
var request = CreateRequest("not_affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.NotNull(decision.GateId);
Assert.StartsWith("gate:vex:not_affected:", decision.GateId);
}
[Fact]
public async Task Decision_ContainsSubject()
{
var request = CreateRequest("not_affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal("CVE-2025-12345", decision.Subject.VulnId);
Assert.Equal("pkg:maven/com.example/foo@1.0.0", decision.Subject.Purl);
}
[Fact]
public async Task Decision_ContainsEvidence()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal("CU", decision.Evidence.LatticeState);
Assert.Equal("T4", decision.Evidence.UncertaintyTier);
}
[Fact]
public async Task Decision_ContainsGateResults()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
var decision = await _evaluator.EvaluateAsync(request);
Assert.NotEmpty(decision.Gates);
Assert.Contains(decision.Gates, g => g.Name == "EvidenceCompleteness");
Assert.Contains(decision.Gates, g => g.Name == "LatticeState");
Assert.Contains(decision.Gates, g => g.Name == "UncertaintyTier");
}
private static PolicyGateRequest CreateRequest(
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 sealed class OptionsMonitorWrapper : IOptionsMonitor<PolicyGateOptions>
{
private readonly PolicyGateOptions _options;
public OptionsMonitorWrapper(PolicyGateOptions options) => _options = options;
public PolicyGateOptions CurrentValue => _options;
public PolicyGateOptions Get(string? name) => _options;
public IDisposable? OnChange(Action<PolicyGateOptions, string?> listener) => null;
}
}

View File

@@ -231,6 +231,168 @@ public sealed class PolicyRuntimeEvaluationServiceTests
Assert.Equal("warn", response.Status);
}
[Fact]
public async Task EvaluateAsync_GatesUnreachableWithoutEvidenceRef_ToUnderInvestigation()
{
const string policy = """
policy "Reachability gate policy" syntax "stella-dsl@1" {
rule unreachable_to_not_affected priority 10 {
when reachability.state == "unreachable"
then status := "not_affected"
because "unreachable + evidence"
}
rule gated_to_under_investigation priority 20 {
when reachability.state == "under_investigation"
then status := "under_investigation"
because "unreachable but missing evidence"
}
rule default priority 100 {
when true
then status := "affected"
because "default"
}
}
""";
var harness = CreateHarness();
await harness.StoreTestPolicyAsync("pack-3", 1, policy);
var fact = new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:npm/lodash@4.17.21",
AdvisoryId = "CVE-2024-0001",
State = ReachabilityState.Unreachable,
Confidence = 0.92m,
Score = 0m,
HasRuntimeEvidence = false,
Source = "graph-analyzer",
Method = AnalysisMethod.Static,
EvidenceRef = null,
EvidenceHash = "sha256:deadbeef",
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Metadata = new Dictionary<string, object?>()
};
await harness.ReachabilityStore.SaveAsync(fact, CancellationToken.None);
var request = CreateRequest("pack-3", 1, severity: "Low");
var response = await harness.Service.EvaluateAsync(request, CancellationToken.None);
Assert.Equal("under_investigation", response.Status);
}
[Fact]
public async Task EvaluateAsync_GatesUnreachableWithLowConfidence_ToUnderInvestigation()
{
const string policy = """
policy "Reachability gate policy" syntax "stella-dsl@1" {
rule unreachable_to_not_affected priority 10 {
when reachability.state == "unreachable"
then status := "not_affected"
because "unreachable + evidence"
}
rule gated_to_under_investigation priority 20 {
when reachability.state == "under_investigation"
then status := "under_investigation"
because "unreachable but low confidence"
}
rule default priority 100 {
when true
then status := "affected"
because "default"
}
}
""";
var harness = CreateHarness();
await harness.StoreTestPolicyAsync("pack-4", 1, policy);
var fact = new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:npm/lodash@4.17.21",
AdvisoryId = "CVE-2024-0001",
State = ReachabilityState.Unreachable,
Confidence = 0.7m,
Score = 0m,
HasRuntimeEvidence = false,
Source = "graph-analyzer",
Method = AnalysisMethod.Static,
EvidenceRef = "cas://reachability/facts/fact-1",
EvidenceHash = "sha256:deadbeef",
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Metadata = new Dictionary<string, object?>()
};
await harness.ReachabilityStore.SaveAsync(fact, CancellationToken.None);
var request = CreateRequest("pack-4", 1, severity: "Low");
var response = await harness.Service.EvaluateAsync(request, CancellationToken.None);
Assert.Equal("under_investigation", response.Status);
}
[Fact]
public async Task EvaluateAsync_AllowsUnreachableWithEvidenceRefAndHighConfidence()
{
const string policy = """
policy "Reachability gate policy" syntax "stella-dsl@1" {
rule unreachable_to_not_affected priority 10 {
when reachability.state == "unreachable"
then status := "not_affected"
because "unreachable + evidence"
}
rule gated_to_under_investigation priority 20 {
when reachability.state == "under_investigation"
then status := "under_investigation"
because "gated"
}
rule default priority 100 {
when true
then status := "affected"
because "default"
}
}
""";
var harness = CreateHarness();
await harness.StoreTestPolicyAsync("pack-5", 1, policy);
var fact = new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:npm/lodash@4.17.21",
AdvisoryId = "CVE-2024-0001",
State = ReachabilityState.Unreachable,
Confidence = 0.92m,
Score = 0m,
HasRuntimeEvidence = false,
Source = "graph-analyzer",
Method = AnalysisMethod.Static,
EvidenceRef = "cas://reachability/facts/fact-1",
EvidenceHash = "sha256:deadbeef",
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Metadata = new Dictionary<string, object?>()
};
await harness.ReachabilityStore.SaveAsync(fact, CancellationToken.None);
var request = CreateRequest("pack-5", 1, severity: "Low");
var response = await harness.Service.EvaluateAsync(request, CancellationToken.None);
Assert.Equal("not_affected", response.Status);
}
private static RuntimeEvaluationRequest CreateRequest(
string packId,
int version,

View File

@@ -1,13 +1,11 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Workers;
@@ -15,74 +13,98 @@ namespace StellaOps.Policy.Engine.Tests.Workers;
public sealed class ExceptionLifecycleServiceTests
{
[Fact]
public async Task Activates_pending_exceptions_and_publishes_event()
public async Task Skips_processing_when_no_tenants_configured()
{
var time = new FakeTimeProvider(new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero));
var repo = new InMemoryExceptionRepository();
await repo.CreateExceptionAsync(new PolicyExceptionDocument
{
Id = "exc-1",
TenantId = "tenant-a",
Status = "approved",
Name = "Test exception",
EffectiveFrom = time.GetUtcNow().AddMinutes(-1),
}, CancellationToken.None);
var publisher = new RecordingPublisher();
var repository = new RecordingExceptionRepository();
var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
var service = new ExceptionLifecycleService(
repo,
publisher,
repository,
options,
time,
NullLogger<ExceptionLifecycleService>.Instance);
await service.ProcessOnceAsync(CancellationToken.None);
var updated = await repo.GetExceptionAsync("tenant-a", "exc-1", CancellationToken.None);
updated!.Status.Should().Be("active");
publisher.Events.Should().ContainSingle(e => e.EventType == "activated" && e.ExceptionId == "exc-1");
repository.ExpiredTenants.Should().BeEmpty();
}
[Fact]
public async Task Expires_active_exceptions_and_publishes_event()
public async Task Expires_active_exceptions_for_configured_tenants()
{
var time = new FakeTimeProvider(new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero));
var repo = new InMemoryExceptionRepository();
await repo.CreateExceptionAsync(new PolicyExceptionDocument
var id = Guid.Parse("8b0f1d8a-bcc8-4c11-a3db-2f1b10c31821");
await repo.CreateAsync(new ExceptionEntity
{
Id = "exc-2",
Id = id,
TenantId = "tenant-b",
Status = "active",
Status = ExceptionStatus.Active,
Name = "Expiring exception",
ExpiresAt = time.GetUtcNow().AddMinutes(-1),
Reason = "test-fixture",
ExpiresAt = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero),
CreatedAt = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero)
}, CancellationToken.None);
var publisher = new RecordingPublisher();
var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
var configured = new PolicyEngineOptions();
configured.ResourceServer.RequiredTenants.Add("tenant-b");
var service = new ExceptionLifecycleService(
repo,
publisher,
options,
time,
Microsoft.Extensions.Options.Options.Create(configured),
NullLogger<ExceptionLifecycleService>.Instance);
await service.ProcessOnceAsync(CancellationToken.None);
var updated = await repo.GetExceptionAsync("tenant-b", "exc-2", CancellationToken.None);
updated!.Status.Should().Be("expired");
publisher.Events.Should().ContainSingle(e => e.EventType == "expired" && e.ExceptionId == "exc-2");
var updated = await repo.GetByIdAsync("tenant-b", id, CancellationToken.None);
updated!.Status.Should().Be(ExceptionStatus.Expired);
updated.RevokedAt.Should().NotBeNull();
}
private sealed class RecordingPublisher : IExceptionEventPublisher
private sealed class RecordingExceptionRepository : IExceptionRepository
{
public List<ExceptionEvent> Events { get; } = new();
public List<string> ExpiredTenants { get; } = new();
public Task PublishAsync(ExceptionEvent exceptionEvent, CancellationToken cancellationToken = default)
public Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<ExceptionEntity>> GetAllAsync(
string tenantId,
ExceptionStatus? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ExceptionEntity>> GetActiveForProjectAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ExceptionEntity>> GetActiveForRuleAsync(
string tenantId,
string ruleName,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<bool> UpdateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> ApproveAsync(string tenantId, Guid id, string approvedBy, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default)
{
Events.Add(exceptionEvent);
return Task.CompletedTask;
ExpiredTenants.Add(tenantId);
return Task.FromResult(0);
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}