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:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user