Add comprehensive security tests for OWASP A02, A05, A07, and A08 categories
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled

- Implemented tests for Cryptographic Failures (A02) to ensure proper handling of sensitive data, secure algorithms, and key management.
- Added tests for Security Misconfiguration (A05) to validate production configurations, security headers, CORS settings, and feature management.
- Developed tests for Authentication Failures (A07) to enforce strong password policies, rate limiting, session management, and MFA support.
- Created tests for Software and Data Integrity Failures (A08) to verify artifact signatures, SBOM integrity, attestation chains, and feed updates.
This commit is contained in:
master
2025-12-16 16:40:19 +02:00
parent 415eff1207
commit 2170a58734
206 changed files with 30547 additions and 534 deletions

View File

@@ -0,0 +1,136 @@
// =============================================================================
// CycloneDxParserTests.cs
// Golden-file tests for CycloneDX SBOM parsing
// Part of Task T24: Golden-file tests for determinism
// =============================================================================
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation;
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class CycloneDxParserTests
{
private static readonly string FixturesPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Reconciliation", "Fixtures");
[Fact]
public async Task ParseAsync_ValidCycloneDx_ExtractsAllSubjects()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
// Skip if fixtures not available
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.IsSuccess.Should().BeTrue();
result.Format.Should().Be(SbomFormat.CycloneDx);
result.SpecVersion.Should().Be("1.6");
result.SerialNumber.Should().Be("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79");
result.GeneratorTool.Should().Contain("syft");
// Should have 3 subjects with SHA-256 hashes (primary + 2 components)
result.Subjects.Should().HaveCount(3);
// Verify subjects are sorted by digest
result.Subjects.Should().BeInAscendingOrder(s => s.Digest, StringComparer.Ordinal);
}
[Fact]
public async Task ParseAsync_ExtractsPrimarySubject()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.PrimarySubject.Should().NotBeNull();
result.PrimarySubject!.Name.Should().Be("test-app");
result.PrimarySubject.Version.Should().Be("1.0.0");
result.PrimarySubject.Digest.Should().StartWith("sha256:");
}
[Fact]
public async Task ParseAsync_SubjectDigestsAreNormalized()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert - all digests should be normalized sha256:lowercase format
foreach (var subject in result.Subjects)
{
subject.Digest.Should().StartWith("sha256:");
subject.Digest[7..].Should().MatchRegex("^[a-f0-9]{64}$");
}
}
[Fact]
public void DetectFormat_CycloneDxFile_ReturnsCycloneDx()
{
var parser = new CycloneDxParser();
parser.DetectFormat("test.cdx.json").Should().Be(SbomFormat.CycloneDx);
parser.DetectFormat("test.bom.json").Should().Be(SbomFormat.CycloneDx);
}
[Fact]
public void DetectFormat_NonCycloneDxFile_ReturnsUnknown()
{
var parser = new CycloneDxParser();
parser.DetectFormat("test.spdx.json").Should().Be(SbomFormat.Unknown);
parser.DetectFormat("test.json").Should().Be(SbomFormat.Unknown);
}
[Fact]
public async Task ParseAsync_Deterministic_SameOutputForSameInput()
{
// Arrange
var parser = new CycloneDxParser();
var filePath = Path.Combine(FixturesPath, "sample.cdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act - parse twice
var result1 = await parser.ParseAsync(filePath);
var result2 = await parser.ParseAsync(filePath);
// Assert - results should be identical
result1.Subjects.Select(s => s.Digest)
.Should().BeEquivalentTo(result2.Subjects.Select(s => s.Digest));
result1.Subjects.Select(s => s.Name)
.Should().BeEquivalentTo(result2.Subjects.Select(s => s.Name));
// Order should be the same
result1.Subjects.Select(s => s.Digest).Should().Equal(result2.Subjects.Select(s => s.Digest));
}
}

View File

