feat: Add PathViewer and RiskDriftCard components with templates and styles

- Implemented PathViewerComponent for visualizing reachability call paths.
- Added RiskDriftCardComponent to display reachability drift results.
- Created corresponding HTML templates and SCSS styles for both components.
- Introduced test fixtures for reachability analysis in JSON format.
- Enhanced user interaction with collapsible and expandable features in PathViewer.
- Included risk trend visualization and summary metrics in RiskDriftCard.
This commit is contained in:
master
2025-12-18 18:35:30 +02:00
parent 811f35cba7
commit 0dc71e760a
70 changed files with 8904 additions and 163 deletions

View File

@@ -0,0 +1,304 @@
// -----------------------------------------------------------------------------
// AttestingRichGraphWriterTests.cs
// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
// Description: Tests for AttestingRichGraphWriter integration.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Attestation;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class AttestingRichGraphWriterTests : IAsyncLifetime
{
private DirectoryInfo _tempDir = null!;
public Task InitializeAsync()
{
_tempDir = Directory.CreateTempSubdirectory("attesting-writer-test-");
return Task.CompletedTask;
}
public Task DisposeAsync()
{
try
{
if (_tempDir.Exists)
{
_tempDir.Delete(recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
return Task.CompletedTask;
}
[Fact]
public async Task WriteWithAttestationAsync_WhenEnabled_ProducesAttestationFile()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result);
Assert.True(File.Exists(result.GraphPath));
Assert.True(File.Exists(result.MetaPath));
Assert.NotNull(result.AttestationPath);
Assert.True(File.Exists(result.AttestationPath));
Assert.NotNull(result.WitnessResult);
Assert.NotEmpty(result.WitnessResult.StatementHash);
}
[Fact]
public async Task WriteWithAttestationAsync_WhenDisabled_NoAttestationFile()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result);
Assert.True(File.Exists(result.GraphPath));
Assert.True(File.Exists(result.MetaPath));
Assert.Null(result.AttestationPath);
Assert.Null(result.WitnessResult);
}
[Fact]
public async Task WriteWithAttestationAsync_AttestationContainsValidDsse()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result.AttestationPath);
var dsseJson = await File.ReadAllTextAsync(result.AttestationPath);
Assert.Contains("payloadType", dsseJson);
// Note: + may be encoded as \u002B in JSON
Assert.True(dsseJson.Contains("application/vnd.in-toto+json") || dsseJson.Contains("application/vnd.in-toto\\u002Bjson"));
Assert.Contains("payload", dsseJson);
}
[Fact]
public async Task WriteWithAttestationAsync_GraphHashIsDeterministic()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act - write twice with same input
var result1 = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"analysis-1",
"sha256:abc123");
var result2 = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"analysis-2",
"sha256:abc123");
// Assert - same graph should produce same hash
Assert.Equal(result1.GraphHash, result2.GraphHash);
}
private static RichGraph CreateTestGraph()
{
return new RichGraph(
Nodes: new[]
{
new RichGraphNode(
Id: "entry-1",
SymbolId: "Handler.handle",
CodeId: null,
Purl: "pkg:maven/com.example/handler@1.0.0",
Lang: "java",
Kind: "http_handler",
Display: "GET /api/users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: "sha256:entry1digest"),
new RichGraphNode(
Id: "sink-1",
SymbolId: "DB.executeQuery",
CodeId: null,
Purl: "pkg:maven/org.database/driver@2.0.0",
Lang: "java",
Kind: "sql_sink",
Display: "executeQuery(String)",
BuildId: null,
Evidence: null,
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: "sha256:sink1digest")
},
Edges: new[]
{
new RichGraphEdge(
From: "entry-1",
To: "sink-1",
Kind: "call",
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: 1.0,
Candidates: null)
},
Roots: new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Analyzer: new RichGraphAnalyzer("stellaops.scanner.reachability", "1.0.0", null),
Schema: "richgraph-v1"
);
}
/// <summary>
/// Test crypto hash implementation.
/// </summary>
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> System.Security.Cryptography.SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return System.Security.Cryptography.SHA256.HashData(buffer.ToArray());
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data);
public async ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> await ComputeHashAsync(stream, null, cancellationToken).ConfigureAwait(false);
public async ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> await ComputeHashHexAsync(stream, null, cancellationToken).ConfigureAwait(false);
public string GetAlgorithmForPurpose(string purpose) => "blake3";
public string GetHashPrefix(string purpose) => "blake3:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> $"blake3:{ComputeHashHex(data)}";
}
}

