Implement VEX document verification system with issuer management and signature verification

- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation.
- Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments.
- Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats.
- Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats.
- Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction.
- Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
StellaOps Bot
2025-12-06 13:41:22 +02:00
parent 2141196496
commit 5e514532df
112 changed files with 24861 additions and 211 deletions

View File

@@ -0,0 +1,660 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using StellaOps.Policy.Engine.AirGap;
using StellaOps.Policy.RiskProfile.Models;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.AirGap;
public sealed class RiskProfileAirGapExportServiceTests
{
private readonly FakeCryptoHash _cryptoHash = new();
private readonly FakeTimeProvider _timeProvider = new();
private readonly NullLogger<RiskProfileAirGapExportService> _logger = new();
private RiskProfileAirGapExportService CreateService(ISealedModeService? sealedMode = null)
{
return new RiskProfileAirGapExportService(
_cryptoHash,
_timeProvider,
_logger,
sealedMode);
}
private static RiskProfileModel CreateTestProfile(string id = "test-profile", string version = "1.0.0")
{
return new RiskProfileModel
{
Id = id,
Version = version,
Description = $"Test profile {id} for air-gap tests",
Signals = new List<RiskSignal>
{
new() { Name = "cvss", Source = "nvd", Type = RiskSignalType.Numeric },
new() { Name = "kev", Source = "cisa", Type = RiskSignalType.Boolean }
},
Weights = new Dictionary<string, double>
{
["cvss"] = 0.7,
["kev"] = 0.3
}
};
}
#region Export Tests
[Fact]
public async Task ExportAsync_SingleProfile_CreatesValidBundle()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var request = new AirGapExportRequest(SignBundle: true);
// Act
var bundle = await service.ExportAsync(profiles, request);
// Assert
Assert.NotNull(bundle);
Assert.Equal(1, bundle.SchemaVersion);
Assert.Equal("risk-profiles", bundle.DomainId);
Assert.Single(bundle.Exports);
Assert.NotNull(bundle.MerkleRoot);
Assert.NotNull(bundle.Signature);
Assert.NotNull(bundle.Profiles);
Assert.Single(bundle.Profiles);
}
[Fact]
public async Task ExportAsync_MultipleProfiles_CreatesExportForEach()
{
// Arrange
var service = CreateService();
var profiles = new List<RiskProfileModel>
{
CreateTestProfile("profile-1", "1.0.0"),
CreateTestProfile("profile-2", "2.0.0"),
CreateTestProfile("profile-3", "1.5.0")
};
var request = new AirGapExportRequest(SignBundle: true);
// Act
var bundle = await service.ExportAsync(profiles, request);
// Assert
Assert.Equal(3, bundle.Exports.Count);
Assert.Equal(3, bundle.Profiles?.Count);
foreach (var export in bundle.Exports)
{
Assert.NotEmpty(export.ContentHash);
Assert.NotEmpty(export.ArtifactDigest);
Assert.Contains("sha256:", export.ArtifactDigest);
}
}
[Fact]
public async Task ExportAsync_WithoutSigning_OmitsSignature()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var request = new AirGapExportRequest(SignBundle: false);
// Act
var bundle = await service.ExportAsync(profiles, request);
// Assert
Assert.Null(bundle.Signature);
Assert.NotNull(bundle.MerkleRoot);
}
[Fact]
public async Task ExportAsync_WithTenant_IncludesTenantId()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var request = new AirGapExportRequest();
// Act
var bundle = await service.ExportAsync(profiles, request, "tenant-123");
// Assert
Assert.Equal("tenant-123", bundle.TenantId);
}
[Fact]
public async Task ExportAsync_WithDisplayName_UsesProvidedName()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var request = new AirGapExportRequest(DisplayName: "Custom Bundle Name");
// Act
var bundle = await service.ExportAsync(profiles, request);
// Assert
Assert.Equal("Custom Bundle Name", bundle.DisplayName);
}
[Fact]
public async Task ExportAsync_EmptyProfiles_CreatesEmptyBundle()
{
// Arrange
var service = CreateService();
var profiles = new List<RiskProfileModel>();
var request = new AirGapExportRequest();
// Act
var bundle = await service.ExportAsync(profiles, request);
// Assert
Assert.Empty(bundle.Exports);
Assert.Empty(bundle.MerkleRoot ?? "");
}
[Fact]
public async Task ExportAsync_ComputesCorrectMerkleRoot()
{
// Arrange
var service = CreateService();
var profiles = new List<RiskProfileModel>
{
CreateTestProfile("profile-a"),
CreateTestProfile("profile-b")
};
var request = new AirGapExportRequest();
// Act
var bundle1 = await service.ExportAsync(profiles, request);
var bundle2 = await service.ExportAsync(profiles, request);
// Assert - same profiles should produce same merkle root
Assert.Equal(bundle1.MerkleRoot, bundle2.MerkleRoot);
}
#endregion
#region Import Tests
[Fact]
public async Task ImportAsync_ValidBundle_ImportsSuccessfully()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: true);
var bundle = await service.ExportAsync(profiles, exportRequest);
var importRequest = new AirGapImportRequest(
VerifySignature: true,
VerifyMerkle: true,
EnforceSealedMode: false);
// Act
var result = await service.ImportAsync(bundle, importRequest, "tenant-123");
// Assert
Assert.True(result.Success);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.ImportedCount);
Assert.Equal(0, result.ErrorCount);
Assert.True(result.SignatureVerified);
Assert.True(result.MerkleVerified);
}
[Fact]
public async Task ImportAsync_TamperedBundle_FailsMerkleVerification()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: true);
var bundle = await service.ExportAsync(profiles, exportRequest);
// Tamper with merkle root
var tamperedBundle = bundle with { MerkleRoot = "sha256:tampered" };
var importRequest = new AirGapImportRequest(
VerifySignature: false,
VerifyMerkle: true,
RejectOnMerkleFailure: true,
EnforceSealedMode: false);
// Act
var result = await service.ImportAsync(tamperedBundle, importRequest, "tenant-123");
// Assert
Assert.False(result.Success);
Assert.False(result.MerkleVerified);
Assert.Contains(result.Errors, e => e.Contains("Merkle root verification failed"));
}
[Fact]
public async Task ImportAsync_TamperedProfile_FailsContentHashVerification()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: false);
var bundle = await service.ExportAsync(profiles, exportRequest);
// Tamper with profile by modifying it after export
// Need to create a completely different profile that won't match the hash
var tamperedProfile = new RiskProfileModel
{
Id = profile.Id,
Version = profile.Version,
Description = "COMPLETELY DIFFERENT DESCRIPTION TO BREAK HASH",
Signals = new List<RiskSignal>
{
new() { Name = "tampered", Source = "fake", Type = RiskSignalType.Boolean }
},
Weights = new Dictionary<string, double> { ["tampered"] = 1.0 }
};
var tamperedBundle = bundle with { Profiles = new[] { tamperedProfile } };
var importRequest = new AirGapImportRequest(
VerifySignature: false,
VerifyMerkle: false,
EnforceSealedMode: false);
// Act
var result = await service.ImportAsync(tamperedBundle, importRequest, "tenant-123");
// Assert
Assert.False(result.Success);
Assert.Equal(1, result.ErrorCount);
Assert.Contains(result.Details, d => d.Message?.Contains("hash mismatch") == true);
}
[Fact]
public async Task ImportAsync_MissingProfile_ReportsError()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: false);
var bundle = await service.ExportAsync(profiles, exportRequest);
// Remove profiles
var bundleWithoutProfiles = bundle with { Profiles = Array.Empty<RiskProfileModel>() };
var importRequest = new AirGapImportRequest(
VerifySignature: false,
VerifyMerkle: false,
EnforceSealedMode: false);
// Act
var result = await service.ImportAsync(bundleWithoutProfiles, importRequest, "tenant-123");
// Assert
Assert.False(result.Success);
Assert.Equal(1, result.ErrorCount);
Assert.Contains(result.Details, d => d.Message == "Profile data missing from bundle");
}
[Fact]
public async Task ImportAsync_InvalidSignature_FailsWhenEnforced()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: true);
var bundle = await service.ExportAsync(profiles, exportRequest);
// Tamper with signature
var tamperedSignature = bundle.Signature! with { Path = "invalid-signature" };
var tamperedBundle = bundle with { Signature = tamperedSignature };
var importRequest = new AirGapImportRequest(
VerifySignature: true,
RejectOnSignatureFailure: true,
VerifyMerkle: false,
EnforceSealedMode: false);
// Act
var result = await service.ImportAsync(tamperedBundle, importRequest, "tenant-123");
// Assert
Assert.False(result.Success);
Assert.False(result.SignatureVerified);
Assert.Contains(result.Errors, e => e.Contains("signature verification failed"));
}
[Fact]
public async Task ImportAsync_SealedModeBlocked_ReturnsBlockedResult()
{
// Arrange
var sealedModeService = new FakeSealedModeService(allowed: false, reason: "Environment is locked");
var service = CreateService(sealedModeService);
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: false);
var bundle = await service.ExportAsync(profiles, exportRequest);
var importRequest = new AirGapImportRequest(EnforceSealedMode: true);
// Act
var result = await service.ImportAsync(bundle, importRequest, "tenant-123");
// Assert
Assert.False(result.Success);
Assert.Equal(0, result.ImportedCount);
Assert.Contains(result.Errors, e => e.Contains("Sealed-mode blocked"));
}
[Fact]
public async Task ImportAsync_SealedModeAllowed_ProceedsWithImport()
{
// Arrange
var sealedModeService = new FakeSealedModeService(allowed: true);
var service = CreateService(sealedModeService);
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: false);
var bundle = await service.ExportAsync(profiles, exportRequest);
var importRequest = new AirGapImportRequest(
VerifySignature: false,
VerifyMerkle: false,
EnforceSealedMode: true);
// Act
var result = await service.ImportAsync(bundle, importRequest, "tenant-123");
// Assert
Assert.True(result.Success);
Assert.Equal(1, result.ImportedCount);
}
[Fact]
public async Task ImportAsync_RequiresTenantId()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest();
var bundle = await service.ExportAsync(profiles, exportRequest);
var importRequest = new AirGapImportRequest();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() =>
service.ImportAsync(bundle, importRequest, ""));
}
#endregion
#region Verify Tests
[Fact]
public async Task Verify_ValidBundle_ReturnsAllValid()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: true);
var bundle = await service.ExportAsync(profiles, exportRequest);
// Act
var verification = service.Verify(bundle);
// Assert
Assert.True(verification.SignatureValid);
Assert.True(verification.MerkleValid);
Assert.True(verification.AllValid);
Assert.All(verification.ExportDigests, d => Assert.True(d.Valid));
}
[Fact]
public async Task Verify_TamperedMerkle_ReturnsMerkleInvalid()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: true);
var bundle = await service.ExportAsync(profiles, exportRequest);
var tamperedBundle = bundle with { MerkleRoot = "sha256:invalid" };
// Act
var verification = service.Verify(tamperedBundle);
// Assert
Assert.False(verification.MerkleValid);
Assert.False(verification.AllValid);
}
[Fact]
public async Task Verify_TamperedSignature_ReturnsSignatureInvalid()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: true);
var bundle = await service.ExportAsync(profiles, exportRequest);
var tamperedSignature = bundle.Signature! with { Path = "invalid" };
var tamperedBundle = bundle with { Signature = tamperedSignature };
// Act
var verification = service.Verify(tamperedBundle);
// Assert
Assert.False(verification.SignatureValid);
Assert.False(verification.AllValid);
}
[Fact]
public async Task Verify_TamperedProfile_ReturnsExportDigestInvalid()
{
// Arrange
var service = CreateService();
var profile = CreateTestProfile();
var profiles = new List<RiskProfileModel> { profile };
var exportRequest = new AirGapExportRequest(SignBundle: false);
var bundle = await service.ExportAsync(profiles, exportRequest);
// Tamper with profile by creating a completely different one to break hash
var tamperedProfile = new RiskProfileModel
{
Id = profile.Id,
Version = profile.Version,
Description = "COMPLETELY DIFFERENT FOR HASH BREAK",
Signals = new List<RiskSignal>
{
new() { Name = "tampered_verify", Source = "fake", Type = RiskSignalType.Categorical }
},
Weights = new Dictionary<string, double> { ["tampered_verify"] = 0.5 }
};
var tamperedBundle = bundle with { Profiles = new[] { tamperedProfile } };
// Act
var verification = service.Verify(tamperedBundle);
// Assert
Assert.Contains(verification.ExportDigests, d => !d.Valid);
Assert.False(verification.AllValid);
}
#endregion
}
#region Fakes
internal sealed class FakeCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
return sha256.ComputeHash(data.ToArray());
}
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(data.ToArray());
return Convert.ToHexStringLower(hash);
}
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(data.ToArray());
return Convert.ToBase64String(hash);
}
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
return new ValueTask<byte[]>(sha256.ComputeHash(stream));
}
public ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(stream);
return new ValueTask<string>(Convert.ToHexStringLower(hash));
}
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
return sha256.ComputeHash(data.ToArray());
}
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(data.ToArray());
return Convert.ToHexStringLower(hash);
}
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(data.ToArray());
return Convert.ToBase64String(hash);
}
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
return new ValueTask<byte[]>(sha256.ComputeHash(stream));
}
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(stream);
return new ValueTask<string>(Convert.ToHexStringLower(hash));
}
public string GetAlgorithmForPurpose(string purpose) => "SHA256";
public string GetHashPrefix(string purpose) => "sha256:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
{
return $"sha256:{ComputeHashHexForPurpose(data, purpose)}";
}
}
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2025, 12, 6, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
internal sealed class FakeSealedModeService : ISealedModeService
{
private readonly bool _allowed;
private readonly string? _reason;
public FakeSealedModeService(bool allowed, string? reason = null)
{
_allowed = allowed;
_reason = reason;
}
public bool IsSealed => !_allowed;
public Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default)
{
return Task.FromResult(new PolicyPackSealedState(
TenantId: tenantId,
IsSealed: !_allowed,
PolicyHash: null,
TimeAnchor: null,
StalenessBudget: StalenessBudget.Default,
LastTransitionAt: DateTimeOffset.UtcNow));
}
public Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default)
{
return Task.FromResult(new SealedStatusResponse(
Sealed: !_allowed,
TenantId: tenantId,
Staleness: null,
TimeAnchor: null,
PolicyHash: null));
}
public Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default)
{
return Task.FromResult(new SealResponse(Sealed: true, LastTransitionAt: DateTimeOffset.UtcNow));
}
public Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default)
{
return Task.FromResult(new SealResponse(Sealed: false, LastTransitionAt: DateTimeOffset.UtcNow));
}
public Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default)
{
return Task.FromResult<StalenessEvaluation?>(null);
}
public Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
string tenantId, string bundlePath, CancellationToken cancellationToken = default)
{
return Task.FromResult(new SealedModeEnforcementResult(
Allowed: _allowed,
Reason: _allowed ? null : _reason,
Remediation: _allowed ? null : "Contact administrator"));
}
public Task<BundleVerifyResponse> VerifyBundleAsync(
BundleVerifyRequest request, CancellationToken cancellationToken = default)
{
return Task.FromResult(new BundleVerifyResponse(
Valid: true,
VerificationResult: new BundleVerificationResult(
DsseValid: true,
TufValid: true,
MerkleValid: true,
Error: null)));
}
}
#endregion

