sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -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++;
}
}
}
}

View File

@@ -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");
}
}

View File

@@ -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
}