@@ -0,0 +1,141 @@
// =============================================================================
// DsseAttestationParserTests.cs
// Golden-file tests for DSSE attestation parsing
// Part of Task T24: Golden-file tests for determinism
// =============================================================================
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class DsseAttestationParserTests
{
private static readonly string FixturesPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Reconciliation", "Fixtures");
[Fact]
public async Task ParseAsync_ValidDsse_ExtractsEnvelope()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.IsSuccess.Should().BeTrue();
result.Envelope.Should().NotBeNull();
result.Envelope!.PayloadType.Should().Be("application/vnd.in-toto+json");
result.Envelope.Signatures.Should().HaveCount(1);
result.Envelope.Signatures[0].KeyId.Should().Be("test-key-id");
}
[Fact]
public async Task ParseAsync_ValidDsse_ExtractsStatement()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.Statement.Should().NotBeNull();
result.Statement!.Type.Should().Be("https://in-toto.io/Statement/v1");
result.Statement.PredicateType.Should().Be("https://slsa.dev/provenance/v1");
result.Statement.Subjects.Should().HaveCount(1);
}
[Fact]
public async Task ParseAsync_ExtractsSubjectDigests()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
var subject = result.Statement!.Subjects[0];
subject.Name.Should().Be("test-app");
subject.GetSha256Digest().Should().Be("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
}
[Fact]
public void IsAttestation_DsseFile_ReturnsTrue()
{
var parser = new DsseAttestationParser();
parser.IsAttestation("test.intoto.json").Should().BeTrue();
parser.IsAttestation("test.intoto.jsonl").Should().BeTrue();
parser.IsAttestation("test.dsig").Should().BeTrue();
parser.IsAttestation("test.dsse").Should().BeTrue();
}
[Fact]
public void IsAttestation_NonDsseFile_ReturnsFalse()
{
var parser = new DsseAttestationParser();
parser.IsAttestation("test.json").Should().BeFalse();
parser.IsAttestation("test.cdx.json").Should().BeFalse();
parser.IsAttestation("test.spdx.json").Should().BeFalse();
}
[Fact]
public async Task ParseAsync_Deterministic_SameOutputForSameInput()
{
// Arrange
var parser = new DsseAttestationParser();
var filePath = Path.Combine(FixturesPath, "sample.intoto.json");
if (!File.Exists(filePath))
{
return;
}
// Act - parse twice
var result1 = await parser.ParseAsync(filePath);
var result2 = await parser.ParseAsync(filePath);
// Assert - results should be identical
result1.Statement!.PredicateType.Should().Be(result2.Statement!.PredicateType);
result1.Statement.Subjects.Count.Should().Be(result2.Statement.Subjects.Count);
result1.Statement.Subjects[0].GetSha256Digest()
.Should().Be(result2.Statement.Subjects[0].GetSha256Digest());
}
[Fact]
public async Task ParseAsync_InvalidJson_ReturnsFailure()
{
// Arrange
var parser = new DsseAttestationParser();
var json = "not valid json";
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
// Act
var result = await parser.ParseAsync(stream);
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorMessage.Should().Contain("parsing error");
}
}

View File

@@ -0,0 +1,56 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
"metadata": {
"timestamp": "2025-01-15T10:00:00Z",
"component": {
"type": "application",
"name": "test-app",
"version": "1.0.0",
"hashes": [
{
"alg": "SHA-256",
"content": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
]
},
"tools": {
"components": [
{
"name": "syft",
"version": "1.0.0"
}
]
}
},
"components": [
{
"type": "library",
"name": "zlib",
"version": "1.2.11",
"bom-ref": "pkg:generic/zlib@1.2.11",
"purl": "pkg:generic/zlib@1.2.11",
"hashes": [
{
"alg": "SHA-256",
"content": "c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1"
}
]
},
{
"type": "library",
"name": "openssl",
"version": "3.0.0",
"bom-ref": "pkg:generic/openssl@3.0.0",
"purl": "pkg:generic/openssl@3.0.0",
"hashes": [
{
"alg": "SHA-256",
"content": "919b4a3e65a8deade6b3c94dd44cb98e0f65a1785a787689c23e6b5c0b4edfea"
}
]
}
]
}

View File