View File

@@ -0,0 +1,493 @@
using StellaOps.Policy.RiskProfile.Models;
using StellaOps.Policy.RiskProfile.Overrides;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Overrides;
public sealed class OverrideServiceTests
{
private readonly OverrideService _service;
public OverrideServiceTests()
{
_service = new OverrideService();
}
[Fact]
public void Create_ValidRequest_ReturnsAuditedOverride()
{
var request = CreateValidRequest();
var result = _service.Create(request, "admin@example.com");
Assert.NotNull(result);
Assert.StartsWith("ovr-", result.OverrideId);
Assert.Equal("test-profile", result.ProfileId);
Assert.Equal(OverrideType.Severity, result.OverrideType);
Assert.Equal(OverrideStatus.Active, result.Status);
Assert.Equal("admin@example.com", result.Audit.CreatedBy);
Assert.Equal("KEV findings should be critical", result.Audit.Reason);
}
[Fact]
public void Create_ReviewRequired_CreatesDisabledOverride()
{
var request = new CreateOverrideRequest(
ProfileId: "test-profile",
OverrideType: OverrideType.Severity,
Predicate: CreateKevPredicate(),
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
Priority: 100,
Reason: "Needs approval",
Justification: null,
TicketRef: null,
Expiration: null,
Tags: null,
ReviewRequired: true);
var result = _service.Create(request);
Assert.Equal(OverrideStatus.Disabled, result.Status);
Assert.True(result.Audit.ReviewRequired);
}
[Fact]
public void Create_MissingReason_ThrowsException()
{
var request = new CreateOverrideRequest(
ProfileId: "test-profile",
OverrideType: OverrideType.Severity,
Predicate: CreateKevPredicate(),
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
Priority: 100,
Reason: "",
Justification: null,
TicketRef: null,
Expiration: null,
Tags: null);
var ex = Assert.Throws<ArgumentException>(() => _service.Create(request));
Assert.Contains("Reason", ex.Message);
}
[Fact]
public void Get_ExistingOverride_ReturnsOverride()
{
var created = _service.Create(CreateValidRequest());
var fetched = _service.Get(created.OverrideId);
Assert.NotNull(fetched);
Assert.Equal(created.OverrideId, fetched.OverrideId);
}
[Fact]
public void Get_NonExistingOverride_ReturnsNull()
{
var fetched = _service.Get("non-existent");
Assert.Null(fetched);
}
[Fact]
public void ListByProfile_ReturnsOverridesOrderedByPriority()
{
var request1 = CreateValidRequest() with { Priority = 50 };
var request2 = CreateValidRequest() with { Priority = 200 };
var request3 = CreateValidRequest() with { Priority = 100 };
_service.Create(request1);
_service.Create(request2);
_service.Create(request3);
var results = _service.ListByProfile("test-profile");
Assert.Equal(3, results.Count);
Assert.Equal(200, results[0].Priority);
Assert.Equal(100, results[1].Priority);
Assert.Equal(50, results[2].Priority);
}
[Fact]
public void ListByProfile_ExcludesDisabledByDefault()
{
var active = _service.Create(CreateValidRequest());
var disabled = _service.Create(CreateValidRequest() with { ReviewRequired = true });
var activeResults = _service.ListByProfile("test-profile", includeInactive: false);
var allResults = _service.ListByProfile("test-profile", includeInactive: true);
Assert.Single(activeResults);
Assert.Equal(active.OverrideId, activeResults[0].OverrideId);
Assert.Equal(2, allResults.Count);
}
[Fact]
public void Approve_ReviewRequiredOverride_ActivatesAndRecordsApproval()
{
var created = _service.Create(CreateValidRequest() with { ReviewRequired = true });
Assert.Equal(OverrideStatus.Disabled, created.Status);
var approved = _service.Approve(created.OverrideId, "manager@example.com");
Assert.NotNull(approved);
Assert.Equal(OverrideStatus.Active, approved.Status);
Assert.Equal("manager@example.com", approved.Audit.ApprovedBy);
Assert.NotNull(approved.Audit.ApprovedAt);
}
[Fact]
public void Approve_AlreadyActiveOverride_ThrowsException()
{
var created = _service.Create(CreateValidRequest());
Assert.Equal(OverrideStatus.Active, created.Status);
var ex = Assert.Throws<InvalidOperationException>(() =>
_service.Approve(created.OverrideId, "manager@example.com"));
Assert.Contains("does not require approval", ex.Message);
}
[Fact]
public void Disable_ActiveOverride_DisablesAndRecordsModification()
{
var created = _service.Create(CreateValidRequest());
var disabled = _service.Disable(created.OverrideId, "admin@example.com", "No longer needed");
Assert.NotNull(disabled);
Assert.Equal(OverrideStatus.Disabled, disabled.Status);
Assert.Equal("admin@example.com", disabled.Audit.LastModifiedBy);
Assert.NotNull(disabled.Audit.LastModifiedAt);
}
[Fact]
public void Delete_ExistingOverride_RemovesFromStorage()
{
var created = _service.Create(CreateValidRequest());
var deleted = _service.Delete(created.OverrideId);
Assert.True(deleted);
Assert.Null(_service.Get(created.OverrideId));
Assert.Empty(_service.ListByProfile("test-profile"));
}
[Fact]
public void ValidateConflicts_SamePredicate_DetectsConflict()
{
var original = _service.Create(CreateValidRequest());
var newRequest = CreateValidRequest();
var validation = _service.ValidateConflicts(newRequest);
Assert.True(validation.HasConflicts);
Assert.Contains(validation.Conflicts, c => c.ConflictType == ConflictType.SamePredicate);
}
[Fact]
public void ValidateConflicts_OverlappingPredicateWithContradictoryAction_DetectsConflict()
{
// Create an override that sets severity to Critical for high cvss
var originalPredicate = new OverridePredicate(
Conditions: new[] { new OverrideCondition("cvss", ConditionOperator.GreaterThan, 8.0) },
MatchMode: PredicateMatchMode.All);
var originalRequest = new CreateOverrideRequest(
ProfileId: "test-profile",
OverrideType: OverrideType.Severity,
Predicate: originalPredicate,
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
Priority: 100,
Reason: "High CVSS should be critical",
Justification: null,
TicketRef: null,
Expiration: null,
Tags: null);
_service.Create(originalRequest);
// Try to create overlapping override (also uses cvss) with contradictory action (Low severity)
var newPredicate = new OverridePredicate(
Conditions: new[]
{
new OverrideCondition("cvss", ConditionOperator.GreaterThan, 7.0),
new OverrideCondition("reachability", ConditionOperator.LessThan, 0.5)
},
MatchMode: PredicateMatchMode.All);
var newRequest = new CreateOverrideRequest(
ProfileId: "test-profile",
OverrideType: OverrideType.Severity,
Predicate: newPredicate,
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Low),
Priority: 50,
Reason: "Low reachability should reduce severity",
Justification: null,
TicketRef: null,
Expiration: null,
Tags: null);
var validation = _service.ValidateConflicts(newRequest);
Assert.True(validation.HasConflicts);
Assert.Contains(validation.Conflicts, c => c.ConflictType == ConflictType.ContradictoryAction);
}
[Fact]
public void ValidateConflicts_PriorityCollision_DetectsConflict()
{
var original = _service.Create(CreateValidRequest() with { Priority = 100 });
var newRequest = CreateValidRequest() with { Priority = 100 };
var validation = _service.ValidateConflicts(newRequest);
Assert.True(validation.HasConflicts);
Assert.Contains(validation.Conflicts, c => c.ConflictType == ConflictType.PriorityCollision);
}
[Fact]
public void ValidateConflicts_NoConflicts_ReturnsClean()
{
var differentProfileRequest = new CreateOverrideRequest(
ProfileId: "other-profile",
OverrideType: OverrideType.Severity,
Predicate: CreateKevPredicate(),
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
Priority: 100,
Reason: "Different profile",
Justification: null,
TicketRef: null,
Expiration: null,
Tags: null);
var validation = _service.ValidateConflicts(differentProfileRequest);
Assert.False(validation.HasConflicts);
Assert.Empty(validation.Conflicts);
}
[Fact]
public void RecordApplication_StoresHistory()
{
var created = _service.Create(CreateValidRequest());
_service.RecordApplication(
overrideId: created.OverrideId,
findingId: "finding-001",
originalValue: RiskSeverity.High,
appliedValue: RiskSeverity.Critical,
context: new Dictionary<string, object?> { ["component"] = "pkg:npm/lodash" });
var history = _service.GetApplicationHistory(created.OverrideId);
Assert.Single(history);
Assert.Equal(created.OverrideId, history[0].OverrideId);
Assert.Equal("finding-001", history[0].FindingId);
}
[Fact]
public void GetApplicationHistory_LimitsResults()
{
var created = _service.Create(CreateValidRequest());
// Record 10 applications
for (var i = 0; i < 10; i++)
{
_service.RecordApplication(
overrideId: created.OverrideId,
findingId: $"finding-{i:D3}",
originalValue: RiskSeverity.High,
appliedValue: RiskSeverity.Critical);
}
var limitedHistory = _service.GetApplicationHistory(created.OverrideId, limit: 5);
Assert.Equal(5, limitedHistory.Count);
}
[Fact]
public void EvaluatePredicate_AllConditionsMustMatch_WhenModeIsAll()
{
var predicate = new OverridePredicate(
Conditions: new[]
{
new OverrideCondition("kev", ConditionOperator.Equals, true),
new OverrideCondition("cvss", ConditionOperator.GreaterThan, 7.0)
},
MatchMode: PredicateMatchMode.All);
var matchingSignals = new Dictionary<string, object?>
{
["kev"] = true,
["cvss"] = 8.5
};
var partialMatch = new Dictionary<string, object?>
{
["kev"] = true,
["cvss"] = 5.0
};
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
Assert.False(_service.EvaluatePredicate(predicate, partialMatch));
}
[Fact]
public void EvaluatePredicate_AnyConditionCanMatch_WhenModeIsAny()
{
var predicate = new OverridePredicate(
Conditions: new[]
{
new OverrideCondition("kev", ConditionOperator.Equals, true),
new OverrideCondition("cvss", ConditionOperator.GreaterThan, 9.0)
},
MatchMode: PredicateMatchMode.Any);
var kevOnly = new Dictionary<string, object?>
{
["kev"] = true,
["cvss"] = 5.0
};
var cvssOnly = new Dictionary<string, object?>
{
["kev"] = false,
["cvss"] = 9.5
};
var neither = new Dictionary<string, object?>
{
["kev"] = false,
["cvss"] = 5.0
};
Assert.True(_service.EvaluatePredicate(predicate, kevOnly));
Assert.True(_service.EvaluatePredicate(predicate, cvssOnly));
Assert.False(_service.EvaluatePredicate(predicate, neither));
}
[Theory]
[InlineData(ConditionOperator.Equals, "high", "high", true)]
[InlineData(ConditionOperator.Equals, "high", "low", false)]
[InlineData(ConditionOperator.NotEquals, "high", "low", true)]
[InlineData(ConditionOperator.NotEquals, "high", "high", false)]
public void EvaluatePredicate_StringComparisons(ConditionOperator op, object expected, object actual, bool shouldMatch)
{
var predicate = new OverridePredicate(
Conditions: new[] { new OverrideCondition("severity", op, expected) },
MatchMode: PredicateMatchMode.All);
var signals = new Dictionary<string, object?> { ["severity"] = actual };
Assert.Equal(shouldMatch, _service.EvaluatePredicate(predicate, signals));
}
[Theory]
[InlineData(ConditionOperator.GreaterThan, 5.0, 7.5, true)]
[InlineData(ConditionOperator.GreaterThan, 5.0, 5.0, false)]
[InlineData(ConditionOperator.GreaterThanOrEqual, 5.0, 5.0, true)]
[InlineData(ConditionOperator.LessThan, 5.0, 3.0, true)]
[InlineData(ConditionOperator.LessThanOrEqual, 5.0, 5.0, true)]
public void EvaluatePredicate_NumericComparisons(ConditionOperator op, object threshold, object actual, bool shouldMatch)
{
var predicate = new OverridePredicate(
Conditions: new[] { new OverrideCondition("cvss", op, threshold) },
MatchMode: PredicateMatchMode.All);
var signals = new Dictionary<string, object?> { ["cvss"] = actual };
Assert.Equal(shouldMatch, _service.EvaluatePredicate(predicate, signals));
}
[Fact]
public void EvaluatePredicate_InOperator_MatchesCollection()
{
var predicate = new OverridePredicate(
Conditions: new[] { new OverrideCondition("ecosystem", ConditionOperator.In, "npm,maven,pypi") },
MatchMode: PredicateMatchMode.All);
var matchingSignals = new Dictionary<string, object?> { ["ecosystem"] = "npm" };
var nonMatchingSignals = new Dictionary<string, object?> { ["ecosystem"] = "go" };
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
Assert.False(_service.EvaluatePredicate(predicate, nonMatchingSignals));
}
[Fact]
public void EvaluatePredicate_ContainsOperator_MatchesSubstring()
{
var predicate = new OverridePredicate(
Conditions: new[] { new OverrideCondition("purl", ConditionOperator.Contains, "@angular") },
MatchMode: PredicateMatchMode.All);
var matchingSignals = new Dictionary<string, object?> { ["purl"] = "pkg:npm/@angular/core@15.0.0" };
var nonMatchingSignals = new Dictionary<string, object?> { ["purl"] = "pkg:npm/lodash@4.17.21" };
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
Assert.False(_service.EvaluatePredicate(predicate, nonMatchingSignals));
}
[Fact]
public void EvaluatePredicate_RegexOperator_MatchesPattern()
{
var predicate = new OverridePredicate(
Conditions: new[] { new OverrideCondition("advisory_id", ConditionOperator.Regex, "^CVE-2024-.*") },
MatchMode: PredicateMatchMode.All);
var matchingSignals = new Dictionary<string, object?> { ["advisory_id"] = "CVE-2024-1234" };
var nonMatchingSignals = new Dictionary<string, object?> { ["advisory_id"] = "CVE-2023-5678" };
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
Assert.False(_service.EvaluatePredicate(predicate, nonMatchingSignals));
}
[Fact]
public void Create_WithExpirationAndTags_StoresMetadata()
{
var expiration = DateTimeOffset.UtcNow.AddDays(30);
var tags = new[] { "emergency", "security-team" };
var request = CreateValidRequest() with
{
Expiration = expiration,
Tags = tags
};
var result = _service.Create(request);
Assert.Equal(expiration, result.Expiration);
Assert.NotNull(result.Tags);
Assert.Equal(2, result.Tags.Count);
Assert.Contains("emergency", result.Tags);
Assert.Contains("security-team", result.Tags);
}
[Fact]
public void ListByProfile_ExcludesExpiredOverrides()
{
// Create override that expired in the past
var pastExpiration = DateTimeOffset.UtcNow.AddDays(-1);
var request = CreateValidRequest() with { Expiration = pastExpiration };
var expired = _service.Create(request);
// Create override with no expiration
var active = _service.Create(CreateValidRequest() with { Priority = 200 });
var results = _service.ListByProfile("test-profile", includeInactive: false);
Assert.Single(results);
Assert.Equal(active.OverrideId, results[0].OverrideId);
}
private static CreateOverrideRequest CreateValidRequest() => new(
ProfileId: "test-profile",
OverrideType: OverrideType.Severity,
Predicate: CreateKevPredicate(),
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
Priority: 100,
Reason: "KEV findings should be critical",
Justification: "Security policy requires KEV to be critical",
TicketRef: "SEC-1234",
Expiration: null,
Tags: null);
private static OverridePredicate CreateKevPredicate() => new(
Conditions: new[] { new OverrideCondition("kev", ConditionOperator.Equals, true) },
MatchMode: PredicateMatchMode.All);
}

