using StellaOps.Cryptography; using StellaOps.Scanner.Reachability.Gates; using StellaOps.Scanner.Reachability.Witnesses; using Xunit; namespace StellaOps.Scanner.Reachability.Tests; public class PathWitnessBuilderTests { private readonly ICryptoHash _cryptoHash; private readonly TimeProvider _timeProvider; public PathWitnessBuilderTests() { _cryptoHash = DefaultCryptoHash.CreateForTests(); _timeProvider = TimeProvider.System; } [Fact] public async Task BuildAsync_ReturnsNull_WhenNoPathExists() { // Arrange var graph = CreateSimpleGraph(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new PathWitnessRequest { SbomDigest = "sha256:abc123", ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3", VulnId = "CVE-2024-12345", VulnSource = "NVD", AffectedRange = "<=12.0.3", EntrypointSymbolId = "sym:entry1", EntrypointKind = "http", EntrypointName = "GET /api/test", SinkSymbolId = "sym:unreachable", // Not in graph SinkType = "deserialization", CallGraph = graph, CallgraphDigest = "blake3:abc123" }; // Act var result = await builder.BuildAsync(request); // Assert Assert.Null(result); } [Fact] public async Task BuildAsync_ReturnsWitness_WhenPathExists() { // Arrange var graph = CreateSimpleGraph(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new PathWitnessRequest { SbomDigest = "sha256:abc123", ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3", VulnId = "CVE-2024-12345", VulnSource = "NVD", AffectedRange = "<=12.0.3", EntrypointSymbolId = "sym:entry1", EntrypointKind = "http", EntrypointName = "GET /api/test", SinkSymbolId = "sym:sink1", SinkType = "deserialization", CallGraph = graph, CallgraphDigest = "blake3:abc123" }; // Act var result = await builder.BuildAsync(request); // Assert Assert.NotNull(result); Assert.Equal(WitnessSchema.Version, result.WitnessSchema); Assert.StartsWith(WitnessSchema.WitnessIdPrefix, result.WitnessId); Assert.Equal("CVE-2024-12345", result.Vuln.Id); Assert.Equal("sym:entry1", result.Entrypoint.SymbolId); Assert.Equal("sym:sink1", result.Sink.SymbolId); Assert.NotEmpty(result.Path); } [Fact] public async Task BuildAsync_GeneratesContentAddressedWitnessId() { // Arrange var graph = CreateSimpleGraph(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new PathWitnessRequest { SbomDigest = "sha256:abc123", ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3", VulnId = "CVE-2024-12345", VulnSource = "NVD", AffectedRange = "<=12.0.3", EntrypointSymbolId = "sym:entry1", EntrypointKind = "http", EntrypointName = "GET /api/test", SinkSymbolId = "sym:sink1", SinkType = "deserialization", CallGraph = graph, CallgraphDigest = "blake3:abc123" }; // Act var result1 = await builder.BuildAsync(request); var result2 = await builder.BuildAsync(request); // Assert Assert.NotNull(result1); Assert.NotNull(result2); // The witness ID should be deterministic (same input = same hash) // Note: ObservedAt differs, but witness ID is computed without it Assert.Equal(result1.WitnessId, result2.WitnessId); } [Fact] public async Task BuildAsync_PopulatesArtifactInfo() { // Arrange var graph = CreateSimpleGraph(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new PathWitnessRequest { SbomDigest = "sha256:sbom123", ComponentPurl = "pkg:npm/lodash@4.17.21", VulnId = "CVE-2024-99999", VulnSource = "GHSA", AffectedRange = "<4.17.21", EntrypointSymbolId = "sym:entry1", EntrypointKind = "grpc", EntrypointName = "UserService.GetUser", SinkSymbolId = "sym:sink1", SinkType = "prototype_pollution", CallGraph = graph, CallgraphDigest = "blake3:graph456" }; // Act var result = await builder.BuildAsync(request); // Assert Assert.NotNull(result); Assert.Equal("sha256:sbom123", result.Artifact.SbomDigest); Assert.Equal("pkg:npm/lodash@4.17.21", result.Artifact.ComponentPurl); } [Fact] public async Task BuildAsync_PopulatesEvidenceInfo() { // Arrange var graph = CreateSimpleGraph(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new PathWitnessRequest { SbomDigest = "sha256:abc123", ComponentPurl = "pkg:nuget/Test@1.0.0", VulnId = "CVE-2024-12345", VulnSource = "NVD", AffectedRange = "<=1.0.0", EntrypointSymbolId = "sym:entry1", EntrypointKind = "http", EntrypointName = "TestController.Get", SinkSymbolId = "sym:sink1", SinkType = "sql_injection", CallGraph = graph, CallgraphDigest = "blake3:callgraph789", SurfaceDigest = "sha256:surface123", AnalysisConfigDigest = "sha256:config456", BuildId = "build:xyz789" }; // Act var result = await builder.BuildAsync(request); // Assert Assert.NotNull(result); Assert.Equal("blake3:callgraph789", result.Evidence.CallgraphDigest); Assert.Equal("sha256:surface123", result.Evidence.SurfaceDigest); Assert.Equal("sha256:config456", result.Evidence.AnalysisConfigDigest); Assert.Equal("build:xyz789", result.Evidence.BuildId); } [Fact] public async Task BuildAsync_FindsShortestPath() { // Arrange - graph with multiple paths var graph = CreateGraphWithMultiplePaths(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new PathWitnessRequest { SbomDigest = "sha256:abc123", ComponentPurl = "pkg:nuget/Test@1.0.0", VulnId = "CVE-2024-12345", VulnSource = "NVD", AffectedRange = "<=1.0.0", EntrypointSymbolId = "sym:start", EntrypointKind = "http", EntrypointName = "Start", SinkSymbolId = "sym:end", SinkType = "deserialization", CallGraph = graph, CallgraphDigest = "blake3:abc123" }; // Act var result = await builder.BuildAsync(request); // Assert Assert.NotNull(result); // Short path: start -> direct -> end (3 steps) // Long path: start -> long1 -> long2 -> long3 -> end (5 steps) Assert.Equal(3, result.Path.Count); Assert.Equal("sym:start", result.Path[0].SymbolId); Assert.Equal("sym:direct", result.Path[1].SymbolId); Assert.Equal("sym:end", result.Path[2].SymbolId); } [Fact] public async Task BuildAllAsync_YieldsMultipleWitnesses_WhenMultipleRootsReachSink() { // Arrange var graph = CreateGraphWithMultipleRoots(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new BatchWitnessRequest { SbomDigest = "sha256:abc123", ComponentPurl = "pkg:nuget/Test@1.0.0", VulnId = "CVE-2024-12345", VulnSource = "NVD", AffectedRange = "<=1.0.0", SinkSymbolId = "sym:sink", SinkType = "deserialization", CallGraph = graph, CallgraphDigest = "blake3:abc123", MaxWitnesses = 10 }; // Act var witnesses = new List(); await foreach (var witness in builder.BuildAllAsync(request)) { witnesses.Add(witness); } // Assert Assert.Equal(2, witnesses.Count); Assert.Contains(witnesses, w => w.Entrypoint.SymbolId == "sym:root1"); Assert.Contains(witnesses, w => w.Entrypoint.SymbolId == "sym:root2"); } [Fact] public async Task BuildAllAsync_RespectsMaxWitnesses() { // Arrange var graph = CreateGraphWithMultipleRoots(); var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider); var request = new BatchWitnessRequest { SbomDigest = "sha256:abc123", ComponentPurl = "pkg:nuget/Test@1.0.0", VulnId = "CVE-2024-12345", VulnSource = "NVD", AffectedRange = "<=1.0.0", SinkSymbolId = "sym:sink", SinkType = "deserialization", CallGraph = graph, CallgraphDigest = "blake3:abc123", MaxWitnesses = 1 // Limit to 1 }; // Act var witnesses = new List(); await foreach (var witness in builder.BuildAllAsync(request)) { witnesses.Add(witness); } // Assert Assert.Single(witnesses); } #region Test Helpers private static RichGraph CreateSimpleGraph() { var nodes = new List { new("n1", "sym:entry1", null, null, "dotnet", "method", "Entry1", null, null, null, null), new("n2", "sym:middle1", null, null, "dotnet", "method", "Middle1", null, null, null, null), new("n3", "sym:sink1", null, null, "dotnet", "method", "Sink1", null, null, null, null) }; var edges = new List { new("n1", "n2", "call", null, null, null, 1.0, null), new("n2", "n3", "call", null, null, null, 1.0, null) }; var roots = new List { new("n1", "http", "/api/test") }; return new RichGraph( nodes, edges, roots, new RichGraphAnalyzer("test", "1.0.0", null)); } private static RichGraph CreateGraphWithMultiplePaths() { var nodes = new List { new("n0", "sym:start", null, null, "dotnet", "method", "Start", null, null, null, null), new("n1", "sym:direct", null, null, "dotnet", "method", "Direct", null, null, null, null), new("n2", "sym:long1", null, null, "dotnet", "method", "Long1", null, null, null, null), new("n3", "sym:long2", null, null, "dotnet", "method", "Long2", null, null, null, null), new("n4", "sym:long3", null, null, "dotnet", "method", "Long3", null, null, null, null), new("n5", "sym:end", null, null, "dotnet", "method", "End", null, null, null, null) }; var edges = new List { // Short path: start -> direct -> end new("n0", "n1", "call", null, null, null, 1.0, null), new("n1", "n5", "call", null, null, null, 1.0, null), // Long path: start -> long1 -> long2 -> long3 -> end new("n0", "n2", "call", null, null, null, 1.0, null), new("n2", "n3", "call", null, null, null, 1.0, null), new("n3", "n4", "call", null, null, null, 1.0, null), new("n4", "n5", "call", null, null, null, 1.0, null) }; var roots = new List { new("n0", "http", "/api/start") }; return new RichGraph( nodes, edges, roots, new RichGraphAnalyzer("test", "1.0.0", null)); } private static RichGraph CreateGraphWithMultipleRoots() { var nodes = new List { new("n1", "sym:root1", null, null, "dotnet", "method", "Root1", null, null, null, null), new("n2", "sym:root2", null, null, "dotnet", "method", "Root2", null, null, null, null), new("n3", "sym:middle", null, null, "dotnet", "method", "Middle", null, null, null, null), new("n4", "sym:sink", null, null, "dotnet", "method", "Sink", null, null, null, null) }; var edges = new List { new("n1", "n3", "call", null, null, null, 1.0, null), new("n2", "n3", "call", null, null, null, 1.0, null), new("n3", "n4", "call", null, null, null, 1.0, null) }; var roots = new List { new("n1", "http", "/api/root1"), new("n2", "http", "/api/root2") }; return new RichGraph( nodes, edges, roots, new RichGraphAnalyzer("test", "1.0.0", null)); } #endregion }