up
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.BatchEvaluation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.BatchEvaluation;
|
||||
|
||||
public sealed class BatchEvaluationMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenTimestampMissing()
|
||||
{
|
||||
var request = new BatchEvaluationRequestDto(
|
||||
TenantId: "acme",
|
||||
Items: new[]
|
||||
{
|
||||
new BatchEvaluationItemDto(
|
||||
PackId: "pack-1",
|
||||
Version: 1,
|
||||
SubjectPurl: "pkg:npm/lodash@4.17.21",
|
||||
AdvisoryId: "ADV-1",
|
||||
Severity: new EvaluationSeverityDto("high", 7.5m),
|
||||
Advisory: new AdvisoryDto(new Dictionary<string, string>(), "nvd"),
|
||||
Vex: new VexEvidenceDto(Array.Empty<VexStatementDto>()),
|
||||
Sbom: new SbomDto(Array.Empty<string>()),
|
||||
Exceptions: new ExceptionsDto(),
|
||||
Reachability: new ReachabilityDto("unknown"),
|
||||
EvaluationTimestamp: null)
|
||||
});
|
||||
|
||||
var ok = BatchEvaluationValidator.TryValidate(request, out var error);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Contains("evaluationTimestamp", error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mapper_Produces_RuntimeRequest_WithSuppliedValues()
|
||||
{
|
||||
var item = new BatchEvaluationItemDto(
|
||||
PackId: "pack-1",
|
||||
Version: 2,
|
||||
SubjectPurl: "pkg:npm/foo@1.0.0",
|
||||
AdvisoryId: "ADV-1",
|
||||
Severity: new EvaluationSeverityDto("high", 8.0m),
|
||||
Advisory: new AdvisoryDto(new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = "CVE-2025-0001"
|
||||
}, "nvd"),
|
||||
Vex: new VexEvidenceDto(new[]
|
||||
{
|
||||
new VexStatementDto("not_affected", "vendor_confirmed", "stmt-1", new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
}),
|
||||
Sbom: new SbomDto(
|
||||
Tags: new[] { "runtime", "server" },
|
||||
Components: new[]
|
||||
{
|
||||
new ComponentDto("foo", "1.0.0", "npm", "pkg:npm/foo@1.0.0")
|
||||
}),
|
||||
Exceptions: new ExceptionsDto(
|
||||
Effects: new Dictionary<string, PolicyExceptionEffect>(),
|
||||
Instances: new[]
|
||||
{
|
||||
new ExceptionInstanceDto(
|
||||
Id: "ex-1",
|
||||
EffectId: "suppress",
|
||||
Scope: new ExceptionScopeDto(
|
||||
RuleNames: new[] { "rule-1" },
|
||||
Severities: new[] { "high" }),
|
||||
CreatedAt: new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero))
|
||||
}),
|
||||
Reachability: new ReachabilityDto("reachable", 0.9m, 0.8m, HasRuntimeEvidence: true, Source: "scanner", Method: "static", EvidenceRef: "evidence-1"),
|
||||
EvaluationTimestamp: new DateTimeOffset(2025, 1, 3, 0, 0, 0, TimeSpan.Zero),
|
||||
BypassCache: false);
|
||||
|
||||
var runtimeRequests = BatchEvaluationMapper.ToRuntimeRequests("acme", new[] { item });
|
||||
var runtime = Assert.Single(runtimeRequests);
|
||||
|
||||
Assert.Equal("acme", runtime.TenantId);
|
||||
Assert.Equal("pack-1", runtime.PackId);
|
||||
Assert.Equal("pkg:npm/foo@1.0.0", runtime.SubjectPurl);
|
||||
Assert.Equal(new DateTimeOffset(2025, 1, 3, 0, 0, 0, TimeSpan.Zero), runtime.EvaluationTimestamp);
|
||||
Assert.Equal("reachable", runtime.Reachability.State);
|
||||
Assert.True(runtime.Reachability.HasRuntimeEvidence);
|
||||
Assert.Equal("scanner", runtime.Reachability.Source);
|
||||
Assert.Equal("high", runtime.Severity.Normalized);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Policy.Engine.Caching;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.PolicyDsl;
|
||||
@@ -180,6 +181,55 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.NotEqual(response1.CorrelationId, response2.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnrichesReachabilityFromFacts()
|
||||
{
|
||||
const string policy = """
|
||||
policy "Reachability policy" syntax "stella-dsl@1" {
|
||||
rule reachable_then_warn priority 5 {
|
||||
when reachability.state == "reachable"
|
||||
then status := "warn"
|
||||
because "reachable path detected"
|
||||
}
|
||||
|
||||
rule default priority 100 {
|
||||
when true
|
||||
then status := "affected"
|
||||
because "default"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var harness = CreateHarness();
|
||||
await harness.StoreTestPolicyAsync("pack-2", 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.Reachable,
|
||||
Confidence = 0.92m,
|
||||
Score = 0.85m,
|
||||
HasRuntimeEvidence = true,
|
||||
Source = "graph-analyzer",
|
||||
Method = AnalysisMethod.Hybrid,
|
||||
EvidenceRef = "evidence/callgraph.json",
|
||||
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
ExpiresAt = null,
|
||||
Metadata = new Dictionary<string, object?>()
|
||||
};
|
||||
|
||||
await harness.ReachabilityStore.SaveAsync(fact, CancellationToken.None);
|
||||
|
||||
var request = CreateRequest("pack-2", 1, severity: "Low");
|
||||
|
||||
var response = await harness.Service.EvaluateAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal("warn", response.Status);
|
||||
}
|
||||
|
||||
private static RuntimeEvaluationRequest CreateRequest(
|
||||
string packId,
|
||||
int version,
|
||||
@@ -213,16 +263,28 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
var cache = new InMemoryPolicyEvaluationCache(cacheLogger, TimeProvider.System, options);
|
||||
var evaluator = new PolicyEvaluator();
|
||||
|
||||
var reachabilityStore = new InMemoryReachabilityFactsStore(TimeProvider.System);
|
||||
var reachabilityCache = new InMemoryReachabilityFactsOverlayCache(
|
||||
NullLogger<InMemoryReachabilityFactsOverlayCache>.Instance,
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions()));
|
||||
var reachabilityService = new ReachabilityFactsJoiningService(
|
||||
reachabilityStore,
|
||||
reachabilityCache,
|
||||
NullLogger<ReachabilityFactsJoiningService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var compilationService = CreateCompilationService();
|
||||
|
||||
var service = new PolicyRuntimeEvaluationService(
|
||||
repository,
|
||||
cache,
|
||||
evaluator,
|
||||
reachabilityService,
|
||||
TimeProvider.System,
|
||||
serviceLogger);
|
||||
|
||||
return new TestHarness(service, repository, compilationService);
|
||||
return new TestHarness(service, repository, compilationService, reachabilityStore);
|
||||
}
|
||||
|
||||
private static PolicyCompilationService CreateCompilationService()
|
||||
@@ -238,7 +300,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
private sealed record TestHarness(
|
||||
PolicyRuntimeEvaluationService Service,
|
||||
InMemoryPolicyPackRepository Repository,
|
||||
PolicyCompilationService CompilationService)
|
||||
PolicyCompilationService CompilationService,
|
||||
InMemoryReachabilityFactsStore ReachabilityStore)
|
||||
{
|
||||
public async Task StoreTestPolicyAsync(string packId, int version, string dsl)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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 Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Workers;
|
||||
|
||||
public sealed class ExceptionLifecycleServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Activates_pending_exceptions_and_publishes_event()
|
||||
{
|
||||
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 options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
|
||||
var service = new ExceptionLifecycleService(
|
||||
repo,
|
||||
publisher,
|
||||
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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expires_active_exceptions_and_publishes_event()
|
||||
{
|
||||
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-2",
|
||||
TenantId = "tenant-b",
|
||||
Status = "active",
|
||||
Name = "Expiring exception",
|
||||
ExpiresAt = time.GetUtcNow().AddMinutes(-1),
|
||||
}, CancellationToken.None);
|
||||
|
||||
var publisher = new RecordingPublisher();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
|
||||
var service = new ExceptionLifecycleService(
|
||||
repo,
|
||||
publisher,
|
||||
options,
|
||||
time,
|
||||
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");
|
||||
}
|
||||
|
||||
private sealed class RecordingPublisher : IExceptionEventPublisher
|
||||
{
|
||||
public List<ExceptionEvent> Events { get; } = new();
|
||||
|
||||
public Task PublishAsync(ExceptionEvent exceptionEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Events.Add(exceptionEvent);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Tests;
|
||||
|
||||
public class CvssVectorInteropTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", "CVSS:4.0/AV:N/AC:L/PR:N/UI:N/VC:H/VI:H/VA:H")]
|
||||
[InlineData("CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L", "CVSS:4.0/AV:L/AC:H/PR:H/UI:R/VC:L/VI:L/VA:L")]
|
||||
public void ConvertV31ToV4_ProducesDeterministicVector(string v31, string expectedPrefix)
|
||||
{
|
||||
var v4 = CvssVectorInterop.ConvertV31ToV4(v31);
|
||||
|
||||
v4.Should().StartWith(expectedPrefix);
|
||||
// determinism: same input produces identical output
|
||||
CvssVectorInterop.ConvertV31ToV4(v31).Should().Be(v4);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using System.Linq;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
@@ -71,6 +72,74 @@ public sealed class ReceiptBuilderTests
|
||||
_repository.Contains(receipt1.ReceiptId).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_InputHashIgnoresPropertyOrder()
|
||||
{
|
||||
var policy = new CvssPolicy
|
||||
{
|
||||
PolicyId = "default",
|
||||
Version = "1.0.0",
|
||||
Name = "Default",
|
||||
EffectiveFrom = new DateTimeOffset(2025, 01, 01, 0, 0, 0, TimeSpan.Zero),
|
||||
Hash = "abc123",
|
||||
};
|
||||
|
||||
var baseMetrics = new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = AttackVector.Network,
|
||||
AttackComplexity = AttackComplexity.Low,
|
||||
AttackRequirements = AttackRequirements.None,
|
||||
PrivilegesRequired = PrivilegesRequired.None,
|
||||
UserInteraction = UserInteraction.None,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.High,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.High,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.High,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.High,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.High,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.High
|
||||
};
|
||||
|
||||
var evidence1 = ImmutableList<CvssEvidenceItem>.Empty.Add(new CvssEvidenceItem
|
||||
{
|
||||
Type = "scan",
|
||||
Uri = "sha256:1",
|
||||
Description = "First",
|
||||
IsAuthoritative = false
|
||||
}).Add(new CvssEvidenceItem
|
||||
{
|
||||
Type = "advisory",
|
||||
Uri = "sha256:2",
|
||||
Description = "Second",
|
||||
IsAuthoritative = true
|
||||
});
|
||||
|
||||
var evidence2 = evidence1.Reverse().ToImmutableList();
|
||||
|
||||
var builder = new ReceiptBuilder(_engine, _repository);
|
||||
|
||||
var r1 = await builder.CreateAsync(new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2025-1111",
|
||||
TenantId = "t",
|
||||
CreatedBy = "u",
|
||||
Policy = policy,
|
||||
BaseMetrics = baseMetrics,
|
||||
Evidence = evidence1
|
||||
});
|
||||
|
||||
var r2 = await builder.CreateAsync(new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2025-1111",
|
||||
TenantId = "t",
|
||||
CreatedBy = "u",
|
||||
Policy = policy,
|
||||
BaseMetrics = baseMetrics,
|
||||
Evidence = evidence2
|
||||
});
|
||||
|
||||
r1.InputHash.Should().Be(r2.InputHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithSigningKey_AttachesDsseReference()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user