View File

@@ -0,0 +1,360 @@
using StellaOps.Policy.RiskProfile.Scope;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Scope;
public sealed class EffectivePolicyServiceTests
{
private readonly EffectivePolicyService _service;
public EffectivePolicyServiceTests()
{
_service = new EffectivePolicyService();
}
[Fact]
public void Create_ValidRequest_ReturnsPolicy()
{
var request = new CreateEffectivePolicyRequest(
TenantId: "default",
PolicyId: "security-policy-v1",
PolicyVersion: "1.0.0",
SubjectPattern: "pkg:npm/*",
Priority: 100);
var policy = _service.Create(request, "admin@example.com");
Assert.NotNull(policy);
Assert.StartsWith("eff-", policy.EffectivePolicyId);
Assert.Equal("default", policy.TenantId);
Assert.Equal("security-policy-v1", policy.PolicyId);
Assert.Equal("1.0.0", policy.PolicyVersion);
Assert.Equal("pkg:npm/*", policy.SubjectPattern);
Assert.Equal(100, policy.Priority);
Assert.True(policy.Enabled);
Assert.Equal("admin@example.com", policy.CreatedBy);
}
[Fact]
public void Create_InvalidPattern_ThrowsException()
{
var request = new CreateEffectivePolicyRequest(
TenantId: "default",
PolicyId: "policy-1",
PolicyVersion: null,
SubjectPattern: "invalid-pattern",
Priority: 100);
var ex = Assert.Throws<ArgumentException>(() => _service.Create(request));
Assert.Contains("Invalid subject pattern", ex.Message);
}
[Theory]
[InlineData("*")]
[InlineData("pkg:npm/*")]
[InlineData("pkg:npm/@org/*")]
[InlineData("pkg:maven/com.example/*")]
[InlineData("oci://registry.example.com/*")]
public void IsValidSubjectPattern_ValidPatterns_ReturnsTrue(string pattern)
{
Assert.True(EffectivePolicyService.IsValidSubjectPattern(pattern));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("invalid")]
[InlineData("pkg:**")]
public void IsValidSubjectPattern_InvalidPatterns_ReturnsFalse(string pattern)
{
Assert.False(EffectivePolicyService.IsValidSubjectPattern(pattern));
}
[Theory]
[InlineData("pkg:npm/lodash@4.17.20", "*", true)]
[InlineData("pkg:npm/lodash@4.17.20", "pkg:npm/*", true)]
[InlineData("pkg:npm/@org/utils@1.0.0", "pkg:npm/@org/*", true)]
[InlineData("pkg:maven/com.example/lib@1.0", "pkg:maven/*", true)]
[InlineData("pkg:npm/lodash@4.17.20", "pkg:maven/*", false)]
[InlineData("oci://registry.io/image:tag", "oci://registry.io/*", true)]
[InlineData("oci://other.io/image:tag", "oci://registry.io/*", false)]
public void MatchesPattern_ReturnsExpectedResult(string subject, string pattern, bool expected)
{
Assert.Equal(expected, EffectivePolicyService.MatchesPattern(subject, pattern));
}
[Fact]
public void GetPatternSpecificity_UniversalWildcard_ReturnsZero()
{
Assert.Equal(0, EffectivePolicyService.GetPatternSpecificity("*"));
}
[Fact]
public void GetPatternSpecificity_MoreSpecificPatterns_ReturnHigherScores()
{
var universal = EffectivePolicyService.GetPatternSpecificity("*");
var pkgWildcard = EffectivePolicyService.GetPatternSpecificity("pkg:*");
var npmWildcard = EffectivePolicyService.GetPatternSpecificity("pkg:npm/*");
var orgWildcard = EffectivePolicyService.GetPatternSpecificity("pkg:npm/@org/*");
Assert.True(pkgWildcard > universal);
Assert.True(npmWildcard > pkgWildcard);
Assert.True(orgWildcard > npmWildcard);
}
[Fact]
public void Get_ExistingPolicy_ReturnsPolicy()
{
var request = new CreateEffectivePolicyRequest(
TenantId: "default",
PolicyId: "policy-1",
PolicyVersion: null,
SubjectPattern: "pkg:npm/*",
Priority: 100);
var created = _service.Create(request);
var fetched = _service.Get(created.EffectivePolicyId);
Assert.NotNull(fetched);
Assert.Equal(created.EffectivePolicyId, fetched.EffectivePolicyId);
}
[Fact]
public void Get_NonExistingPolicy_ReturnsNull()
{
var fetched = _service.Get("non-existent-id");
Assert.Null(fetched);
}
[Fact]
public void Update_ExistingPolicy_UpdatesFields()
{
var request = new CreateEffectivePolicyRequest(
TenantId: "default",
PolicyId: "policy-1",
PolicyVersion: null,
SubjectPattern: "pkg:npm/*",
Priority: 100);
var created = _service.Create(request);
var updateRequest = new UpdateEffectivePolicyRequest(
Priority: 150,
Enabled: false);
var updated = _service.Update(created.EffectivePolicyId, updateRequest);
Assert.NotNull(updated);
Assert.Equal(150, updated.Priority);
Assert.False(updated.Enabled);
Assert.True(updated.UpdatedAt > created.UpdatedAt);
}
[Fact]
public void Delete_ExistingPolicy_ReturnsTrue()
{
var request = new CreateEffectivePolicyRequest(
TenantId: "default",
PolicyId: "policy-1",
PolicyVersion: null,
SubjectPattern: "pkg:npm/*",
Priority: 100);
var created = _service.Create(request);
var deleted = _service.Delete(created.EffectivePolicyId);
Assert.True(deleted);
Assert.Null(_service.Get(created.EffectivePolicyId));
}
[Fact]
public void Query_ByTenant_ReturnsMatchingPolicies()
{
_service.Create(new CreateEffectivePolicyRequest("tenant-a", "policy-1", null, "pkg:npm/*", 100));
_service.Create(new CreateEffectivePolicyRequest("tenant-a", "policy-2", null, "pkg:maven/*", 100));
_service.Create(new CreateEffectivePolicyRequest("tenant-b", "policy-3", null, "pkg:*", 100));
var query = new EffectivePolicyQuery(TenantId: "tenant-a");
var results = _service.Query(query);
Assert.Equal(2, results.Count);
Assert.All(results, p => Assert.Equal("tenant-a", p.TenantId));
}
[Fact]
public void Query_EnabledOnly_ExcludesDisabled()
{
_service.Create(new CreateEffectivePolicyRequest("default", "policy-1", null, "pkg:npm/*", 100, Enabled: true));
var disabled = _service.Create(new CreateEffectivePolicyRequest("default", "policy-2", null, "pkg:maven/*", 100, Enabled: false));
var query = new EffectivePolicyQuery(TenantId: "default", EnabledOnly: true);
var results = _service.Query(query);
Assert.Single(results);
Assert.DoesNotContain(results, p => p.EffectivePolicyId == disabled.EffectivePolicyId);
}
[Fact]
public void AttachScope_ValidRequest_ReturnsAttachment()
{
var policy = _service.Create(new CreateEffectivePolicyRequest(
"default", "policy-1", null, "pkg:npm/*", 100));
var attachment = _service.AttachScope(new AttachAuthorityScopeRequest(
EffectivePolicyId: policy.EffectivePolicyId,
Scope: "scan:write",
Conditions: new Dictionary<string, string> { ["environment"] = "production" }));
Assert.NotNull(attachment);
Assert.StartsWith("att-", attachment.AttachmentId);
Assert.Equal(policy.EffectivePolicyId, attachment.EffectivePolicyId);
Assert.Equal("scan:write", attachment.Scope);
Assert.NotNull(attachment.Conditions);
Assert.Equal("production", attachment.Conditions["environment"]);
}
[Fact]
public void AttachScope_NonExistingPolicy_ThrowsException()
{
var ex = Assert.Throws<ArgumentException>(() =>
_service.AttachScope(new AttachAuthorityScopeRequest(
EffectivePolicyId: "non-existent",
Scope: "scan:write")));
Assert.Contains("not found", ex.Message);
}
[Fact]
public void DetachScope_ExistingAttachment_ReturnsTrue()
{
var policy = _service.Create(new CreateEffectivePolicyRequest(
"default", "policy-1", null, "pkg:npm/*", 100));
var attachment = _service.AttachScope(new AttachAuthorityScopeRequest(
EffectivePolicyId: policy.EffectivePolicyId,
Scope: "scan:write"));
var detached = _service.DetachScope(attachment.AttachmentId);
Assert.True(detached);
Assert.Empty(_service.GetScopeAttachments(policy.EffectivePolicyId));
}
[Fact]
public void GetScopeAttachments_MultipleAttachments_ReturnsAll()
{
var policy = _service.Create(new CreateEffectivePolicyRequest(
"default", "policy-1", null, "pkg:npm/*", 100));
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:read"));
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:write"));
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "promotion:approve"));
var attachments = _service.GetScopeAttachments(policy.EffectivePolicyId);
Assert.Equal(3, attachments.Count);
}
[Fact]
public void Resolve_MatchingPolicy_ReturnsCorrectResult()
{
_service.Create(new CreateEffectivePolicyRequest(
"default", "npm-policy", null, "pkg:npm/*", 100,
Scopes: new[] { "scan:read", "scan:write" }));
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
Assert.NotNull(result.EffectivePolicy);
Assert.Equal("npm-policy", result.EffectivePolicy.PolicyId);
Assert.Equal("pkg:npm/*", result.MatchedPattern);
Assert.Contains("scan:read", result.GrantedScopes);
Assert.Contains("scan:write", result.GrantedScopes);
}
[Fact]
public void Resolve_NoMatchingPolicy_ReturnsNullPolicy()
{
_service.Create(new CreateEffectivePolicyRequest(
"default", "npm-policy", null, "pkg:npm/*", 100));
var result = _service.Resolve("pkg:maven/com.example/lib@1.0");
Assert.Null(result.EffectivePolicy);
Assert.Null(result.MatchedPattern);
Assert.Empty(result.GrantedScopes);
}
[Fact]
public void Resolve_PriorityResolution_HigherPriorityWins()
{
_service.Create(new CreateEffectivePolicyRequest(
"default", "low-priority", null, "pkg:npm/*", 50));
_service.Create(new CreateEffectivePolicyRequest(
"default", "high-priority", null, "pkg:npm/*", 200));
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
Assert.NotNull(result.EffectivePolicy);
Assert.Equal("high-priority", result.EffectivePolicy.PolicyId);
}
[Fact]
public void Resolve_EqualPriority_MoreSpecificPatternWins()
{
_service.Create(new CreateEffectivePolicyRequest(
"default", "broad-policy", null, "pkg:npm/*", 100));
_service.Create(new CreateEffectivePolicyRequest(
"default", "specific-policy", null, "pkg:npm/@org/*", 100));
var result = _service.Resolve("pkg:npm/@org/utils@1.0.0");
Assert.NotNull(result.EffectivePolicy);
Assert.Equal("specific-policy", result.EffectivePolicy.PolicyId);
Assert.Equal("pkg:npm/@org/*", result.MatchedPattern);
}
[Fact]
public void Resolve_IncludesAttachedScopes()
{
var policy = _service.Create(new CreateEffectivePolicyRequest(
"default", "policy-1", null, "pkg:npm/*", 100,
Scopes: new[] { "scan:read" }));
_service.AttachScope(new AttachAuthorityScopeRequest(
EffectivePolicyId: policy.EffectivePolicyId,
Scope: "scan:write"));
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
Assert.Contains("scan:read", result.GrantedScopes);
Assert.Contains("scan:write", result.GrantedScopes);
}
[Fact]
public void Resolve_DisabledPolicies_AreExcluded()
{
_service.Create(new CreateEffectivePolicyRequest(
"default", "enabled-policy", null, "pkg:npm/*", 100, Enabled: true));
_service.Create(new CreateEffectivePolicyRequest(
"default", "disabled-policy", null, "pkg:npm/*", 200, Enabled: false));
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
Assert.NotNull(result.EffectivePolicy);
Assert.Equal("enabled-policy", result.EffectivePolicy.PolicyId);
}
[Fact]
public void Delete_RemovesAssociatedScopeAttachments()
{
var policy = _service.Create(new CreateEffectivePolicyRequest(
"default", "policy-1", null, "pkg:npm/*", 100));
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:read"));
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:write"));
_service.Delete(policy.EffectivePolicyId);
Assert.Empty(_service.GetScopeAttachments(policy.EffectivePolicyId));
}
}

