Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/Witnesses/SuppressionWitnessIdPropertyTests.cs
2026-01-07 09:43:12 +02:00

534 lines
20 KiB
C#

// <copyright file="SuppressionWitnessIdPropertyTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Property-based tests for SuppressionWitness ID determinism.
/// Uses FsCheck to verify properties across many random inputs.
/// </summary>
[Trait("Category", "Property")]
public sealed class SuppressionWitnessIdPropertyTests
{
private static readonly DateTimeOffset FixedTime = new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// Test implementation of ICryptoHash that uses real SHA256 for determinism verification.
/// </summary>
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> await SHA256.HashDataAsync(stream, cancellationToken);
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> Convert.ToHexString(await ComputeHashAsync(stream, algorithmId, cancellationToken)).ToLowerInvariant();
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data);
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashAsync(stream, null, cancellationToken);
public ValueTask<string> 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<byte> data, string purpose)
=> GetHashPrefix(purpose) + ComputeHashHex(data);
}
private static SuppressionWitnessBuilder CreateBuilder()
{
var timeProvider = new Mock<TimeProvider>();
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<TimeProvider>();
timeProvider1.Setup(x => x.GetUtcNow()).Returns(time1);
var timeProvider2 = new Mock<TimeProvider>();
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>();
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<string>();
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
}