462 lines
17 KiB
C#
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");
|
|
}
|
|
}
|