// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // // ----------------------------------------------------------------------------- // SuppressionWitnessIdPropertyTests.cs // Sprint: SPRINT_20260106_001_002_SCANNER // Task: SUP-024 - Write property tests: witness ID determinism // Description: Property-based tests ensuring witness IDs are deterministic, // content-addressed, and follow the expected format. // ----------------------------------------------------------------------------- using System.Security.Cryptography; using FluentAssertions; using FsCheck.Xunit; using Moq; using StellaOps.Cryptography; using StellaOps.Scanner.Reachability.Witnesses; using Xunit; namespace StellaOps.Scanner.Reachability.Tests.Witnesses; /// /// Property-based tests for SuppressionWitness ID determinism. /// Uses FsCheck to verify properties across many random inputs. /// [Trait("Category", "Property")] public sealed class SuppressionWitnessIdPropertyTests { private static readonly DateTimeOffset FixedTime = new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero); /// /// Test implementation of ICryptoHash that uses real SHA256 for determinism verification. /// private sealed class TestCryptoHash : ICryptoHash { public byte[] ComputeHash(ReadOnlySpan data, string? algorithmId = null) => SHA256.HashData(data); public string ComputeHashHex(ReadOnlySpan data, string? algorithmId = null) => Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant(); public string ComputeHashBase64(ReadOnlySpan data, string? algorithmId = null) => Convert.ToBase64String(ComputeHash(data, algorithmId)); public async ValueTask ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) => await SHA256.HashDataAsync(stream, cancellationToken); public async ValueTask ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) => Convert.ToHexString(await ComputeHashAsync(stream, algorithmId, cancellationToken)).ToLowerInvariant(); public byte[] ComputeHashForPurpose(ReadOnlySpan data, string purpose) => ComputeHash(data); public string ComputeHashHexForPurpose(ReadOnlySpan data, string purpose) => ComputeHashHex(data); public string ComputeHashBase64ForPurpose(ReadOnlySpan data, string purpose) => ComputeHashBase64(data); public ValueTask ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => ComputeHashAsync(stream, null, cancellationToken); public ValueTask ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => ComputeHashHexAsync(stream, null, cancellationToken); public string GetAlgorithmForPurpose(string purpose) => "sha256"; public string GetHashPrefix(string purpose) => "sha256:"; public string ComputePrefixedHashForPurpose(ReadOnlySpan data, string purpose) => GetHashPrefix(purpose) + ComputeHashHex(data); } private static SuppressionWitnessBuilder CreateBuilder() { var timeProvider = new Mock(); timeProvider.Setup(x => x.GetUtcNow()).Returns(FixedTime); return new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider.Object); } #region Determinism Properties [Property(MaxTest = 100)] public bool SameInputs_AlwaysProduceSameWitnessId(string sbomDigest, string componentPurl, string vulnId) { if (string.IsNullOrWhiteSpace(sbomDigest) || string.IsNullOrWhiteSpace(componentPurl) || string.IsNullOrWhiteSpace(vulnId)) { return true; // Skip invalid inputs } var builder = CreateBuilder(); var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId); var result1 = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult(); var result2 = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult(); return result1.WitnessId == result2.WitnessId; } [Property(MaxTest = 100)] public bool DifferentSbomDigest_ProducesDifferentWitnessId( string sbomDigest1, string sbomDigest2, string componentPurl, string vulnId) { if (string.IsNullOrWhiteSpace(sbomDigest1) || string.IsNullOrWhiteSpace(sbomDigest2) || string.IsNullOrWhiteSpace(componentPurl) || string.IsNullOrWhiteSpace(vulnId) || sbomDigest1 == sbomDigest2) { return true; // Skip invalid or same inputs } var builder = CreateBuilder(); var request1 = CreateUnreachabilityRequest(sbomDigest1, componentPurl, vulnId); var request2 = CreateUnreachabilityRequest(sbomDigest2, componentPurl, vulnId); var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult(); var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult(); return result1.WitnessId != result2.WitnessId; } [Property(MaxTest = 100)] public bool DifferentComponentPurl_ProducesDifferentWitnessId( string sbomDigest, string componentPurl1, string componentPurl2, string vulnId) { if (string.IsNullOrWhiteSpace(sbomDigest) || string.IsNullOrWhiteSpace(componentPurl1) || string.IsNullOrWhiteSpace(componentPurl2) || string.IsNullOrWhiteSpace(vulnId) || componentPurl1 == componentPurl2) { return true; // Skip invalid or same inputs } var builder = CreateBuilder(); var request1 = CreateUnreachabilityRequest(sbomDigest, componentPurl1, vulnId); var request2 = CreateUnreachabilityRequest(sbomDigest, componentPurl2, vulnId); var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult(); var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult(); return result1.WitnessId != result2.WitnessId; } [Property(MaxTest = 100)] public bool DifferentVulnId_ProducesDifferentWitnessId( string sbomDigest, string componentPurl, string vulnId1, string vulnId2) { if (string.IsNullOrWhiteSpace(sbomDigest) || string.IsNullOrWhiteSpace(componentPurl) || string.IsNullOrWhiteSpace(vulnId1) || string.IsNullOrWhiteSpace(vulnId2) || vulnId1 == vulnId2) { return true; // Skip invalid or same inputs } var builder = CreateBuilder(); var request1 = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId1); var request2 = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId2); var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult(); var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult(); return result1.WitnessId != result2.WitnessId; } #endregion #region Format Properties [Property(MaxTest = 100)] public bool WitnessId_AlwaysStartsWithSupPrefix(string sbomDigest, string componentPurl, string vulnId) { if (string.IsNullOrWhiteSpace(sbomDigest) || string.IsNullOrWhiteSpace(componentPurl) || string.IsNullOrWhiteSpace(vulnId)) { return true; // Skip invalid inputs } var builder = CreateBuilder(); var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId); var result = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult(); return result.WitnessId.StartsWith("sup:sha256:"); } [Property(MaxTest = 100)] public bool WitnessId_ContainsValidHexDigest(string sbomDigest, string componentPurl, string vulnId) { if (string.IsNullOrWhiteSpace(sbomDigest) || string.IsNullOrWhiteSpace(componentPurl) || string.IsNullOrWhiteSpace(vulnId)) { return true; // Skip invalid inputs } var builder = CreateBuilder(); var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId); var result = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult(); // Extract hex part after "sup:sha256:" var hexPart = result.WitnessId["sup:sha256:".Length..]; // Should be valid lowercase hex and have correct length (SHA256 = 64 hex chars) return hexPart.Length == 64 && hexPart.All(c => char.IsAsciiHexDigitLower(c) || char.IsDigit(c)); } #endregion #region Suppression Type Independence [Property(MaxTest = 50)] public bool DifferentSuppressionTypes_WithSameArtifactAndVuln_ProduceDifferentWitnessIds( string sbomDigest, string componentPurl, string vulnId) { if (string.IsNullOrWhiteSpace(sbomDigest) || string.IsNullOrWhiteSpace(componentPurl) || string.IsNullOrWhiteSpace(vulnId)) { return true; // Skip invalid inputs } var builder = CreateBuilder(); var unreachableRequest = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId); var versionRequest = new VersionRangeRequest { SbomDigest = sbomDigest, ComponentPurl = componentPurl, VulnId = vulnId, VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "Version not affected", InstalledVersion = "2.0.0", ComparisonResult = "not_affected", VersionScheme = "semver", Confidence = 1.0 }; var unreachableResult = builder.BuildUnreachableAsync(unreachableRequest).GetAwaiter().GetResult(); var versionResult = builder.BuildVersionNotAffectedAsync(versionRequest).GetAwaiter().GetResult(); // Different suppression types should produce different witness IDs return unreachableResult.WitnessId != versionResult.WitnessId; } #endregion #region Content-Addressed Behavior [Fact] public async Task WitnessId_IncludesObservedAtInHash() { // The witness ID is content-addressed over the entire witness document, // including ObservedAt. Different timestamps produce different IDs. // This ensures audit trail integrity. // Arrange var time1 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); var time2 = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero); var timeProvider1 = new Mock(); timeProvider1.Setup(x => x.GetUtcNow()).Returns(time1); var timeProvider2 = new Mock(); timeProvider2.Setup(x => x.GetUtcNow()).Returns(time2); var builder1 = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider1.Object); var builder2 = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider2.Object); var request = CreateUnreachabilityRequest("sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234"); // Act var result1 = await builder1.BuildUnreachableAsync(request); var result2 = await builder2.BuildUnreachableAsync(request); // Assert - different timestamps produce different witness IDs (content-addressed) result1.WitnessId.Should().NotBe(result2.WitnessId); result1.ObservedAt.Should().NotBe(result2.ObservedAt); // But both should still be valid witness IDs result1.WitnessId.Should().StartWith("sup:sha256:"); result2.WitnessId.Should().StartWith("sup:sha256:"); } [Fact] public async Task WitnessId_SameTimestamp_ProducesSameId() { // With the same timestamp, the witness ID should be deterministic var fixedTime = new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); var timeProvider = new Mock(); timeProvider.Setup(x => x.GetUtcNow()).Returns(fixedTime); var builder = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider.Object); var request = CreateUnreachabilityRequest("sbom:sha256:test", "pkg:npm/lib@1.0.0", "CVE-2026-5555"); // Act var result1 = await builder.BuildUnreachableAsync(request); var result2 = await builder.BuildUnreachableAsync(request); // Assert - same inputs with same timestamp = same ID result1.WitnessId.Should().Be(result2.WitnessId); } [Property(MaxTest = 50)] public bool WitnessId_IncludesConfidenceInHash(double confidence1, double confidence2) { // Skip invalid doubles (infinity, NaN) if (!double.IsFinite(confidence1) || !double.IsFinite(confidence2)) { return true; } // The witness ID is content-addressed over the entire witness including confidence. // Different confidence values produce different IDs. // Clamp to valid range [0, 1] but ensure they're different confidence1 = Math.Clamp(Math.Abs(confidence1) % 0.5, 0.01, 0.49); confidence2 = Math.Clamp(Math.Abs(confidence2) % 0.5 + 0.5, 0.51, 1.0); var builder = CreateBuilder(); var request1 = CreateUnreachabilityRequest( "sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234", confidence: confidence1); var request2 = CreateUnreachabilityRequest( "sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234", confidence: confidence2); var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult(); var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult(); // Different confidence values produce different witness IDs return result1.WitnessId != result2.WitnessId; } [Property(MaxTest = 50)] public bool WitnessId_SameConfidence_ProducesSameId(double confidence) { // Skip invalid doubles (infinity, NaN) if (!double.IsFinite(confidence)) { return true; } // Same confidence should produce same witness ID confidence = Math.Clamp(Math.Abs(confidence) % 1.0, 0.01, 1.0); var builder = CreateBuilder(); var request1 = CreateUnreachabilityRequest( "sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234", confidence: confidence); var request2 = CreateUnreachabilityRequest( "sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234", confidence: confidence); var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult(); var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult(); return result1.WitnessId == result2.WitnessId; } #endregion #region Collision Resistance [Fact] public async Task GeneratedWitnessIds_AreUnique_AcrossManyInputs() { // Arrange var builder = CreateBuilder(); var witnessIds = new HashSet(); var iterations = 1000; // Act for (int i = 0; i < iterations; i++) { var request = CreateUnreachabilityRequest( $"sbom:sha256:{i:x8}", $"pkg:npm/test@{i}.0.0", $"CVE-2026-{i:D4}"); var result = await builder.BuildUnreachableAsync(request); witnessIds.Add(result.WitnessId); } // Assert - All witness IDs should be unique (no collisions) witnessIds.Should().HaveCount(iterations); } #endregion #region Cross-Builder Determinism [Fact] public async Task DifferentBuilderInstances_SameInputs_ProduceSameWitnessId() { // Arrange var builder1 = CreateBuilder(); var builder2 = CreateBuilder(); var request = CreateUnreachabilityRequest( "sbom:sha256:determinism", "pkg:npm/determinism@1.0.0", "CVE-2026-0001"); // Act var result1 = await builder1.BuildUnreachableAsync(request); var result2 = await builder2.BuildUnreachableAsync(request); // Assert result1.WitnessId.Should().Be(result2.WitnessId); } #endregion #region All Suppression Types Produce Valid IDs [Fact] public async Task AllSuppressionTypes_ProduceValidWitnessIds() { // Arrange var builder = CreateBuilder(); // Act & Assert - Test each suppression type var unreachable = await builder.BuildUnreachableAsync(new UnreachabilityRequest { SbomDigest = "sbom:sha256:ur", ComponentPurl = "pkg:npm/test@1.0.0", VulnId = "CVE-2026-0001", VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "Unreachable", GraphDigest = "graph:sha256:def", AnalyzedEntrypoints = 1, UnreachableSymbol = "func", AnalysisMethod = "static", Confidence = 0.95 }); unreachable.WitnessId.Should().StartWith("sup:sha256:"); var patched = await builder.BuildPatchedSymbolAsync(new PatchedSymbolRequest { SbomDigest = "sbom:sha256:ps", ComponentPurl = "pkg:deb/openssl@1.1.1", VulnId = "CVE-2026-0002", VulnSource = "Debian", AffectedRange = "<= 1.1.0", Justification = "Backported", VulnerableSymbol = "old_func", PatchedSymbol = "new_func", SymbolDiff = "diff", PatchRef = "debian/patches/fix.patch", Confidence = 0.99 }); patched.WitnessId.Should().StartWith("sup:sha256:"); var functionAbsent = await builder.BuildFunctionAbsentAsync(new FunctionAbsentRequest { SbomDigest = "sbom:sha256:fa", ComponentPurl = "pkg:generic/app@3.0.0", VulnId = "CVE-2026-0003", VulnSource = "GitHub", AffectedRange = "< 3.0.0", Justification = "Function removed", FunctionName = "deprecated_api", BinaryDigest = "binary:sha256:123", VerificationMethod = "symbol-table", Confidence = 1.0 }); functionAbsent.WitnessId.Should().StartWith("sup:sha256:"); var versionNotAffected = await builder.BuildVersionNotAffectedAsync(new VersionRangeRequest { SbomDigest = "sbom:sha256:vna", ComponentPurl = "pkg:pypi/django@4.2.0", VulnId = "CVE-2026-0004", VulnSource = "OSV", AffectedRange = ">= 3.0.0, < 4.0.0", Justification = "Version outside range", InstalledVersion = "4.2.0", ComparisonResult = "not_affected", VersionScheme = "semver", Confidence = 1.0 }); versionNotAffected.WitnessId.Should().StartWith("sup:sha256:"); // Verify all IDs are unique var allIds = new[] { unreachable.WitnessId, patched.WitnessId, functionAbsent.WitnessId, versionNotAffected.WitnessId }; allIds.Should().OnlyHaveUniqueItems(); } #endregion #region Helper Methods private static UnreachabilityRequest CreateUnreachabilityRequest( string sbomDigest, string componentPurl, string vulnId, double confidence = 0.95) { return new UnreachabilityRequest { SbomDigest = sbomDigest, ComponentPurl = componentPurl, VulnId = vulnId, VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "Property test", GraphDigest = "graph:sha256:fixed", AnalyzedEntrypoints = 1, UnreachableSymbol = "vulnerable_func", AnalysisMethod = "static", Confidence = confidence }; } #endregion }