@@ -0,0 +1,10 @@
{
"payloadType": "application/vnd.in-toto+json",
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YxIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QtYXBwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUifX1dLCJwcmVkaWNhdGUiOnsiYnVpbGRlcklkIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9idWlsZGVyIiwiYnVpbGRUeXBlIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9idWlsZC10eXBlIn19",
"signatures": [
{
"keyid": "test-key-id",
"sig": "MEUCIQDFmJRQSwWMbQGiS8X5mY9CvZxVbVmXJ7JQVGEYIhXEBQIgbqDBJxP2P9N2kGPXDlX7Qx8KPVQjN3P1Y5Z9A8B2C3D="
}
]
}

View File

@@ -0,0 +1,88 @@
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "test-app-sbom",
"documentNamespace": "https://example.com/test-app/1.0.0",
"creationInfo": {
"created": "2025-01-15T10:00:00Z",
"creators": [
"Tool: syft-1.0.0"
]
},
"documentDescribes": [
"SPDXRef-Package-test-app"
],
"packages": [
{
"SPDXID": "SPDXRef-Package-test-app",
"name": "test-app",
"versionInfo": "1.0.0",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"checksums": [
{
"algorithm": "SHA256",
"checksumValue": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
]
},
{
"SPDXID": "SPDXRef-Package-zlib",
"name": "zlib",
"versionInfo": "1.2.11",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"checksums": [
{
"algorithm": "SHA256",
"checksumValue": "c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1"
}
],
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:generic/zlib@1.2.11"
}
]
},
{
"SPDXID": "SPDXRef-Package-openssl",
"name": "openssl",
"versionInfo": "3.0.0",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"checksums": [
{
"algorithm": "SHA256",
"checksumValue": "919b4a3e65a8deade6b3c94dd44cb98e0f65a1785a787689c23e6b5c0b4edfea"
}
],
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:generic/openssl@3.0.0"
}
]
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relatedSpdxElement": "SPDXRef-Package-test-app",
"relationshipType": "DESCRIBES"
},
{
"spdxElementId": "SPDXRef-Package-test-app",
"relatedSpdxElement": "SPDXRef-Package-zlib",
"relationshipType": "DEPENDS_ON"
},
{
"spdxElementId": "SPDXRef-Package-test-app",
"relatedSpdxElement": "SPDXRef-Package-openssl",
"relationshipType": "DEPENDS_ON"
}
]
}

View File

