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
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YxIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QtYXBwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUifX1dLCJwcmVkaWNhdGUiOnsiYnVpbGRlcklkIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9idWlsZGVyIiwiYnVpbGRUeXBlIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9idWlsZC10eXBlIn19",
|
||||
"signatures": [
|
||||
{
|
||||
"keyid": "test-key-id",
|
||||
"sig": "MEUCIQDFmJRQSwWMbQGiS8X5mY9CvZxVbVmXJ7JQVGEYIhXEBQIgbqDBJxP2P9N2kGPXDlX7Qx8KPVQjN3P1Y5Z9A8B2C3D="
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user