Files
git.stella-ops.org/tests/integration/StellaOps.Integration.Determinism/EvidenceBundleDeterminismTests.cs
master 5590a99a1a Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
2025-12-23 23:51:58 +02:00

561 lines
20 KiB
C#

// -----------------------------------------------------------------------------
// EvidenceBundleDeterminismTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T6 - Evidence Bundle Determinism (DSSE envelopes, in-toto attestations)
// Description: Tests to validate evidence bundle generation determinism
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Integration.Determinism;
/// <summary>
/// Determinism validation tests for evidence bundle generation.
/// Ensures identical inputs produce identical bundles across:
/// - Evidence bundle creation
/// - DSSE envelope wrapping
/// - in-toto attestation generation
/// - Multiple runs with frozen time
/// - Parallel execution
/// </summary>
public class EvidenceBundleDeterminismTests
{
#region Evidence Bundle Determinism Tests
[Fact]
public void EvidenceBundle_WithIdenticalInput_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate bundle multiple times
var bundle1 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundle2 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundle3 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Assert - All outputs should be identical
bundle1.Should().Be(bundle2);
bundle2.Should().Be(bundle3);
}
[Fact]
public void EvidenceBundle_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate bundle and compute canonical hash twice
var bundle1 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle1));
var bundle2 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(bundle2));
// Assert
hash1.Should().Be(hash2, "Same input should produce same canonical hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void EvidenceBundle_DeterminismManifest_CanBeCreated()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundleBytes = Encoding.UTF8.GetBytes(bundle);
var artifactInfo = new ArtifactInfo
{
Type = "evidence-bundle",
Name = "test-finding-evidence",
Version = "1.0.0",
Format = "EvidenceBundle JSON"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Evidence.Bundle", Version = "1.0.0" }
}
};
// Act - Create determinism manifest
var manifest = DeterminismManifestWriter.CreateManifest(
bundleBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Format.Should().Be("EvidenceBundle JSON");
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public async Task EvidenceBundle_ParallelGeneration_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate in parallel 20 times
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => CreateEvidenceBundle(input, frozenTime, deterministicBundleId)))
.ToArray();
var bundles = await Task.WhenAll(tasks);
// Assert - All outputs should be identical
bundles.Should().AllBe(bundles[0]);
}
#endregion
#region DSSE Envelope Determinism Tests
[Fact]
public void DsseEnvelope_WithIdenticalPayload_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Act - Wrap in DSSE envelope multiple times
var envelope1 = CreateDsseEnvelope(bundle, frozenTime);
var envelope2 = CreateDsseEnvelope(bundle, frozenTime);
var envelope3 = CreateDsseEnvelope(bundle, frozenTime);
// Assert - Payloads should be identical (signatures depend on key)
var payload1 = ExtractDssePayload(envelope1);
var payload2 = ExtractDssePayload(envelope2);
var payload3 = ExtractDssePayload(envelope3);
payload1.Should().Be(payload2);
payload2.Should().Be(payload3);
}
[Fact]
public void DsseEnvelope_PayloadHash_IsStable()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Act
var envelope1 = CreateDsseEnvelope(bundle, frozenTime);
var payload1 = ExtractDssePayload(envelope1);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(payload1));
var envelope2 = CreateDsseEnvelope(bundle, frozenTime);
var payload2 = ExtractDssePayload(envelope2);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(payload2));
// Assert
hash1.Should().Be(hash2);
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void DsseEnvelope_PayloadType_IsConsistent()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
var bundle = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Act
var envelope = CreateDsseEnvelope(bundle, frozenTime);
// Assert
envelope.Should().Contain("\"payloadType\"");
envelope.Should().Contain("application/vnd.stellaops.evidence+json");
}
#endregion
#region in-toto Attestation Determinism Tests
[Fact]
public void InTotoAttestation_WithIdenticalSubject_ProducesDeterministicOutput()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act - Generate attestation multiple times
var attestation1 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var attestation2 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var attestation3 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert - All outputs should be identical
attestation1.Should().Be(attestation2);
attestation2.Should().Be(attestation3);
}
[Fact]
public void InTotoAttestation_CanonicalHash_IsStable()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation1 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(attestation1));
var attestation2 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(attestation2));
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void InTotoAttestation_SubjectOrdering_IsDeterministic()
{
// Arrange - Multiple subjects
var input = CreateMultiSubjectEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation1 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
var attestation2 = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert - Subject order should be deterministic
attestation1.Should().Be(attestation2);
}
[Fact]
public void InTotoAttestation_PredicateType_IsConsistent()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert
attestation.Should().Contain("\"predicateType\"");
attestation.Should().Contain("https://stellaops.io/evidence/v1");
}
[Fact]
public void InTotoAttestation_StatementType_IsConsistent()
{
// Arrange
var input = CreateSampleEvidenceInput();
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var attestation = CreateInTotoAttestation(input, frozenTime, deterministicBundleId);
// Assert
attestation.Should().Contain("\"_type\"");
attestation.Should().Contain("https://in-toto.io/Statement/v1");
}
#endregion
#region Evidence Hash Determinism Tests
[Fact]
public void EvidenceHashes_WithIdenticalContent_ProduceDeterministicHashes()
{
// Arrange
var content = "test content for hashing";
// Act - Hash the same content multiple times
var hash1 = ComputeEvidenceHash(content);
var hash2 = ComputeEvidenceHash(content);
var hash3 = ComputeEvidenceHash(content);
// Assert
hash1.Should().Be(hash2);
hash2.Should().Be(hash3);
hash1.Should().MatchRegex("^sha256:[0-9a-f]{64}$");
}
[Fact]
public void EvidenceHashSet_Ordering_IsDeterministic()
{
// Arrange - Multiple hashes in random order
var hashes = new[]
{
("artifact", "sha256:abcd1234"),
("sbom", "sha256:efgh5678"),
("vex", "sha256:ijkl9012"),
("policy", "sha256:mnop3456")
};
// Act - Create hash sets multiple times
var hashSet1 = CreateHashSet(hashes);
var hashSet2 = CreateHashSet(hashes);
// Assert - Serialized hash sets should be identical
var json1 = SerializeHashSet(hashSet1);
var json2 = SerializeHashSet(hashSet2);
json1.Should().Be(json2);
}
#endregion
#region Completeness Score Determinism Tests
[Theory]
[InlineData(true, true, true, true, 4)]
[InlineData(true, true, true, false, 3)]
[InlineData(true, true, false, false, 2)]
[InlineData(true, false, false, false, 1)]
[InlineData(false, false, false, false, 0)]
public void CompletenessScore_IsDeterministic(
bool hasReachability,
bool hasCallStack,
bool hasProvenance,
bool hasVexStatus,
int expectedScore)
{
// Arrange
var input = new EvidenceInput
{
AlertId = "ALERT-001",
ArtifactId = "sha256:abc123",
FindingId = "CVE-2024-1234",
HasReachability = hasReachability,
HasCallStack = hasCallStack,
HasProvenance = hasProvenance,
HasVexStatus = hasVexStatus,
Subjects = Array.Empty<string>()
};
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
var deterministicBundleId = GenerateDeterministicBundleId(input, frozenTime);
// Act
var bundle1 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
var bundle2 = CreateEvidenceBundle(input, frozenTime, deterministicBundleId);
// Assert - Both should have same completeness score
bundle1.Should().Contain($"\"completenessScore\": {expectedScore}");
bundle2.Should().Contain($"\"completenessScore\": {expectedScore}");
}
#endregion
#region Helper Methods
private static EvidenceInput CreateSampleEvidenceInput()
{
return new EvidenceInput
{
AlertId = "ALERT-2024-001",
ArtifactId = "sha256:abc123def456",
FindingId = "CVE-2024-1234",
HasReachability = true,
HasCallStack = true,
HasProvenance = true,
HasVexStatus = true,
Subjects = new[] { "pkg:oci/myapp@sha256:abc123" }
};
}
private static EvidenceInput CreateMultiSubjectEvidenceInput()
{
return new EvidenceInput
{
AlertId = "ALERT-2024-002",
ArtifactId = "sha256:multi123",
FindingId = "CVE-2024-5678",
HasReachability = true,
HasCallStack = false,
HasProvenance = true,
HasVexStatus = false,
Subjects = new[]
{
"pkg:oci/app-c@sha256:ccc",
"pkg:oci/app-a@sha256:aaa",
"pkg:oci/app-b@sha256:bbb"
}
};
}
private static string GenerateDeterministicBundleId(EvidenceInput input, DateTimeOffset timestamp)
{
var seed = $"{input.AlertId}:{input.ArtifactId}:{input.FindingId}:{timestamp:O}";
var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(seed));
return hash[..32]; // Use first 32 chars as bundle ID
}
private static string CreateEvidenceBundle(EvidenceInput input, DateTimeOffset timestamp, string bundleId)
{
var completenessScore = CalculateCompletenessScore(input);
var reachabilityStatus = input.HasReachability ? "available" : "unavailable";
var callStackStatus = input.HasCallStack ? "available" : "unavailable";
var provenanceStatus = input.HasProvenance ? "available" : "unavailable";
var vexStatusValue = input.HasVexStatus ? "available" : "unavailable";
var artifactHash = ComputeEvidenceHash(input.ArtifactId);
return $$"""
{
"bundleId": "{{bundleId}}",
"schemaVersion": "1.0",
"alertId": "{{input.AlertId}}",
"artifactId": "{{input.ArtifactId}}",
"completenessScore": {{completenessScore}},
"createdAt": "{{timestamp:O}}",
"hashes": {
"artifact": "{{artifactHash}}",
"bundle": "sha256:{{bundleId}}"
},
"reachability": {
"status": "{{reachabilityStatus}}"
},
"callStack": {
"status": "{{callStackStatus}}"
},
"provenance": {
"status": "{{provenanceStatus}}"
},
"vexStatus": {
"status": "{{vexStatusValue}}"
}
}
""";
}
private static string CreateDsseEnvelope(string payload, DateTimeOffset timestamp)
{
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var payloadHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(payload));
// Note: In production, signature would be computed with actual key
// For determinism testing, we use a deterministic placeholder
var deterministicSig = $"sig:{payloadHash[..32]}";
var sigBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(deterministicSig));
return $$"""
{
"payloadType": "application/vnd.stellaops.evidence+json",
"payload": "{{payloadBase64}}",
"signatures": [
{
"keyid": "stellaops-signing-key-v1",
"sig": "{{sigBase64}}"
}
]
}
""";
}
private static string ExtractDssePayload(string envelope)
{
// Extract base64 payload and decode
var payloadStart = envelope.IndexOf("\"payload\": \"") + 12;
var payloadEnd = envelope.IndexOf("\"", payloadStart);
var payloadBase64 = envelope[payloadStart..payloadEnd];
return Encoding.UTF8.GetString(Convert.FromBase64String(payloadBase64));
}
private static string CreateInTotoAttestation(EvidenceInput input, DateTimeOffset timestamp, string bundleId)
{
var subjects = input.Subjects
.OrderBy(s => s, StringComparer.Ordinal)
.Select(s => $$"""
{
"name": "{{s}}",
"digest": {
"sha256": "{{CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(s))}}"
}
}
""");
var bundle = CreateEvidenceBundle(input, timestamp, bundleId);
return $$"""
{
"_type": "https://in-toto.io/Statement/v1",
"predicateType": "https://stellaops.io/evidence/v1",
"subject": [
{{string.Join(",\n ", subjects)}}
],
"predicate": {{bundle}}
}
""";
}
private static int CalculateCompletenessScore(EvidenceInput input)
{
var score = 0;
if (input.HasReachability) score++;
if (input.HasCallStack) score++;
if (input.HasProvenance) score++;
if (input.HasVexStatus) score++;
return score;
}
private static string ComputeEvidenceHash(string content)
{
var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(content));
return $"sha256:{hash}";
}
private static Dictionary<string, string> CreateHashSet((string name, string hash)[] hashes)
{
return hashes
.OrderBy(h => h.name, StringComparer.Ordinal)
.ToDictionary(h => h.name, h => h.hash);
}
private static string SerializeHashSet(Dictionary<string, string> hashSet)
{
var entries = hashSet
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => $"\"{kvp.Key}\": \"{kvp.Value}\"");
return $"{{\n {string.Join(",\n ", entries)}\n}}";
}
#endregion
#region DTOs
private sealed record EvidenceInput
{
public required string AlertId { get; init; }
public required string ArtifactId { get; init; }
public required string FindingId { get; init; }
public required bool HasReachability { get; init; }
public required bool HasCallStack { get; init; }
public required bool HasProvenance { get; init; }
public required bool HasVexStatus { get; init; }
public required string[] Subjects { get; init; }
}
#endregion
}