sprints and audit work
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Witnesses;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SuppressionDsseSigner"/>.
|
||||
/// Sprint: SPRINT_20260106_001_002 (SUP-021)
|
||||
/// Golden fixture tests for DSSE sign/verify of suppression witnesses.
|
||||
/// </summary>
|
||||
public sealed class SuppressionDsseSignerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a deterministic Ed25519 key pair for testing.
|
||||
/// </summary>
|
||||
private static (byte[] privateKey, byte[] publicKey) CreateTestKeyPair()
|
||||
{
|
||||
// Use a fixed seed for deterministic tests
|
||||
var generator = new Ed25519KeyPairGenerator();
|
||||
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
|
||||
var keyPair = generator.GenerateKeyPair();
|
||||
|
||||
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
|
||||
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
|
||||
|
||||
// Ed25519 private key = 32-byte seed + 32-byte public key
|
||||
var privateKey = new byte[64];
|
||||
privateParams.Encode(privateKey, 0);
|
||||
var publicKey = publicParams.GetEncoded();
|
||||
|
||||
// Append public key to make 64-byte expanded form
|
||||
Array.Copy(publicKey, 0, privateKey, 32, 32);
|
||||
|
||||
return (privateKey, publicKey);
|
||||
}
|
||||
|
||||
private static SuppressionWitness CreateTestWitness()
|
||||
{
|
||||
return new SuppressionWitness
|
||||
{
|
||||
WitnessSchema = SuppressionWitnessSchema.Version,
|
||||
WitnessId = "sup:sha256:test123",
|
||||
Artifact = new WitnessArtifact
|
||||
{
|
||||
SbomDigest = "sbom:sha256:abc",
|
||||
ComponentPurl = "pkg:npm/test@1.0.0"
|
||||
},
|
||||
Vuln = new WitnessVuln
|
||||
{
|
||||
Id = "CVE-2025-TEST",
|
||||
Source = "NVD",
|
||||
AffectedRange = "< 2.0.0"
|
||||
},
|
||||
SuppressionType = SuppressionType.Unreachable,
|
||||
Evidence = new SuppressionEvidence
|
||||
{
|
||||
WitnessEvidence = new WitnessEvidence
|
||||
{
|
||||
CallgraphDigest = "graph:sha256:def",
|
||||
BuildId = "StellaOps.Scanner/1.0.0"
|
||||
},
|
||||
Unreachability = new UnreachabilityEvidence
|
||||
{
|
||||
AnalyzedEntrypoints = 1,
|
||||
UnreachableSymbol = "vuln_func",
|
||||
AnalysisMethod = "static-dataflow",
|
||||
GraphDigest = "graph:sha256:def"
|
||||
}
|
||||
},
|
||||
Confidence = 0.95,
|
||||
ObservedAt = new DateTimeOffset(2025, 1, 7, 12, 0, 0, TimeSpan.Zero),
|
||||
Justification = "Test suppression witness"
|
||||
};
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignWitness_WithValidKey_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Act
|
||||
var result = signer.SignWitness(witness, key);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess, result.Error);
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Equal(SuppressionWitnessSchema.DssePayloadType, result.Envelope.PayloadType);
|
||||
Assert.Single(result.Envelope.Signatures);
|
||||
Assert.NotEmpty(result.PayloadBytes!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithValidSignature_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Sign the witness
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
Assert.True(signResult.IsSuccess, signResult.Error);
|
||||
|
||||
// Create public key for verification
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
|
||||
// Act
|
||||
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
|
||||
|
||||
// Assert
|
||||
Assert.True(verifyResult.IsSuccess, verifyResult.Error);
|
||||
Assert.NotNull(verifyResult.Witness);
|
||||
Assert.Equal(witness.WitnessId, verifyResult.Witness.WitnessId);
|
||||
Assert.Equal(witness.Vuln.Id, verifyResult.Witness.Vuln.Id);
|
||||
Assert.Equal(witness.SuppressionType, verifyResult.Witness.SuppressionType);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithWrongKey_ReturnsFails()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Sign with first key
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
Assert.True(signResult.IsSuccess);
|
||||
|
||||
// Try to verify with different key
|
||||
var (_, wrongPublicKey) = CreateTestKeyPair();
|
||||
var wrongKey = EnvelopeKey.CreateEd25519Verifier(wrongPublicKey);
|
||||
|
||||
// Act
|
||||
var verifyResult = signer.VerifyWitness(signResult.Envelope!, wrongKey);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsSuccess);
|
||||
Assert.NotNull(verifyResult.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithInvalidPayloadType_ReturnsFails()
|
||||
{
|
||||
// Arrange
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Create envelope with wrong payload type
|
||||
var badEnvelope = new DsseEnvelope(
|
||||
payloadType: "https://wrong.type/v1",
|
||||
payload: "test"u8.ToArray(),
|
||||
signatures: []);
|
||||
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
|
||||
// Act
|
||||
var result = signer.VerifyWitness(badEnvelope, verifyKey);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Contains("Invalid payload type", result.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithUnsupportedSchema_ReturnsFails()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness() with
|
||||
{
|
||||
WitnessSchema = "stellaops.suppression.v99"
|
||||
};
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Sign witness with wrong schema
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
Assert.True(signResult.IsSuccess);
|
||||
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
|
||||
// Act
|
||||
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsSuccess);
|
||||
Assert.Contains("Unsupported witness schema", verifyResult.Error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignWitness_WithNullWitness_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => signer.SignWitness(null!, key));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignWitness_WithNullKey_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => signer.SignWitness(witness, null!));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithNullEnvelope_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var (_, publicKey) = CreateTestKeyPair();
|
||||
var key = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => signer.VerifyWitness(null!, key));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifyWitness_WithNullKey_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType: SuppressionWitnessSchema.DssePayloadType,
|
||||
payload: "test"u8.ToArray(),
|
||||
signatures: []);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => signer.VerifyWitness(envelope, null!));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignAndVerify_ProducesVerifiableEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
var signer = new SuppressionDsseSigner();
|
||||
|
||||
// Act
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
|
||||
|
||||
// Assert
|
||||
Assert.True(signResult.IsSuccess);
|
||||
Assert.True(verifyResult.IsSuccess);
|
||||
Assert.NotNull(verifyResult.Witness);
|
||||
Assert.Equal(witness.WitnessId, verifyResult.Witness.WitnessId);
|
||||
Assert.Equal(witness.Artifact.ComponentPurl, verifyResult.Witness.Artifact.ComponentPurl);
|
||||
Assert.Equal(witness.Evidence.Unreachability?.UnreachableSymbol,
|
||||
verifyResult.Witness.Evidence.Unreachability?.UnreachableSymbol);
|
||||
}
|
||||
|
||||
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
|
||||
{
|
||||
private byte _value = 0x42;
|
||||
|
||||
public void AddSeedMaterial(byte[] seed) { }
|
||||
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
|
||||
public void AddSeedMaterial(long seed) { }
|
||||
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
|
||||
public void NextBytes(byte[] bytes, int start, int len)
|
||||
{
|
||||
for (int i = start; i < start + len; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
public void NextBytes(Span<byte> bytes)
|
||||
{
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
// <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
|
||||
}
|
||||
Reference in New Issue
Block a user