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

462 lines
17 KiB
C#

using System.Security.Cryptography;
using FluentAssertions;
using Moq;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Witnesses;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Witnesses;
/// <summary>
/// Tests for SuppressionWitnessBuilder.
/// Sprint: SPRINT_20260106_001_002 (SUP-020)
/// </summary>
[Trait("Category", "Unit")]
public sealed class SuppressionWitnessBuilderTests
{
private readonly Mock<TimeProvider> _mockTimeProvider;
private readonly SuppressionWitnessBuilder _builder;
private static readonly DateTimeOffset FixedTime = new(2025, 1, 7, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// Test implementation of ICryptoHash.
/// Note: Moq can't mock ReadOnlySpan parameters, so we use a concrete implementation.
/// </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);
}
public SuppressionWitnessBuilderTests()
{
_mockTimeProvider = new Mock<TimeProvider>();
_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<DetectedGate>
{
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<ArgumentNullException>().WithParameterName("cryptoHash");
}
[Fact]
public void Constructor_ThrowsWhenTimeProviderIsNull()
{
// Arrange
var mockHash = new Mock<ICryptoHash>();
// Act & Assert
var act = () => new SuppressionWitnessBuilder(mockHash.Object, null!);
act.Should().Throw<ArgumentNullException>().WithParameterName("timeProvider");
}
}