View File

@@ -0,0 +1,662 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Engine.Simulation;
using StellaOps.Policy.RiskProfile.Models;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Simulation;
/// <summary>
/// Tests for RiskSimulationBreakdownService.
/// Per POLICY-RISK-67-003.
/// </summary>
public sealed class RiskSimulationBreakdownServiceTests
{
private readonly RiskSimulationBreakdownService _service;
public RiskSimulationBreakdownServiceTests()
{
_service = new RiskSimulationBreakdownService(
NullLogger<RiskSimulationBreakdownService>.Instance);
}
[Fact]
public void GenerateBreakdown_WithValidInput_ReturnsBreakdown()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(5);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.Should().NotBeNull();
breakdown.SimulationId.Should().Be(result.SimulationId);
breakdown.ProfileRef.Should().NotBeNull();
breakdown.ProfileRef.Id.Should().Be(profile.Id);
breakdown.SignalAnalysis.Should().NotBeNull();
breakdown.OverrideAnalysis.Should().NotBeNull();
breakdown.ScoreDistribution.Should().NotBeNull();
breakdown.SeverityBreakdown.Should().NotBeNull();
breakdown.ActionBreakdown.Should().NotBeNull();
breakdown.DeterminismHash.Should().StartWith("sha256:");
}
[Fact]
public void GenerateBreakdown_SignalAnalysis_ComputesCorrectCoverage()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(10);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SignalAnalysis.TotalSignals.Should().Be(profile.Signals.Count);
breakdown.SignalAnalysis.SignalsUsed.Should().BeGreaterThan(0);
breakdown.SignalAnalysis.SignalCoverage.Should().BeGreaterThan(0);
breakdown.SignalAnalysis.SignalStats.Should().NotBeEmpty();
}
[Fact]
public void GenerateBreakdown_SignalAnalysis_IdentifiesTopContributors()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(20);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SignalAnalysis.TopContributors.Should().NotBeEmpty();
breakdown.SignalAnalysis.TopContributors.Length.Should().BeLessOrEqualTo(10);
// Top contributors should be ordered by contribution
for (var i = 1; i < breakdown.SignalAnalysis.TopContributors.Length; i++)
{
breakdown.SignalAnalysis.TopContributors[i - 1].TotalContribution
.Should().BeGreaterOrEqualTo(breakdown.SignalAnalysis.TopContributors[i].TotalContribution);
}
}
[Fact]
public void GenerateBreakdown_OverrideAnalysis_TracksApplications()
{
// Arrange
var profile = CreateTestProfileWithOverrides();
var findings = CreateTestFindingsWithKev(5);
var result = CreateTestResultWithOverrides(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.OverrideAnalysis.Should().NotBeNull();
breakdown.OverrideAnalysis.TotalOverridesEvaluated.Should().BeGreaterThan(0);
}
[Fact]
public void GenerateBreakdown_ScoreDistribution_ComputesStatistics()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(50);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ScoreDistribution.Should().NotBeNull();
breakdown.ScoreDistribution.RawScoreStats.Should().NotBeNull();
breakdown.ScoreDistribution.NormalizedScoreStats.Should().NotBeNull();
breakdown.ScoreDistribution.ScoreBuckets.Should().HaveCount(10);
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p50");
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p90");
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p99");
}
[Fact]
public void GenerateBreakdown_ScoreDistribution_ComputesSkewnessAndKurtosis()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(100);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
var stats = breakdown.ScoreDistribution.NormalizedScoreStats;
stats.Skewness.Should().NotBe(0); // With random data, unlikely to be exactly 0
// Kurtosis can be any value, just verify it's computed
}
[Fact]
public void GenerateBreakdown_ScoreDistribution_IdentifiesOutliers()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(50);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ScoreDistribution.Outliers.Should().NotBeNull();
breakdown.ScoreDistribution.Outliers.OutlierThreshold.Should().BeGreaterThan(0);
}
[Fact]
public void GenerateBreakdown_SeverityBreakdown_GroupsCorrectly()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(30);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SeverityBreakdown.Should().NotBeNull();
breakdown.SeverityBreakdown.BySeverity.Should().NotBeEmpty();
// Total count should match findings
var totalCount = breakdown.SeverityBreakdown.BySeverity.Values.Sum(b => b.Count);
totalCount.Should().Be(findings.Count);
}
[Fact]
public void GenerateBreakdown_SeverityBreakdown_ComputesConcentration()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(20);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
// HHI ranges from 1/n to 1
breakdown.SeverityBreakdown.SeverityConcentration.Should().BeGreaterOrEqualTo(0);
breakdown.SeverityBreakdown.SeverityConcentration.Should().BeLessOrEqualTo(1);
}
[Fact]
public void GenerateBreakdown_ActionBreakdown_GroupsCorrectly()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(25);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ActionBreakdown.Should().NotBeNull();
breakdown.ActionBreakdown.ByAction.Should().NotBeEmpty();
// Total count should match findings
var totalCount = breakdown.ActionBreakdown.ByAction.Values.Sum(b => b.Count);
totalCount.Should().Be(findings.Count);
}
[Fact]
public void GenerateBreakdown_ActionBreakdown_ComputesStability()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(20);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
// Stability ranges from 0 to 1
breakdown.ActionBreakdown.DecisionStability.Should().BeGreaterOrEqualTo(0);
breakdown.ActionBreakdown.DecisionStability.Should().BeLessOrEqualTo(1);
}
[Fact]
public void GenerateBreakdown_ComponentBreakdown_IncludedByDefault()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(15);
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ComponentBreakdown.Should().NotBeNull();
breakdown.ComponentBreakdown!.TotalComponents.Should().BeGreaterThan(0);
breakdown.ComponentBreakdown.TopRiskComponents.Should().NotBeEmpty();
}
[Fact]
public void GenerateBreakdown_ComponentBreakdown_ExtractsEcosystems()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateMixedEcosystemFindings();
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.ComponentBreakdown.Should().NotBeNull();
breakdown.ComponentBreakdown!.EcosystemBreakdown.Should().NotBeEmpty();
breakdown.ComponentBreakdown.EcosystemBreakdown.Should().ContainKey("npm");
breakdown.ComponentBreakdown.EcosystemBreakdown.Should().ContainKey("maven");
}
[Fact]
public void GenerateBreakdown_WithQuickOptions_ExcludesComponentBreakdown()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(10);
var result = CreateTestResult(findings, profile);
var options = RiskSimulationBreakdownOptions.Quick;
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings, options);
// Assert
breakdown.ComponentBreakdown.Should().BeNull();
}
[Fact]
public void GenerateBreakdown_DeterminismHash_IsConsistent()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateTestFindings(10);
var result = CreateTestResult(findings, profile);
// Act
var breakdown1 = _service.GenerateBreakdown(result, profile, findings);
var breakdown2 = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown1.DeterminismHash.Should().Be(breakdown2.DeterminismHash);
}
[Fact]
public void GenerateComparisonBreakdown_IncludesRiskTrends()
{
// Arrange
var baseProfile = CreateTestProfile();
var compareProfile = CreateTestProfileVariant();
var findings = CreateTestFindings(20);
var baseResult = CreateTestResult(findings, baseProfile);
var compareResult = CreateTestResult(findings, compareProfile);
// Act
var breakdown = _service.GenerateComparisonBreakdown(
baseResult, compareResult,
baseProfile, compareProfile,
findings);
// Assert
breakdown.RiskTrends.Should().NotBeNull();
breakdown.RiskTrends!.ComparisonType.Should().Be("profile_comparison");
breakdown.RiskTrends.ScoreTrend.Should().NotBeNull();
breakdown.RiskTrends.SeverityTrend.Should().NotBeNull();
breakdown.RiskTrends.ActionTrend.Should().NotBeNull();
}
[Fact]
public void GenerateComparisonBreakdown_TracksImprovementsAndRegressions()
{
// Arrange
var baseProfile = CreateTestProfile();
var compareProfile = CreateTestProfile(); // Same profile = no changes
var findings = CreateTestFindings(15);
var baseResult = CreateTestResult(findings, baseProfile);
var compareResult = CreateTestResult(findings, compareProfile);
// Act
var breakdown = _service.GenerateComparisonBreakdown(
baseResult, compareResult,
baseProfile, compareProfile,
findings);
// Assert
var trends = breakdown.RiskTrends!;
var total = trends.FindingsImproved + trends.FindingsWorsened + trends.FindingsUnchanged;
total.Should().Be(findings.Count);
}
[Fact]
public void GenerateBreakdown_EmptyFindings_ReturnsValidBreakdown()
{
// Arrange
var profile = CreateTestProfile();
var findings = Array.Empty<SimulationFinding>();
var result = CreateEmptyResult(profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.Should().NotBeNull();
breakdown.ScoreDistribution.RawScoreStats.Count.Should().Be(0);
breakdown.SeverityBreakdown.BySeverity.Should().BeEmpty();
}
[Fact]
public void GenerateBreakdown_MissingSignals_ReportsImpact()
{
// Arrange
var profile = CreateTestProfile();
var findings = CreateFindingsWithMissingSignals();
var result = CreateTestResult(findings, profile);
// Act
var breakdown = _service.GenerateBreakdown(result, profile, findings);
// Assert
breakdown.SignalAnalysis.MissingSignalImpact.Should().NotBeNull();
// Some findings have missing signals
breakdown.SignalAnalysis.SignalsMissing.Should().BeGreaterOrEqualTo(0);
}
#region Test Helpers
private static RiskProfileModel CreateTestProfile()
{
return new RiskProfileModel
{
Id = "test-profile",
Version = "1.0.0",
Description = "Test profile for unit tests",
Signals = new List<RiskSignal>
{
new() { Name = "cvss", Source = "nvd", Type = RiskSignalType.Numeric },
new() { Name = "kev", Source = "cisa", Type = RiskSignalType.Boolean },
new() { Name = "reachability", Source = "scanner", Type = RiskSignalType.Numeric },
new() { Name = "exploit_maturity", Source = "epss", Type = RiskSignalType.Categorical }
},
Weights = new Dictionary<string, double>
{
["cvss"] = 0.4,
["kev"] = 0.3,
["reachability"] = 0.2,
["exploit_maturity"] = 0.1
},
Overrides = new RiskOverrides()
};
}
private static RiskProfileModel CreateTestProfileWithOverrides()
{
var profile = CreateTestProfile();
profile.Overrides = new RiskOverrides
{
Severity = new List<SeverityOverride>
{
new()
{
When = new Dictionary<string, object> { ["kev"] = true },
Set = RiskSeverity.Critical
}
},
Decisions = new List<DecisionOverride>
{
new()
{
When = new Dictionary<string, object> { ["kev"] = true },
Action = RiskAction.Deny,
Reason = "KEV findings must be denied"
}
}
};
return profile;
}
private static RiskProfileModel CreateTestProfileVariant()
{
var profile = CreateTestProfile();
profile.Id = "test-profile-variant";
profile.Weights = new Dictionary<string, double>
{
["cvss"] = 0.5, // Higher weight for CVSS
["kev"] = 0.2,
["reachability"] = 0.2,
["exploit_maturity"] = 0.1
};
return profile;
}
private static IReadOnlyList<SimulationFinding> CreateTestFindings(int count)
{
var random = new Random(42); // Deterministic seed
return Enumerable.Range(1, count)
.Select(i => new SimulationFinding(
$"finding-{i}",
$"pkg:npm/package-{i}@{i}.0.0",
$"CVE-2024-{i:D4}",
new Dictionary<string, object?>
{
["cvss"] = Math.Round(random.NextDouble() * 10, 1),
["kev"] = random.Next(10) < 2, // 20% chance of KEV
["reachability"] = Math.Round(random.NextDouble(), 2),
["exploit_maturity"] = random.Next(4) switch
{
0 => "none",
1 => "low",
2 => "medium",
_ => "high"
}
}))
.ToList();
}
private static IReadOnlyList<SimulationFinding> CreateTestFindingsWithKev(int count)
{
return Enumerable.Range(1, count)
.Select(i => new SimulationFinding(
$"finding-{i}",
$"pkg:npm/package-{i}@{i}.0.0",
$"CVE-2024-{i:D4}",
new Dictionary<string, object?>
{
["cvss"] = 8.0 + (i % 3),
["kev"] = true, // All have KEV
["reachability"] = 0.9,
["exploit_maturity"] = "high"
}))
.ToList();
}
private static IReadOnlyList<SimulationFinding> CreateMixedEcosystemFindings()
{
return new List<SimulationFinding>
{
new("f1", "pkg:npm/lodash@4.17.0", "CVE-2024-0001", CreateSignals(7.5)),
new("f2", "pkg:npm/express@4.0.0", "CVE-2024-0002", CreateSignals(6.0)),
new("f3", "pkg:maven/org.apache.log4j/log4j-core@2.0.0", "CVE-2024-0003", CreateSignals(9.8)),
new("f4", "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.9.0", "CVE-2024-0004", CreateSignals(7.2)),
new("f5", "pkg:pypi/requests@2.25.0", "CVE-2024-0005", CreateSignals(5.5)),
};
}
private static IReadOnlyList<SimulationFinding> CreateFindingsWithMissingSignals()
{
return new List<SimulationFinding>
{
new("f1", "pkg:npm/a@1.0.0", "CVE-2024-0001",
new Dictionary<string, object?> { ["cvss"] = 7.0 }), // Missing kev, reachability
new("f2", "pkg:npm/b@1.0.0", "CVE-2024-0002",
new Dictionary<string, object?> { ["cvss"] = 6.0, ["kev"] = false }), // Missing reachability
new("f3", "pkg:npm/c@1.0.0", "CVE-2024-0003",
new Dictionary<string, object?> { ["cvss"] = 8.0, ["kev"] = true, ["reachability"] = 0.5 }), // All present
};
}
private static Dictionary<string, object?> CreateSignals(double cvss)
{
return new Dictionary<string, object?>
{
["cvss"] = cvss,
["kev"] = cvss >= 9.0,
["reachability"] = 0.7,
["exploit_maturity"] = cvss >= 8.0 ? "high" : "medium"
};
}
private static RiskSimulationResult CreateTestResult(
IReadOnlyList<SimulationFinding> findings,
RiskProfileModel profile)
{
var findingScores = findings.Select(f =>
{
var cvss = f.Signals.GetValueOrDefault("cvss") switch
{
double d => d,
_ => 5.0
};
var kev = f.Signals.GetValueOrDefault("kev") switch
{
bool b => b,
_ => false
};
var reachability = f.Signals.GetValueOrDefault("reachability") switch
{
double d => d,
_ => 0.5
};
var rawScore = cvss * 0.4 + (kev ? 1.0 : 0.0) * 0.3 + reachability * 0.2;
var normalizedScore = Math.Clamp(rawScore * 10, 0, 100);
var severity = normalizedScore switch
{
>= 90 => RiskSeverity.Critical,
>= 70 => RiskSeverity.High,
>= 40 => RiskSeverity.Medium,
>= 10 => RiskSeverity.Low,
_ => RiskSeverity.Informational
};
var action = severity switch
{
RiskSeverity.Critical or RiskSeverity.High => RiskAction.Deny,
RiskSeverity.Medium => RiskAction.Review,
_ => RiskAction.Allow
};
var contributions = new List<SignalContribution>
{
new("cvss", cvss, 0.4, cvss * 0.4, rawScore > 0 ? cvss * 0.4 / rawScore * 100 : 0),
new("kev", kev, 0.3, (kev ? 1.0 : 0.0) * 0.3, rawScore > 0 ? (kev ? 0.3 : 0.0) / rawScore * 100 : 0),
new("reachability", reachability, 0.2, reachability * 0.2, rawScore > 0 ? reachability * 0.2 / rawScore * 100 : 0)
};
return new FindingScore(
f.FindingId,
rawScore,
normalizedScore,
severity,
action,
contributions,
null);
}).ToList();
var aggregateMetrics = new AggregateRiskMetrics(
findings.Count,
findingScores.Count > 0 ? findingScores.Average(s => s.NormalizedScore) : 0,
findingScores.Count > 0 ? findingScores.OrderBy(s => s.NormalizedScore).ElementAt(findingScores.Count / 2).NormalizedScore : 0,
0, // std dev
findingScores.Count > 0 ? findingScores.Max(s => s.NormalizedScore) : 0,
findingScores.Count > 0 ? findingScores.Min(s => s.NormalizedScore) : 0,
findingScores.Count(s => s.Severity == RiskSeverity.Critical),
findingScores.Count(s => s.Severity == RiskSeverity.High),
findingScores.Count(s => s.Severity == RiskSeverity.Medium),
findingScores.Count(s => s.Severity == RiskSeverity.Low),
findingScores.Count(s => s.Severity == RiskSeverity.Informational));
return new RiskSimulationResult(
SimulationId: $"rsim-test-{Guid.NewGuid():N}",
ProfileId: profile.Id,
ProfileVersion: profile.Version,
ProfileHash: $"sha256:test{profile.Id.GetHashCode():x8}",
Timestamp: DateTimeOffset.UtcNow,
FindingScores: findingScores,
Distribution: null,
TopMovers: null,
AggregateMetrics: aggregateMetrics,
ExecutionTimeMs: 10.5);
}
private static RiskSimulationResult CreateTestResultWithOverrides(
IReadOnlyList<SimulationFinding> findings,
RiskProfileModel profile)
{
var result = CreateTestResult(findings, profile);
// Add overrides to findings with KEV
var findingScoresWithOverrides = result.FindingScores.Select(fs =>
{
var finding = findings.FirstOrDefault(f => f.FindingId == fs.FindingId);
var kev = finding?.Signals.GetValueOrDefault("kev") switch { bool b => b, _ => false };
if (kev)
{
return fs with
{
Severity = RiskSeverity.Critical,
RecommendedAction = RiskAction.Deny,
OverridesApplied = new List<AppliedOverride>
{
new("severity",
new Dictionary<string, object> { ["kev"] = true },
fs.Severity.ToString(),
RiskSeverity.Critical.ToString(),
null),
new("decision",
new Dictionary<string, object> { ["kev"] = true },
fs.RecommendedAction.ToString(),
RiskAction.Deny.ToString(),
"KEV findings must be denied")
}
};
}
return fs;
}).ToList();
return result with { FindingScores = findingScoresWithOverrides };
}
private static RiskSimulationResult CreateEmptyResult(RiskProfileModel profile)
{
return new RiskSimulationResult(
SimulationId: "rsim-empty",
ProfileId: profile.Id,
ProfileVersion: profile.Version,
ProfileHash: "sha256:empty",
Timestamp: DateTimeOffset.UtcNow,
FindingScores: Array.Empty<FindingScore>(),
Distribution: null,
TopMovers: null,
AggregateMetrics: new AggregateRiskMetrics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
ExecutionTimeMs: 1.0);
}
#endregion
}