Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Reachability.MiniMap;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.MiniMap;
|
||||
|
||||
public class MiniMapExtractorTests
|
||||
{
|
||||
private readonly MiniMapExtractor _extractor = new();
|
||||
|
||||
[Fact]
|
||||
public void Extract_ReachableComponent_ReturnsPaths()
|
||||
{
|
||||
var graph = CreateGraphWithPaths();
|
||||
|
||||
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0");
|
||||
|
||||
result.State.Should().Be(ReachabilityState.StaticReachable);
|
||||
result.Paths.Should().NotBeEmpty();
|
||||
result.Entrypoints.Should().NotBeEmpty();
|
||||
result.Confidence.Should().BeGreaterThan(0.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_UnreachableComponent_ReturnsEmptyPaths()
|
||||
{
|
||||
var graph = CreateGraphWithoutPaths();
|
||||
|
||||
var result = _extractor.Extract(graph, "pkg:npm/isolated@1.0.0");
|
||||
|
||||
result.State.Should().Be(ReachabilityState.StaticUnreachable);
|
||||
result.Paths.Should().BeEmpty();
|
||||
result.Confidence.Should().Be(0.9m); // High confidence in unreachability
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithRuntimeEvidence_ReturnsConfirmedReachable()
|
||||
{
|
||||
var graph = CreateGraphWithRuntimeEvidence();
|
||||
|
||||
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0");
|
||||
|
||||
result.State.Should().Be(ReachabilityState.ConfirmedReachable);
|
||||
result.Paths.Should().Contain(p => p.HasRuntimeEvidence);
|
||||
result.Confidence.Should().BeGreaterThan(0.8m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_NonExistentComponent_ReturnsNotFoundMap()
|
||||
{
|
||||
var graph = CreateGraphWithPaths();
|
||||
|
||||
var result = _extractor.Extract(graph, "pkg:npm/nonexistent@1.0.0");
|
||||
|
||||
result.State.Should().Be(ReachabilityState.Unknown);
|
||||
result.Confidence.Should().Be(0m);
|
||||
result.VulnerableComponent.Id.Should().Be("pkg:npm/nonexistent@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_RespectMaxPaths_LimitsResults()
|
||||
{
|
||||
var graph = CreateGraphWithManyPaths();
|
||||
|
||||
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0", maxPaths: 5);
|
||||
|
||||
result.Paths.Count.Should().BeLessOrEqualTo(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_ClassifiesEntrypointKinds_Correctly()
|
||||
{
|
||||
var graph = CreateGraphWithDifferentEntrypoints();
|
||||
|
||||
var result = _extractor.Extract(graph, "pkg:npm/vulnerable@1.0.0");
|
||||
|
||||
result.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.HttpEndpoint);
|
||||
result.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.MainFunction);
|
||||
}
|
||||
|
||||
private static RichGraph CreateGraphWithPaths()
|
||||
{
|
||||
var nodes = new List<RichGraphNode>
|
||||
{
|
||||
new(
|
||||
Id: "entrypoint:main",
|
||||
SymbolId: "main",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "main",
|
||||
Display: "main()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null),
|
||||
new(
|
||||
Id: "function:process",
|
||||
SymbolId: "process",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "function",
|
||||
Display: "process()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null),
|
||||
new(
|
||||
Id: "vuln:component",
|
||||
SymbolId: "vulnerable",
|
||||
CodeId: null,
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
Lang: "javascript",
|
||||
Kind: "function",
|
||||
Display: "vulnerable()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null)
|
||||
};
|
||||
|
||||
var edges = new List<RichGraphEdge>
|
||||
{
|
||||
new(
|
||||
From: "entrypoint:main",
|
||||
To: "function:process",
|
||||
Kind: "call",
|
||||
Purl: null,
|
||||
SymbolDigest: null,
|
||||
Evidence: null,
|
||||
Confidence: 0.9,
|
||||
Candidates: null),
|
||||
new(
|
||||
From: "function:process",
|
||||
To: "vuln:component",
|
||||
Kind: "call",
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
SymbolDigest: null,
|
||||
Evidence: null,
|
||||
Confidence: 0.9,
|
||||
Candidates: null)
|
||||
};
|
||||
|
||||
return new RichGraph(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
|
||||
}
|
||||
|
||||
private static RichGraph CreateGraphWithoutPaths()
|
||||
{
|
||||
var nodes = new List<RichGraphNode>
|
||||
{
|
||||
new(
|
||||
Id: "entrypoint:main",
|
||||
SymbolId: "main",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "main",
|
||||
Display: "main()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null),
|
||||
new(
|
||||
Id: "isolated:component",
|
||||
SymbolId: "isolated",
|
||||
CodeId: null,
|
||||
Purl: "pkg:npm/isolated@1.0.0",
|
||||
Lang: "javascript",
|
||||
Kind: "function",
|
||||
Display: "isolated()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null)
|
||||
};
|
||||
|
||||
// No edges - isolated component
|
||||
var edges = new List<RichGraphEdge>();
|
||||
|
||||
return new RichGraph(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
|
||||
}
|
||||
|
||||
private static RichGraph CreateGraphWithRuntimeEvidence()
|
||||
{
|
||||
var nodes = new List<RichGraphNode>
|
||||
{
|
||||
new(
|
||||
Id: "entrypoint:main",
|
||||
SymbolId: "main",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "main",
|
||||
Display: "main()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null),
|
||||
new(
|
||||
Id: "vuln:component",
|
||||
SymbolId: "vulnerable",
|
||||
CodeId: null,
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
Lang: "javascript",
|
||||
Kind: "function",
|
||||
Display: "vulnerable()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null)
|
||||
};
|
||||
|
||||
var edges = new List<RichGraphEdge>
|
||||
{
|
||||
new(
|
||||
From: "entrypoint:main",
|
||||
To: "vuln:component",
|
||||
Kind: "call",
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
SymbolDigest: null,
|
||||
Evidence: new[] { "runtime", "static" },
|
||||
Confidence: 0.95,
|
||||
Candidates: null)
|
||||
};
|
||||
|
||||
return new RichGraph(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
|
||||
}
|
||||
|
||||
private static RichGraph CreateGraphWithManyPaths()
|
||||
{
|
||||
var nodes = new List<RichGraphNode>
|
||||
{
|
||||
new(
|
||||
Id: "entrypoint:main",
|
||||
SymbolId: "main",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "main",
|
||||
Display: "main()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null),
|
||||
new(
|
||||
Id: "vuln:component",
|
||||
SymbolId: "vulnerable",
|
||||
CodeId: null,
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
Lang: "javascript",
|
||||
Kind: "function",
|
||||
Display: "vulnerable()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null)
|
||||
};
|
||||
|
||||
// Add intermediate nodes to create multiple paths
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
nodes.Add(new RichGraphNode(
|
||||
Id: $"function:intermediate{i}",
|
||||
SymbolId: $"intermediate{i}",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "function",
|
||||
Display: $"intermediate{i}()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null));
|
||||
}
|
||||
|
||||
var edges = new List<RichGraphEdge>();
|
||||
|
||||
// Create multiple paths from main to vuln through different intermediates
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
edges.Add(new RichGraphEdge(
|
||||
From: "entrypoint:main",
|
||||
To: $"function:intermediate{i}",
|
||||
Kind: "call",
|
||||
Purl: null,
|
||||
SymbolDigest: null,
|
||||
Evidence: null,
|
||||
Confidence: 0.9,
|
||||
Candidates: null));
|
||||
|
||||
edges.Add(new RichGraphEdge(
|
||||
From: $"function:intermediate{i}",
|
||||
To: "vuln:component",
|
||||
Kind: "call",
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
SymbolDigest: null,
|
||||
Evidence: null,
|
||||
Confidence: 0.9,
|
||||
Candidates: null));
|
||||
}
|
||||
|
||||
return new RichGraph(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
|
||||
}
|
||||
|
||||
private static RichGraph CreateGraphWithDifferentEntrypoints()
|
||||
{
|
||||
var nodes = new List<RichGraphNode>
|
||||
{
|
||||
new(
|
||||
Id: "entrypoint:http",
|
||||
SymbolId: "handleRequest",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "entrypoint",
|
||||
Display: "handleRequest()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: new Dictionary<string, string> { ["http_method"] = "POST" },
|
||||
SymbolDigest: null),
|
||||
new(
|
||||
Id: "entrypoint:main",
|
||||
SymbolId: "main",
|
||||
CodeId: null,
|
||||
Purl: null,
|
||||
Lang: "javascript",
|
||||
Kind: "main",
|
||||
Display: "main()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null),
|
||||
new(
|
||||
Id: "vuln:component",
|
||||
SymbolId: "vulnerable",
|
||||
CodeId: null,
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
Lang: "javascript",
|
||||
Kind: "function",
|
||||
Display: "vulnerable()",
|
||||
BuildId: null,
|
||||
Evidence: null,
|
||||
Attributes: null,
|
||||
SymbolDigest: null)
|
||||
};
|
||||
|
||||
var edges = new List<RichGraphEdge>
|
||||
{
|
||||
new(
|
||||
From: "entrypoint:http",
|
||||
To: "vuln:component",
|
||||
Kind: "call",
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
SymbolDigest: null,
|
||||
Evidence: null,
|
||||
Confidence: 0.9,
|
||||
Candidates: null),
|
||||
new(
|
||||
From: "entrypoint:main",
|
||||
To: "vuln:component",
|
||||
Kind: "call",
|
||||
Purl: "pkg:npm/vulnerable@1.0.0",
|
||||
SymbolDigest: null,
|
||||
Evidence: null,
|
||||
Confidence: 0.9,
|
||||
Candidates: null)
|
||||
};
|
||||
|
||||
return new RichGraph(
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Roots: Array.Empty<RichGraphRoot>(),
|
||||
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
using StellaOps.Scanner.Reachability.Subgraph;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class ReachabilitySubgraphExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extract_BuildsSubgraphWithEntrypointAndVulnerable()
|
||||
{
|
||||
var graph = CreateGraph();
|
||||
var request = new ReachabilitySubgraphRequest(
|
||||
Graph: graph,
|
||||
FindingKeys: ["CVE-2025-1234@pkg:npm/demo@1.0.0"],
|
||||
TargetSymbols: ["sink"],
|
||||
Entrypoints: []);
|
||||
|
||||
var extractor = new ReachabilitySubgraphExtractor();
|
||||
var subgraph = extractor.Extract(request);
|
||||
|
||||
Assert.Equal(3, subgraph.Nodes.Length);
|
||||
Assert.Equal(2, subgraph.Edges.Length);
|
||||
Assert.Contains(subgraph.Nodes, n => n.Id == "root" && n.Type == ReachabilitySubgraphNodeType.Entrypoint);
|
||||
Assert.Contains(subgraph.Nodes, n => n.Id == "sink" && n.Type == ReachabilitySubgraphNodeType.Vulnerable);
|
||||
Assert.Contains(subgraph.Nodes, n => n.Id == "call" && n.Type == ReachabilitySubgraphNodeType.Call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_MapsGateMetadata()
|
||||
{
|
||||
var graph = CreateGraph(withGate: true);
|
||||
var request = new ReachabilitySubgraphRequest(
|
||||
Graph: graph,
|
||||
FindingKeys: ["CVE-2025-1234@pkg:npm/demo@1.0.0"],
|
||||
TargetSymbols: ["sink"],
|
||||
Entrypoints: []);
|
||||
|
||||
var extractor = new ReachabilitySubgraphExtractor();
|
||||
var subgraph = extractor.Extract(request);
|
||||
|
||||
var gatedEdge = subgraph.Edges.First(e => e.To == "sink");
|
||||
Assert.NotNull(gatedEdge.Gate);
|
||||
Assert.Equal("auth", gatedEdge.Gate!.GateType);
|
||||
Assert.Equal("auth.check", gatedEdge.Gate.GuardSymbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNoTargets_ReturnsEmptySubgraph()
|
||||
{
|
||||
var graph = CreateGraph();
|
||||
var request = new ReachabilitySubgraphRequest(
|
||||
Graph: graph,
|
||||
FindingKeys: [],
|
||||
TargetSymbols: [],
|
||||
Entrypoints: []);
|
||||
|
||||
var extractor = new ReachabilitySubgraphExtractor();
|
||||
var subgraph = extractor.Extract(request);
|
||||
|
||||
Assert.Empty(subgraph.Nodes);
|
||||
Assert.Empty(subgraph.Edges);
|
||||
Assert.NotNull(subgraph.AnalysisMetadata);
|
||||
}
|
||||
|
||||
private static RichGraph CreateGraph(bool withGate = false)
|
||||
{
|
||||
var nodes = new List<RichGraphNode>
|
||||
{
|
||||
new("root", "root", null, null, "csharp", "entrypoint", "root", null, null, null, null),
|
||||
new("call", "call", null, null, "csharp", "call", "call", null, null, null, null),
|
||||
new("sink", "sink", null, "pkg:npm/demo@1.0.0", "csharp", "sink", "sink", null, null, null, null)
|
||||
};
|
||||
|
||||
var edges = new List<RichGraphEdge>
|
||||
{
|
||||
new("root", "call", "call", null, null, null, 0.9, null),
|
||||
new("call", "sink", "call", null, null, null, 0.8, null,
|
||||
withGate ? new[]
|
||||
{
|
||||
new DetectedGate
|
||||
{
|
||||
Type = GateType.AuthRequired,
|
||||
Detail = "auth",
|
||||
GuardSymbol = "auth.check",
|
||||
Confidence = 0.9,
|
||||
DetectionMethod = "static"
|
||||
}
|
||||
} : null)
|
||||
};
|
||||
|
||||
var roots = new List<RichGraphRoot> { new("root", "runtime", null) };
|
||||
var analyzer = new RichGraphAnalyzer("reachability", "1.0.0", null);
|
||||
return new RichGraph(nodes, edges, roots, analyzer).Trimmed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Reachability.Attestation;
|
||||
using StellaOps.Scanner.Reachability.Subgraph;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class ReachabilitySubgraphPublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishAsync_BuildsDigestAndStoresInCas()
|
||||
{
|
||||
var subgraph = new ReachabilitySubgraph
|
||||
{
|
||||
FindingKeys = ["CVE-2025-1234@pkg:npm/demo@1.0.0"],
|
||||
Nodes =
|
||||
[
|
||||
new ReachabilitySubgraphNode { Id = "root", Type = ReachabilitySubgraphNodeType.Entrypoint, Symbol = "root" },
|
||||
new ReachabilitySubgraphNode { Id = "sink", Type = ReachabilitySubgraphNodeType.Vulnerable, Symbol = "sink" }
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new ReachabilitySubgraphEdge { From = "root", To = "sink", Type = "call", Confidence = 0.9 }
|
||||
],
|
||||
AnalysisMetadata = new ReachabilitySubgraphMetadata
|
||||
{
|
||||
Analyzer = "reachability",
|
||||
AnalyzerVersion = "1.0.0",
|
||||
Confidence = 0.9,
|
||||
Completeness = "partial",
|
||||
GeneratedAt = new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero)
|
||||
}
|
||||
};
|
||||
|
||||
var options = Options.Create(new ReachabilitySubgraphOptions { Enabled = true, StoreInCas = true });
|
||||
var cas = new FakeFileContentAddressableStore();
|
||||
var publisher = new ReachabilitySubgraphPublisher(
|
||||
options,
|
||||
CryptoHashFactory.CreateDefault(),
|
||||
NullLogger<ReachabilitySubgraphPublisher>.Instance,
|
||||
cas: cas);
|
||||
|
||||
var result = await publisher.PublishAsync(subgraph, "sha256:subject");
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.SubgraphDigest));
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.AttestationDigest));
|
||||
Assert.NotNull(result.CasUri);
|
||||
Assert.NotEmpty(result.DsseEnvelopeBytes);
|
||||
Assert.NotNull(cas.GetBytes(result.SubgraphDigest.Split(':')[1]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.Scanner.Reachability.Tests;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using System;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
using StellaOps.Scanner.ProofSpine.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Slices;
|
||||
|
||||
[Trait("Category", "Slice")]
|
||||
[Trait("Sprint", "3810")]
|
||||
public sealed class SliceCasStorageTests
|
||||
{
|
||||
[Fact(DisplayName = "SliceCasStorage stores slice and DSSE envelope in CAS")]
|
||||
public async Task StoreAsync_WritesSliceAndDsseToCas()
|
||||
{
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var signer = CreateDeterministicSigner("slice-test-key");
|
||||
var cryptoProfile = new TestCryptoProfile("slice-test-key", "hs256");
|
||||
var hasher = new SliceHasher(cryptoHash);
|
||||
var dsseSigner = new SliceDsseSigner(signer, cryptoProfile, hasher, new FixedTimeProvider());
|
||||
var storage = new SliceCasStorage(hasher, dsseSigner, cryptoHash);
|
||||
var cas = new FakeFileContentAddressableStore();
|
||||
var slice = SliceTestData.CreateSlice();
|
||||
|
||||
var result = await storage.StoreAsync(slice, cas);
|
||||
var key = ExtractDigestHex(result.SliceDigest);
|
||||
|
||||
Assert.NotNull(cas.GetBytes(key));
|
||||
Assert.NotNull(cas.GetBytes(key + ".dsse"));
|
||||
Assert.StartsWith("cas://slices/", result.SliceCasUri, StringComparison.Ordinal);
|
||||
Assert.EndsWith(".dsse", result.DsseCasUri, StringComparison.Ordinal);
|
||||
Assert.Equal(SliceSchema.DssePayloadType, result.SignedSlice.Envelope.PayloadType);
|
||||
}
|
||||
|
||||
private static IDsseSigningService CreateDeterministicSigner(string keyId)
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new ProofSpineDsseSigningOptions
|
||||
{
|
||||
Mode = "hash",
|
||||
KeyId = keyId,
|
||||
Algorithm = "hs256",
|
||||
AllowDeterministicFallback = true,
|
||||
});
|
||||
|
||||
return new HmacDsseSigningService(
|
||||
options,
|
||||
DefaultCryptoHmac.CreateForTests(),
|
||||
DefaultCryptoHash.CreateForTests());
|
||||
}
|
||||
|
||||
private static string ExtractDigestHex(string prefixed)
|
||||
{
|
||||
var index = prefixed.IndexOf(':');
|
||||
return index >= 0 ? prefixed[(index + 1)..] : prefixed;
|
||||
}
|
||||
|
||||
private sealed record TestCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(2025, 12, 22, 10, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Slices;
|
||||
|
||||
[Trait("Category", "Slice")]
|
||||
[Trait("Sprint", "3810")]
|
||||
public sealed class SliceExtractorTests
|
||||
{
|
||||
[Fact(DisplayName = "SliceExtractor returns reachable slice for entrypoint -> target path")]
|
||||
public void Extract_WithPath_ReturnsReachableSlice()
|
||||
{
|
||||
var graph = SliceTestData.CreateGraph();
|
||||
var request = new SliceExtractionRequest(
|
||||
Graph: graph,
|
||||
Inputs: SliceTestData.CreateInputs(),
|
||||
Query: SliceTestData.CreateQuery(
|
||||
targets: ImmutableArray.Create("target"),
|
||||
entrypoints: ImmutableArray.Create("entry")),
|
||||
Manifest: SliceTestData.CreateManifest());
|
||||
|
||||
var extractor = new SliceExtractor(new VerdictComputer());
|
||||
var slice = extractor.Extract(request);
|
||||
|
||||
Assert.Equal(SliceVerdictStatus.Reachable, slice.Verdict.Status);
|
||||
Assert.Contains(slice.Subgraph.Nodes, n => n.Id == "entry" && n.Kind == SliceNodeKind.Entrypoint);
|
||||
Assert.Contains(slice.Subgraph.Nodes, n => n.Id == "target" && n.Kind == SliceNodeKind.Target);
|
||||
Assert.DoesNotContain(slice.Subgraph.Nodes, n => n.Id == "other");
|
||||
Assert.Equal(2, slice.Subgraph.Edges.Length);
|
||||
Assert.Contains(slice.Verdict.PathWitnesses, witness => witness.Contains("entry", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SliceExtractor returns unknown verdict when entrypoints are missing")]
|
||||
public void Extract_MissingEntrypoints_ReturnsUnknown()
|
||||
{
|
||||
var graph = SliceTestData.CreateGraph();
|
||||
var request = new SliceExtractionRequest(
|
||||
Graph: graph,
|
||||
Inputs: SliceTestData.CreateInputs(),
|
||||
Query: SliceTestData.CreateQuery(
|
||||
targets: ImmutableArray.Create("target"),
|
||||
entrypoints: ImmutableArray.Create("missing")),
|
||||
Manifest: SliceTestData.CreateManifest());
|
||||
|
||||
var extractor = new SliceExtractor(new VerdictComputer());
|
||||
var slice = extractor.Extract(request);
|
||||
|
||||
Assert.Equal(SliceVerdictStatus.Unknown, slice.Verdict.Status);
|
||||
Assert.Contains("missing_entrypoints", slice.Verdict.Reasons);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Slices;
|
||||
|
||||
[Trait("Category", "Determinism")]
|
||||
[Trait("Sprint", "3810")]
|
||||
public sealed class SliceHasherTests
|
||||
{
|
||||
[Fact(DisplayName = "SliceHasher produces deterministic bytes across ordering differences")]
|
||||
public void ComputeDigest_IsDeterministicAcrossOrdering()
|
||||
{
|
||||
var nodesA = ImmutableArray.Create(
|
||||
new SliceNode { Id = "node:2", Symbol = "b", Kind = SliceNodeKind.Intermediate },
|
||||
new SliceNode { Id = "node:1", Symbol = "a", Kind = SliceNodeKind.Entrypoint },
|
||||
new SliceNode { Id = "node:3", Symbol = "c", Kind = SliceNodeKind.Target });
|
||||
|
||||
var nodesB = ImmutableArray.Create(
|
||||
new SliceNode { Id = "node:3", Symbol = "c", Kind = SliceNodeKind.Target },
|
||||
new SliceNode { Id = "node:1", Symbol = "a", Kind = SliceNodeKind.Entrypoint },
|
||||
new SliceNode { Id = "node:2", Symbol = "b", Kind = SliceNodeKind.Intermediate });
|
||||
|
||||
var edgesA = ImmutableArray.Create(
|
||||
new SliceEdge { From = "node:2", To = "node:3", Kind = SliceEdgeKind.Direct, Confidence = 0.9 },
|
||||
new SliceEdge { From = "node:1", To = "node:2", Kind = SliceEdgeKind.Direct, Confidence = 1.0 });
|
||||
|
||||
var edgesB = ImmutableArray.Create(
|
||||
new SliceEdge { From = "node:1", To = "node:2", Kind = SliceEdgeKind.Direct, Confidence = 1.0 },
|
||||
new SliceEdge { From = "node:2", To = "node:3", Kind = SliceEdgeKind.Direct, Confidence = 0.9 });
|
||||
|
||||
var sliceA = SliceTestData.CreateSlice(nodesA, edgesA);
|
||||
var sliceB = SliceTestData.CreateSlice(nodesB, edgesB);
|
||||
|
||||
var hasher = new SliceHasher(DefaultCryptoHash.CreateForTests());
|
||||
var digestA = hasher.ComputeDigest(sliceA);
|
||||
var digestB = hasher.ComputeDigest(sliceB);
|
||||
|
||||
Assert.Equal(digestA.Digest, digestB.Digest);
|
||||
Assert.Equal(digestA.CanonicalBytes, digestB.CanonicalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentAssertions;
|
||||
using Json.Schema;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Slices;
|
||||
|
||||
[Trait("Category", "Schema")]
|
||||
[Trait("Sprint", "3810")]
|
||||
public sealed class SliceSchemaValidationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
[Fact(DisplayName = "Valid ReachabilitySlice passes schema validation")]
|
||||
public void ValidSlice_PassesValidation()
|
||||
{
|
||||
var schema = LoadSchema();
|
||||
var slice = SliceTestData.CreateSlice();
|
||||
var json = JsonSerializer.Serialize(slice, JsonOptions);
|
||||
var node = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
var result = schema.Evaluate(node);
|
||||
|
||||
result.IsValid.Should().BeTrue("valid slices should pass schema validation");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Slice missing required fields fails validation")]
|
||||
public void MissingRequiredField_FailsValidation()
|
||||
{
|
||||
var schema = LoadSchema();
|
||||
var json = """
|
||||
{
|
||||
"_type": "stellaops.dev/predicates/reachability-slice@v1",
|
||||
"inputs": { "graphDigest": "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" }
|
||||
}
|
||||
""";
|
||||
var node = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
var result = schema.Evaluate(node);
|
||||
|
||||
result.IsValid.Should().BeFalse("missing required subgraph/verdict/manifest should fail validation");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Slice with invalid verdict status fails validation")]
|
||||
public void InvalidVerdictStatus_FailsValidation()
|
||||
{
|
||||
var schema = LoadSchema();
|
||||
var json = """
|
||||
{
|
||||
"_type": "stellaops.dev/predicates/reachability-slice@v1",
|
||||
"inputs": { "graphDigest": "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" },
|
||||
"query": { "cveId": "CVE-2024-1234" },
|
||||
"subgraph": { "nodes": [], "edges": [] },
|
||||
"verdict": { "status": "invalid", "confidence": 0.5 },
|
||||
"manifest": {
|
||||
"scanId": "scan-1",
|
||||
"createdAtUtc": "2025-12-22T10:00:00Z",
|
||||
"artifactDigest": "sha256:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
|
||||
"scannerVersion": "scanner.native:1.2.0",
|
||||
"workerVersion": "scanner.worker:1.2.0",
|
||||
"concelierSnapshotHash": "sha256:1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff",
|
||||
"excititorSnapshotHash": "sha256:2222333344445555666677778888999900001111aaaabbbbccccddddeeeeffff",
|
||||
"latticePolicyHash": "sha256:3333444455556666777788889999000011112222aaaabbbbccccddddeeeeffff",
|
||||
"deterministic": true,
|
||||
"seed": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"knobs": { "maxDepth": "20" }
|
||||
}
|
||||
}
|
||||
""";
|
||||
var node = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
var result = schema.Evaluate(node);
|
||||
|
||||
result.IsValid.Should().BeFalse("invalid verdict status should fail validation");
|
||||
}
|
||||
|
||||
private static JsonSchema LoadSchema()
|
||||
{
|
||||
var schemaPath = FindSchemaPath();
|
||||
var json = File.ReadAllText(schemaPath);
|
||||
return JsonSchema.FromText(json);
|
||||
}
|
||||
|
||||
private static string FindSchemaPath()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "docs", "schemas", "stellaops-slice.v1.schema.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Could not locate stellaops-slice.v1.schema.json from test directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using StellaOps.Scanner.Core;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Slices;
|
||||
|
||||
internal static class SliceTestData
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 12, 22, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public static ScanManifest CreateManifest(
|
||||
string scanId = "scan-1",
|
||||
string artifactDigest = "sha256:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
|
||||
{
|
||||
var seed = new byte[32];
|
||||
var builder = ScanManifest.CreateBuilder(scanId, artifactDigest)
|
||||
.WithCreatedAt(FixedTimestamp)
|
||||
.WithArtifactPurl("pkg:generic/app@1.0.0")
|
||||
.WithScannerVersion("scanner.native:1.2.0")
|
||||
.WithWorkerVersion("scanner.worker:1.2.0")
|
||||
.WithConcelierSnapshot("sha256:1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff")
|
||||
.WithExcititorSnapshot("sha256:2222333344445555666677778888999900001111aaaabbbbccccddddeeeeffff")
|
||||
.WithLatticePolicyHash("sha256:3333444455556666777788889999000011112222aaaabbbbccccddddeeeeffff")
|
||||
.WithDeterministic(true)
|
||||
.WithSeed(seed)
|
||||
.WithKnob("maxDepth", "20");
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
public static SliceInputs CreateInputs()
|
||||
{
|
||||
return new SliceInputs
|
||||
{
|
||||
GraphDigest = "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
|
||||
BinaryDigests = ImmutableArray.Create(
|
||||
"sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
|
||||
SbomDigest = "sha256:cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe"
|
||||
};
|
||||
}
|
||||
|
||||
public static SliceQuery CreateQuery(
|
||||
ImmutableArray<string>? targets = null,
|
||||
ImmutableArray<string>? entrypoints = null)
|
||||
{
|
||||
return new SliceQuery
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
TargetSymbols = targets ?? ImmutableArray.Create("openssl:EVP_PKEY_decrypt"),
|
||||
Entrypoints = entrypoints ?? ImmutableArray.Create("main")
|
||||
};
|
||||
}
|
||||
|
||||
public static ReachabilitySlice CreateSlice(
|
||||
ImmutableArray<SliceNode>? nodes = null,
|
||||
ImmutableArray<SliceEdge>? edges = null)
|
||||
{
|
||||
return new ReachabilitySlice
|
||||
{
|
||||
Inputs = CreateInputs(),
|
||||
Query = CreateQuery(),
|
||||
Subgraph = new SliceSubgraph
|
||||
{
|
||||
Nodes = nodes ?? ImmutableArray.Create(
|
||||
new SliceNode { Id = "node:1", Symbol = "main", Kind = SliceNodeKind.Entrypoint },
|
||||
new SliceNode { Id = "node:2", Symbol = "decrypt_data", Kind = SliceNodeKind.Intermediate },
|
||||
new SliceNode { Id = "node:3", Symbol = "EVP_PKEY_decrypt", Kind = SliceNodeKind.Target }
|
||||
),
|
||||
Edges = edges ?? ImmutableArray.Create(
|
||||
new SliceEdge { From = "node:1", To = "node:2", Kind = SliceEdgeKind.Direct, Confidence = 1.0 },
|
||||
new SliceEdge { From = "node:2", To = "node:3", Kind = SliceEdgeKind.Plt, Confidence = 0.9 }
|
||||
)
|
||||
},
|
||||
Verdict = new SliceVerdict
|
||||
{
|
||||
Status = SliceVerdictStatus.Reachable,
|
||||
Confidence = 0.9,
|
||||
Reasons = ImmutableArray.Create("path_exists_high_confidence"),
|
||||
PathWitnesses = ImmutableArray.Create("main -> decrypt_data -> EVP_PKEY_decrypt"),
|
||||
UnknownCount = 0
|
||||
},
|
||||
Manifest = CreateManifest()
|
||||
};
|
||||
}
|
||||
|
||||
public static RichGraph CreateGraph()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
new RichGraphNode("entry", "entry", null, null, "native", "method", "entry", null, null, null, null),
|
||||
new RichGraphNode("mid", "mid", null, null, "native", "method", "mid", null, null, null, null),
|
||||
new RichGraphNode("target", "target", null, null, "native", "method", "target", null, null, null, null),
|
||||
new RichGraphNode("other", "other", null, null, "native", "method", "other", null, null, null, null)
|
||||
};
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
new RichGraphEdge("entry", "mid", "call", null, null, null, 0.95, null),
|
||||
new RichGraphEdge("mid", "target", "call", null, null, null, 0.9, null),
|
||||
new RichGraphEdge("other", "mid", "call", null, null, null, 0.5, null)
|
||||
};
|
||||
|
||||
var roots = new[] { new RichGraphRoot("entry", "runtime", null) };
|
||||
|
||||
return new RichGraph(nodes, edges, roots, new RichGraphAnalyzer("slice-test", "1.0.0", null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests.Slices;
|
||||
|
||||
[Trait("Category", "Slice")]
|
||||
[Trait("Sprint", "3810")]
|
||||
public sealed class SliceVerdictComputerTests
|
||||
{
|
||||
[Fact(DisplayName = "VerdictComputer returns reachable when path is strong and no unknowns")]
|
||||
public void Compute_ReturnsReachable()
|
||||
{
|
||||
var paths = new[] { new SlicePathSummary("path-1", 0.85, "entry -> target") };
|
||||
var verdict = new VerdictComputer().Compute(paths, unknownEdgeCount: 0);
|
||||
|
||||
Assert.Equal(SliceVerdictStatus.Reachable, verdict.Status);
|
||||
Assert.Contains("path_exists_high_confidence", verdict.Reasons);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "VerdictComputer returns unreachable when no paths and no unknowns")]
|
||||
public void Compute_ReturnsUnreachable()
|
||||
{
|
||||
var verdict = new VerdictComputer().Compute(Array.Empty<SlicePathSummary>(), unknownEdgeCount: 0);
|
||||
|
||||
Assert.Equal(SliceVerdictStatus.Unreachable, verdict.Status);
|
||||
Assert.Contains("no_paths_found", verdict.Reasons);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "VerdictComputer returns unknown when unknown edges exist")]
|
||||
public void Compute_ReturnsUnknownWhenUnknownEdgesPresent()
|
||||
{
|
||||
var paths = new[] { new SlicePathSummary("path-1", 0.9, "entry -> target") };
|
||||
var verdict = new VerdictComputer().Compute(paths, unknownEdgeCount: 2);
|
||||
|
||||
Assert.Equal(SliceVerdictStatus.Unknown, verdict.Status);
|
||||
Assert.Contains("unknown_edges:2", verdict.Reasons);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.3.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
||||
Reference in New Issue
Block a user