// ----------------------------------------------------------------------------- // CrossModuleEvidenceLinkingTests.cs // Sprint: SPRINT_8100_0012_0002 - Unified Evidence Model // Task: EVID-8100-018 - Cross-module evidence linking integration tests // Description: Integration tests verifying evidence linking across modules: // - Same subject can have evidence from multiple modules // - Evidence types from Scanner, Attestor, Policy, Excititor // - Evidence chain/graph queries work correctly // ----------------------------------------------------------------------------- using System.Text; using System.Text.Json; using FluentAssertions; using StellaOps.Evidence.Core; using StellaOps.Evidence.Persistence.Postgres.Tests.Fixtures; using Xunit; using StellaOps.TestKit; namespace StellaOps.Evidence.Persistence.Postgres.Tests; /// /// Integration tests for cross-module evidence linking. /// Verifies that the unified evidence model correctly links evidence /// from different modules (Scanner, Attestor, Policy, Excititor) to the same subject. /// [Collection(EvidencePostgresTestCollection.Name)] public sealed class CrossModuleEvidenceLinkingTests : IAsyncLifetime { private readonly EvidencePostgresContainerFixture _fixture; private readonly ITestOutputHelper _output; private readonly string _tenantId = Guid.NewGuid().ToString(); private PostgresEvidenceStore _store = null!; public CrossModuleEvidenceLinkingTests( EvidencePostgresContainerFixture fixture, ITestOutputHelper output) { _fixture = fixture; _output = output; } public Task InitializeAsync() { _store = _fixture.CreateStore(_tenantId); return Task.CompletedTask; } public async Task DisposeAsync() { await _fixture.TruncateAllTablesAsync(); } #region Multi-Module Evidence for Same Subject [Trait("Category", TestCategories.Unit)] [Fact] public async Task SameSubject_MultipleEvidenceTypes_AllLinked() { // Arrange - A container image subject with evidence from multiple modules var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; // Container image digest var scannerEvidence = CreateScannerEvidence(subjectNodeId); var reachabilityEvidence = CreateReachabilityEvidence(subjectNodeId); var policyEvidence = CreatePolicyEvidence(subjectNodeId); var vexEvidence = CreateVexEvidence(subjectNodeId); var provenanceEvidence = CreateProvenanceEvidence(subjectNodeId); // Act - Store all evidence await _store.StoreAsync(scannerEvidence); await _store.StoreAsync(reachabilityEvidence); await _store.StoreAsync(policyEvidence); await _store.StoreAsync(vexEvidence); await _store.StoreAsync(provenanceEvidence); // Assert - All evidence linked to same subject var allEvidence = await _store.GetBySubjectAsync(subjectNodeId); allEvidence.Should().HaveCount(5); allEvidence.Select(e => e.EvidenceType).Should().Contain(new[] { EvidenceType.Scan, EvidenceType.Reachability, EvidenceType.Policy, EvidenceType.Vex, EvidenceType.Provenance }); _output.WriteLine($"Subject {subjectNodeId} has {allEvidence.Count} evidence records from different modules"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task SameSubject_FilterByType_ReturnsCorrectEvidence() { // Arrange var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; await _store.StoreAsync(CreateScannerEvidence(subjectNodeId)); await _store.StoreAsync(CreateScannerEvidence(subjectNodeId)); // Another scan finding await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId)); await _store.StoreAsync(CreatePolicyEvidence(subjectNodeId)); // Act - Filter by Scan type var scanEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Scan); var policyEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Policy); // Assert scanEvidence.Should().HaveCount(2); policyEvidence.Should().HaveCount(1); } #endregion #region Evidence Chain Scenarios [Trait("Category", TestCategories.Unit)] [Fact] public async Task EvidenceChain_ScanToVexToPolicy_LinkedCorrectly() { // Scenario: Vulnerability scan → VEX assessment → Policy decision // All evidence points to the same subject (vulnerability finding) var vulnerabilitySubject = $"sha256:{Guid.NewGuid():N}"; // 1. Scanner finds vulnerability var scanEvidence = CreateScannerEvidence(vulnerabilitySubject); await _store.StoreAsync(scanEvidence); // 2. VEX assessment received var vexEvidence = CreateVexEvidence(vulnerabilitySubject, referencedEvidenceId: scanEvidence.EvidenceId); await _store.StoreAsync(vexEvidence); // 3. Policy engine makes decision var policyEvidence = CreatePolicyEvidence(vulnerabilitySubject, referencedEvidenceId: vexEvidence.EvidenceId); await _store.StoreAsync(policyEvidence); // Assert - Chain is queryable var allEvidence = await _store.GetBySubjectAsync(vulnerabilitySubject); allEvidence.Should().HaveCount(3); // Verify order by type represents the chain var scan = allEvidence.First(e => e.EvidenceType == EvidenceType.Scan); var vex = allEvidence.First(e => e.EvidenceType == EvidenceType.Vex); var policy = allEvidence.First(e => e.EvidenceType == EvidenceType.Policy); scan.Should().NotBeNull(); vex.Should().NotBeNull(); policy.Should().NotBeNull(); _output.WriteLine($"Evidence chain: Scan({scan.EvidenceId}) → VEX({vex.EvidenceId}) → Policy({policy.EvidenceId})"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task EvidenceChain_ReachabilityToEpssToPolicy_LinkedCorrectly() { // Scenario: Reachability analysis + EPSS score → Policy decision var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; // 1. Reachability analysis var reachability = CreateReachabilityEvidence(subjectNodeId); await _store.StoreAsync(reachability); // 2. EPSS score var epss = CreateEpssEvidence(subjectNodeId); await _store.StoreAsync(epss); // 3. Policy decision based on both var policy = CreatePolicyEvidence(subjectNodeId, referencedEvidenceIds: new[] { reachability.EvidenceId, epss.EvidenceId }); await _store.StoreAsync(policy); // Assert var allEvidence = await _store.GetBySubjectAsync(subjectNodeId); allEvidence.Should().HaveCount(3); } #endregion #region Multi-Tenant Evidence Isolation [Trait("Category", TestCategories.Unit)] [Fact] public async Task MultiTenant_SameSubject_IsolatedByTenant() { // Arrange - Two tenants with evidence for the same subject var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; var tenantA = Guid.NewGuid().ToString(); var tenantB = Guid.NewGuid().ToString(); var storeA = _fixture.CreateStore(tenantA); var storeB = _fixture.CreateStore(tenantB); var evidenceA = CreateScannerEvidence(subjectNodeId); var evidenceB = CreateScannerEvidence(subjectNodeId); // Act - Store in different tenant stores await storeA.StoreAsync(evidenceA); await storeB.StoreAsync(evidenceB); // Assert - Each tenant only sees their own evidence var retrievedA = await storeA.GetBySubjectAsync(subjectNodeId); var retrievedB = await storeB.GetBySubjectAsync(subjectNodeId); retrievedA.Should().HaveCount(1); retrievedA[0].EvidenceId.Should().Be(evidenceA.EvidenceId); retrievedB.Should().HaveCount(1); retrievedB[0].EvidenceId.Should().Be(evidenceB.EvidenceId); _output.WriteLine($"Tenant A evidence: {evidenceA.EvidenceId}"); _output.WriteLine($"Tenant B evidence: {evidenceB.EvidenceId}"); } #endregion #region Evidence Graph Queries [Trait("Category", TestCategories.Unit)] [Fact] public async Task EvidenceGraph_AllTypesForArtifact_ReturnsComplete() { // Arrange - Simulate a complete evidence graph for a container artifact var artifactDigest = $"sha256:{Guid.NewGuid():N}"; var evidenceRecords = new[] { CreateArtifactEvidence(artifactDigest), // SBOM entry CreateScannerEvidence(artifactDigest), // Vulnerability scan CreateReachabilityEvidence(artifactDigest), // Reachability analysis CreateEpssEvidence(artifactDigest), // EPSS score CreateVexEvidence(artifactDigest), // VEX statement CreatePolicyEvidence(artifactDigest), // Policy decision CreateProvenanceEvidence(artifactDigest), // Build provenance CreateExceptionEvidence(artifactDigest) // Exception applied }; foreach (var record in evidenceRecords) { await _store.StoreAsync(record); } // Act - Query all evidence types var allEvidence = await _store.GetBySubjectAsync(artifactDigest); // Assert - Complete evidence graph allEvidence.Should().HaveCount(8); allEvidence.Select(e => e.EvidenceType).Distinct().Should().HaveCount(8); // Log evidence graph foreach (var evidence in allEvidence) { _output.WriteLine($" {evidence.EvidenceType}: {evidence.EvidenceId}"); } } [Trait("Category", TestCategories.Unit)] [Fact] public async Task EvidenceGraph_ExistsCheck_ForAllTypes() { // Arrange var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; await _store.StoreAsync(CreateScannerEvidence(subjectNodeId)); await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId)); // Note: No Policy evidence // Act & Assert (await _store.ExistsAsync(subjectNodeId, EvidenceType.Scan)).Should().BeTrue(); (await _store.ExistsAsync(subjectNodeId, EvidenceType.Reachability)).Should().BeTrue(); (await _store.ExistsAsync(subjectNodeId, EvidenceType.Policy)).Should().BeFalse(); (await _store.ExistsAsync(subjectNodeId, EvidenceType.Vex)).Should().BeFalse(); } #endregion #region Cross-Module Evidence Correlation [Trait("Category", TestCategories.Unit)] [Fact] public async Task Correlation_SameCorrelationId_FindsRelatedEvidence() { // Arrange - Evidence from different modules with same correlation ID var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; var correlationId = Guid.NewGuid().ToString(); var scanEvidence = CreateScannerEvidence(subjectNodeId, correlationId: correlationId); var reachEvidence = CreateReachabilityEvidence(subjectNodeId, correlationId: correlationId); var policyEvidence = CreatePolicyEvidence(subjectNodeId, correlationId: correlationId); await _store.StoreAsync(scanEvidence); await _store.StoreAsync(reachEvidence); await _store.StoreAsync(policyEvidence); // Act - Get all evidence for subject var allEvidence = await _store.GetBySubjectAsync(subjectNodeId); // Assert - All have same correlation ID allEvidence.Should().HaveCount(3); allEvidence.Should().OnlyContain(e => e.Provenance.CorrelationId == correlationId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Generators_MultiplePerSubject_AllPreserved() { // Arrange - Evidence from different generators var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; var trivyEvidence = CreateScannerEvidence(subjectNodeId, generator: "stellaops/scanner/trivy"); var grypeEvidence = CreateScannerEvidence(subjectNodeId, generator: "stellaops/scanner/grype"); var snykEvidence = CreateScannerEvidence(subjectNodeId, generator: "vendor/snyk"); await _store.StoreAsync(trivyEvidence); await _store.StoreAsync(grypeEvidence); await _store.StoreAsync(snykEvidence); // Act var scanEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Scan); // Assert scanEvidence.Should().HaveCount(3); scanEvidence.Select(e => e.Provenance.GeneratorId).Should() .Contain(new[] { "stellaops/scanner/trivy", "stellaops/scanner/grype", "vendor/snyk" }); } #endregion #region Evidence Count and Statistics [Trait("Category", TestCategories.Unit)] [Fact] public async Task CountBySubject_AfterMultiModuleInserts_ReturnsCorrectCount() { // Arrange var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; await _store.StoreAsync(CreateScannerEvidence(subjectNodeId)); await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId)); await _store.StoreAsync(CreatePolicyEvidence(subjectNodeId)); // Act var count = await _store.CountBySubjectAsync(subjectNodeId); // Assert count.Should().Be(3); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByType_AcrossSubjects_ReturnsAll() { // Arrange - Multiple subjects with same evidence type var subject1 = $"sha256:{Guid.NewGuid():N}"; var subject2 = $"sha256:{Guid.NewGuid():N}"; var subject3 = $"sha256:{Guid.NewGuid():N}"; await _store.StoreAsync(CreateScannerEvidence(subject1)); await _store.StoreAsync(CreateScannerEvidence(subject2)); await _store.StoreAsync(CreateScannerEvidence(subject3)); await _store.StoreAsync(CreateReachabilityEvidence(subject1)); // Different type // Act var scanEvidence = await _store.GetByTypeAsync(EvidenceType.Scan); // Assert scanEvidence.Should().HaveCount(3); scanEvidence.Select(e => e.SubjectNodeId).Should() .Contain(new[] { subject1, subject2, subject3 }); } #endregion #region Helpers private static EvidenceRecord CreateScannerEvidence( string subjectNodeId, string? correlationId = null, string generator = "stellaops/scanner/trivy") { var payload = JsonSerializer.SerializeToUtf8Bytes(new { cve = $"CVE-2024-{Random.Shared.Next(1000, 9999)}", severity = "HIGH", affectedPackage = "example-lib@1.0.0" }); var provenance = new EvidenceProvenance { GeneratorId = generator, GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow, CorrelationId = correlationId ?? Guid.NewGuid().ToString() }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Scan, payload, provenance, "1.0.0"); } private static EvidenceRecord CreateReachabilityEvidence( string subjectNodeId, string? correlationId = null) { var payload = JsonSerializer.SerializeToUtf8Bytes(new { reachable = true, confidence = 0.95, paths = new[] { "main.go:42", "handler.go:128" } }); var provenance = new EvidenceProvenance { GeneratorId = "stellaops/scanner/reachability", GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow, CorrelationId = correlationId ?? Guid.NewGuid().ToString() }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Reachability, payload, provenance, "1.0.0"); } private static EvidenceRecord CreatePolicyEvidence( string subjectNodeId, string? referencedEvidenceId = null, string[]? referencedEvidenceIds = null, string? correlationId = null) { var refs = referencedEvidenceIds ?? (referencedEvidenceId is not null ? new[] { referencedEvidenceId } : null); var payload = JsonSerializer.SerializeToUtf8Bytes(new { ruleId = "vuln-severity-block", verdict = "BLOCK", referencedEvidence = refs }); var provenance = new EvidenceProvenance { GeneratorId = "stellaops/policy/opa", GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow, CorrelationId = correlationId ?? Guid.NewGuid().ToString() }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Policy, payload, provenance, "1.0.0"); } private static EvidenceRecord CreateVexEvidence( string subjectNodeId, string? referencedEvidenceId = null) { var payload = JsonSerializer.SerializeToUtf8Bytes(new { status = "not_affected", justification = "vulnerable_code_not_in_execute_path", referencedEvidence = referencedEvidenceId }); var provenance = new EvidenceProvenance { GeneratorId = "stellaops/excititor/vex", GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Vex, payload, provenance, "1.0.0"); } private static EvidenceRecord CreateEpssEvidence(string subjectNodeId) { var payload = JsonSerializer.SerializeToUtf8Bytes(new { score = 0.0342, percentile = 0.89, modelDate = "2024-12-25" }); var provenance = new EvidenceProvenance { GeneratorId = "stellaops/scanner/epss", GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Epss, payload, provenance, "1.0.0"); } private static EvidenceRecord CreateProvenanceEvidence(string subjectNodeId) { var payload = JsonSerializer.SerializeToUtf8Bytes(new { buildId = Guid.NewGuid().ToString(), builder = "github-actions", inputs = new[] { "go.mod", "main.go" } }); var provenance = new EvidenceProvenance { GeneratorId = "stellaops/attestor/provenance", GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Provenance, payload, provenance, "1.0.0"); } private static EvidenceRecord CreateArtifactEvidence(string subjectNodeId) { var payload = JsonSerializer.SerializeToUtf8Bytes(new { purl = "pkg:golang/example.com/mylib@1.0.0", digest = subjectNodeId, sbomFormat = "SPDX-3.0.1" }); var provenance = new EvidenceProvenance { GeneratorId = "stellaops/scanner/sbom", GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Artifact, payload, provenance, "1.0.0"); } private static EvidenceRecord CreateExceptionEvidence(string subjectNodeId) { var payload = JsonSerializer.SerializeToUtf8Bytes(new { exceptionId = Guid.NewGuid().ToString(), reason = "Risk accepted per security review", expiry = DateTimeOffset.UtcNow.AddDays(90) }); var provenance = new EvidenceProvenance { GeneratorId = "stellaops/policy/exceptions", GeneratorVersion = "1.0.0", GeneratedAt = DateTimeOffset.UtcNow }; return EvidenceRecord.Create(subjectNodeId, EvidenceType.Exception, payload, provenance, "1.0.0"); } #endregion }