Add determinism tests for verdict artifact generation and update SHA256 sums script

- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering.
- Created helper methods for generating sample verdict inputs and computing canonical hashes.
- Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics.
- Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
This commit is contained in:
StellaOps Bot
2025-12-24 02:17:34 +02:00
parent e59921374e
commit 7503c19b8f
390 changed files with 37389 additions and 5380 deletions

View File

@@ -0,0 +1,518 @@
// -----------------------------------------------------------------------------
// MergeExportSnapshotTests.cs
// Sprint: SPRINT_5100_0009_0002
// Task: CONCELIER-5100-011
// Description: Snapshot tests for merged normalized DB export (canonical JSON)
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Canonical.Json;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
/// <summary>
/// Snapshot tests for merged advisory exports.
/// Verifies that merged advisories produce deterministic canonical JSON output.
/// </summary>
public sealed class MergeExportSnapshotTests
{
private static readonly DateTimeOffset FixedTime = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
#region Canonical JSON Snapshot Tests
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ProducesCanonicalJsonSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
// Act
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
var canonicalJson = CanonJson.Serialize(merged);
// Assert - verify canonical JSON structure (not exact match due to merge provenance timestamp)
canonicalJson.Should().Contain("\"advisoryKey\":\"CVE-2025-1000\"");
canonicalJson.Should().Contain("\"severity\":\"high\""); // Vendor takes precedence
canonicalJson.Should().Contain("\"exploitKnown\":false");
canonicalJson.Should().Contain("\"RHSA-2025:1000\""); // Vendor alias preserved
canonicalJson.Should().Contain("\"CVE-2025-1000\""); // CVE alias preserved
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_CanonicalJsonIsDeterministic()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
// Act - merge and serialize multiple times
var results = new List<string>();
for (int i = 0; i < 3; i++)
{
timeProvider.SetUtcNow(FixedTime); // Reset for determinism
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
results.Add(CanonJson.Serialize(merged));
}
// Assert
results.Distinct().Should().HaveCount(1,
"canonical JSON should be identical across multiple merge runs");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_OrderedFieldsInCanonicalJson()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
// Act
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
var canonicalJson = CanonJson.Serialize(merged);
// Assert - canonical JSON should have fields in deterministic order
var advisoryKeyIndex = canonicalJson.IndexOf("\"advisoryKey\"", StringComparison.Ordinal);
var titleIndex = canonicalJson.IndexOf("\"title\"", StringComparison.Ordinal);
var severityIndex = canonicalJson.IndexOf("\"severity\"", StringComparison.Ordinal);
advisoryKeyIndex.Should().BeGreaterOrEqualTo(0);
titleIndex.Should().BeGreaterOrEqualTo(0);
severityIndex.Should().BeGreaterOrEqualTo(0);
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_AliasesOrderedDeterministically()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act
var merged = merger.Merge(new[] { a, b, c }).Advisory;
// Assert - aliases should be collected from all sources
merged.Aliases.Should().Contain("CVE-2025-3000");
merged.Aliases.Should().Contain("RHSA-2025:3000");
merged.Aliases.Should().Contain("GHSA-3333-4444-5555");
merged.Aliases.Should().Contain("OSV-2025-3000");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ProvenanceOrderedBySource()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act
var merged = merger.Merge(new[] { a, b, c }).Advisory;
var canonicalJson = CanonJson.Serialize(merged);
// Assert - provenance should include all sources
merged.Provenance.Should().HaveCountGreaterThan(3); // Original + merge provenance
merged.Provenance.Should().Contain(p => p.Source == "redhat");
merged.Provenance.Should().Contain(p => p.Source == "ghsa");
merged.Provenance.Should().Contain(p => p.Source == "osv");
merged.Provenance.Should().Contain(p => p.Source == "merge");
}
#endregion
#region Snapshot Serialization Tests
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void SnapshotSerializer_MergedAdvisory_ProducesDeterministicOutput()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
// Act
var results = new List<string>();
for (int i = 0; i < 3; i++)
{
results.Add(SnapshotSerializer.ToSnapshot(merged));
}
// Assert
results.Distinct().Should().HaveCount(1,
"SnapshotSerializer should produce identical output");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void SnapshotSerializer_MergedAdvisory_ContainsExpectedFields()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateAdvisoriesWithCvss();
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
// Act
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
snapshot.Should().Contain("CVE-2025-1000");
snapshot.Should().Contain("CVSS:3.1"); // CVSS vector preserved
snapshot.Should().Contain("redhat"); // Source provenance
snapshot.Should().Contain("nvd"); // Source provenance
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void SnapshotSerializer_MergedAdvisory_PreservesAffectedPackages()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
// Act
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
snapshot.Should().Contain("affectedPackages");
snapshot.Should().Contain("cpe:2.3:o:redhat:enterprise_linux:9");
}
#endregion
#region Export Result Verification
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ExploitKnownFromKev_PreservedInSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var baseAdvisory = CreateNvdAdvisory();
var kevAdvisory = CreateKevAdvisory();
// Act
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory }).Advisory;
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
merged.ExploitKnown.Should().BeTrue("KEV should set exploitKnown to true");
snapshot.Should().Contain("\"exploitKnown\":true");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_CreditsFromMultipleSources_PreservedInSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (ghsa, osv) = CreateAdvisoriesWithCredits();
// Act
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
merged.Credits.Should().HaveCountGreaterThan(2, "credits from multiple sources should be merged");
snapshot.Should().Contain("credits");
snapshot.Should().Contain("researcher-a");
snapshot.Should().Contain("researcher-b");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ReferencesFromMultipleSources_PreservedInSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (ghsa, osv) = CreateAdvisoriesWithReferences();
// Act
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
merged.References.Should().HaveCountGreaterThan(2, "references from multiple sources should be merged");
snapshot.Should().Contain("references");
snapshot.Should().Contain("github.com");
snapshot.Should().Contain("osv.dev");
}
#endregion
#region Helper Methods
private static (Advisory Vendor, Advisory Nvd) CreateVendorAndNvdAdvisories()
{
var vendorProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:1000", FixedTime);
var vendor = new Advisory(
"CVE-2025-1000",
"Red Hat Security Advisory",
"Vendor-confirmed impact",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000", "RHSA-2025:1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
null,
Array.Empty<AffectedVersionRange>(),
new[] { new AffectedPackageStatus("known_affected", vendorProvenance) },
new[] { vendorProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { vendorProvenance });
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
var nvd = new Advisory(
"CVE-2025-1000",
"CVE-2025-1000",
"NVD summary",
"en",
FixedTime.AddDays(-1),
FixedTime,
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
null,
new[] { new AffectedVersionRange("cpe", null, null, null, "<=9.0", nvdProvenance) },
Array.Empty<AffectedPackageStatus>(),
new[] { nvdProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { nvdProvenance });
return (vendor, nvd);
}
private static (Advisory A, Advisory B, Advisory C) CreateThreeAdvisories()
{
var redhatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:3000", FixedTime);
var redhat = new Advisory(
"CVE-2025-3000", "Red Hat Advisory", "Vendor summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-3000", "RHSA-2025:3000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { redhatProvenance });
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-3333-4444-5555", FixedTime);
var ghsa = new Advisory(
"CVE-2025-3000", "GHSA Advisory", "GHSA summary", "en",
FixedTime.AddHours(1), FixedTime.AddHours(1), "high", exploitKnown: true,
aliases: new[] { "CVE-2025-3000", "GHSA-3333-4444-5555" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev/vulnerability/OSV-2025-3000", FixedTime);
var osv = new Advisory(
"CVE-2025-3000", "OSV Advisory", "OSV summary", "en",
FixedTime.AddHours(2), FixedTime.AddHours(2), "medium", exploitKnown: false,
aliases: new[] { "CVE-2025-3000", "OSV-2025-3000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { osvProvenance });
return (redhat, ghsa, osv);
}
private static (Advisory Vendor, Advisory Nvd) CreateAdvisoriesWithCvss()
{
var vendorProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:1000", FixedTime);
var vendor = new Advisory(
"CVE-2025-1000", "Red Hat Advisory", "Summary", "en",
FixedTime, FixedTime, "critical", exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: new[]
{
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical",
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:1000", FixedTime))
},
provenance: new[] { vendorProvenance });
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
var nvd = new Advisory(
"CVE-2025-1000", "CVE-2025-1000", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: new[]
{
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N", 7.3, "high",
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", FixedTime))
},
provenance: new[] { nvdProvenance });
return (vendor, nvd);
}
private static Advisory CreateNvdAdvisory()
{
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
return new Advisory(
"CVE-2025-2000", "CVE-2025-2000", "NVD summary", "en",
FixedTime, FixedTime, "medium", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { nvdProvenance });
}
private static Advisory CreateKevAdvisory()
{
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", FixedTime);
return new Advisory(
"CVE-2025-2000", "Known Exploited Vulnerability", null, null,
null, null, null, exploitKnown: true,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { kevProvenance });
}
private static (Advisory Ghsa, Advisory Osv) CreateAdvisoriesWithCredits()
{
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories", FixedTime);
var ghsa = new Advisory(
"CVE-2025-2000", "GHSA Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: new[]
{
new AdvisoryCredit("researcher-a", "reporter", new[] { "https://example.com/a" },
new AdvisoryProvenance("ghsa", "credit", "researcher-a", FixedTime)),
new AdvisoryCredit("maintainer", "remediation_developer", new[] { "https://example.com/m" },
new AdvisoryProvenance("ghsa", "credit", "maintainer", FixedTime))
},
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev", FixedTime);
var osv = new Advisory(
"CVE-2025-2000", "OSV Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: new[]
{
new AdvisoryCredit("researcher-b", "reporter", new[] { "https://example.com/b" },
new AdvisoryProvenance("osv", "credit", "researcher-b", FixedTime))
},
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { osvProvenance });
return (ghsa, osv);
}
private static (Advisory Ghsa, Advisory Osv) CreateAdvisoriesWithReferences()
{
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories", FixedTime);
var ghsa = new Advisory(
"CVE-2025-2000", "GHSA Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty<AdvisoryCredit>(),
references: new[]
{
new AdvisoryReference("https://github.com/org/repo/security/advisories/GHSA-xxxx", "advisory", "ghsa", "GitHub advisory", ghsaProvenance),
new AdvisoryReference("https://github.com/org/repo/pull/123", "fix", "ghsa", "Fix PR", ghsaProvenance)
},
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev", FixedTime);
var osv = new Advisory(
"CVE-2025-2000", "OSV Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty<AdvisoryCredit>(),
references: new[]
{
new AdvisoryReference("https://osv.dev/vulnerability/CVE-2025-2000", "advisory", "osv", "OSV entry", osvProvenance),
new AdvisoryReference("https://example.com/blog/vuln-disclosure", "article", "osv", "Blog post", osvProvenance)
},
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { osvProvenance });
return (ghsa, osv);
}
#endregion
}

View File

@@ -0,0 +1,663 @@
// -----------------------------------------------------------------------------
// MergePropertyTests.cs
// Sprint: SPRINT_5100_0009_0002
// Tasks: CONCELIER-5100-008, CONCELIER-5100-009, CONCELIER-5100-010
// Description: Property-based tests for merge engine semantics
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Canonical.Json;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
/// <summary>
/// Property-based tests for the advisory merge engine.
/// Verifies commutativity, associativity, and link-not-merge semantics.
/// </summary>
public sealed class MergePropertyTests
{
private static readonly DateTimeOffset FixedTime = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
#region Commutativity Tests (Task 8)
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_SameRankAdvisories_OrderIndependent_Title()
{
// Arrange - two advisories with same precedence rank
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (advisoryA, advisoryB) = CreateSameRankAdvisories("osv", "osv");
// Act - merge in both orders
var resultAB = merger.Merge(new[] { advisoryA, advisoryB }).Advisory;
timeProvider.SetUtcNow(FixedTime); // Reset time for determinism
var resultBA = merger.Merge(new[] { advisoryB, advisoryA }).Advisory;
// Assert - core identity should be same regardless of order
resultAB.AdvisoryKey.Should().Be(resultBA.AdvisoryKey);
resultAB.Aliases.Should().BeEquivalentTo(resultBA.Aliases);
resultAB.ExploitKnown.Should().Be(resultBA.ExploitKnown);
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_SameRankAdvisories_AliasesUnionedIdentically()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (advisoryA, advisoryB) = CreateSameRankAdvisories("ghsa", "ghsa");
// Act
var resultAB = merger.Merge(new[] { advisoryA, advisoryB }).Advisory;
timeProvider.SetUtcNow(FixedTime);
var resultBA = merger.Merge(new[] { advisoryB, advisoryA }).Advisory;
// Assert - aliases should be identical set regardless of order
resultAB.Aliases.OrderBy(a => a).Should().BeEquivalentTo(resultBA.Aliases.OrderBy(a => a));
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_SameRankAdvisories_CreditsUnionedIdentically()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (advisoryA, advisoryB) = CreateAdvisoriesWithCredits();
// Act
var resultAB = merger.Merge(new[] { advisoryA, advisoryB }).Advisory;
timeProvider.SetUtcNow(FixedTime);
var resultBA = merger.Merge(new[] { advisoryB, advisoryA }).Advisory;
// Assert - credits should be unioned identically
resultAB.Credits.Select(c => c.DisplayName).OrderBy(n => n)
.Should().BeEquivalentTo(resultBA.Credits.Select(c => c.DisplayName).OrderBy(n => n));
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_SameRankAdvisories_ReferencesUnionedIdentically()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (advisoryA, advisoryB) = CreateAdvisoriesWithReferences();
// Act
var resultAB = merger.Merge(new[] { advisoryA, advisoryB }).Advisory;
timeProvider.SetUtcNow(FixedTime);
var resultBA = merger.Merge(new[] { advisoryB, advisoryA }).Advisory;
// Assert - references should be unioned identically
resultAB.References.Select(r => r.Url).OrderBy(u => u)
.Should().BeEquivalentTo(resultBA.References.Select(r => r.Url).OrderBy(u => u));
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_DifferentRankAdvisories_HigherRankWins()
{
// Arrange - vendor (higher rank) vs NVD (lower rank)
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateDifferentRankAdvisories();
// Act - merge in both orders
var resultVendorFirst = merger.Merge(new[] { vendor, nvd }).Advisory;
timeProvider.SetUtcNow(FixedTime);
var resultNvdFirst = merger.Merge(new[] { nvd, vendor }).Advisory;
// Assert - vendor should win regardless of order
resultVendorFirst.Title.Should().Be(resultNvdFirst.Title);
resultVendorFirst.Severity.Should().Be(resultNvdFirst.Severity);
resultVendorFirst.Summary.Should().Be(resultNvdFirst.Summary);
}
#endregion
#region Associativity Tests (Task 9)
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_ThreeAdvisories_AllAtOnce_ProducesConsistentResult()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act - merge all at once
var result = merger.Merge(new[] { a, b, c }).Advisory;
// Assert - basic properties should be present
result.AdvisoryKey.Should().Be("CVE-2025-3000");
result.Aliases.Should().Contain("CVE-2025-3000");
result.Aliases.Should().Contain("GHSA-3333-4444-5555");
result.Aliases.Should().Contain("OSV-2025-3000");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_ThreeAdvisories_AllPermutations_ProduceEquivalentCore()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act - all 6 permutations
var permutations = new[]
{
new[] { a, b, c },
new[] { a, c, b },
new[] { b, a, c },
new[] { b, c, a },
new[] { c, a, b },
new[] { c, b, a },
};
var results = permutations.Select(perm =>
{
timeProvider.SetUtcNow(FixedTime);
return merger.Merge(perm).Advisory;
}).ToList();
// Assert - core properties should be equivalent across all permutations
var advisoryKeys = results.Select(r => r.AdvisoryKey).Distinct().ToList();
advisoryKeys.Should().HaveCount(1, "advisory key should be same for all permutations");
var aliaseSets = results.Select(r => string.Join(",", r.Aliases.OrderBy(a => a))).Distinct().ToList();
aliaseSets.Should().HaveCount(1, "aliases should be same set for all permutations");
var exploitKnownValues = results.Select(r => r.ExploitKnown).Distinct().ToList();
exploitKnownValues.Should().HaveCount(1, "exploitKnown should be same for all permutations");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_ThreeAdvisories_ProvenanceIncludesAllSources()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act
var result = merger.Merge(new[] { a, b, c }).Advisory;
// Assert - all source provenances should be present
var sources = result.Provenance.Select(p => p.Source).ToHashSet(StringComparer.OrdinalIgnoreCase);
sources.Should().Contain("redhat");
sources.Should().Contain("ghsa");
sources.Should().Contain("osv");
sources.Should().Contain("merge"); // Merge provenance added
}
#endregion
#region Link-Not-Merge Tests (Task 10)
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_PreservesOriginalSourceProvenance()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateDifferentRankAdvisories();
// Act
var result = merger.Merge(new[] { vendor, nvd }).Advisory;
// Assert - original provenances should be preserved, not overwritten
result.Provenance.Should().Contain(p => p.Source == "redhat", "vendor provenance should be preserved");
result.Provenance.Should().Contain(p => p.Source == "nvd", "NVD provenance should be preserved");
result.Provenance.Should().Contain(p => p.Source == "merge", "merge provenance should be added");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_PreservesPackageProvenance()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateDifferentRankAdvisories();
// Act
var result = merger.Merge(new[] { vendor, nvd }).Advisory;
// Assert - affected package provenances should include both sources
var package = result.AffectedPackages.FirstOrDefault();
package.Should().NotBeNull();
package!.Provenance.Should().Contain(p => p.Source == "redhat", "vendor package provenance should be preserved");
package.Provenance.Should().Contain(p => p.Source == "nvd", "NVD package provenance should be preserved");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_PreservesCvssMetricProvenance()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateAdvisoriesWithCvss();
// Act
var result = merger.Merge(new[] { vendor, nvd }).Advisory;
// Assert - CVSS metrics from both sources should be preserved
result.CvssMetrics.Should().Contain(m => m.Provenance.Source == "redhat", "vendor CVSS should be preserved");
result.CvssMetrics.Should().Contain(m => m.Provenance.Source == "nvd", "NVD CVSS should be preserved");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_NeverDestroysOriginalSourceIdentity()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act
var result = merger.Merge(new[] { a, b, c }).Advisory;
// Assert - merge provenance trace should contain all original sources
var mergeProvenance = result.Provenance.FirstOrDefault(p => p.Source == "merge");
mergeProvenance.Should().NotBeNull();
mergeProvenance!.Value.Should().Contain("redhat", StringComparison.OrdinalIgnoreCase);
mergeProvenance.Value.Should().Contain("ghsa", StringComparison.OrdinalIgnoreCase);
mergeProvenance.Value.Should().Contain("osv", StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Property")]
public void Merge_PreservesReferenceProvenance()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (advisoryA, advisoryB) = CreateAdvisoriesWithReferences();
// Act
var result = merger.Merge(new[] { advisoryA, advisoryB }).Advisory;
// Assert - references from both sources should be preserved with their provenance
result.References.Should().Contain(r => r.Provenance.Source == "ghsa");
result.References.Should().Contain(r => r.Provenance.Source == "osv");
}
#endregion
#region Determinism Tests
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Determinism")]
public void Merge_SameInput_ProducesDeterministicOutput()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateDifferentRankAdvisories();
// Act
var results = new List<string>();
for (int i = 0; i < 3; i++)
{
timeProvider.SetUtcNow(FixedTime);
var result = merger.Merge(new[] { vendor, nvd }).Advisory;
results.Add(CanonJson.Serialize(result));
}
// Assert
results.Distinct().Should().HaveCount(1,
"same input should produce identical output on multiple runs");
}
#endregion
#region Helper Methods
private static (Advisory A, Advisory B) CreateSameRankAdvisories(string sourceA, string sourceB)
{
var provenanceA = new AdvisoryProvenance(sourceA, "document", "https://source-a", FixedTime);
var advisoryA = new Advisory(
"CVE-2025-1000",
$"{sourceA.ToUpperInvariant()} Advisory",
$"Summary from {sourceA}",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000", $"{sourceA.ToUpperInvariant()}-ALIAS" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenanceA });
var provenanceB = new AdvisoryProvenance(sourceB, "document", "https://source-b", FixedTime);
var advisoryB = new Advisory(
"CVE-2025-1000",
$"{sourceB.ToUpperInvariant()} Advisory B",
$"Summary from {sourceB} B",
"en",
FixedTime.AddHours(1),
FixedTime.AddHours(1),
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000", $"{sourceB.ToUpperInvariant()}-ALIAS-B" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenanceB });
return (advisoryA, advisoryB);
}
private static (Advisory Vendor, Advisory Nvd) CreateDifferentRankAdvisories()
{
var vendorProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:1000", FixedTime);
var vendor = new Advisory(
"CVE-2025-1000",
"Red Hat Security Advisory",
"Vendor-confirmed impact",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000", "RHSA-2025:1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
null,
Array.Empty<AffectedVersionRange>(),
new[] { new AffectedPackageStatus("known_affected", vendorProvenance) },
new[] { vendorProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { vendorProvenance });
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
var nvd = new Advisory(
"CVE-2025-1000",
"CVE-2025-1000",
"NVD summary",
"en",
FixedTime.AddDays(-1),
FixedTime,
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
null,
new[]
{
new AffectedVersionRange("cpe", null, null, null, "<=9.0", nvdProvenance)
},
Array.Empty<AffectedPackageStatus>(),
new[] { nvdProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { nvdProvenance });
return (vendor, nvd);
}
private static (Advisory A, Advisory B, Advisory C) CreateThreeAdvisories()
{
var redhatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:3000", FixedTime);
var redhat = new Advisory(
"CVE-2025-3000",
"Red Hat Advisory",
"Vendor summary",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-3000", "RHSA-2025:3000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { redhatProvenance });
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-3333-4444-5555", FixedTime);
var ghsa = new Advisory(
"CVE-2025-3000",
"GHSA Advisory",
"GHSA summary",
"en",
FixedTime.AddHours(1),
FixedTime.AddHours(1),
"high",
exploitKnown: true,
aliases: new[] { "CVE-2025-3000", "GHSA-3333-4444-5555" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev/vulnerability/OSV-2025-3000", FixedTime);
var osv = new Advisory(
"CVE-2025-3000",
"OSV Advisory",
"OSV summary",
"en",
FixedTime.AddHours(2),
FixedTime.AddHours(2),
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-3000", "OSV-2025-3000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { osvProvenance });
return (redhat, ghsa, osv);
}
private static (Advisory A, Advisory B) CreateAdvisoriesWithCredits()
{
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories", FixedTime);
var ghsa = new Advisory(
"CVE-2025-2000",
"GHSA Advisory",
"Summary",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: new[]
{
new AdvisoryCredit("researcher-a", "reporter", new[] { "https://example.com/a" },
new AdvisoryProvenance("ghsa", "credit", "researcher-a", FixedTime)),
new AdvisoryCredit("maintainer", "remediation_developer", new[] { "https://example.com/m" },
new AdvisoryProvenance("ghsa", "credit", "maintainer", FixedTime))
},
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev", FixedTime);
var osv = new Advisory(
"CVE-2025-2000",
"OSV Advisory",
"Summary",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: new[]
{
new AdvisoryCredit("researcher-b", "reporter", new[] { "https://example.com/b" },
new AdvisoryProvenance("osv", "credit", "researcher-b", FixedTime)),
new AdvisoryCredit("maintainer", "remediation_developer", new[] { "https://example.com/m" },
new AdvisoryProvenance("osv", "credit", "maintainer", FixedTime))
},
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { osvProvenance });
return (ghsa, osv);
}
private static (Advisory A, Advisory B) CreateAdvisoriesWithReferences()
{
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories", FixedTime);
var ghsa = new Advisory(
"CVE-2025-2000",
"GHSA Advisory",
"Summary",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty<AdvisoryCredit>(),
references: new[]
{
new AdvisoryReference("https://github.com/org/repo/security/advisories/GHSA-xxxx", "advisory", "ghsa", "GitHub advisory", ghsaProvenance),
new AdvisoryReference("https://github.com/org/repo/pull/123", "fix", "ghsa", "Fix PR", ghsaProvenance)
},
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev", FixedTime);
var osv = new Advisory(
"CVE-2025-2000",
"OSV Advisory",
"Summary",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty<AdvisoryCredit>(),
references: new[]
{
new AdvisoryReference("https://osv.dev/vulnerability/CVE-2025-2000", "advisory", "osv", "OSV entry", osvProvenance),
new AdvisoryReference("https://example.com/blog/vuln-disclosure", "article", "osv", "Blog post", osvProvenance)
},
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { osvProvenance });
return (ghsa, osv);
}
private static (Advisory Vendor, Advisory Nvd) CreateAdvisoriesWithCvss()
{
var vendorProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:1000", FixedTime);
var vendor = new Advisory(
"CVE-2025-1000",
"Red Hat Advisory",
"Summary",
"en",
FixedTime,
FixedTime,
"critical",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: new[]
{
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical",
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:1000", FixedTime))
},
provenance: new[] { vendorProvenance });
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
var nvd = new Advisory(
"CVE-2025-1000",
"CVE-2025-1000",
"Summary",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: new[]
{
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N", 7.3, "high",
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", FixedTime))
},
provenance: new[] { nvdProvenance });
return (vendor, nvd);
}
#endregion
}

View File

@@ -9,6 +9,10 @@
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\Golden\**\*">