@@ -0,0 +1,453 @@
// =============================================================================
// SourcePrecedenceLatticePropertyTests.cs
// Property-based tests for lattice properties
// Part of Task T25: Write property-based tests
// =============================================================================
using StellaOps.AirGap.Importer.Reconciliation;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
/// <summary>
/// Property-based tests verifying lattice algebraic properties.
/// A lattice must satisfy: associativity, commutativity, idempotence, and absorption.
/// </summary>
public sealed class SourcePrecedenceLatticePropertyTests
{
private static readonly SourcePrecedence[] AllPrecedences =
[
SourcePrecedence.Unknown,
SourcePrecedence.ThirdParty,
SourcePrecedence.Maintainer,
SourcePrecedence.Vendor
];
#region Lattice Algebraic Properties
/// <summary>
/// Property: Join is commutative - Join(a, b) = Join(b, a)
/// </summary>
[Fact]
public void Join_IsCommutative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var joinAB = SourcePrecedenceLattice.Join(a, b);
var joinBA = SourcePrecedenceLattice.Join(b, a);
Assert.Equal(joinAB, joinBA);
}
}
}
/// <summary>
/// Property: Meet is commutative - Meet(a, b) = Meet(b, a)
/// </summary>
[Fact]
public void Meet_IsCommutative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meetAB = SourcePrecedenceLattice.Meet(a, b);
var meetBA = SourcePrecedenceLattice.Meet(b, a);
Assert.Equal(meetAB, meetBA);
}
}
}
/// <summary>
/// Property: Join is associative - Join(Join(a, b), c) = Join(a, Join(b, c))
/// </summary>
[Fact]
public void Join_IsAssociative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
foreach (var c in AllPrecedences)
{
var left = SourcePrecedenceLattice.Join(SourcePrecedenceLattice.Join(a, b), c);
var right = SourcePrecedenceLattice.Join(a, SourcePrecedenceLattice.Join(b, c));
Assert.Equal(left, right);
}
}
}
}
/// <summary>
/// Property: Meet is associative - Meet(Meet(a, b), c) = Meet(a, Meet(b, c))
/// </summary>
[Fact]
public void Meet_IsAssociative()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
foreach (var c in AllPrecedences)
{
var left = SourcePrecedenceLattice.Meet(SourcePrecedenceLattice.Meet(a, b), c);
var right = SourcePrecedenceLattice.Meet(a, SourcePrecedenceLattice.Meet(b, c));
Assert.Equal(left, right);
}
}
}
}
/// <summary>
/// Property: Join is idempotent - Join(a, a) = a
/// </summary>
[Fact]
public void Join_IsIdempotent()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Join(a, a);
Assert.Equal(a, result);
}
}
/// <summary>
/// Property: Meet is idempotent - Meet(a, a) = a
/// </summary>
[Fact]
public void Meet_IsIdempotent()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Meet(a, a);
Assert.Equal(a, result);
}
}
/// <summary>
/// Property: Absorption law 1 - Join(a, Meet(a, b)) = a
/// </summary>
[Fact]
public void Absorption_JoinMeet_ReturnsFirst()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meet = SourcePrecedenceLattice.Meet(a, b);
var result = SourcePrecedenceLattice.Join(a, meet);
Assert.Equal(a, result);
}
}
}
/// <summary>
/// Property: Absorption law 2 - Meet(a, Join(a, b)) = a
/// </summary>
[Fact]
public void Absorption_MeetJoin_ReturnsFirst()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var join = SourcePrecedenceLattice.Join(a, b);
var result = SourcePrecedenceLattice.Meet(a, join);
Assert.Equal(a, result);
}
}
}
#endregion
#region Ordering Properties
/// <summary>
/// Property: Compare is antisymmetric - if Compare(a,b) > 0 then Compare(b,a) < 0
/// </summary>
[Fact]
public void Compare_IsAntisymmetric()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var compareAB = SourcePrecedenceLattice.Compare(a, b);
var compareBA = SourcePrecedenceLattice.Compare(b, a);
if (compareAB > 0)
{
Assert.True(compareBA < 0);
}
else if (compareAB < 0)
{
Assert.True(compareBA > 0);
}
else
{
Assert.Equal(0, compareBA);
}
}
}
}
/// <summary>
/// Property: Compare is transitive - if Compare(a,b) > 0 and Compare(b,c) > 0 then Compare(a,c) > 0
/// </summary>
[Fact]
public void Compare_IsTransitive()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
foreach (var c in AllPrecedences)
{
var ab = SourcePrecedenceLattice.Compare(a, b);
var bc = SourcePrecedenceLattice.Compare(b, c);
var ac = SourcePrecedenceLattice.Compare(a, c);
if (ab > 0 && bc > 0)
{
Assert.True(ac > 0);
}
if (ab < 0 && bc < 0)
{
Assert.True(ac < 0);
}
}
}
}
}
/// <summary>
/// Property: Compare is reflexive - Compare(a, a) = 0
/// </summary>
[Fact]
public void Compare_IsReflexive()
{
foreach (var a in AllPrecedences)
{
Assert.Equal(0, SourcePrecedenceLattice.Compare(a, a));
}
}
#endregion
#region Join/Meet Bound Properties
/// <summary>
/// Property: Join returns an upper bound - Join(a, b) >= a AND Join(a, b) >= b
/// </summary>
[Fact]
public void Join_ReturnsUpperBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var join = SourcePrecedenceLattice.Join(a, b);
Assert.True(SourcePrecedenceLattice.Compare(join, a) >= 0);
Assert.True(SourcePrecedenceLattice.Compare(join, b) >= 0);
}
}
}
/// <summary>
/// Property: Meet returns a lower bound - Meet(a, b) <= a AND Meet(a, b) <= b
/// </summary>
[Fact]
public void Meet_ReturnsLowerBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meet = SourcePrecedenceLattice.Meet(a, b);
Assert.True(SourcePrecedenceLattice.Compare(meet, a) <= 0);
Assert.True(SourcePrecedenceLattice.Compare(meet, b) <= 0);
}
}
}
/// <summary>
/// Property: Join is least upper bound - for all c, if c >= a and c >= b then c >= Join(a,b)
/// </summary>
[Fact]
public void Join_IsLeastUpperBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var join = SourcePrecedenceLattice.Join(a, b);
foreach (var c in AllPrecedences)
{
var cGeA = SourcePrecedenceLattice.Compare(c, a) >= 0;
var cGeB = SourcePrecedenceLattice.Compare(c, b) >= 0;
if (cGeA && cGeB)
{
Assert.True(SourcePrecedenceLattice.Compare(c, join) >= 0);
}
}
}
}
}
/// <summary>
/// Property: Meet is greatest lower bound - for all c, if c <= a and c <= b then c <= Meet(a,b)
/// </summary>
[Fact]
public void Meet_IsGreatestLowerBound()
{
foreach (var a in AllPrecedences)
{
foreach (var b in AllPrecedences)
{
var meet = SourcePrecedenceLattice.Meet(a, b);
foreach (var c in AllPrecedences)
{
var cLeA = SourcePrecedenceLattice.Compare(c, a) <= 0;
var cLeB = SourcePrecedenceLattice.Compare(c, b) <= 0;
if (cLeA && cLeB)
{
Assert.True(SourcePrecedenceLattice.Compare(c, meet) <= 0);
}
}
}
}
}
#endregion
#region Bounded Lattice Properties
/// <summary>
/// Property: Unknown is the bottom element - Join(Unknown, a) = a
/// </summary>
[Fact]
public void Unknown_IsBottomElement()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Join(SourcePrecedence.Unknown, a);
Assert.Equal(a, result);
}
}
/// <summary>
/// Property: Vendor is the top element - Meet(Vendor, a) = a
/// </summary>
[Fact]
public void Vendor_IsTopElement()
{
foreach (var a in AllPrecedences)
{
var result = SourcePrecedenceLattice.Meet(SourcePrecedence.Vendor, a);
Assert.Equal(a, result);
}
}
#endregion
#region Merge Determinism
/// <summary>
/// Property: Merge is deterministic - same inputs always produce same output
/// </summary>
[Fact]
public void Merge_IsDeterministic()
{
var lattice = new SourcePrecedenceLattice();
var timestamp = new DateTimeOffset(2025, 12, 4, 12, 0, 0, TimeSpan.Zero);
var statements = new[]
{
CreateStatement("CVE-2024-001", "product-1", VexStatus.Affected, SourcePrecedence.ThirdParty, timestamp),
CreateStatement("CVE-2024-001", "product-1", VexStatus.NotAffected, SourcePrecedence.Vendor, timestamp),
CreateStatement("CVE-2024-001", "product-1", VexStatus.Fixed, SourcePrecedence.Maintainer, timestamp)
};
// Run merge 100 times and verify same result
var firstResult = lattice.Merge(statements);
for (int i = 0; i < 100; i++)
{
var result = lattice.Merge(statements);
Assert.Equal(firstResult.Status, result.Status);
Assert.Equal(firstResult.Source, result.Source);
Assert.Equal(firstResult.VulnerabilityId, result.VulnerabilityId);
}
}
/// <summary>
/// Property: Higher precedence always wins in merge
/// </summary>
[Fact]
public void Merge_HigherPrecedenceWins()
{
var lattice = new SourcePrecedenceLattice();
var timestamp = new DateTimeOffset(2025, 12, 4, 12, 0, 0, TimeSpan.Zero);
// Vendor should win over ThirdParty
var vendorStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.NotAffected, SourcePrecedence.Vendor, timestamp);
var thirdPartyStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.Affected, SourcePrecedence.ThirdParty, timestamp);
var result = lattice.Merge(vendorStatement, thirdPartyStatement);
Assert.Equal(SourcePrecedence.Vendor, result.Source);
Assert.Equal(VexStatus.NotAffected, result.Status);
}
/// <summary>
/// Property: More recent timestamp wins when precedence is equal
/// </summary>
[Fact]
public void Merge_MoreRecentTimestampWins_WhenPrecedenceEqual()
{
var lattice = new SourcePrecedenceLattice();
var olderTimestamp = new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero);
var newerTimestamp = new DateTimeOffset(2025, 12, 4, 12, 0, 0, TimeSpan.Zero);
var olderStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.Affected, SourcePrecedence.Maintainer, olderTimestamp);
var newerStatement = CreateStatement("CVE-2024-001", "product-1", VexStatus.Fixed, SourcePrecedence.Maintainer, newerTimestamp);
var result = lattice.Merge(olderStatement, newerStatement);
Assert.Equal(VexStatus.Fixed, result.Status);
Assert.Equal(newerTimestamp, result.Timestamp);
}
private static VexStatement CreateStatement(
string vulnId,
string productId,
VexStatus status,
SourcePrecedence source,
DateTimeOffset? timestamp)
{
return new VexStatement
{
VulnerabilityId = vulnId,
ProductId = productId,
Status = status,
Source = source,
Timestamp = timestamp
};
}
#endregion
}

