using System.Security.Cryptography; using FluentAssertions; using Moq; using StellaOps.Cryptography; using StellaOps.Scanner.Reachability.Witnesses; using Xunit; namespace StellaOps.Scanner.Reachability.Tests.Witnesses; /// /// Tests for SuppressionWitnessBuilder. /// Sprint: SPRINT_20260106_001_002 (SUP-020) /// [Trait("Category", "Unit")] public sealed class SuppressionWitnessBuilderTests { private readonly Mock _mockTimeProvider; private readonly SuppressionWitnessBuilder _builder; private static readonly DateTimeOffset FixedTime = new(2025, 1, 7, 12, 0, 0, TimeSpan.Zero); /// /// Test implementation of ICryptoHash. /// Note: Moq can't mock ReadOnlySpan parameters, so we use a concrete implementation. /// 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); } public SuppressionWitnessBuilderTests() { _mockTimeProvider = new Mock(); _mockTimeProvider .Setup(x => x.GetUtcNow()) .Returns(FixedTime); _builder = new SuppressionWitnessBuilder(new TestCryptoHash(), _mockTimeProvider.Object); } [Fact] public async Task BuildUnreachableAsync_CreatesValidWitness() { // Arrange var request = new UnreachabilityRequest { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:npm/test@1.0.0", VulnId = "CVE-2025-1234", VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "Unreachable test", GraphDigest = "graph:sha256:def", AnalyzedEntrypoints = 2, UnreachableSymbol = "vulnerable_func", AnalysisMethod = "static-dataflow", Confidence = 0.95 }; // Act var result = await _builder.BuildUnreachableAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.Unreachable); result.Artifact.SbomDigest.Should().Be("sbom:sha256:abc"); result.Artifact.ComponentPurl.Should().Be("pkg:npm/test@1.0.0"); result.Vuln.Id.Should().Be("CVE-2025-1234"); result.Vuln.Source.Should().Be("NVD"); result.Confidence.Should().Be(0.95); result.ObservedAt.Should().Be(FixedTime); result.WitnessId.Should().StartWith("sup:sha256:"); result.Evidence.Unreachability.Should().NotBeNull(); result.Evidence.Unreachability!.UnreachableSymbol.Should().Be("vulnerable_func"); result.Evidence.Unreachability.AnalyzedEntrypoints.Should().Be(2); } [Fact] public async Task BuildPatchedSymbolAsync_CreatesValidWitness() { // Arrange var request = new PatchedSymbolRequest { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:deb/openssl@1.1.1", VulnId = "CVE-2025-5678", VulnSource = "Debian", AffectedRange = "<= 1.1.0", Justification = "Backported security patch", VulnerableSymbol = "ssl_encrypt_old", PatchedSymbol = "ssl_encrypt_new", SymbolDiff = "diff --git a/ssl.c b/ssl.c\n...", PatchRef = "debian/patches/CVE-2025-5678.patch", Confidence = 0.99 }; // Act var result = await _builder.BuildPatchedSymbolAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.PatchedSymbol); result.Evidence.PatchedSymbol.Should().NotBeNull(); result.Evidence.PatchedSymbol!.VulnerableSymbol.Should().Be("ssl_encrypt_old"); result.Evidence.PatchedSymbol.PatchedSymbol.Should().Be("ssl_encrypt_new"); result.Evidence.PatchedSymbol.PatchRef.Should().Be("debian/patches/CVE-2025-5678.patch"); } [Fact] public async Task BuildFunctionAbsentAsync_CreatesValidWitness() { // Arrange var request = new FunctionAbsentRequest { SbomDigest = "sbom:sha256:xyz", ComponentPurl = "pkg:generic/app@3.0.0", VulnId = "GHSA-1234-5678-90ab", VulnSource = "GitHub", AffectedRange = "< 3.0.0", Justification = "Function removed in 3.0.0", FunctionName = "deprecated_api", BinaryDigest = "binary:sha256:123", VerificationMethod = "symbol-table-inspection", Confidence = 1.0 }; // Act var result = await _builder.BuildFunctionAbsentAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.FunctionAbsent); result.Evidence.FunctionAbsent.Should().NotBeNull(); result.Evidence.FunctionAbsent!.FunctionName.Should().Be("deprecated_api"); result.Evidence.FunctionAbsent.BinaryDigest.Should().Be("binary:sha256:123"); result.Evidence.FunctionAbsent.VerificationMethod.Should().Be("symbol-table-inspection"); } [Fact] public async Task BuildGateBlockedAsync_CreatesValidWitness() { // Arrange var gates = new List { new() { Type = "permission", GuardSymbol = "check_admin", Confidence = 0.9, Detail = "Requires admin role" }, new() { Type = "feature-flag", GuardSymbol = "FLAG_LEGACY_MODE", Confidence = 0.85, Detail = "Disabled in production" } }; var request = new GateBlockedRequest { SbomDigest = "sbom:sha256:gates", ComponentPurl = "pkg:npm/webapp@2.0.0", VulnId = "CVE-2025-9999", VulnSource = "NVD", AffectedRange = "*", Justification = "All paths protected by gates", DetectedGates = gates, GateCoveragePercent = 100, Effectiveness = "All vulnerable paths blocked", Confidence = 0.88 }; // Act var result = await _builder.BuildGateBlockedAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.GateBlocked); result.Evidence.GateBlocked.Should().NotBeNull(); result.Evidence.GateBlocked!.DetectedGates.Should().HaveCount(2); result.Evidence.GateBlocked.GateCoveragePercent.Should().Be(100); result.Evidence.GateBlocked.Effectiveness.Should().Be("All vulnerable paths blocked"); } [Fact] public async Task BuildFeatureFlagDisabledAsync_CreatesValidWitness() { // Arrange var request = new FeatureFlagRequest { SbomDigest = "sbom:sha256:flags", ComponentPurl = "pkg:golang/service@1.5.0", VulnId = "CVE-2025-8888", VulnSource = "OSV", AffectedRange = "< 2.0.0", Justification = "Vulnerable feature disabled", FlagName = "ENABLE_EXPERIMENTAL_API", FlagState = "false", ConfigSource = "/etc/app/config.yaml", GuardedPath = "src/api/experimental.go:45", Confidence = 0.92 }; // Act var result = await _builder.BuildFeatureFlagDisabledAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.FeatureFlagDisabled); result.Evidence.FeatureFlag.Should().NotBeNull(); result.Evidence.FeatureFlag!.FlagName.Should().Be("ENABLE_EXPERIMENTAL_API"); result.Evidence.FeatureFlag.FlagState.Should().Be("false"); result.Evidence.FeatureFlag.ConfigSource.Should().Be("/etc/app/config.yaml"); } [Fact] public async Task BuildFromVexStatementAsync_CreatesValidWitness() { // Arrange var request = new VexStatementRequest { SbomDigest = "sbom:sha256:vex", ComponentPurl = "pkg:maven/org.example/lib@1.0.0", VulnId = "CVE-2025-7777", VulnSource = "NVD", AffectedRange = "*", Justification = "Vendor VEX statement: not affected", VexId = "vex:vendor/2025-001", VexAuthor = "vendor@example.com", VexStatus = "not_affected", VexJustification = "vulnerable_code_not_present", VexDigest = "vex:sha256:vendor001", Confidence = 0.97 }; // Act var result = await _builder.BuildFromVexStatementAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.VexNotAffected); result.Evidence.VexStatement.Should().NotBeNull(); result.Evidence.VexStatement!.VexId.Should().Be("vex:vendor/2025-001"); result.Evidence.VexStatement.VexAuthor.Should().Be("vendor@example.com"); result.Evidence.VexStatement.VexStatus.Should().Be("not_affected"); } [Fact] public async Task BuildVersionNotAffectedAsync_CreatesValidWitness() { // Arrange var request = new VersionRangeRequest { SbomDigest = "sbom:sha256:version", ComponentPurl = "pkg:pypi/django@4.2.0", VulnId = "CVE-2025-6666", VulnSource = "OSV", AffectedRange = ">= 3.0.0, < 4.0.0", Justification = "Installed version outside affected range", InstalledVersion = "4.2.0", ComparisonResult = "not_affected", VersionScheme = "semver", Confidence = 1.0 }; // Act var result = await _builder.BuildVersionNotAffectedAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.VersionNotAffected); result.Evidence.VersionRange.Should().NotBeNull(); result.Evidence.VersionRange!.InstalledVersion.Should().Be("4.2.0"); result.Evidence.VersionRange.AffectedRange.Should().Be(">= 3.0.0, < 4.0.0"); result.Evidence.VersionRange.ComparisonResult.Should().Be("not_affected"); } [Fact] public async Task BuildLinkerGarbageCollectedAsync_CreatesValidWitness() { // Arrange var request = new LinkerGcRequest { SbomDigest = "sbom:sha256:linker", ComponentPurl = "pkg:generic/static-binary@1.0.0", VulnId = "CVE-2025-5555", VulnSource = "NVD", AffectedRange = "*", Justification = "Vulnerable code removed by linker GC", CollectedSymbol = "unused_vulnerable_func", LinkerLog = "gc: collected unused_vulnerable_func", Linker = "GNU ld 2.40", BuildFlags = "-Wl,--gc-sections -ffunction-sections", Confidence = 0.94 }; // Act var result = await _builder.BuildLinkerGarbageCollectedAsync(request); // Assert result.Should().NotBeNull(); result.SuppressionType.Should().Be(SuppressionType.LinkerGarbageCollected); result.Evidence.LinkerGc.Should().NotBeNull(); result.Evidence.LinkerGc!.CollectedSymbol.Should().Be("unused_vulnerable_func"); result.Evidence.LinkerGc.Linker.Should().Be("GNU ld 2.40"); result.Evidence.LinkerGc.BuildFlags.Should().Be("-Wl,--gc-sections -ffunction-sections"); } [Fact] public async Task BuildUnreachableAsync_ClampsConfidenceToValidRange() { // Arrange var request = new UnreachabilityRequest { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:npm/test@1.0.0", VulnId = "CVE-2025-1234", VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "Confidence test", GraphDigest = "graph:sha256:def", AnalyzedEntrypoints = 1, UnreachableSymbol = "vulnerable_func", AnalysisMethod = "static", Confidence = 1.5 // Out of range }; // Act var result = await _builder.BuildUnreachableAsync(request); // Assert result.Confidence.Should().Be(1.0); // Clamped to max } [Fact] public async Task BuildAsync_GeneratesDeterministicWitnessId() { // Arrange var request = new UnreachabilityRequest { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:npm/test@1.0.0", VulnId = "CVE-2025-1234", VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "ID test", GraphDigest = "graph:sha256:def", AnalyzedEntrypoints = 1, UnreachableSymbol = "func", AnalysisMethod = "static", Confidence = 0.95 }; // Act var result1 = await _builder.BuildUnreachableAsync(request); var result2 = await _builder.BuildUnreachableAsync(request); // Assert result1.WitnessId.Should().Be(result2.WitnessId); result1.WitnessId.Should().StartWith("sup:sha256:"); } [Fact] public async Task BuildAsync_SetsObservedAtFromTimeProvider() { // Arrange var request = new UnreachabilityRequest { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:npm/test@1.0.0", VulnId = "CVE-2025-1234", VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "Time test", GraphDigest = "graph:sha256:def", AnalyzedEntrypoints = 1, UnreachableSymbol = "func", AnalysisMethod = "static", Confidence = 0.95 }; // Act var result = await _builder.BuildUnreachableAsync(request); // Assert result.ObservedAt.Should().Be(FixedTime); } [Fact] public async Task BuildAsync_PreservesExpiresAtWhenProvided() { // Arrange var expiresAt = DateTimeOffset.UtcNow.AddDays(30); var request = new UnreachabilityRequest { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:npm/test@1.0.0", VulnId = "CVE-2025-1234", VulnSource = "NVD", AffectedRange = "< 2.0.0", Justification = "Expiry test", GraphDigest = "graph:sha256:def", AnalyzedEntrypoints = 1, UnreachableSymbol = "func", AnalysisMethod = "static", Confidence = 0.95, ExpiresAt = expiresAt }; // Act var result = await _builder.BuildUnreachableAsync(request); // Assert result.ExpiresAt.Should().Be(expiresAt); } [Fact] public void Constructor_ThrowsWhenCryptoHashIsNull() { // Act & Assert var act = () => new SuppressionWitnessBuilder(null!, TimeProvider.System); act.Should().Throw().WithParameterName("cryptoHash"); } [Fact] public void Constructor_ThrowsWhenTimeProviderIsNull() { // Arrange var mockHash = new Mock(); // Act & Assert var act = () => new SuppressionWitnessBuilder(mockHash.Object, null!); act.Should().Throw().WithParameterName("timeProvider"); } }