Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Advisory;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class AdvisoryLinksetProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessAsync_persists_batch_and_records_success()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
var transformer = new AdvisoryLinksetTransformer();
|
||||
var writer = new CaptureWriter();
|
||||
var metrics = new CaptureMetrics();
|
||||
var processor = new AdvisoryLinksetProcessor(
|
||||
transformer,
|
||||
writer,
|
||||
metrics,
|
||||
NullLogger<AdvisoryLinksetProcessor>.Instance);
|
||||
|
||||
await processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
writer.LastBatch.Should().NotBeNull();
|
||||
writer.LastBatch!.Edges.Length.Should().Be(1, "duplicate impacts should collapse into one edge");
|
||||
metrics.LastRecord.Should().NotBeNull();
|
||||
metrics.LastRecord!.Success.Should().BeTrue();
|
||||
metrics.LastRecord.NodeCount.Should().Be(writer.LastBatch!.Nodes.Length);
|
||||
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_records_failure_when_writer_throws()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
var transformer = new AdvisoryLinksetTransformer();
|
||||
var writer = new CaptureWriter(shouldThrow: true);
|
||||
var metrics = new CaptureMetrics();
|
||||
var processor = new AdvisoryLinksetProcessor(
|
||||
transformer,
|
||||
writer,
|
||||
metrics,
|
||||
NullLogger<AdvisoryLinksetProcessor>.Instance);
|
||||
|
||||
var act = () => processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
metrics.LastRecord.Should().NotBeNull();
|
||||
metrics.LastRecord!.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static AdvisoryLinksetSnapshot CreateSnapshot()
|
||||
{
|
||||
return new AdvisoryLinksetSnapshot
|
||||
{
|
||||
Tenant = "tenant-alpha",
|
||||
Source = "concelier.overlay.v1",
|
||||
LinksetDigest = "sha256:linkset001",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:05:00Z"),
|
||||
EventOffset = 2201,
|
||||
Advisory = new AdvisoryDetails
|
||||
{
|
||||
Source = "concelier.linkset.v1",
|
||||
AdvisorySource = "ghsa",
|
||||
AdvisoryId = "GHSA-1234-5678-90AB",
|
||||
Severity = "HIGH",
|
||||
PublishedAt = DateTimeOffset.Parse("2025-10-25T09:00:00Z"),
|
||||
ContentHash = "sha256:ddd444"
|
||||
},
|
||||
Components = new[]
|
||||
{
|
||||
new AdvisoryComponentImpact
|
||||
{
|
||||
ComponentPurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
ComponentSourceType = "inventory",
|
||||
EvidenceDigest = "sha256:evidence004",
|
||||
MatchedVersions = new[] { "13.0.3" },
|
||||
Cvss = 8.1,
|
||||
Confidence = 0.9,
|
||||
Source = "concelier.overlay.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:05:10Z"),
|
||||
EventOffset = 3100,
|
||||
SbomDigest = "sha256:sbom111"
|
||||
},
|
||||
new AdvisoryComponentImpact
|
||||
{
|
||||
ComponentPurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
ComponentSourceType = "inventory",
|
||||
EvidenceDigest = "sha256:evidence004",
|
||||
MatchedVersions = new[] { "13.0.3" },
|
||||
Cvss = 8.1,
|
||||
Confidence = 0.9,
|
||||
Source = "concelier.overlay.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:05:10Z"),
|
||||
EventOffset = 3100,
|
||||
SbomDigest = "sha256:sbom111"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class CaptureWriter : IGraphDocumentWriter
|
||||
{
|
||||
private readonly bool _shouldThrow;
|
||||
|
||||
public CaptureWriter(bool shouldThrow = false)
|
||||
{
|
||||
_shouldThrow = shouldThrow;
|
||||
}
|
||||
|
||||
public GraphBuildBatch? LastBatch { get; private set; }
|
||||
|
||||
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
|
||||
{
|
||||
LastBatch = batch;
|
||||
|
||||
if (_shouldThrow)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated write failure");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureMetrics : IAdvisoryLinksetMetrics
|
||||
{
|
||||
public BatchRecord? LastRecord { get; private set; }
|
||||
|
||||
public void RecordBatch(string source, string tenant, int nodeCount, int edgeCount, TimeSpan duration, bool success)
|
||||
{
|
||||
LastRecord = new BatchRecord(source, tenant, nodeCount, edgeCount, duration, success);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BatchRecord(
|
||||
string Source,
|
||||
string Tenant,
|
||||
int NodeCount,
|
||||
int EdgeCount,
|
||||
TimeSpan Duration,
|
||||
bool Success);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Advisory;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class AdvisoryLinksetTransformerTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public AdvisoryLinksetTransformerTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"advisory"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"AFFECTED_BY"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Transform_projects_advisory_nodes_and_affected_by_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("concelier-linkset.json");
|
||||
var transformer = new AdvisoryLinksetTransformer();
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
var expectedNodes = LoadArray("nodes.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var expectedEdges = LoadArray("edges.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualNodes = batch.Nodes
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualEdges = batch.Edges
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
actualNodes.Length.Should().Be(expectedNodes.Length);
|
||||
actualEdges.Length.Should().Be(expectedEdges.Length);
|
||||
|
||||
for (var i = 0; i < expectedNodes.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
|
||||
_output.WriteLine($"Actual Node: {actualNodes[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
|
||||
}
|
||||
|
||||
for (var i = 0; i < expectedEdges.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
|
||||
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvisoryLinksetSnapshot LoadSnapshot(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<AdvisoryLinksetSnapshot>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
private static JsonArray LoadArray(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.IO;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class FileSystemSnapshotFileWriterTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"graph-snapshots-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public async Task WriteJsonAsync_writes_canonical_json()
|
||||
{
|
||||
var writer = new FileSystemSnapshotFileWriter(_root);
|
||||
var json = new JsonObject
|
||||
{
|
||||
["b"] = "value2",
|
||||
["a"] = "value1"
|
||||
};
|
||||
|
||||
await writer.WriteJsonAsync("manifest.json", json, CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(_root, "manifest.json"));
|
||||
content.Should().Be("{\"a\":\"value1\",\"b\":\"value2\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteJsonLinesAsync_writes_each_object_on_new_line()
|
||||
{
|
||||
var writer = new FileSystemSnapshotFileWriter(_root);
|
||||
var items = new[]
|
||||
{
|
||||
new JsonObject { ["id"] = "1", ["kind"] = "component" },
|
||||
new JsonObject { ["id"] = "2", ["kind"] = "artifact" }
|
||||
};
|
||||
|
||||
await writer.WriteJsonLinesAsync("nodes.jsonl", items, CancellationToken.None);
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(Path.Combine(_root, "nodes.jsonl"));
|
||||
lines.Should().HaveCount(2);
|
||||
lines[0].Should().Be("{\"id\":\"1\",\"kind\":\"component\"}");
|
||||
lines[1].Should().Be("{\"id\":\"2\",\"kind\":\"artifact\"}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"tenant": "tenant-alpha",
|
||||
"source": "concelier.overlay.v1",
|
||||
"linksetDigest": "sha256:linkset001",
|
||||
"collectedAt": "2025-10-30T12:05:10Z",
|
||||
"eventOffset": 3100,
|
||||
"advisory": {
|
||||
"source": "concelier.linkset.v1",
|
||||
"advisorySource": "ghsa",
|
||||
"advisoryId": "GHSA-1234-5678-90AB",
|
||||
"severity": "HIGH",
|
||||
"publishedAt": "2025-10-25T09:00:00Z",
|
||||
"contentHash": "sha256:ddd444",
|
||||
"linksetDigest": "sha256:linkset001"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
"sourceType": "inventory",
|
||||
"sbomDigest": "sha256:sbom111",
|
||||
"evidenceDigest": "sha256:evidence004",
|
||||
"matchedVersions": [
|
||||
"13.0.3"
|
||||
],
|
||||
"cvss": 8.1,
|
||||
"confidence": 0.9,
|
||||
"source": "concelier.overlay.v1",
|
||||
"collectedAt": "2025-10-30T12:05:10Z",
|
||||
"eventOffset": 3100
|
||||
}
|
||||
]
|
||||
}
|
||||
209
tests/Graph/StellaOps.Graph.Indexer.Tests/Fixtures/v1/edges.json
Normal file
209
tests/Graph/StellaOps.Graph.Indexer.Tests/Fixtures/v1/edges.json
Normal file
@@ -0,0 +1,209 @@
|
||||
[
|
||||
{
|
||||
"kind": "CONTAINS",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"artifact_node_id": "gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG",
|
||||
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"sbom_digest": "sha256:sbom111"
|
||||
},
|
||||
"attributes": {
|
||||
"detected_by": "sbom.analyzer.nuget",
|
||||
"layer_digest": "sha256:layer123",
|
||||
"scope": "runtime",
|
||||
"evidence_digest": "sha256:evidence001"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.sbom.v1",
|
||||
"collected_at": "2025-10-30T12:00:02Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 2100
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:02Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:CONTAINS:EVA5N7P029VYV9W8Q7XJC0JFTEQYFSAQ6381SNVM3T1G5290XHTG",
|
||||
"hash": "139e534be32f666cbd8e4fb0daee629b7b133ef8d10e98413ffc33fde59f7935"
|
||||
},
|
||||
{
|
||||
"kind": "DEPENDS_ON",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"dependency_purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
|
||||
"sbom_digest": "sha256:sbom111"
|
||||
},
|
||||
"attributes": {
|
||||
"dependency_purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
|
||||
"dependency_version": "4.7.0",
|
||||
"relationship": "direct",
|
||||
"evidence_digest": "sha256:evidence002"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.sbom.v1",
|
||||
"collected_at": "2025-10-30T12:00:02Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 2101
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:02Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:DEPENDS_ON:FJ7GZ9RHPKPR30XVKECD702QG20PGT3V75DY1GST8AAW9SR8TBB0",
|
||||
"hash": "4caae0dff840dee840d413005f1b493936446322e8cfcecd393983184cc399c1"
|
||||
},
|
||||
{
|
||||
"kind": "DECLARED_IN",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"file_node_id": "gn:tenant-alpha:file:M1MWHCXA66MQE8FZMPK3RNRMN7Z18H4VGWX6QTNNBKABFKRACKDG",
|
||||
"sbom_digest": "sha256:sbom111"
|
||||
},
|
||||
"attributes": {
|
||||
"detected_by": "sbom.analyzer.nuget",
|
||||
"scope": "runtime",
|
||||
"evidence_digest": "sha256:evidence003"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.layer.v1",
|
||||
"collected_at": "2025-10-30T12:00:03Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 2102
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:03Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:DECLARED_IN:T7E8NQEMKXPZ3T1SWT8HXKWAHJVS9QKD87XBKAQAAQ29CDHEA47G",
|
||||
"hash": "2a2e7ba8785d75eb11feebc2df99a6a04d05ee609b36cbe0b15fa142e4c4f184"
|
||||
},
|
||||
{
|
||||
"kind": "BUILT_FROM",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"parent_artifact_node_id": "gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG",
|
||||
"child_artifact_digest": "sha256:base000"
|
||||
},
|
||||
"attributes": {
|
||||
"build_type": "https://slsa.dev/provenance/v1",
|
||||
"builder_id": "builder://tekton/pipeline/default",
|
||||
"attestation_digest": "sha256:attestation001"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.provenance.v1",
|
||||
"collected_at": "2025-10-30T12:00:05Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 2103
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:05Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:BUILT_FROM:HJNKVFSDSA44HRY0XAJ0GBEVPD2S82JFF58BZVRT9QF6HB2EGPJG",
|
||||
"hash": "17bdb166f4ba05406ed17ec38d460fb83bd72cec60095f0966b1d79c2a55f1de"
|
||||
},
|
||||
{
|
||||
"kind": "AFFECTED_BY",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"advisory_node_id": "gn:tenant-alpha:advisory:RFGYXZ2TG0BF117T3HCX3XYAZFXPD72991QD0JZWDVY7FXYY87R0",
|
||||
"linkset_digest": "sha256:linkset001"
|
||||
},
|
||||
"attributes": {
|
||||
"evidence_digest": "sha256:evidence004",
|
||||
"matched_versions": [
|
||||
"13.0.3"
|
||||
],
|
||||
"cvss": 8.1,
|
||||
"confidence": 0.9
|
||||
},
|
||||
"provenance": {
|
||||
"source": "concelier.overlay.v1",
|
||||
"collected_at": "2025-10-30T12:05:10Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 3100
|
||||
},
|
||||
"valid_from": "2025-10-30T12:05:10Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:AFFECTED_BY:1V3NRKAR6KMXAWZ89R69G8JAY3HV7DXNB16YY9X25X1TAFW9VGYG",
|
||||
"hash": "45e845ee51dc2e8e8990707906bddcd3ecedf209de10b87ce8eed604dcc51ff5"
|
||||
},
|
||||
{
|
||||
"kind": "VEX_EXEMPTS",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"vex_node_id": "gn:tenant-alpha:vex_statement:BVRF35CX6TZTHPD7YFHYTJJACPYJD86JP7C74SH07QT9JT82NDSG",
|
||||
"statement_hash": "sha256:eee555"
|
||||
},
|
||||
"attributes": {
|
||||
"status": "not_affected",
|
||||
"justification": "component not present",
|
||||
"impact_statement": "Library not loaded at runtime",
|
||||
"evidence_digest": "sha256:evidence005"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "excititor.overlay.v1",
|
||||
"collected_at": "2025-10-30T12:06:10Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 3200
|
||||
},
|
||||
"valid_from": "2025-10-30T12:06:10Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:VEX_EXEMPTS:DT0BBCM9S0KJVF61KVR7D2W8DVFTKK03F3TFD4DR9DRS0T5CWZM0",
|
||||
"hash": "0ae4085e510898e68ad5cb48b7385a1ae9af68fcfea9bd5c22c47d78bb1c2f2e"
|
||||
},
|
||||
{
|
||||
"kind": "GOVERNS_WITH",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"policy_node_id": "gn:tenant-alpha:policy_version:YZSMWHHR6Y5XR1HFRBV3H5TR6GMZVN9BPDAAVQEACV7XRYP06390",
|
||||
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"finding_explain_hash": "sha256:explain001"
|
||||
},
|
||||
"attributes": {
|
||||
"verdict": "fail",
|
||||
"explain_hash": "sha256:explain001",
|
||||
"policy_rule_id": "rule:runtime/critical-dependency",
|
||||
"evaluation_timestamp": "2025-10-30T12:07:00Z"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "policy.engine.v1",
|
||||
"collected_at": "2025-10-30T12:07:00Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 4200
|
||||
},
|
||||
"valid_from": "2025-10-30T12:07:00Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:GOVERNS_WITH:XG3KQTYT8D4NY0BTFXWGBQY6TXR2MRYDWZBQT07T0200NQ72AFG0",
|
||||
"hash": "38a05081a9b046bfd391505d47da6b7c6e3a74e114999b38a4e4e9341f2dc279"
|
||||
},
|
||||
{
|
||||
"kind": "OBSERVED_RUNTIME",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"runtime_node_id": "gn:tenant-alpha:runtime_context:EFVARD7VM4710F8554Q3NGH0X8W7XRF3RDARE8YJWK1H3GABX8A0",
|
||||
"component_node_id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"runtime_fingerprint": "pod-abc123"
|
||||
},
|
||||
"attributes": {
|
||||
"process_name": "dotnet",
|
||||
"entrypoint_kind": "container",
|
||||
"runtime_evidence_digest": "sha256:evidence006",
|
||||
"confidence": 0.8
|
||||
},
|
||||
"provenance": {
|
||||
"source": "signals.runtime.v1",
|
||||
"collected_at": "2025-10-30T12:15:10Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 5200
|
||||
},
|
||||
"valid_from": "2025-10-30T12:15:10Z",
|
||||
"valid_to": null,
|
||||
"id": "ge:tenant-alpha:OBSERVED_RUNTIME:CVV4ACPPJVHWX2NRZATB8H045F71HXT59TQHEZE2QBAQGJDK1FY0",
|
||||
"hash": "15d24ebdf126b6f8947d3041f8cbb291bb66e8f595737a7c7dd2683215568367"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"tenant": "tenant-alpha",
|
||||
"source": "excititor.overlay.v1",
|
||||
"collectedAt": "2025-10-30T12:06:10Z",
|
||||
"eventOffset": 3200,
|
||||
"statement": {
|
||||
"vexSource": "vendor-x",
|
||||
"statementId": "statement-789",
|
||||
"status": "not_affected",
|
||||
"justification": "component not present",
|
||||
"impactStatement": "Library not loaded at runtime",
|
||||
"issuedAt": "2025-10-27T14:30:00Z",
|
||||
"expiresAt": "2026-10-27T14:30:00Z",
|
||||
"contentHash": "sha256:eee555",
|
||||
"provenanceSource": "excititor.vex.v1",
|
||||
"collectedAt": "2025-10-30T12:06:00Z",
|
||||
"eventOffset": 3302
|
||||
},
|
||||
"exemptions": [
|
||||
{
|
||||
"componentPurl": "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
"componentSourceType": "inventory",
|
||||
"sbomDigest": "sha256:sbom111",
|
||||
"statementHash": "sha256:eee555",
|
||||
"status": "not_affected",
|
||||
"justification": "component not present",
|
||||
"impactStatement": "Library not loaded at runtime",
|
||||
"evidenceDigest": "sha256:evidence005",
|
||||
"provenanceSource": "excititor.overlay.v1",
|
||||
"collectedAt": "2025-10-30T12:06:10Z",
|
||||
"eventOffset": 3200
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
tenant: tenant-alpha,
|
||||
source: concelier.overlay.v1,
|
||||
linksetDigest: sha256:linkset001,
|
||||
collectedAt: 2025-10-30T12:05:00Z,
|
||||
eventOffset: 2201,
|
||||
advisory: {
|
||||
source: concelier.linkset.v1,
|
||||
advisorySource: ghsa,
|
||||
advisoryId: GHSA-1234-5678-90AB,
|
||||
contentHash: sha256:ddd444,
|
||||
severity: HIGH,
|
||||
publishedAt: 2025-10-25T09:00:00Z
|
||||
},
|
||||
components: [
|
||||
{
|
||||
purl: pkg:nuget/Newtonsoft.Json@13.0.3,
|
||||
sourceType: inventory,
|
||||
sbomDigest: sha256:sbom111,
|
||||
evidenceDigest: sha256:evidence004,
|
||||
matchedVersions: [13.0.3],
|
||||
cvss: 8.1,
|
||||
confidence: 0.9,
|
||||
collectedAt: 2025-10-30T12:05:10Z,
|
||||
eventOffset: 3100,
|
||||
source: concelier.overlay.v1
|
||||
}
|
||||
]
|
||||
}
|
||||
280
tests/Graph/StellaOps.Graph.Indexer.Tests/Fixtures/v1/nodes.json
Normal file
280
tests/Graph/StellaOps.Graph.Indexer.Tests/Fixtures/v1/nodes.json
Normal file
@@ -0,0 +1,280 @@
|
||||
[
|
||||
{
|
||||
"kind": "artifact",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"artifact_digest": "sha256:aaa111",
|
||||
"sbom_digest": "sha256:sbom111"
|
||||
},
|
||||
"attributes": {
|
||||
"display_name": "registry.example.com/team/app:1.2.3",
|
||||
"artifact_digest": "sha256:aaa111",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"environment": "prod",
|
||||
"labels": [
|
||||
"critical",
|
||||
"payments"
|
||||
],
|
||||
"origin_registry": "registry.example.com",
|
||||
"supply_chain_stage": "deploy"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.sbom.v1",
|
||||
"collected_at": "2025-10-30T12:00:00Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 1182
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:00Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG",
|
||||
"hash": "891601471f7dea636ec2988966b3aee3721a1faedb7e1c8e2834355eb4e31cfd"
|
||||
},
|
||||
{
|
||||
"kind": "artifact",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"artifact_digest": "sha256:base000",
|
||||
"sbom_digest": "sha256:sbom-base"
|
||||
},
|
||||
"attributes": {
|
||||
"display_name": "registry.example.com/base/runtime:2025.09",
|
||||
"artifact_digest": "sha256:base000",
|
||||
"sbom_digest": "sha256:sbom-base",
|
||||
"environment": "prod",
|
||||
"labels": [
|
||||
"base-image"
|
||||
],
|
||||
"origin_registry": "registry.example.com",
|
||||
"supply_chain_stage": "build"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.sbom.v1",
|
||||
"collected_at": "2025-10-22T08:00:00Z",
|
||||
"sbom_digest": "sha256:sbom-base",
|
||||
"event_offset": 800
|
||||
},
|
||||
"valid_from": "2025-10-22T08:00:00Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:artifact:KD207PSJ36Q0B19CT8K8H2FQCV0HGQRNK8QWHFXE1VWAKPF9XH00",
|
||||
"hash": "11593184fe6aa37a0e1d1909d4a401084a9ca452959a369590ac20d4dff77bd8"
|
||||
},
|
||||
{
|
||||
"kind": "component",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
"source_type": "inventory"
|
||||
},
|
||||
"attributes": {
|
||||
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
"version": "13.0.3",
|
||||
"ecosystem": "nuget",
|
||||
"scope": "runtime",
|
||||
"license_spdx": "MIT",
|
||||
"usage": "direct"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.sbom.v1",
|
||||
"collected_at": "2025-10-30T12:00:01Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 1183
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:01Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0",
|
||||
"hash": "e4c22e7522573b746c654bb6bdd05d01db1bcd34db8b22e5e12d2e8528268786"
|
||||
},
|
||||
{
|
||||
"kind": "component",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
|
||||
"source_type": "inventory"
|
||||
},
|
||||
"attributes": {
|
||||
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
|
||||
"version": "4.7.0",
|
||||
"ecosystem": "nuget",
|
||||
"scope": "runtime",
|
||||
"license_spdx": "MIT",
|
||||
"usage": "transitive"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.sbom.v1",
|
||||
"collected_at": "2025-10-30T12:00:01Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 1184
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:01Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:component:FZ9EHXFFGPDQAEKAPWZ4JX5X6KYS467PJ5D1Y4T9NFFQG2SG0DV0",
|
||||
"hash": "b941ff7178451b7a0403357d08ed8996e8aea1bf40032660e18406787e57ce3f"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"artifact_digest": "sha256:aaa111",
|
||||
"normalized_path": "/src/app/Program.cs",
|
||||
"content_sha256": "sha256:bbb222"
|
||||
},
|
||||
"attributes": {
|
||||
"normalized_path": "/src/app/Program.cs",
|
||||
"content_sha256": "sha256:bbb222",
|
||||
"language_hint": "csharp",
|
||||
"size_bytes": 3472,
|
||||
"scope": "build"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.layer.v1",
|
||||
"collected_at": "2025-10-30T12:00:02Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 1185
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:02Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:file:M1MWHCXA66MQE8FZMPK3RNRMN7Z18H4VGWX6QTNNBKABFKRACKDG",
|
||||
"hash": "a0a7e7b6ff4a8357bea3273e38b3a3d801531a4f6b716513b7d4972026db3a76"
|
||||
},
|
||||
{
|
||||
"kind": "license",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"license_spdx": "Apache-2.0",
|
||||
"source_digest": "sha256:ccc333"
|
||||
},
|
||||
"attributes": {
|
||||
"license_spdx": "Apache-2.0",
|
||||
"name": "Apache License 2.0",
|
||||
"classification": "permissive",
|
||||
"notice_uri": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "scanner.sbom.v1",
|
||||
"collected_at": "2025-10-30T12:00:03Z",
|
||||
"sbom_digest": "sha256:sbom111",
|
||||
"event_offset": 1186
|
||||
},
|
||||
"valid_from": "2025-10-30T12:00:03Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:license:7SDDWTRKXYG9MBK89X7JFMAQRBEZHV1NFZNSN2PBRZT5H0FHZB90",
|
||||
"hash": "790f1d803dd35d9f77b08977e4dd3fc9145218ee7c68524881ee13b7a2e9ede8"
|
||||
},
|
||||
{
|
||||
"tenant": "tenant-alpha",
|
||||
"kind": "advisory",
|
||||
"canonical_key": {
|
||||
"advisory_id": "GHSA-1234-5678-90AB",
|
||||
"advisory_source": "ghsa",
|
||||
"content_hash": "sha256:ddd444",
|
||||
"tenant": "tenant-alpha"
|
||||
},
|
||||
"attributes": {
|
||||
"advisory_source": "ghsa",
|
||||
"advisory_id": "GHSA-1234-5678-90AB",
|
||||
"severity": "HIGH",
|
||||
"published_at": "2025-10-25T09:00:00Z",
|
||||
"content_hash": "sha256:ddd444",
|
||||
"linkset_digest": "sha256:linkset001"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "concelier.linkset.v1",
|
||||
"collected_at": "2025-10-30T12:05:10Z",
|
||||
"sbom_digest": null,
|
||||
"event_offset": 3100
|
||||
},
|
||||
"valid_from": "2025-10-25T09:00:00Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:advisory:RFGYXZ2TG0BF117T3HCX3XYAZFXPD72991QD0JZWDVY7FXYY87R0",
|
||||
"hash": "df4b4087dc6bf4c8b071ce808b97025036a6d33d30ea538a279a4f55ed7ffb8e"
|
||||
},
|
||||
{
|
||||
"tenant": "tenant-alpha",
|
||||
"kind": "vex_statement",
|
||||
"canonical_key": {
|
||||
"content_hash": "sha256:eee555",
|
||||
"statement_id": "statement-789",
|
||||
"tenant": "tenant-alpha",
|
||||
"vex_source": "vendor-x"
|
||||
},
|
||||
"attributes": {
|
||||
"status": "not_affected",
|
||||
"statement_id": "statement-789",
|
||||
"justification": "component not present",
|
||||
"issued_at": "2025-10-27T14:30:00Z",
|
||||
"expires_at": "2026-10-27T14:30:00Z",
|
||||
"content_hash": "sha256:eee555"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "excititor.vex.v1",
|
||||
"collected_at": "2025-10-30T12:06:00Z",
|
||||
"sbom_digest": null,
|
||||
"event_offset": 3302
|
||||
},
|
||||
"valid_from": "2025-10-27T14:30:00Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:vex_statement:BVRF35CX6TZTHPD7YFHYTJJACPYJD86JP7C74SH07QT9JT82NDSG",
|
||||
"hash": "4b613e2b8460c542597bbc70b8ba3e6796c3e1d261d0c74ce30fba42f7681f25"
|
||||
},
|
||||
{
|
||||
"kind": "policy_version",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"policy_pack_digest": "sha256:fff666",
|
||||
"effective_from": "2025-10-28T00:00:00Z"
|
||||
},
|
||||
"attributes": {
|
||||
"policy_pack_digest": "sha256:fff666",
|
||||
"policy_name": "Default Runtime Policy",
|
||||
"effective_from": "2025-10-28T00:00:00Z",
|
||||
"expires_at": "2026-01-01T00:00:00Z",
|
||||
"explain_hash": "sha256:explain001"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "policy.engine.v1",
|
||||
"collected_at": "2025-10-28T00:00:05Z",
|
||||
"sbom_digest": null,
|
||||
"event_offset": 4100
|
||||
},
|
||||
"valid_from": "2025-10-28T00:00:00Z",
|
||||
"valid_to": "2026-01-01T00:00:00Z",
|
||||
"id": "gn:tenant-alpha:policy_version:YZSMWHHR6Y5XR1HFRBV3H5TR6GMZVN9BPDAAVQEACV7XRYP06390",
|
||||
"hash": "a8539c4d611535c3afcfd406a08208ab3bbfc81f6e31f87dd727b7d8bd9c4209"
|
||||
},
|
||||
{
|
||||
"kind": "runtime_context",
|
||||
"tenant": "tenant-alpha",
|
||||
"canonical_key": {
|
||||
"tenant": "tenant-alpha",
|
||||
"runtime_fingerprint": "pod-abc123",
|
||||
"collector": "zastava.v1",
|
||||
"observed_at": "2025-10-30T12:15:00Z"
|
||||
},
|
||||
"attributes": {
|
||||
"runtime_fingerprint": "pod-abc123",
|
||||
"collector": "zastava.v1",
|
||||
"observed_at": "2025-10-30T12:15:00Z",
|
||||
"cluster": "prod-cluster-1",
|
||||
"namespace": "payments",
|
||||
"workload_kind": "deployment",
|
||||
"runtime_state": "Running"
|
||||
},
|
||||
"provenance": {
|
||||
"source": "signals.runtime.v1",
|
||||
"collected_at": "2025-10-30T12:15:05Z",
|
||||
"sbom_digest": null,
|
||||
"event_offset": 5109
|
||||
},
|
||||
"valid_from": "2025-10-30T12:15:00Z",
|
||||
"valid_to": null,
|
||||
"id": "gn:tenant-alpha:runtime_context:EFVARD7VM4710F8554Q3NGH0X8W7XRF3RDARE8YJWK1H3GABX8A0",
|
||||
"hash": "0294c4131ba98d52674ca31a409488b73f47a193cf3a13cede8671e6112a5a29"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"tenant": "tenant-alpha",
|
||||
"source": "policy.engine.v1",
|
||||
"collectedAt": "2025-10-30T12:07:00Z",
|
||||
"eventOffset": 4200,
|
||||
"policy": {
|
||||
"source": "policy.engine.v1",
|
||||
"policyPackDigest": "sha256:fff666",
|
||||
"policyName": "Default Runtime Policy",
|
||||
"effectiveFrom": "2025-10-28T00:00:00Z",
|
||||
"expiresAt": "2026-01-01T00:00:00Z",
|
||||
"explainHash": "sha256:explain001",
|
||||
"collectedAt": "2025-10-28T00:00:05Z",
|
||||
"eventOffset": 4100
|
||||
},
|
||||
"evaluations": [
|
||||
{
|
||||
"componentPurl": "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
"componentSourceType": "inventory",
|
||||
"findingExplainHash": "sha256:explain001",
|
||||
"explainHash": "sha256:explain001",
|
||||
"policyRuleId": "rule:runtime/critical-dependency",
|
||||
"verdict": "fail",
|
||||
"evaluationTimestamp": "2025-10-30T12:07:00Z",
|
||||
"sbomDigest": "sha256:sbom111",
|
||||
"source": "policy.engine.v1",
|
||||
"collectedAt": "2025-10-30T12:07:00Z",
|
||||
"eventOffset": 4200
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"tenant": "tenant-alpha",
|
||||
"source": "scanner.sbom.v1",
|
||||
"artifactDigest": "sha256:aaa111",
|
||||
"sbomDigest": "sha256:sbom111",
|
||||
"collectedAt": "2025-10-30T12:00:00Z",
|
||||
"eventOffset": 1182,
|
||||
"artifact": {
|
||||
"displayName": "registry.example.com/team/app:1.2.3",
|
||||
"environment": "prod",
|
||||
"labels": [
|
||||
"critical",
|
||||
"payments"
|
||||
],
|
||||
"originRegistry": "registry.example.com",
|
||||
"supplyChainStage": "deploy"
|
||||
},
|
||||
"build": {
|
||||
"builderId": "builder://tekton/pipeline/default",
|
||||
"buildType": "https://slsa.dev/provenance/v1",
|
||||
"attestationDigest": "sha256:attestation001",
|
||||
"source": "scanner.provenance.v1",
|
||||
"collectedAt": "2025-10-30T12:00:05Z",
|
||||
"eventOffset": 2103
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"purl": "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
"version": "13.0.3",
|
||||
"ecosystem": "nuget",
|
||||
"scope": "runtime",
|
||||
"license": {
|
||||
"spdx": "MIT",
|
||||
"name": "MIT License",
|
||||
"classification": "permissive",
|
||||
"noticeUri": "https://opensource.org/licenses/MIT",
|
||||
"sourceDigest": "sha256:ccc333"
|
||||
},
|
||||
"usage": "direct",
|
||||
"detectedBy": "sbom.analyzer.nuget",
|
||||
"layerDigest": "sha256:layer123",
|
||||
"evidenceDigest": "sha256:evidence001",
|
||||
"collectedAt": "2025-10-30T12:00:01Z",
|
||||
"eventOffset": 1183,
|
||||
"source": "scanner.sbom.v1",
|
||||
"files": [
|
||||
{
|
||||
"path": "/src/app/Program.cs",
|
||||
"contentSha256": "sha256:bbb222",
|
||||
"languageHint": "csharp",
|
||||
"sizeBytes": 3472,
|
||||
"scope": "build",
|
||||
"detectedBy": "sbom.analyzer.nuget",
|
||||
"evidenceDigest": "sha256:evidence003",
|
||||
"collectedAt": "2025-10-30T12:00:02Z",
|
||||
"eventOffset": 1185,
|
||||
"source": "scanner.layer.v1"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{
|
||||
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
|
||||
"version": "4.7.0",
|
||||
"relationship": "direct",
|
||||
"evidenceDigest": "sha256:evidence002",
|
||||
"collectedAt": "2025-10-30T12:00:01Z",
|
||||
"eventOffset": 1183
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"purl": "pkg:nuget/System.Text.Encoding.Extensions@4.7.0",
|
||||
"version": "4.7.0",
|
||||
"ecosystem": "nuget",
|
||||
"scope": "runtime",
|
||||
"license": {
|
||||
"spdx": "MIT",
|
||||
"name": "MIT License",
|
||||
"classification": "permissive",
|
||||
"noticeUri": "https://opensource.org/licenses/MIT",
|
||||
"sourceDigest": "sha256:ccc333"
|
||||
},
|
||||
"usage": "transitive",
|
||||
"detectedBy": "sbom.analyzer.nuget",
|
||||
"layerDigest": "sha256:layer123",
|
||||
"evidenceDigest": "sha256:evidence001",
|
||||
"collectedAt": "2025-10-30T12:00:01Z",
|
||||
"eventOffset": 1184,
|
||||
"source": "scanner.sbom.v1",
|
||||
"files": [],
|
||||
"dependencies": []
|
||||
}
|
||||
],
|
||||
"baseArtifacts": [
|
||||
{
|
||||
"artifactDigest": "sha256:base000",
|
||||
"sbomDigest": "sha256:sbom-base",
|
||||
"displayName": "registry.example.com/base/runtime:2025.09",
|
||||
"environment": "prod",
|
||||
"labels": [
|
||||
"base-image"
|
||||
],
|
||||
"originRegistry": "registry.example.com",
|
||||
"supplyChainStage": "build",
|
||||
"collectedAt": "2025-10-22T08:00:00Z",
|
||||
"eventOffset": 800,
|
||||
"source": "scanner.sbom.v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"nodes": {
|
||||
"artifact": [
|
||||
"display_name",
|
||||
"artifact_digest",
|
||||
"sbom_digest",
|
||||
"environment",
|
||||
"labels",
|
||||
"origin_registry",
|
||||
"supply_chain_stage"
|
||||
],
|
||||
"component": [
|
||||
"purl",
|
||||
"version",
|
||||
"ecosystem",
|
||||
"scope",
|
||||
"license_spdx",
|
||||
"usage"
|
||||
],
|
||||
"file": [
|
||||
"normalized_path",
|
||||
"content_sha256",
|
||||
"language_hint",
|
||||
"size_bytes",
|
||||
"scope"
|
||||
],
|
||||
"license": [
|
||||
"license_spdx",
|
||||
"name",
|
||||
"classification",
|
||||
"notice_uri"
|
||||
],
|
||||
"advisory": [
|
||||
"advisory_source",
|
||||
"advisory_id",
|
||||
"severity",
|
||||
"published_at",
|
||||
"content_hash",
|
||||
"linkset_digest"
|
||||
],
|
||||
"vex_statement": [
|
||||
"status",
|
||||
"statement_id",
|
||||
"justification",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
"content_hash"
|
||||
],
|
||||
"policy_version": [
|
||||
"policy_pack_digest",
|
||||
"policy_name",
|
||||
"effective_from",
|
||||
"expires_at",
|
||||
"explain_hash"
|
||||
],
|
||||
"runtime_context": [
|
||||
"runtime_fingerprint",
|
||||
"collector",
|
||||
"observed_at",
|
||||
"cluster",
|
||||
"namespace",
|
||||
"workload_kind",
|
||||
"runtime_state"
|
||||
]
|
||||
},
|
||||
"edges": {
|
||||
"CONTAINS": [
|
||||
"detected_by",
|
||||
"layer_digest",
|
||||
"scope",
|
||||
"evidence_digest"
|
||||
],
|
||||
"DEPENDS_ON": [
|
||||
"dependency_purl",
|
||||
"dependency_version",
|
||||
"relationship",
|
||||
"evidence_digest"
|
||||
],
|
||||
"DECLARED_IN": [
|
||||
"detected_by",
|
||||
"scope",
|
||||
"evidence_digest"
|
||||
],
|
||||
"BUILT_FROM": [
|
||||
"build_type",
|
||||
"builder_id",
|
||||
"attestation_digest"
|
||||
],
|
||||
"AFFECTED_BY": [
|
||||
"evidence_digest",
|
||||
"matched_versions",
|
||||
"cvss",
|
||||
"confidence"
|
||||
],
|
||||
"VEX_EXEMPTS": [
|
||||
"status",
|
||||
"justification",
|
||||
"impact_statement",
|
||||
"evidence_digest"
|
||||
],
|
||||
"GOVERNS_WITH": [
|
||||
"verdict",
|
||||
"explain_hash",
|
||||
"policy_rule_id",
|
||||
"evaluation_timestamp"
|
||||
],
|
||||
"OBSERVED_RUNTIME": [
|
||||
"process_name",
|
||||
"entrypoint_kind",
|
||||
"runtime_evidence_digest",
|
||||
"confidence"
|
||||
]
|
||||
}
|
||||
}
|
||||
110
tests/Graph/StellaOps.Graph.Indexer.Tests/GraphIdentityTests.cs
Normal file
110
tests/Graph/StellaOps.Graph.Indexer.Tests/GraphIdentityTests.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class GraphIdentityTests
|
||||
{
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
[Fact]
|
||||
public void NodeIds_are_stable()
|
||||
{
|
||||
var nodes = LoadArray("nodes.json");
|
||||
|
||||
foreach (var node in nodes.Cast<JsonObject>())
|
||||
{
|
||||
var tenant = node["tenant"]!.GetValue<string>();
|
||||
var kind = node["kind"]!.GetValue<string>();
|
||||
var canonicalKey = (JsonObject)node["canonical_key"]!;
|
||||
var tuple = GraphIdentity.ExtractIdentityTuple(canonicalKey);
|
||||
|
||||
var expectedId = node["id"]!.GetValue<string>();
|
||||
var actualId = GraphIdentity.ComputeNodeId(tenant, kind, tuple);
|
||||
|
||||
actualId.Should()
|
||||
.Be(expectedId, $"node {kind} with canonical tuple {canonicalKey.ToJsonString()} must have deterministic id");
|
||||
|
||||
var documentClone = JsonNode.Parse(node.ToJsonString())!.AsObject();
|
||||
documentClone.Remove("hash");
|
||||
|
||||
var expectedHash = node["hash"]!.GetValue<string>();
|
||||
var actualHash = GraphIdentity.ComputeDocumentHash(documentClone);
|
||||
|
||||
actualHash.Should()
|
||||
.Be(expectedHash, $"node {kind}:{expectedId} must have deterministic document hash");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeIds_are_stable()
|
||||
{
|
||||
var edges = LoadArray("edges.json");
|
||||
|
||||
foreach (var edge in edges.Cast<JsonObject>())
|
||||
{
|
||||
var tenant = edge["tenant"]!.GetValue<string>();
|
||||
var kind = edge["kind"]!.GetValue<string>();
|
||||
var canonicalKey = (JsonObject)edge["canonical_key"]!;
|
||||
var tuple = GraphIdentity.ExtractIdentityTuple(canonicalKey);
|
||||
|
||||
var expectedId = edge["id"]!.GetValue<string>();
|
||||
var actualId = GraphIdentity.ComputeEdgeId(tenant, kind, tuple);
|
||||
|
||||
actualId.Should()
|
||||
.Be(expectedId, $"edge {kind} with canonical tuple {canonicalKey.ToJsonString()} must have deterministic id");
|
||||
|
||||
var documentClone = JsonNode.Parse(edge.ToJsonString())!.AsObject();
|
||||
documentClone.Remove("hash");
|
||||
|
||||
var expectedHash = edge["hash"]!.GetValue<string>();
|
||||
var actualHash = GraphIdentity.ComputeDocumentHash(documentClone);
|
||||
|
||||
actualHash.Should()
|
||||
.Be(expectedHash, $"edge {kind}:{expectedId} must have deterministic document hash");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributeCoverage_matches_matrix()
|
||||
{
|
||||
var matrix = LoadObject("schema-matrix.json");
|
||||
var nodeExpectations = (JsonObject)matrix["nodes"]!;
|
||||
var edgeExpectations = (JsonObject)matrix["edges"]!;
|
||||
|
||||
var nodes = LoadArray("nodes.json");
|
||||
foreach (var node in nodes.Cast<JsonObject>())
|
||||
{
|
||||
var kind = node["kind"]!.GetValue<string>();
|
||||
var expectedAttributes = nodeExpectations[kind]!.AsArray().Select(x => x!.GetValue<string>()).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
var actualAttributes = ((JsonObject)node["attributes"]!).Select(pair => pair.Key).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
|
||||
actualAttributes.Should()
|
||||
.Equal(expectedAttributes, $"node kind {kind} must align with schema matrix");
|
||||
}
|
||||
|
||||
var edges = LoadArray("edges.json");
|
||||
foreach (var edge in edges.Cast<JsonObject>())
|
||||
{
|
||||
var kind = edge["kind"]!.GetValue<string>();
|
||||
var expectedAttributes = edgeExpectations[kind]!.AsArray().Select(x => x!.GetValue<string>()).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
var actualAttributes = ((JsonObject)edge["attributes"]!).Select(pair => pair.Key).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
|
||||
actualAttributes.Should()
|
||||
.Equal(expectedAttributes, $"edge kind {kind} must align with schema matrix");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonArray LoadArray(string fileName)
|
||||
=> (JsonArray)JsonNode.Parse(File.ReadAllText(GetFixturePath(fileName)))!;
|
||||
|
||||
private static JsonObject LoadObject(string fileName)
|
||||
=> (JsonObject)JsonNode.Parse(File.ReadAllText(GetFixturePath(fileName)))!;
|
||||
|
||||
private static string GetFixturePath(string fileName)
|
||||
=> Path.Combine(FixturesRoot, fileName);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Documents;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Advisory;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Policy;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Vex;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class GraphSnapshotBuilderTests
|
||||
{
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
[Fact]
|
||||
public void Build_creates_manifest_and_adjacency_with_lineage()
|
||||
{
|
||||
var sbomSnapshot = Load<SbomSnapshot>("sbom-snapshot.json");
|
||||
var linksetSnapshot = Load<AdvisoryLinksetSnapshot>("concelier-linkset.json");
|
||||
var vexSnapshot = Load<VexOverlaySnapshot>("excititor-vex.json");
|
||||
var policySnapshot = Load<PolicyOverlaySnapshot>("policy-overlay.json");
|
||||
|
||||
var sbomBatch = new SbomIngestTransformer().Transform(sbomSnapshot);
|
||||
var advisoryBatch = new AdvisoryLinksetTransformer().Transform(linksetSnapshot);
|
||||
var vexBatch = new VexOverlayTransformer().Transform(vexSnapshot);
|
||||
var policyBatch = new PolicyOverlayTransformer().Transform(policySnapshot);
|
||||
|
||||
var combinedBatch = MergeBatches(sbomBatch, advisoryBatch, vexBatch, policyBatch);
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var generatedAt = DateTimeOffset.Parse("2025-10-30T12:06:30Z");
|
||||
|
||||
var snapshot = builder.Build(sbomSnapshot, combinedBatch, generatedAt);
|
||||
|
||||
snapshot.Manifest.Tenant.Should().Be("tenant-alpha");
|
||||
snapshot.Manifest.ArtifactDigest.Should().Be("sha256:aaa111");
|
||||
snapshot.Manifest.SbomDigest.Should().Be("sha256:sbom111");
|
||||
snapshot.Manifest.GeneratedAt.Should().Be(generatedAt);
|
||||
snapshot.Manifest.NodeCount.Should().Be(combinedBatch.Nodes.Length);
|
||||
snapshot.Manifest.EdgeCount.Should().Be(combinedBatch.Edges.Length);
|
||||
snapshot.Manifest.Files.Nodes.Should().Be("nodes.jsonl");
|
||||
snapshot.Manifest.Files.Edges.Should().Be("edges.jsonl");
|
||||
snapshot.Manifest.Files.Adjacency.Should().Be("adjacency.json");
|
||||
|
||||
snapshot.Manifest.Lineage.DerivedFromSbomDigests.Should().BeEquivalentTo(new[] { "sha256:sbom-base" }, options => options.WithStrictOrdering());
|
||||
snapshot.Manifest.Lineage.BaseArtifactDigests.Should().BeEquivalentTo(new[] { "sha256:base000" }, options => options.WithStrictOrdering());
|
||||
snapshot.Manifest.Lineage.SourceSnapshotId.Should().BeNull();
|
||||
|
||||
var manifestJson = snapshot.Manifest.ToJson();
|
||||
manifestJson.Should().NotBeNull();
|
||||
manifestJson["hash"]!.GetValue<string>().Should().Be(snapshot.Manifest.Hash);
|
||||
|
||||
var manifestWithoutHash = (JsonObject)manifestJson.DeepClone();
|
||||
manifestWithoutHash.Remove("hash");
|
||||
var expectedManifestHash = GraphIdentity.ComputeDocumentHash(manifestWithoutHash);
|
||||
snapshot.Manifest.Hash.Should().Be(expectedManifestHash);
|
||||
|
||||
var adjacency = snapshot.Adjacency;
|
||||
adjacency.Tenant.Should().Be("tenant-alpha");
|
||||
adjacency.SnapshotId.Should().Be(snapshot.Manifest.SnapshotId);
|
||||
adjacency.GeneratedAt.Should().Be(generatedAt);
|
||||
|
||||
var adjacencyNodes = adjacency.Nodes.ToDictionary(node => node.NodeId, StringComparer.Ordinal);
|
||||
adjacencyNodes.Should().ContainKey("gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG");
|
||||
|
||||
var artifactAdjacency = adjacencyNodes["gn:tenant-alpha:artifact:RX033HH7S6JXMY66QM51S89SX76B3JXJHWHPXPPBJCD05BR3GVXG"];
|
||||
artifactAdjacency.OutgoingEdges.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"ge:tenant-alpha:BUILT_FROM:HJNKVFSDSA44HRY0XAJ0GBEVPD2S82JFF58BZVRT9QF6HB2EGPJG",
|
||||
"ge:tenant-alpha:CONTAINS:EVA5N7P029VYV9W8Q7XJC0JFTEQYFSAQ6381SNVM3T1G5290XHTG"
|
||||
}, options => options.WithStrictOrdering());
|
||||
artifactAdjacency.IncomingEdges.Should().BeEmpty();
|
||||
|
||||
var componentAdjacency = adjacencyNodes["gn:tenant-alpha:component:BQSZFXSPNGS6M8XEQZ6XX3E7775XZQABM301GFPFXCQSQSA1WHZ0"];
|
||||
componentAdjacency.IncomingEdges.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"ge:tenant-alpha:CONTAINS:EVA5N7P029VYV9W8Q7XJC0JFTEQYFSAQ6381SNVM3T1G5290XHTG",
|
||||
"ge:tenant-alpha:GOVERNS_WITH:XG3KQTYT8D4NY0BTFXWGBQY6TXR2MRYDWZBQT07T0200NQ72AFG0"
|
||||
});
|
||||
componentAdjacency.OutgoingEdges.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"ge:tenant-alpha:DEPENDS_ON:FJ7GZ9RHPKPR30XVKECD702QG20PGT3V75DY1GST8AAW9SR8TBB0",
|
||||
"ge:tenant-alpha:DECLARED_IN:T7E8NQEMKXPZ3T1SWT8HXKWAHJVS9QKD87XBKAQAAQ29CDHEA47G",
|
||||
"ge:tenant-alpha:AFFECTED_BY:1V3NRKAR6KMXAWZ89R69G8JAY3HV7DXNB16YY9X25X1TAFW9VGYG",
|
||||
"ge:tenant-alpha:VEX_EXEMPTS:DT0BBCM9S0KJVF61KVR7D2W8DVFTKK03F3TFD4DR9DRS0T5CWZM0"
|
||||
});
|
||||
|
||||
var dependencyComponent = adjacencyNodes["gn:tenant-alpha:component:FZ9EHXFFGPDQAEKAPWZ4JX5X6KYS467PJ5D1Y4T9NFFQG2SG0DV0"];
|
||||
dependencyComponent.IncomingEdges.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"ge:tenant-alpha:DEPENDS_ON:FJ7GZ9RHPKPR30XVKECD702QG20PGT3V75DY1GST8AAW9SR8TBB0"
|
||||
});
|
||||
dependencyComponent.OutgoingEdges.Should().BeEmpty();
|
||||
|
||||
adjacency.Nodes.Length.Should().Be(combinedBatch.Nodes.Length);
|
||||
}
|
||||
|
||||
private static GraphBuildBatch MergeBatches(params GraphBuildBatch[] batches)
|
||||
{
|
||||
var nodes = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
|
||||
var edges = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
foreach (var node in batch.Nodes)
|
||||
{
|
||||
nodes[node["id"]!.GetValue<string>()] = node;
|
||||
}
|
||||
|
||||
foreach (var edge in batch.Edges)
|
||||
{
|
||||
edges[edge["id"]!.GetValue<string>()] = edge;
|
||||
}
|
||||
}
|
||||
|
||||
var orderedNodes = nodes.Values
|
||||
.OrderBy(node => node["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges.Values
|
||||
.OrderBy(edge => edge["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new GraphBuildBatch(orderedNodes, orderedEdges);
|
||||
}
|
||||
|
||||
private static T Load<T>(string fixtureFile)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fixtureFile);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Advisory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class MongoGraphDocumentWriterTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
private readonly MongoTestContext _context;
|
||||
private readonly MongoGraphDocumentWriter? _writer;
|
||||
private readonly IMongoCollection<BsonDocument>? _nodeCollection;
|
||||
private readonly IMongoCollection<BsonDocument>? _edgeCollection;
|
||||
|
||||
public MongoGraphDocumentWriterTests()
|
||||
{
|
||||
_context = MongoTestContext.Create();
|
||||
if (_context.SkipReason is null)
|
||||
{
|
||||
var database = _context.Database ?? throw new InvalidOperationException("MongoDB test context initialized without a database.");
|
||||
_writer = new MongoGraphDocumentWriter(database);
|
||||
_nodeCollection = database.GetCollection<BsonDocument>("graph_nodes");
|
||||
_edgeCollection = database.GetCollection<BsonDocument>("graph_edges");
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task WriteAsync_upserts_nodes_and_edges()
|
||||
{
|
||||
Skip.If(_context.SkipReason is not null, _context.SkipReason ?? string.Empty);
|
||||
|
||||
var writer = _writer!;
|
||||
var nodeCollection = _nodeCollection!;
|
||||
var edgeCollection = _edgeCollection!;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
var transformer = new AdvisoryLinksetTransformer();
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
await writer.WriteAsync(batch, CancellationToken.None);
|
||||
|
||||
var nodes = await nodeCollection
|
||||
.Find(FilterDefinition<BsonDocument>.Empty)
|
||||
.ToListAsync();
|
||||
var edges = await edgeCollection
|
||||
.Find(FilterDefinition<BsonDocument>.Empty)
|
||||
.ToListAsync();
|
||||
|
||||
nodes.Should().HaveCount(batch.Nodes.Length);
|
||||
edges.Should().HaveCount(batch.Edges.Length);
|
||||
|
||||
// Write the same batch again to ensure idempotency through upsert.
|
||||
await writer.WriteAsync(batch, CancellationToken.None);
|
||||
|
||||
var nodesAfter = await nodeCollection
|
||||
.Find(Builders<BsonDocument>.Filter.Empty)
|
||||
.ToListAsync();
|
||||
var edgesAfter = await edgeCollection
|
||||
.Find(Builders<BsonDocument>.Filter.Empty)
|
||||
.ToListAsync();
|
||||
|
||||
nodesAfter.Should().HaveCount(batch.Nodes.Length);
|
||||
edgesAfter.Should().HaveCount(batch.Edges.Length);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task WriteAsync_replaces_existing_documents()
|
||||
{
|
||||
Skip.If(_context.SkipReason is not null, _context.SkipReason ?? string.Empty);
|
||||
|
||||
var writer = _writer!;
|
||||
var edgeCollection = _edgeCollection!;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
var transformer = new AdvisoryLinksetTransformer();
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
await writer.WriteAsync(batch, CancellationToken.None);
|
||||
|
||||
// change provenance offset to ensure replacement occurs
|
||||
var snapshotJson = JsonSerializer.Serialize(snapshot);
|
||||
var document = JsonNode.Parse(snapshotJson)!.AsObject();
|
||||
document["eventOffset"] = snapshot.EventOffset + 10;
|
||||
var mutated = document.Deserialize<AdvisoryLinksetSnapshot>(new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
var mutatedBatch = transformer.Transform(mutated);
|
||||
|
||||
await writer.WriteAsync(mutatedBatch, CancellationToken.None);
|
||||
|
||||
var edges = await edgeCollection
|
||||
.Find(FilterDefinition<BsonDocument>.Empty)
|
||||
.ToListAsync();
|
||||
|
||||
edges.Should().HaveCount(1);
|
||||
edges.Single()["provenance"]["event_offset"].AsInt64.Should().Be(mutated.EventOffset);
|
||||
}
|
||||
|
||||
private static AdvisoryLinksetSnapshot LoadSnapshot()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1", "linkset-snapshot.json");
|
||||
return JsonSerializer.Deserialize<AdvisoryLinksetSnapshot>(File.ReadAllText(path), new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => _context.DisposeAsync().AsTask();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
private sealed class MongoTestContext : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private const string ExternalMongoEnv = "STELLAOPS_TEST_MONGO_URI";
|
||||
private const string DefaultLocalMongo = "mongodb://127.0.0.1:27017";
|
||||
|
||||
private readonly bool _ownsDatabase;
|
||||
private readonly string? _databaseName;
|
||||
|
||||
private MongoTestContext(IMongoClient? client, IMongoDatabase? database, MongoDbRunner? runner, bool ownsDatabase, string? skipReason)
|
||||
{
|
||||
Client = client;
|
||||
Database = database;
|
||||
Runner = runner;
|
||||
_ownsDatabase = ownsDatabase;
|
||||
_databaseName = database?.DatabaseNamespace.DatabaseName;
|
||||
SkipReason = skipReason;
|
||||
}
|
||||
|
||||
public IMongoClient? Client { get; }
|
||||
public IMongoDatabase? Database { get; }
|
||||
public MongoDbRunner? Runner { get; }
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public static MongoTestContext Create()
|
||||
{
|
||||
// 1) Explicit override via env var (CI/local scripted).
|
||||
var uri = Environment.GetEnvironmentVariable(ExternalMongoEnv);
|
||||
if (TryCreateExternal(uri, out var externalContext))
|
||||
{
|
||||
return externalContext!;
|
||||
}
|
||||
|
||||
// 2) Try localhost default.
|
||||
if (TryCreateExternal(DefaultLocalMongo, out externalContext))
|
||||
{
|
||||
return externalContext!;
|
||||
}
|
||||
|
||||
// 3) Fallback to Mongo2Go embedded runner.
|
||||
if (TryCreateEmbedded(out var embeddedContext))
|
||||
{
|
||||
return embeddedContext!;
|
||||
}
|
||||
|
||||
return new MongoTestContext(null, null, null, ownsDatabase: false,
|
||||
skipReason: "MongoDB unavailable: set STELLAOPS_TEST_MONGO_URI or run mongod on 127.0.0.1:27017.");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Runner is not null)
|
||||
{
|
||||
Runner.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ownsDatabase && Client is not null && _databaseName is not null)
|
||||
{
|
||||
await Client.DropDatabaseAsync(_databaseName).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Runner?.Dispose();
|
||||
if (_ownsDatabase && Client is not null && _databaseName is not null)
|
||||
{
|
||||
Client.DropDatabase(_databaseName);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateExternal(string? uri, out MongoTestContext? context)
|
||||
{
|
||||
context = null;
|
||||
if (string.IsNullOrWhiteSpace(uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = new MongoClient(uri);
|
||||
var dbName = $"graph-indexer-tests-{Guid.NewGuid():N}";
|
||||
var database = client.GetDatabase(dbName);
|
||||
// Ping to ensure connectivity.
|
||||
database.RunCommand<BsonDocument>(new BsonDocument("ping", 1));
|
||||
context = new MongoTestContext(client, database, runner: null, ownsDatabase: true, skipReason: null);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateEmbedded(out MongoTestContext? context)
|
||||
{
|
||||
context = null;
|
||||
try
|
||||
{
|
||||
var runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
var client = new MongoClient(runner.ConnectionString);
|
||||
var database = client.GetDatabase("graph-indexer-tests");
|
||||
context = new MongoTestContext(client, database, runner, ownsDatabase: false, skipReason: null);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Policy;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class PolicyOverlayProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessAsync_persists_overlay_and_records_success_metrics()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
var transformer = new PolicyOverlayTransformer();
|
||||
var writer = new CaptureWriter();
|
||||
var metrics = new CaptureMetrics();
|
||||
var processor = new PolicyOverlayProcessor(
|
||||
transformer,
|
||||
writer,
|
||||
metrics,
|
||||
NullLogger<PolicyOverlayProcessor>.Instance);
|
||||
|
||||
await processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
writer.LastBatch.Should().NotBeNull();
|
||||
metrics.LastRecord.Should().NotBeNull();
|
||||
metrics.LastRecord!.Success.Should().BeTrue();
|
||||
metrics.LastRecord.NodeCount.Should().Be(writer.LastBatch!.Nodes.Length);
|
||||
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_records_failure_when_writer_throws()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
var transformer = new PolicyOverlayTransformer();
|
||||
var writer = new CaptureWriter(shouldThrow: true);
|
||||
var metrics = new CaptureMetrics();
|
||||
var processor = new PolicyOverlayProcessor(
|
||||
transformer,
|
||||
writer,
|
||||
metrics,
|
||||
NullLogger<PolicyOverlayProcessor>.Instance);
|
||||
|
||||
var act = () => processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
metrics.LastRecord.Should().NotBeNull();
|
||||
metrics.LastRecord!.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static PolicyOverlaySnapshot CreateSnapshot()
|
||||
{
|
||||
return new PolicyOverlaySnapshot
|
||||
{
|
||||
Tenant = "tenant-alpha",
|
||||
Source = "policy.engine.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:07:00Z"),
|
||||
EventOffset = 4200,
|
||||
Policy = new PolicyVersionDetails
|
||||
{
|
||||
Source = "policy.engine.v1",
|
||||
PolicyPackDigest = "sha256:fff666",
|
||||
PolicyName = "Default Runtime Policy",
|
||||
EffectiveFrom = DateTimeOffset.Parse("2025-10-28T00:00:00Z"),
|
||||
ExpiresAt = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
ExplainHash = "sha256:explain001",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-28T00:00:05Z"),
|
||||
EventOffset = 4100
|
||||
},
|
||||
Evaluations = new[]
|
||||
{
|
||||
new PolicyEvaluation
|
||||
{
|
||||
ComponentPurl = "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
ComponentSourceType = "inventory",
|
||||
FindingExplainHash = "sha256:explain001",
|
||||
ExplainHash = "sha256:explain001",
|
||||
PolicyRuleId = "rule:runtime/critical-dependency",
|
||||
Verdict = "fail",
|
||||
EvaluationTimestamp = DateTimeOffset.Parse("2025-10-30T12:07:00Z"),
|
||||
SbomDigest = "sha256:sbom111",
|
||||
Source = "policy.engine.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:07:00Z"),
|
||||
EventOffset = 4200
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class CaptureWriter : IGraphDocumentWriter
|
||||
{
|
||||
private readonly bool _shouldThrow;
|
||||
|
||||
public CaptureWriter(bool shouldThrow = false)
|
||||
{
|
||||
_shouldThrow = shouldThrow;
|
||||
}
|
||||
|
||||
public GraphBuildBatch? LastBatch { get; private set; }
|
||||
|
||||
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
|
||||
{
|
||||
LastBatch = batch;
|
||||
|
||||
if (_shouldThrow)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated persistence failure");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureMetrics : IPolicyOverlayMetrics
|
||||
{
|
||||
public MetricRecord? LastRecord { get; private set; }
|
||||
|
||||
public void RecordBatch(string source, string tenant, int nodeCount, int edgeCount, TimeSpan duration, bool success)
|
||||
{
|
||||
LastRecord = new MetricRecord(source, tenant, nodeCount, edgeCount, duration, success);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MetricRecord(
|
||||
string Source,
|
||||
string Tenant,
|
||||
int NodeCount,
|
||||
int EdgeCount,
|
||||
TimeSpan Duration,
|
||||
bool Success);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Policy;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class PolicyOverlayTransformerTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PolicyOverlayTransformerTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"policy_version"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"GOVERNS_WITH"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Transform_projects_policy_nodes_and_governs_with_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("policy-overlay.json");
|
||||
var transformer = new PolicyOverlayTransformer();
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
var expectedNodes = LoadArray("nodes.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var expectedEdges = LoadArray("edges.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualNodes = batch.Nodes
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualEdges = batch.Edges
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
actualNodes.Length.Should().Be(expectedNodes.Length);
|
||||
actualEdges.Length.Should().Be(expectedEdges.Length);
|
||||
|
||||
for (var i = 0; i < expectedNodes.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
|
||||
_output.WriteLine($"Actual Node: {actualNodes[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
|
||||
}
|
||||
|
||||
for (var i = 0; i < expectedEdges.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
|
||||
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyOverlaySnapshot LoadSnapshot(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<PolicyOverlaySnapshot>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
private static JsonArray LoadArray(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
|
||||
}
|
||||
}
|
||||
14
tests/Graph/StellaOps.Graph.Indexer.Tests/README.md
Normal file
14
tests/Graph/StellaOps.Graph.Indexer.Tests/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# StellaOps Graph Indexer Tests
|
||||
|
||||
The Graph Indexer integration tests exercise the Mongo-backed document writer.
|
||||
To run the suite locally (or in CI) you **must** point the tests at a reachable MongoDB instance.
|
||||
|
||||
## Required environment
|
||||
|
||||
```bash
|
||||
export STELLAOPS_TEST_MONGO_URI="mongodb://user:pass@host:27017/test-db"
|
||||
```
|
||||
|
||||
The harness will try the connection string above first, then fall back to `mongodb://127.0.0.1:27017`, and finally to an embedded MongoDB instance via Mongo2Go. If neither the URI nor a local `mongod` is reachable, the Mongo writer tests are skipped with a diagnostic message.
|
||||
|
||||
CI pipelines are configured to fail early when `STELLAOPS_TEST_MONGO_URI` is missing so that the integration coverage always runs with a known database.
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomIngestProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessAsync_writes_batch_and_records_success_metrics()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
var transformer = new SbomIngestTransformer();
|
||||
var writer = new CaptureWriter();
|
||||
var metrics = new CaptureMetrics();
|
||||
var snapshotExporter = new CaptureSnapshotExporter();
|
||||
var processor = new SbomIngestProcessor(transformer, writer, metrics, snapshotExporter, NullLogger<SbomIngestProcessor>.Instance);
|
||||
|
||||
await processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
writer.LastBatch.Should().NotBeNull();
|
||||
metrics.LastRecord.Should().NotBeNull();
|
||||
metrics.LastRecord!.Success.Should().BeTrue();
|
||||
metrics.LastRecord.NodeCount.Should().Be(writer.LastBatch!.Nodes.Length);
|
||||
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
|
||||
snapshotExporter.LastSnapshot.Should().BeSameAs(snapshot);
|
||||
snapshotExporter.LastBatch.Should().BeSameAs(writer.LastBatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_records_failure_when_writer_throws()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
var transformer = new SbomIngestTransformer();
|
||||
var writer = new CaptureWriter(shouldThrow: true);
|
||||
var metrics = new CaptureMetrics();
|
||||
var snapshotExporter = new CaptureSnapshotExporter();
|
||||
var processor = new SbomIngestProcessor(transformer, writer, metrics, snapshotExporter, NullLogger<SbomIngestProcessor>.Instance);
|
||||
|
||||
var act = () => processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
metrics.LastRecord.Should().NotBeNull();
|
||||
metrics.LastRecord!.Success.Should().BeFalse();
|
||||
snapshotExporter.LastSnapshot.Should().BeNull();
|
||||
snapshotExporter.LastBatch.Should().BeNull();
|
||||
}
|
||||
|
||||
private static SbomSnapshot CreateSnapshot()
|
||||
{
|
||||
return new SbomSnapshot
|
||||
{
|
||||
Tenant = "tenant-alpha",
|
||||
Source = "scanner.sbom.v1",
|
||||
ArtifactDigest = "sha256:test-artifact",
|
||||
SbomDigest = "sha256:test-sbom",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:00Z"),
|
||||
EventOffset = 1000,
|
||||
Artifact = new SbomArtifactMetadata
|
||||
{
|
||||
DisplayName = "registry.example.com/app:latest",
|
||||
Environment = "prod",
|
||||
Labels = new[] { "demo" },
|
||||
OriginRegistry = "registry.example.com",
|
||||
SupplyChainStage = "deploy"
|
||||
},
|
||||
Build = new SbomBuildMetadata
|
||||
{
|
||||
BuilderId = "builder://tekton/default",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
AttestationDigest = "sha256:attestation",
|
||||
Source = "scanner.build.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:05Z"),
|
||||
EventOffset = 2000
|
||||
},
|
||||
Components = new[]
|
||||
{
|
||||
new SbomComponent
|
||||
{
|
||||
Purl = "pkg:nuget/Example.Primary@1.0.0",
|
||||
Version = "1.0.0",
|
||||
Ecosystem = "nuget",
|
||||
Scope = "runtime",
|
||||
License = new SbomLicense
|
||||
{
|
||||
Spdx = "MIT",
|
||||
Name = "MIT License",
|
||||
Classification = "permissive",
|
||||
SourceDigest = "sha256:license001"
|
||||
},
|
||||
Usage = "direct",
|
||||
DetectedBy = "sbom.analyzer.transformer",
|
||||
LayerDigest = "sha256:layer",
|
||||
EvidenceDigest = "sha256:evidence",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:01Z"),
|
||||
EventOffset = 1201,
|
||||
Source = "scanner.component.v1",
|
||||
Files = new[]
|
||||
{
|
||||
new SbomComponentFile
|
||||
{
|
||||
Path = "/src/app/Program.cs",
|
||||
ContentSha256 = "sha256:file",
|
||||
LanguageHint = "csharp",
|
||||
SizeBytes = 1024,
|
||||
Scope = "build",
|
||||
DetectedBy = "sbom.analyzer.transformer",
|
||||
EvidenceDigest = "sha256:file-evidence",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:02Z"),
|
||||
EventOffset = 1202,
|
||||
Source = "scanner.layer.v1"
|
||||
}
|
||||
},
|
||||
Dependencies = Array.Empty<SbomDependency>(),
|
||||
SourceType = "inventory"
|
||||
}
|
||||
},
|
||||
BaseArtifacts = new[]
|
||||
{
|
||||
new SbomBaseArtifact
|
||||
{
|
||||
ArtifactDigest = "sha256:base",
|
||||
SbomDigest = "sha256:base-sbom",
|
||||
DisplayName = "registry.example.com/base:2025.09",
|
||||
Environment = "prod",
|
||||
Labels = new[] { "base-image" },
|
||||
OriginRegistry = "registry.example.com",
|
||||
SupplyChainStage = "build",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-10-22T08:00:00Z"),
|
||||
EventOffset = 800,
|
||||
Source = "scanner.sbom.v1"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class CaptureWriter : IGraphDocumentWriter
|
||||
{
|
||||
private readonly bool _shouldThrow;
|
||||
|
||||
public CaptureWriter(bool shouldThrow = false)
|
||||
{
|
||||
_shouldThrow = shouldThrow;
|
||||
}
|
||||
|
||||
public GraphBuildBatch? LastBatch { get; private set; }
|
||||
|
||||
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
|
||||
{
|
||||
LastBatch = batch;
|
||||
|
||||
if (_shouldThrow)
|
||||
{
|
||||
throw new InvalidOperationException("Simulated persistence failure");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureMetrics : ISbomIngestMetrics
|
||||
{
|
||||
public MetricRecord? LastRecord { get; private set; }
|
||||
|
||||
public void RecordBatch(string source, string tenant, int nodeCount, int edgeCount, TimeSpan duration, bool success)
|
||||
{
|
||||
LastRecord = new MetricRecord(source, tenant, nodeCount, edgeCount, duration, success);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureSnapshotExporter : ISbomSnapshotExporter
|
||||
{
|
||||
public SbomSnapshot? LastSnapshot { get; private set; }
|
||||
public GraphBuildBatch? LastBatch { get; private set; }
|
||||
|
||||
public Task ExportAsync(SbomSnapshot snapshot, GraphBuildBatch batch, CancellationToken cancellationToken)
|
||||
{
|
||||
LastSnapshot = snapshot;
|
||||
LastBatch = batch;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MetricRecord(
|
||||
string Source,
|
||||
string Tenant,
|
||||
int NodeCount,
|
||||
int EdgeCount,
|
||||
TimeSpan Duration,
|
||||
bool Success);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomIngestServiceCollectionExtensionsTests : IDisposable
|
||||
{
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
private readonly string _tempDirectory;
|
||||
|
||||
public SbomIngestServiceCollectionExtensionsTests()
|
||||
{
|
||||
_tempDirectory = Path.Combine(Path.GetTempPath(), $"graph-indexer-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSbomIngestPipeline_exports_snapshots_to_configured_directory()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IGraphDocumentWriter, CaptureWriter>();
|
||||
services.AddSbomIngestPipeline(options => options.SnapshotRootDirectory = _tempDirectory);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var processor = provider.GetRequiredService<SbomIngestProcessor>();
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
await processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
AssertSnapshotOutputs(_tempDirectory);
|
||||
|
||||
var writer = provider.GetRequiredService<IGraphDocumentWriter>() as CaptureWriter;
|
||||
writer!.LastBatch.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSbomIngestPipeline_uses_environment_variable_when_not_configured()
|
||||
{
|
||||
var previous = Environment.GetEnvironmentVariable("STELLAOPS_GRAPH_SNAPSHOT_DIR");
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_GRAPH_SNAPSHOT_DIR", _tempDirectory);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IGraphDocumentWriter, CaptureWriter>();
|
||||
services.AddSbomIngestPipeline();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var processor = provider.GetRequiredService<SbomIngestProcessor>();
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
await processor.ProcessAsync(snapshot, CancellationToken.None);
|
||||
|
||||
AssertSnapshotOutputs(_tempDirectory);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_GRAPH_SNAPSHOT_DIR", previous);
|
||||
}
|
||||
}
|
||||
|
||||
private static SbomSnapshot LoadSnapshot()
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, "sbom-snapshot.json");
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<SbomSnapshot>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
private static void AssertSnapshotOutputs(string root)
|
||||
{
|
||||
var manifestPath = Path.Combine(root, "manifest.json");
|
||||
var adjacencyPath = Path.Combine(root, "adjacency.json");
|
||||
var nodesPath = Path.Combine(root, "nodes.jsonl");
|
||||
var edgesPath = Path.Combine(root, "edges.jsonl");
|
||||
|
||||
File.Exists(manifestPath).Should().BeTrue("manifest should be exported");
|
||||
File.Exists(adjacencyPath).Should().BeTrue("adjacency manifest should be exported");
|
||||
File.Exists(nodesPath).Should().BeTrue("node stream should be exported");
|
||||
File.Exists(edgesPath).Should().BeTrue("edge stream should be exported");
|
||||
|
||||
new FileInfo(manifestPath).Length.Should().BeGreaterThan(0);
|
||||
new FileInfo(adjacencyPath).Length.Should().BeGreaterThan(0);
|
||||
new FileInfo(nodesPath).Length.Should().BeGreaterThan(0);
|
||||
new FileInfo(edgesPath).Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDirectory))
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures in CI environments.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureWriter : IGraphDocumentWriter
|
||||
{
|
||||
public GraphBuildBatch? LastBatch { get; private set; }
|
||||
|
||||
public Task WriteAsync(GraphBuildBatch batch, CancellationToken cancellationToken)
|
||||
{
|
||||
LastBatch = batch;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomIngestTransformerTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public SbomIngestTransformerTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"artifact",
|
||||
"component",
|
||||
"file"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"CONTAINS",
|
||||
"DEPENDS_ON",
|
||||
"DECLARED_IN",
|
||||
"BUILT_FROM"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Transform_produces_expected_nodes_and_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("sbom-snapshot.json");
|
||||
var transformer = new SbomIngestTransformer();
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
var expectedNodes = LoadArray("nodes.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var expectedEdges = LoadArray("edges.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualNodes = batch.Nodes
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualEdges = batch.Edges
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
actualNodes.Length.Should().Be(expectedNodes.Length);
|
||||
actualEdges.Length.Should().Be(expectedEdges.Length);
|
||||
|
||||
for (var i = 0; i < expectedNodes.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
|
||||
_output.WriteLine($"Actual Node: {actualNodes[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
|
||||
}
|
||||
|
||||
for (var i = 0; i < expectedEdges.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
|
||||
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_deduplicates_license_nodes_case_insensitive()
|
||||
{
|
||||
var baseCollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:00Z");
|
||||
var components = new[]
|
||||
{
|
||||
CreateComponent(
|
||||
purl: "pkg:nuget/Example.Primary@1.0.0",
|
||||
spdx: "MIT",
|
||||
sourceDigest: "sha256:license001",
|
||||
collectedAt: baseCollectedAt.AddSeconds(1),
|
||||
eventOffset: 1201,
|
||||
source: "scanner.component.v1"),
|
||||
CreateComponent(
|
||||
purl: "pkg:nuget/Example.Secondary@2.0.0",
|
||||
spdx: "mit",
|
||||
sourceDigest: "SHA256:LICENSE001",
|
||||
collectedAt: baseCollectedAt.AddSeconds(2),
|
||||
eventOffset: 1202,
|
||||
usage: "transitive",
|
||||
source: "scanner.component.v1")
|
||||
};
|
||||
|
||||
var snapshot = CreateSnapshot(components: components);
|
||||
var transformer = new SbomIngestTransformer();
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
var licenseNodes = batch.Nodes
|
||||
.Where(node => string.Equals(node["kind"]!.GetValue<string>(), "license", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
licenseNodes.Should().HaveCount(1);
|
||||
var canonicalKey = licenseNodes[0]["canonical_key"]!.AsObject();
|
||||
canonicalKey["license_spdx"]!.GetValue<string>().Should().Be("MIT");
|
||||
canonicalKey["source_digest"]!.GetValue<string>().Should().Be("sha256:license001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_emits_built_from_edge_with_provenance()
|
||||
{
|
||||
var snapshot = LoadSnapshot("sbom-snapshot.json");
|
||||
var transformer = new SbomIngestTransformer();
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
var builtFrom = batch.Edges.Single(edge => edge["kind"]!.GetValue<string>() == "BUILT_FROM");
|
||||
|
||||
var attributes = builtFrom["attributes"]!.AsObject();
|
||||
attributes["build_type"]!.GetValue<string>().Should().Be(snapshot.Build.BuildType);
|
||||
attributes["builder_id"]!.GetValue<string>().Should().Be(snapshot.Build.BuilderId);
|
||||
attributes["attestation_digest"]!.GetValue<string>().Should().Be(snapshot.Build.AttestationDigest);
|
||||
|
||||
var provenance = builtFrom["provenance"]!.AsObject();
|
||||
provenance["source"]!.GetValue<string>().Should().Be(snapshot.Build.Source);
|
||||
provenance["collected_at"]!.GetValue<string>()
|
||||
.Should().Be(snapshot.Build.CollectedAt.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"));
|
||||
|
||||
var canonicalKey = builtFrom["canonical_key"]!.AsObject();
|
||||
canonicalKey.ContainsKey("parent_artifact_node_id").Should().BeTrue();
|
||||
canonicalKey.ContainsKey("child_artifact_digest").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_normalizes_valid_from_to_utc()
|
||||
{
|
||||
var componentCollectedAt = new DateTimeOffset(2025, 11, 1, 15, 30, 45, TimeSpan.FromHours(2));
|
||||
var components = new[]
|
||||
{
|
||||
CreateComponent(
|
||||
purl: "pkg:nuget/Example.Primary@1.0.0",
|
||||
spdx: "Apache-2.0",
|
||||
sourceDigest: "sha256:license002",
|
||||
collectedAt: componentCollectedAt,
|
||||
eventOffset: 2101,
|
||||
source: "scanner.component.v1")
|
||||
};
|
||||
|
||||
var snapshot = CreateSnapshot(
|
||||
components: components,
|
||||
collectedAt: componentCollectedAt.AddSeconds(-1),
|
||||
eventOffset: 2000);
|
||||
|
||||
var transformer = new SbomIngestTransformer();
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
var componentNode = batch.Nodes.Single(node => node["kind"]!.GetValue<string>() == "component");
|
||||
componentNode["valid_from"]!.GetValue<string>().Should().Be("2025-11-01T13:30:45Z");
|
||||
|
||||
var containsEdge = batch.Edges.Single(edge => edge["kind"]!.GetValue<string>() == "CONTAINS");
|
||||
containsEdge["valid_from"]!.GetValue<string>().Should().Be("2025-11-01T13:30:46Z");
|
||||
}
|
||||
|
||||
private static SbomSnapshot CreateSnapshot(
|
||||
IEnumerable<SbomComponent>? components = null,
|
||||
IEnumerable<SbomBaseArtifact>? baseArtifacts = null,
|
||||
DateTimeOffset? collectedAt = null,
|
||||
long eventOffset = 1000,
|
||||
string? source = null,
|
||||
SbomArtifactMetadata? artifact = null,
|
||||
SbomBuildMetadata? build = null)
|
||||
{
|
||||
return new SbomSnapshot
|
||||
{
|
||||
Tenant = "tenant-alpha",
|
||||
Source = source ?? "scanner.sbom.v1",
|
||||
ArtifactDigest = "sha256:test-artifact",
|
||||
SbomDigest = "sha256:test-sbom",
|
||||
CollectedAt = collectedAt ?? DateTimeOffset.Parse("2025-10-30T12:00:00Z"),
|
||||
EventOffset = eventOffset,
|
||||
Artifact = artifact ?? new SbomArtifactMetadata
|
||||
{
|
||||
DisplayName = "registry.example.com/app:latest",
|
||||
Environment = "prod",
|
||||
Labels = new[] { "critical" },
|
||||
OriginRegistry = "registry.example.com",
|
||||
SupplyChainStage = "deploy"
|
||||
},
|
||||
Build = build ?? new SbomBuildMetadata
|
||||
{
|
||||
BuilderId = "builder://tekton/default",
|
||||
BuildType = "https://slsa.dev/provenance/v1",
|
||||
AttestationDigest = "sha256:attestation",
|
||||
Source = "scanner.build.v1",
|
||||
CollectedAt = (collectedAt ?? DateTimeOffset.Parse("2025-10-30T12:00:00Z")).AddSeconds(5),
|
||||
EventOffset = eventOffset + 100
|
||||
},
|
||||
Components = (components ?? Array.Empty<SbomComponent>()).ToArray(),
|
||||
BaseArtifacts = (baseArtifacts ?? Array.Empty<SbomBaseArtifact>()).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomComponent CreateComponent(
|
||||
string purl,
|
||||
string spdx,
|
||||
string sourceDigest,
|
||||
DateTimeOffset collectedAt,
|
||||
long eventOffset,
|
||||
string version = "1.0.0",
|
||||
string usage = "direct",
|
||||
string? source = null,
|
||||
string detectedBy = "sbom.analyzer.transformer",
|
||||
string scope = "runtime",
|
||||
IEnumerable<SbomComponentFile>? files = null,
|
||||
IEnumerable<SbomDependency>? dependencies = null)
|
||||
{
|
||||
return new SbomComponent
|
||||
{
|
||||
Purl = purl,
|
||||
Version = version,
|
||||
Ecosystem = "nuget",
|
||||
Scope = scope,
|
||||
License = new SbomLicense
|
||||
{
|
||||
Spdx = spdx,
|
||||
Name = $"{spdx} License",
|
||||
Classification = "permissive",
|
||||
SourceDigest = sourceDigest,
|
||||
NoticeUri = null
|
||||
},
|
||||
Usage = usage,
|
||||
DetectedBy = detectedBy,
|
||||
LayerDigest = "sha256:layer",
|
||||
EvidenceDigest = "sha256:evidence",
|
||||
CollectedAt = collectedAt,
|
||||
EventOffset = eventOffset,
|
||||
Source = source ?? "scanner.component.v1",
|
||||
Files = (files ?? Array.Empty<SbomComponentFile>()).ToArray(),
|
||||
Dependencies = (dependencies ?? Array.Empty<SbomDependency>()).ToArray(),
|
||||
SourceType = "inventory"
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomSnapshot LoadSnapshot(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<SbomSnapshot>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
private static JsonArray LoadArray(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Documents;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Advisory;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Policy;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Vex;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomSnapshotExporterTests
|
||||
{
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_writes_manifest_adjacency_nodes_and_edges()
|
||||
{
|
||||
var sbomSnapshot = Load<SbomSnapshot>("sbom-snapshot.json");
|
||||
var linksetSnapshot = Load<AdvisoryLinksetSnapshot>("concelier-linkset.json");
|
||||
var vexSnapshot = Load<VexOverlaySnapshot>("excititor-vex.json");
|
||||
var policySnapshot = Load<PolicyOverlaySnapshot>("policy-overlay.json");
|
||||
|
||||
var sbomBatch = new SbomIngestTransformer().Transform(sbomSnapshot);
|
||||
var advisoryBatch = new AdvisoryLinksetTransformer().Transform(linksetSnapshot);
|
||||
var vexBatch = new VexOverlayTransformer().Transform(vexSnapshot);
|
||||
var policyBatch = new PolicyOverlayTransformer().Transform(policySnapshot);
|
||||
|
||||
var combinedBatch = MergeBatches(sbomBatch, advisoryBatch, vexBatch, policyBatch);
|
||||
|
||||
var builder = new GraphSnapshotBuilder();
|
||||
var writer = new InMemorySnapshotFileWriter();
|
||||
var exporter = new SbomSnapshotExporter(builder, writer);
|
||||
|
||||
await exporter.ExportAsync(sbomSnapshot, combinedBatch, CancellationToken.None);
|
||||
|
||||
writer.JsonFiles.Should().ContainKey("manifest.json");
|
||||
writer.JsonFiles.Should().ContainKey("adjacency.json");
|
||||
writer.JsonLinesFiles.Should().ContainKey("nodes.jsonl");
|
||||
writer.JsonLinesFiles.Should().ContainKey("edges.jsonl");
|
||||
|
||||
var manifest = writer.JsonFiles["manifest.json"];
|
||||
manifest["tenant"]!.GetValue<string>().Should().Be("tenant-alpha");
|
||||
manifest["node_count"]!.GetValue<int>().Should().Be(combinedBatch.Nodes.Length);
|
||||
manifest["edge_count"]!.GetValue<int>().Should().Be(combinedBatch.Edges.Length);
|
||||
manifest["hash"]!.GetValue<string>().Should().NotBeNullOrEmpty();
|
||||
|
||||
var adjacency = writer.JsonFiles["adjacency.json"];
|
||||
adjacency["tenant"]!.GetValue<string>().Should().Be("tenant-alpha");
|
||||
adjacency["nodes"]!.AsArray().Should().HaveCount(combinedBatch.Nodes.Length);
|
||||
|
||||
writer.JsonLinesFiles["nodes.jsonl"].Should().HaveCount(combinedBatch.Nodes.Length);
|
||||
writer.JsonLinesFiles["edges.jsonl"].Should().HaveCount(combinedBatch.Edges.Length);
|
||||
}
|
||||
|
||||
private static GraphBuildBatch MergeBatches(params GraphBuildBatch[] batches)
|
||||
{
|
||||
var nodes = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
|
||||
var edges = new Dictionary<string, JsonObject>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
foreach (var node in batch.Nodes)
|
||||
{
|
||||
nodes[node["id"]!.GetValue<string>()] = node;
|
||||
}
|
||||
|
||||
foreach (var edge in batch.Edges)
|
||||
{
|
||||
edges[edge["id"]!.GetValue<string>()] = edge;
|
||||
}
|
||||
}
|
||||
|
||||
var orderedNodes = nodes.Values
|
||||
.OrderBy(node => node["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges.Values
|
||||
.OrderBy(edge => edge["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new GraphBuildBatch(orderedNodes, orderedEdges);
|
||||
}
|
||||
|
||||
private static T Load<T>(string fixtureFile)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fixtureFile);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
private sealed class InMemorySnapshotFileWriter : ISnapshotFileWriter
|
||||
{
|
||||
public Dictionary<string, JsonObject> JsonFiles { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, List<JsonObject>> JsonLinesFiles { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Task WriteJsonAsync(string relativePath, JsonObject content, CancellationToken cancellationToken)
|
||||
{
|
||||
JsonFiles[relativePath] = (JsonObject)content.DeepClone();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WriteJsonLinesAsync(string relativePath, IEnumerable<JsonObject> items, CancellationToken cancellationToken)
|
||||
{
|
||||
JsonLinesFiles[relativePath] = items
|
||||
.Select(item => (JsonObject)item.DeepClone())
|
||||
.ToList();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\..\\src\\Graph\\StellaOps.Graph.Indexer\\StellaOps.Graph.Indexer.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\**\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Vex;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class VexOverlayTransformerTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public VexOverlayTransformerTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
private static readonly HashSet<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"vex_statement"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ExpectedEdgeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"VEX_EXEMPTS"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Transform_projects_vex_nodes_and_exempt_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("excititor-vex.json");
|
||||
var transformer = new VexOverlayTransformer();
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
var expectedNodes = LoadArray("nodes.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var expectedEdges = LoadArray("edges.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualNodes = batch.Nodes
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualEdges = batch.Edges
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
actualNodes.Length.Should().Be(expectedNodes.Length);
|
||||
actualEdges.Length.Should().Be(expectedEdges.Length);
|
||||
|
||||
for (var i = 0; i < expectedNodes.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Node: {expectedNodes[i]}");
|
||||
_output.WriteLine($"Actual Node: {actualNodes[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue();
|
||||
}
|
||||
|
||||
for (var i = 0; i < expectedEdges.Length; i++)
|
||||
{
|
||||
if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]))
|
||||
{
|
||||
_output.WriteLine($"Expected Edge: {expectedEdges[i]}");
|
||||
_output.WriteLine($"Actual Edge: {actualEdges[i]}");
|
||||
}
|
||||
|
||||
JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static VexOverlaySnapshot LoadSnapshot(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<VexOverlaySnapshot>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
private static JsonArray LoadArray(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user