Add signal contracts for reachability, exploitability, trust, and unknown symbols
- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties. - Implemented JSON serialization attributes for proper data interchange. - Created project files for the new signal contracts library and corresponding test projects. - Added deterministic test fixtures for micro-interaction testing. - Included cryptographic keys for secure operations with cosign.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Ingestion.Inspector;
|
||||
|
||||
public sealed class GraphInspectorProcessor
|
||||
{
|
||||
private readonly GraphInspectorTransformer _transformer;
|
||||
private readonly IGraphDocumentWriter _writer;
|
||||
private readonly ILogger<GraphInspectorProcessor> _logger;
|
||||
|
||||
public GraphInspectorProcessor(
|
||||
GraphInspectorTransformer transformer,
|
||||
IGraphDocumentWriter writer,
|
||||
ILogger<GraphInspectorProcessor> logger)
|
||||
{
|
||||
_transformer = transformer ?? throw new ArgumentNullException(nameof(transformer));
|
||||
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task ProcessAsync(GraphInspectorSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
GraphBuildBatch batch;
|
||||
|
||||
try
|
||||
{
|
||||
batch = _transformer.Transform(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"graph-indexer: failed to transform graph.inspect snapshot for tenant {Tenant} artifact {ArtifactDigest}",
|
||||
snapshot.Tenant,
|
||||
snapshot.ArtifactDigest);
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await _writer.WriteAsync(batch, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"graph-indexer: ingested graph.inspect snapshot for tenant {Tenant} artifact {ArtifactDigest} with {NodeCount} nodes and {EdgeCount} edges in {DurationMs:F2} ms",
|
||||
snapshot.Tenant,
|
||||
snapshot.ArtifactDigest,
|
||||
batch.Nodes.Length,
|
||||
batch.Edges.Length,
|
||||
stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"graph-indexer: failed to persist graph.inspect snapshot for tenant {Tenant} artifact {ArtifactDigest}",
|
||||
snapshot.Tenant,
|
||||
snapshot.ArtifactDigest);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ public sealed class GraphInspectorTransformer
|
||||
},
|
||||
relationship.Provenance);
|
||||
|
||||
nodes.Add(targetNode);
|
||||
|
||||
var edge = CreateRelationshipEdge(snapshot, componentNode, targetNode, relationship);
|
||||
edges.Add(edge);
|
||||
}
|
||||
@@ -85,16 +87,40 @@ public sealed class GraphInspectorTransformer
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in componentNodes.Values)
|
||||
{
|
||||
var id = node["id"]!.GetValue<string>();
|
||||
if (!nodes.Any(n => n["id"]!.GetValue<string>() == id))
|
||||
{
|
||||
nodes.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all relationship targets are represented as component nodes even if they were not emitted during primary loops.
|
||||
var targetPurls = snapshot.Components
|
||||
.SelectMany(c => c.Relationships ?? Array.Empty<GraphInspectorRelationship>())
|
||||
.Select(r => r.TargetPurl)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var targetPurl in targetPurls)
|
||||
{
|
||||
var key = $"{targetPurl.Trim()}|{DefaultSourceType}";
|
||||
var targetNode = GetOrCreateComponentNode(
|
||||
snapshot,
|
||||
componentNodes,
|
||||
new GraphInspectorComponent { Purl = targetPurl },
|
||||
provenanceOverride: null);
|
||||
componentNodes[key] = targetNode;
|
||||
nodes.Add(targetNode);
|
||||
}
|
||||
|
||||
var orderedNodes = nodes
|
||||
.Distinct(JsonNodeEqualityComparer.Instance)
|
||||
.Select(n => (JsonObject)n)
|
||||
.OrderBy(n => n["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(n => n["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Distinct(JsonNodeEqualityComparer.Instance)
|
||||
.Select(e => (JsonObject)e)
|
||||
.OrderBy(e => e["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(e => e["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
@@ -409,25 +435,4 @@ public sealed class GraphInspectorTransformer
|
||||
EventOffset: eventOffset);
|
||||
}
|
||||
|
||||
private sealed class JsonNodeEqualityComparer : IEqualityComparer<JsonNode>
|
||||
{
|
||||
public static readonly JsonNodeEqualityComparer Instance = new();
|
||||
|
||||
public bool Equals(JsonNode? x, JsonNode? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return x.ToJsonString() == y.ToJsonString();
|
||||
}
|
||||
|
||||
public int GetHashCode(JsonNode obj) => obj.ToJsonString().GetHashCode(StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Ingestion.Inspector;
|
||||
|
||||
public static class InspectorIngestServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddInspectorIngestPipeline(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<GraphInspectorTransformer>();
|
||||
services.TryAddSingleton<GraphInspectorProcessor>(provider =>
|
||||
{
|
||||
var transformer = provider.GetRequiredService<GraphInspectorTransformer>();
|
||||
var writer = provider.GetRequiredService<Ingestion.Sbom.IGraphDocumentWriter>();
|
||||
var logger = provider.GetService<ILogger<GraphInspectorProcessor>>() ?? NullLogger<GraphInspectorProcessor>.Instance;
|
||||
return new GraphInspectorProcessor(transformer, writer, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Inspector;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
@@ -86,21 +88,49 @@ public sealed class GraphInspectorTransformerTests
|
||||
EventOffset = 5123,
|
||||
EvidenceHash = "c1"
|
||||
}
|
||||
},
|
||||
new GraphInspectorComponent
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Scopes = Array.Empty<string>(),
|
||||
Relationships = Array.Empty<GraphInspectorRelationship>(),
|
||||
Advisories = Array.Empty<GraphInspectorAdvisoryObservation>(),
|
||||
VexStatements = Array.Empty<GraphInspectorVexStatement>(),
|
||||
Provenance = new GraphInspectorProvenance
|
||||
{
|
||||
Source = "concelier.linkset.v1",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-12-04T15:29:00Z"),
|
||||
EventOffset = 6000,
|
||||
EvidenceHash = "e1"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var transformer = new GraphInspectorTransformer();
|
||||
|
||||
Assert.NotEmpty(snapshot.Components.First().Relationships);
|
||||
Assert.Contains("pkg:npm/lodash@4.17.21", snapshot.Components.SelectMany(c => c.Relationships).Select(r => r.TargetPurl));
|
||||
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
// Nodes: artifact + source component + target component + advisory + vex
|
||||
Assert.Equal(5, batch.Nodes.Length);
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "artifact");
|
||||
var nodesDebug = string.Join(" | ", batch.Nodes.Select(n => n.ToJsonString()));
|
||||
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:maven/org.example/foo@1.2.3");
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:npm/lodash@4.17.21");
|
||||
|
||||
Assert.True(
|
||||
batch.Nodes.Any(n => n["kind"]!.GetValue<string>() == "component" && n["canonical_key"]!["purl"]!.GetValue<string>() == "pkg:npm/lodash@4.17.21"),
|
||||
$"Missing target component node. Nodes: {nodesDebug}");
|
||||
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "advisory");
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "vex_statement");
|
||||
if (batch.Nodes.Length != 5)
|
||||
{
|
||||
var debug = string.Join(" | ", batch.Nodes.Select(n => n.ToJsonString()));
|
||||
throw new XunitException($"Expected 5 nodes, got {batch.Nodes.Length}: {debug}");
|
||||
}
|
||||
|
||||
// Edges: depends_on, affected_by, vex_exempts
|
||||
Assert.Contains(batch.Edges, e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
|
||||
@@ -108,8 +138,50 @@ public sealed class GraphInspectorTransformerTests
|
||||
Assert.Contains(batch.Edges, e => e["kind"]!.GetValue<string>() == "VEX_EXEMPTS");
|
||||
|
||||
// Provenance should carry sbom digest and event offset from snapshot/provenance overrides.
|
||||
var dependsOn = batch.Edges.Single(e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
|
||||
var dependsOn = batch.Edges.First(e => e["kind"]!.GetValue<string>() == "DEPENDS_ON");
|
||||
Assert.Equal("sha256:sbom", dependsOn["provenance"]!["sbom_digest"]!.GetValue<string>());
|
||||
Assert.Equal(6000, dependsOn["provenance"]!["event_offset"]!.GetValue<long>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_AcceptsPublishedSample()
|
||||
{
|
||||
var samplePath = LocateRepoFile("docs/modules/graph/contracts/examples/graph.inspect.v1.sample.json");
|
||||
var json = File.ReadAllText(samplePath);
|
||||
var snapshot = JsonSerializer.Deserialize<GraphInspectorSnapshot>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
|
||||
var transformer = new GraphInspectorTransformer();
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
Assert.NotEmpty(batch.Nodes);
|
||||
Assert.NotEmpty(batch.Edges);
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "advisory");
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "vex_statement");
|
||||
}
|
||||
|
||||
private static string LocateRepoFile(string relative)
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
var candidate = Path.Combine(dir, relative);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(dir);
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
dir = parent.FullName;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Unable to locate '{relative}' from base directory {AppContext.BaseDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user