consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Excititor CycloneDX Formats Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Validate CycloneDX normalization, component reconciliation, and export output with deterministic fixtures.
|
||||
|
||||
## Responsibilities
|
||||
- Cover analysis state/justification mapping and metadata capture.
|
||||
- Cover component reconciliation for purl/cpe diagnostics.
|
||||
- Cover exporter output structure and severity mapping.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/excititor/architecture.md
|
||||
- docs/modules/excititor/operations/graph-linkouts-implementation.md
|
||||
|
||||
## Definition of Done
|
||||
- Tests cover success and failure paths for CycloneDX normalizer and exporter.
|
||||
- Fixtures avoid nondeterministic inputs (time, random).
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for negative/error paths.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
|
||||
|
||||
public sealed class CycloneDxComponentReconcilerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Reconcile_AssignsBomRefsAndDiagnostics()
|
||||
{
|
||||
var claims = ImmutableArray.Create(
|
||||
new VexClaim(
|
||||
"CVE-2025-7000",
|
||||
"vendor:one",
|
||||
new VexProduct("pkg:demo/component@1.0.0", "Demo Component", "1.0.0", "pkg:demo/component@1.0.0"),
|
||||
VexClaimStatus.Affected,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/vex/1")),
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow),
|
||||
new VexClaim(
|
||||
"CVE-2025-7000",
|
||||
"vendor:two",
|
||||
new VexProduct("component-key", "Component Key"),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/vex/2")),
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
var result = CycloneDxComponentReconciler.Reconcile(claims);
|
||||
|
||||
result.Components.Should().HaveCount(2);
|
||||
result.ComponentRefs.Should().ContainKey(("CVE-2025-7000", "component-key"));
|
||||
result.Diagnostics.Keys.Should().Contain("missing_purl");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
|
||||
|
||||
public sealed class CycloneDxExporterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SerializeAsync_WritesCycloneDxVexDocument()
|
||||
{
|
||||
var claims = ImmutableArray.Create(
|
||||
new VexClaim(
|
||||
"CVE-2025-6000",
|
||||
"vendor:demo",
|
||||
new VexProduct("pkg:demo/component@1.2.3", "Demo Component", "1.2.3", "pkg:demo/component@1.2.3"),
|
||||
VexClaimStatus.Fixed,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/cyclonedx/1")),
|
||||
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
|
||||
detail: "Issue resolved in 1.2.3",
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal(
|
||||
scheme: "cvss-4.0",
|
||||
score: 9.3,
|
||||
label: "critical",
|
||||
vector: "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H"))));
|
||||
|
||||
var request = new VexExportRequest(
|
||||
VexQuery.Empty,
|
||||
ImmutableArray<VexConsensus>.Empty,
|
||||
claims,
|
||||
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var exporter = new CycloneDxExporter();
|
||||
await using var stream = new MemoryStream();
|
||||
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
|
||||
|
||||
stream.Position = 0;
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var root = document.RootElement;
|
||||
|
||||
root.GetProperty("bomFormat").GetString().Should().Be("CycloneDX");
|
||||
root.GetProperty("specVersion").GetString().Should().Be("1.7");
|
||||
root.GetProperty("components").EnumerateArray().Should().HaveCount(1);
|
||||
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(1);
|
||||
|
||||
var vulnerability = root.GetProperty("vulnerabilities").EnumerateArray().Single();
|
||||
var rating = vulnerability.GetProperty("ratings").EnumerateArray().Single();
|
||||
rating.GetProperty("method").GetString().Should().Be("CVSSv4");
|
||||
rating.GetProperty("score").GetDouble().Should().Be(9.3);
|
||||
rating.GetProperty("severity").GetString().Should().Be("critical");
|
||||
rating.GetProperty("vector").GetString().Should().Be("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H");
|
||||
|
||||
var affect = vulnerability.GetProperty("affects").EnumerateArray().Single();
|
||||
var affectedVersion = affect.GetProperty("versions").EnumerateArray().Single();
|
||||
affectedVersion.GetProperty("version").GetString().Should().Be("1.2.3");
|
||||
|
||||
var source = vulnerability.GetProperty("source");
|
||||
source.GetProperty("name").GetString().Should().Be("vendor:demo");
|
||||
source.GetProperty("url").GetString().Should().Be("pkg:demo/component@1.2.3");
|
||||
|
||||
result.Metadata.Should().ContainKey("cyclonedx.vulnerabilityCount");
|
||||
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
|
||||
result.Digest.Algorithm.Should().Be("sha256");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SerializeAsync_IncludesEvidenceMetadata()
|
||||
{
|
||||
var claim = new VexClaim(
|
||||
"CVE-2025-7001",
|
||||
"vendor:evidence",
|
||||
new VexProduct("pkg:demo/agent@2.0.0", "Demo Agent", "2.0.0", "pkg:demo/agent@2.0.0"),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/cyclonedx/2")),
|
||||
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
|
||||
justification: VexJustification.CodeNotReachable);
|
||||
|
||||
var entryId = VexEvidenceLinkIds.BuildVexEntryId(claim.VulnerabilityId, claim.Product.Key);
|
||||
var evidence = new VexEvidenceLink
|
||||
{
|
||||
LinkId = "vexlink:test",
|
||||
VexEntryId = entryId,
|
||||
EvidenceType = EvidenceType.BinaryDiff,
|
||||
EvidenceUri = "oci://registry/evidence@sha256:feed",
|
||||
EnvelopeDigest = "sha256:feed",
|
||||
PredicateType = "stellaops.binarydiff.v1",
|
||||
Confidence = 0.95,
|
||||
Justification = VexJustification.CodeNotPresent,
|
||||
EvidenceCreatedAt = new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
|
||||
LinkedAt = new DateTimeOffset(2025, 10, 12, 0, 0, 1, TimeSpan.Zero),
|
||||
SignatureValidated = true
|
||||
};
|
||||
|
||||
var evidenceLinks = ImmutableDictionary<string, VexEvidenceLinkSet>.Empty.Add(
|
||||
entryId,
|
||||
new VexEvidenceLinkSet
|
||||
{
|
||||
VexEntryId = entryId,
|
||||
Links = ImmutableArray.Create(evidence)
|
||||
});
|
||||
|
||||
var request = new VexExportRequest(
|
||||
VexQuery.Empty,
|
||||
ImmutableArray<VexConsensus>.Empty,
|
||||
ImmutableArray.Create(claim),
|
||||
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
|
||||
evidenceLinks);
|
||||
|
||||
var exporter = new CycloneDxExporter();
|
||||
await using var stream = new MemoryStream();
|
||||
await exporter.SerializeAsync(request, stream, CancellationToken.None);
|
||||
|
||||
stream.Position = 0;
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var vulnerability = document.RootElement.GetProperty("vulnerabilities").EnumerateArray().Single();
|
||||
vulnerability.GetProperty("analysis").GetProperty("detail").GetString()
|
||||
.Should().Be("Evidence: oci://registry/evidence@sha256:feed");
|
||||
|
||||
var properties = vulnerability.GetProperty("properties").EnumerateArray().ToArray();
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:type");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:uri");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:confidence");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:predicate-type");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:envelope-digest");
|
||||
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:signature-validated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
|
||||
|
||||
public sealed class CycloneDxNormalizerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_MapsAnalysisStateAndJustification()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.4",
|
||||
"serialNumber": "urn:uuid:1234",
|
||||
"version": "7",
|
||||
"metadata": {
|
||||
"timestamp": "2025-10-15T12:00:00Z"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"bom-ref": "pkg:npm/acme/lib@1.0.0",
|
||||
"name": "acme-lib",
|
||||
"version": "1.0.0",
|
||||
"purl": "pkg:npm/acme/lib@1.0.0"
|
||||
}
|
||||
],
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2025-1000",
|
||||
"detail": "Library issue",
|
||||
"analysis": {
|
||||
"state": "not_affected",
|
||||
"justification": "code_not_present",
|
||||
"response": [ "can_not_fix", "will_not_fix" ]
|
||||
},
|
||||
"affects": [
|
||||
{ "ref": "pkg:npm/acme/lib@1.0.0" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "CVE-2025-1001",
|
||||
"description": "Investigating impact",
|
||||
"analysis": {
|
||||
"state": "in_triage"
|
||||
},
|
||||
"affects": [
|
||||
{ "ref": "pkg:npm/missing/component@2.0.0" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var rawDocument = new VexRawDocument(
|
||||
"excititor:cyclonedx",
|
||||
VexDocumentFormat.CycloneDx,
|
||||
new Uri("https://example.org/vex.json"),
|
||||
new DateTimeOffset(2025, 10, 16, 0, 0, 0, TimeSpan.Zero),
|
||||
"sha256:dummydigest",
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var provider = new VexProvider("excititor:cyclonedx", "CycloneDX Provider", VexProviderKind.Vendor);
|
||||
var normalizer = new CycloneDxNormalizer(NullLogger<CycloneDxNormalizer>.Instance);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
|
||||
|
||||
batch.Claims.Should().HaveCount(2);
|
||||
|
||||
var notAffected = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-1000");
|
||||
notAffected.Status.Should().Be(VexClaimStatus.NotAffected);
|
||||
notAffected.Justification.Should().Be(VexJustification.CodeNotPresent);
|
||||
notAffected.Product.Key.Should().Be("pkg:npm/acme/lib@1.0.0");
|
||||
notAffected.Product.Purl.Should().Be("pkg:npm/acme/lib@1.0.0");
|
||||
notAffected.Document.Revision.Should().Be("7");
|
||||
notAffected.AdditionalMetadata["cyclonedx.specVersion"].Should().Be("1.4");
|
||||
notAffected.AdditionalMetadata["cyclonedx.analysis.state"].Should().Be("not_affected");
|
||||
notAffected.AdditionalMetadata["cyclonedx.analysis.response"].Should().Be("can_not_fix,will_not_fix");
|
||||
|
||||
var investigating = batch.Claims.Single(c => c.VulnerabilityId == "CVE-2025-1001");
|
||||
investigating.Status.Should().Be(VexClaimStatus.UnderInvestigation);
|
||||
investigating.Justification.Should().BeNull();
|
||||
investigating.Product.Key.Should().Be("pkg:npm/missing/component@2.0.0");
|
||||
investigating.Product.Name.Should().Be("pkg:npm/missing/component@2.0.0");
|
||||
investigating.AdditionalMetadata.Should().ContainKey("cyclonedx.specVersion");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_NormalizesSpecVersion()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7.0",
|
||||
"metadata": {
|
||||
"timestamp": "2025-10-15T12:00:00Z"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"bom-ref": "pkg:npm/acme/lib@2.1.0",
|
||||
"name": "acme-lib",
|
||||
"version": "2.1.0",
|
||||
"purl": "pkg:npm/acme/lib@2.1.0"
|
||||
}
|
||||
],
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2025-2000",
|
||||
"analysis": { "state": "affected" },
|
||||
"affects": [
|
||||
{ "ref": "pkg:npm/acme/lib@2.1.0" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var rawDocument = new VexRawDocument(
|
||||
"excititor:cyclonedx",
|
||||
VexDocumentFormat.CycloneDx,
|
||||
new Uri("https://example.org/vex-17.json"),
|
||||
new DateTimeOffset(2025, 10, 16, 0, 0, 0, TimeSpan.Zero),
|
||||
"sha256:dummydigest",
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var provider = new VexProvider("excititor:cyclonedx", "CycloneDX Provider", VexProviderKind.Vendor);
|
||||
var normalizer = new CycloneDxNormalizer(NullLogger<CycloneDxNormalizer>.Instance);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
|
||||
|
||||
batch.Claims.Should().HaveCount(1);
|
||||
batch.Claims[0].AdditionalMetadata["cyclonedx.specVersion"].Should().Be("1.7");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExportSnapshotTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0003 - Excititor Module Test Implementation
|
||||
// Task: EXCITITOR-5100-008 - Add snapshot tests for CycloneDX VEX export — canonical JSON
|
||||
// Description: Snapshot tests verifying canonical CycloneDX output for VEX export
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Formats.CycloneDX;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.CycloneDX.Tests.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot tests for CycloneDX format export.
|
||||
/// Verifies canonical, deterministic JSON output per Model L0 (Core/Formats) requirements.
|
||||
///
|
||||
/// Snapshot regeneration: Set UPDATE_CYCLONEDX_SNAPSHOTS=1 environment variable.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "Snapshot")]
|
||||
[Trait("Category", "L0")]
|
||||
public sealed class CycloneDxExportSnapshotTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly CycloneDxExporter _exporter;
|
||||
private readonly string _snapshotsDir;
|
||||
private readonly bool _updateSnapshots;
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public CycloneDxExportSnapshotTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_exporter = new CycloneDxExporter();
|
||||
_snapshotsDir = Path.Combine(AppContext.BaseDirectory, "Snapshots", "Fixtures");
|
||||
_updateSnapshots = Environment.GetEnvironmentVariable("UPDATE_CYCLONEDX_SNAPSHOTS") == "1";
|
||||
|
||||
if (!Directory.Exists(_snapshotsDir))
|
||||
{
|
||||
Directory.CreateDirectory(_snapshotsDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_MinimalClaim_MatchesSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(CreateMinimalClaim());
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act
|
||||
var json = await ExportToJsonAsync(request);
|
||||
|
||||
// Assert
|
||||
await AssertOrUpdateSnapshotAsync("cyclonedx-minimal.snapshot.json", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithCvssRating_MatchesSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(CreateClaimWithCvss());
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act
|
||||
var json = await ExportToJsonAsync(request);
|
||||
|
||||
// Assert
|
||||
await AssertOrUpdateSnapshotAsync("cyclonedx-cvss.snapshot.json", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_MultipleClaims_MatchesSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(
|
||||
CreateClaimWithStatus("CVE-2024-5001", VexClaimStatus.Affected),
|
||||
CreateClaimWithStatus("CVE-2024-5002", VexClaimStatus.NotAffected),
|
||||
CreateClaimWithStatus("CVE-2024-5003", VexClaimStatus.UnderInvestigation),
|
||||
CreateClaimWithStatus("CVE-2024-5004", VexClaimStatus.Fixed)
|
||||
);
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act
|
||||
var json = await ExportToJsonAsync(request);
|
||||
|
||||
// Assert
|
||||
await AssertOrUpdateSnapshotAsync("cyclonedx-multiple.snapshot.json", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_MultipleComponents_MatchesSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(
|
||||
CreateClaimForComponent("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "CVE-2024-6001"),
|
||||
CreateClaimForComponent("pkg:npm/express@4.18.2", "express", "4.18.2", "CVE-2024-6002"),
|
||||
CreateClaimForComponent("pkg:pypi/django@4.2.0", "django", "4.2.0", "CVE-2024-6003"),
|
||||
CreateClaimForComponent("pkg:maven/org.apache.commons/commons-text@1.10.0", "commons-text", "1.10.0", "CVE-2024-6004")
|
||||
);
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act
|
||||
var json = await ExportToJsonAsync(request);
|
||||
|
||||
// Assert
|
||||
await AssertOrUpdateSnapshotAsync("cyclonedx-multicomponent.snapshot.json", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_IsDeterministic_HashStable()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(CreateClaimWithCvss());
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act - export multiple times
|
||||
var hashes = new HashSet<string>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var json = await ExportToJsonAsync(request);
|
||||
var hash = ComputeHash(json);
|
||||
hashes.Add(hash);
|
||||
}
|
||||
|
||||
// Assert
|
||||
hashes.Should().HaveCount(1, "Multiple exports should produce identical JSON");
|
||||
_output.WriteLine($"Stable hash: {hashes.First()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_DigestMatchesContent()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(CreateClaimWithCvss());
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act
|
||||
var digest1 = _exporter.Digest(request);
|
||||
|
||||
await using var stream = new MemoryStream();
|
||||
var result = await _exporter.SerializeAsync(request, stream, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
digest1.Should().NotBeNull();
|
||||
digest1.Should().Be(result.Digest, "Pre-computed digest should match serialization result");
|
||||
|
||||
// Verify digest is actually based on content
|
||||
stream.Position = 0;
|
||||
var content = await new StreamReader(stream).ReadToEndAsync();
|
||||
_output.WriteLine($"Content length: {content.Length}");
|
||||
_output.WriteLine($"Export digest: {result.Digest}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_EmptyClaims_MatchesSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateExportRequest(ImmutableArray<VexClaim>.Empty);
|
||||
|
||||
// Act
|
||||
var json = await ExportToJsonAsync(request);
|
||||
|
||||
// Assert
|
||||
await AssertOrUpdateSnapshotAsync("cyclonedx-empty.snapshot.json", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_ParallelExports_AreDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(CreateClaimWithCvss());
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act - parallel exports
|
||||
var tasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => Task.Run(async () =>
|
||||
{
|
||||
var json = await ExportToJsonAsync(request);
|
||||
return ComputeHash(json);
|
||||
}));
|
||||
|
||||
var hashes = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
hashes.Distinct().Should().HaveCount(1, "Parallel exports must produce identical output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_CycloneDxStructure_ContainsRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(CreateClaimWithCvss());
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act
|
||||
var json = await ExportToJsonAsync(request);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Assert - CycloneDX 1.7 required fields for VEX
|
||||
root.TryGetProperty("bomFormat", out var bomFormat).Should().BeTrue();
|
||||
bomFormat.GetString().Should().Be("CycloneDX");
|
||||
|
||||
root.TryGetProperty("specVersion", out var specVersion).Should().BeTrue();
|
||||
specVersion.GetString().Should().Be("1.7");
|
||||
|
||||
root.TryGetProperty("vulnerabilities", out _).Should().BeTrue("VEX BOM should have vulnerabilities array");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_ResultContainsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var claims = ImmutableArray.Create(
|
||||
CreateMinimalClaim(),
|
||||
CreateClaimWithCvss()
|
||||
);
|
||||
var request = CreateExportRequest(claims);
|
||||
|
||||
// Act
|
||||
await using var stream = new MemoryStream();
|
||||
var result = await _exporter.SerializeAsync(request, stream, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_exporter.Format.Should().Be(VexExportFormat.CycloneDx);
|
||||
result.Metadata.Should().ContainKey("cyclonedx.vulnerabilityCount");
|
||||
result.Metadata.Should().ContainKey("cyclonedx.componentCount");
|
||||
result.Digest.Algorithm.Should().Be("sha256");
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<string> ExportToJsonAsync(VexExportRequest request)
|
||||
{
|
||||
await using var stream = new MemoryStream();
|
||||
await _exporter.SerializeAsync(request, stream, CancellationToken.None);
|
||||
stream.Position = 0;
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
private async Task AssertOrUpdateSnapshotAsync(string snapshotName, string actual)
|
||||
{
|
||||
var snapshotPath = Path.Combine(_snapshotsDir, snapshotName);
|
||||
|
||||
if (_updateSnapshots)
|
||||
{
|
||||
await File.WriteAllTextAsync(snapshotPath, actual, Encoding.UTF8);
|
||||
_output.WriteLine($"Updated snapshot: {snapshotName}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(snapshotPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(snapshotPath, actual, Encoding.UTF8);
|
||||
_output.WriteLine($"Created new snapshot: {snapshotName}");
|
||||
return;
|
||||
}
|
||||
|
||||
var expected = await File.ReadAllTextAsync(snapshotPath, Encoding.UTF8);
|
||||
|
||||
// Parse and re-serialize for comparison (handles formatting differences)
|
||||
var expectedDoc = JsonDocument.Parse(expected);
|
||||
var actualDoc = JsonDocument.Parse(actual);
|
||||
|
||||
var expectedNormalized = JsonSerializer.Serialize(expectedDoc.RootElement, CanonicalOptions);
|
||||
var actualNormalized = JsonSerializer.Serialize(actualDoc.RootElement, CanonicalOptions);
|
||||
|
||||
actualNormalized.Should().Be(expectedNormalized,
|
||||
$"CycloneDX export should match snapshot {snapshotName}. Set UPDATE_CYCLONEDX_SNAPSHOTS=1 to update.");
|
||||
}
|
||||
|
||||
private static string ComputeHash(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static VexExportRequest CreateExportRequest(ImmutableArray<VexClaim> claims)
|
||||
{
|
||||
return new VexExportRequest(
|
||||
VexQuery.Empty,
|
||||
ImmutableArray<VexConsensus>.Empty,
|
||||
claims,
|
||||
new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static VexClaim CreateMinimalClaim()
|
||||
{
|
||||
return new VexClaim(
|
||||
"CVE-2024-44444",
|
||||
"cyclonedx-minimal-source",
|
||||
new VexProduct("pkg:npm/minimal@1.0.0", "Minimal Package", "1.0.0", "pkg:npm/minimal@1.0.0"),
|
||||
VexClaimStatus.NotAffected,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:cdx-minimal", new Uri("https://example.com/cdx/minimal")),
|
||||
new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static VexClaim CreateClaimWithCvss()
|
||||
{
|
||||
return new VexClaim(
|
||||
"CVE-2024-55555",
|
||||
"cyclonedx-cvss-source",
|
||||
new VexProduct("pkg:npm/vulnerable@2.0.0", "Vulnerable Component", "2.0.0", "pkg:npm/vulnerable@2.0.0"),
|
||||
VexClaimStatus.Affected,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:cdx-cvss", new Uri("https://example.com/cdx/cvss")),
|
||||
new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
detail: "Critical vulnerability with high CVSS score",
|
||||
signals: new VexSignalSnapshot(
|
||||
new VexSeveritySignal(
|
||||
scheme: "cvss-4.0",
|
||||
score: 9.3,
|
||||
label: "critical",
|
||||
vector: "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H")));
|
||||
}
|
||||
|
||||
private static VexClaim CreateClaimWithStatus(string cveId, VexClaimStatus status)
|
||||
{
|
||||
return new VexClaim(
|
||||
cveId,
|
||||
$"cyclonedx-source-{cveId}",
|
||||
new VexProduct($"pkg:npm/pkg-{cveId}@1.0.0", $"Package {cveId}", "1.0.0", $"pkg:npm/pkg-{cveId}@1.0.0"),
|
||||
status,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, $"sha256:{cveId}", new Uri($"https://example.com/cdx/{cveId}")),
|
||||
new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static VexClaim CreateClaimForComponent(string purl, string name, string version, string cveId)
|
||||
{
|
||||
return new VexClaim(
|
||||
cveId,
|
||||
$"cyclonedx-source-{name}",
|
||||
new VexProduct(purl, name, version, purl),
|
||||
VexClaimStatus.Fixed,
|
||||
new VexClaimDocument(VexDocumentFormat.CycloneDx, $"sha256:{name}", new Uri($"https://example.com/cdx/{name}")),
|
||||
new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
detail: $"Vulnerability in {name} fixed in version {version}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Formats.CycloneDX/StellaOps.Excititor.Formats.CycloneDX.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
# Excititor CycloneDX Formats Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0320-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0320-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0320-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| VEX-LINK-CYCLONEDX-TESTS-0001 | DONE | SPRINT_20260113_003_001 - Evidence properties tests. |
|
||||
Reference in New Issue
Block a user