View File

@@ -0,0 +1,149 @@
// =============================================================================
// SpdxParserTests.cs
// Golden-file tests for SPDX SBOM parsing
// Part of Task T24: Golden-file tests for determinism
// =============================================================================
using FluentAssertions;
using StellaOps.AirGap.Importer.Reconciliation;
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class SpdxParserTests
{
private static readonly string FixturesPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Reconciliation", "Fixtures");
[Fact]
public async Task ParseAsync_ValidSpdx_ExtractsAllSubjects()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.IsSuccess.Should().BeTrue();
result.Format.Should().Be(SbomFormat.Spdx);
result.SpecVersion.Should().Be("2.3");
result.SerialNumber.Should().Be("https://example.com/test-app/1.0.0");
result.GeneratorTool.Should().Contain("syft");
// Should have 3 packages with SHA256 checksums
result.Subjects.Should().HaveCount(3);
// Verify subjects are sorted by digest
result.Subjects.Should().BeInAscendingOrder(s => s.Digest, StringComparer.Ordinal);
}
[Fact]
public async Task ParseAsync_ExtractsPrimarySubject()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert
result.PrimarySubject.Should().NotBeNull();
result.PrimarySubject!.Name.Should().Be("test-app");
result.PrimarySubject.Version.Should().Be("1.0.0");
result.PrimarySubject.SpdxId.Should().Be("SPDXRef-Package-test-app");
}
[Fact]
public async Task ParseAsync_ExtractsPurls()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert - check for components with purls
var zlib = result.Subjects.FirstOrDefault(s => s.Name == "zlib");
zlib.Should().NotBeNull();
zlib!.Purl.Should().Be("pkg:generic/zlib@1.2.11");
}
[Fact]
public async Task ParseAsync_SubjectDigestsAreNormalized()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act
var result = await parser.ParseAsync(filePath);
// Assert - all digests should be normalized sha256:lowercase format
foreach (var subject in result.Subjects)
{
subject.Digest.Should().StartWith("sha256:");
subject.Digest[7..].Should().MatchRegex("^[a-f0-9]{64}$");
}
}
[Fact]
public void DetectFormat_SpdxFile_ReturnsSpdx()
{
var parser = new SpdxParser();
parser.DetectFormat("test.spdx.json").Should().Be(SbomFormat.Spdx);
}
[Fact]
public void DetectFormat_NonSpdxFile_ReturnsUnknown()
{
var parser = new SpdxParser();
parser.DetectFormat("test.cdx.json").Should().Be(SbomFormat.Unknown);
parser.DetectFormat("test.json").Should().Be(SbomFormat.Unknown);
}
[Fact]
public async Task ParseAsync_Deterministic_SameOutputForSameInput()
{
// Arrange
var parser = new SpdxParser();
var filePath = Path.Combine(FixturesPath, "sample.spdx.json");
if (!File.Exists(filePath))
{
return;
}
// Act - parse twice
var result1 = await parser.ParseAsync(filePath);
var result2 = await parser.ParseAsync(filePath);
// Assert - results should be identical and in same order
result1.Subjects.Select(s => s.Digest).Should().Equal(result2.Subjects.Select(s => s.Digest));
result1.Subjects.Select(s => s.Name).Should().Equal(result2.Subjects.Select(s => s.Name));
}
}

View File

@@ -14,4 +14,9 @@
<ItemGroup>
<ProjectReference Include="../../../src/AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Reconciliation/Fixtures/**/*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>