View File

@@ -0,0 +1,32 @@
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "pkg:oci/test-image@sha256:abc123",
"digest": {
"sha256": "abc123def456789012345678901234567890123456789012345678901234"
}
}
],
"predicateType": "https://stellaops.io/attestation/reachabilityWitness/v1",
"predicate": {
"version": "1.0.0",
"analysisTimestamp": "2025-01-01T00:00:00.0000000Z",
"analyzer": {
"name": "stellaops.scanner.reachability",
"version": "1.0.0"
},
"graph": {
"schema": "richgraph-v1",
"hash": "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"nodeCount": 3,
"edgeCount": 2
},
"summary": {
"sinkCount": 1,
"entrypointCount": 1,
"pathCount": 1,
"gateCoverage": 0.0
}
}
}

View File

@@ -0,0 +1,45 @@
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "pkg:oci/production-app@sha256:xyz789",
"digest": {
"sha256": "xyz789abc123def456789012345678901234567890123456789012345678"
}
}
],
"predicateType": "https://stellaops.io/attestation/reachabilityWitness/v1",
"predicate": {
"version": "1.0.0",
"analysisTimestamp": "2025-01-15T12:30:00.0000000Z",
"analyzer": {
"name": "stellaops.scanner.reachability",
"version": "1.0.0"
},
"graph": {
"schema": "richgraph-v1",
"hash": "blake3:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
"nodeCount": 150,
"edgeCount": 340,
"casUri": "cas://reachability/graphs/fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
},
"summary": {
"sinkCount": 12,
"entrypointCount": 8,
"pathCount": 45,
"gateCoverage": 0.67
},
"policy": {
"hash": "sha256:policy123456789012345678901234567890123456789012345678901234"
},
"source": {
"commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
"runtime": {
"observedAt": "2025-01-15T12:25:00.0000000Z",
"traceCount": 1250,
"coveredPaths": 38,
"runtimeConfidence": 0.84
}
}
}

View File

@@ -206,15 +206,8 @@ public class PathExplanationServiceTests
private static RichGraph CreateSimpleGraph()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Nodes = new[]
return new RichGraph(
Nodes: new[]
{
new RichGraphNode(
Id: "entry-1",
@@ -241,21 +234,23 @@ public class PathExplanationServiceTests
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: null)
},
Edges = new[]
Edges: new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null)
}
};
new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null)
},
Roots: new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateGraphWithMultipleSinks()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
return new RichGraph(
Nodes: new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink1", null, null, "java", "sink", null, null, null,
@@ -263,12 +258,15 @@ public class PathExplanationServiceTests
new RichGraphNode("sink-2", "Sink2", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
Edges: new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null),
new RichGraphEdge("entry-1", "sink-2", "call", null)
}
};
new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null),
new RichGraphEdge("entry-1", "sink-2", "call", null, null, null, 1.0, null)
},
Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateGraphWithGates()
@@ -285,22 +283,21 @@ public class PathExplanationServiceTests
}
};
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
return new RichGraph(
Nodes: new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
Edges: new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", gates)
}
};
new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null, gates)
},
Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateDeepGraph(int depth)
@@ -317,18 +314,17 @@ public class PathExplanationServiceTests
if (i > 0)
{
edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null));
edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null, null, null, 1.0, null));
}
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("node-0", "runtime", null) },
Nodes = nodes,
Edges = edges
};
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: new[] { new RichGraphRoot("node-0", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateGraphWithMultiplePaths(int pathCount)
@@ -344,17 +340,16 @@ public class PathExplanationServiceTests
{
nodes.Add(new RichGraphNode($"sink-{i}", $"Sink{i}", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null));
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null));
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null, null, null, 1.0, null));
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = nodes,
Edges = edges
};
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static ExplainedPath CreateTestPath()
@@ -364,7 +359,7 @@ public class PathExplanationServiceTests
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
SinkCategory = Explanation.SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
@@ -402,7 +397,7 @@ public class PathExplanationServiceTests
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
SinkCategory = Explanation.SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,

View File

@@ -132,6 +132,6 @@ public class RichGraphWriterTests
// Verify meta.json also contains the blake3-prefixed hash
var metaJson = await File.ReadAllTextAsync(result.MetaPath);
Assert.Contains("\"graph_hash\":\"blake3:", metaJson);
Assert.Contains("\"graph_hash\": \"blake3:", metaJson);
}
}