save progress
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Tests;
|
||||
|
||||
public sealed class CodeChangeFactExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extract_ReportsEdgeAdditionsAsGuardChanges()
|
||||
{
|
||||
var baseGraph = CreateGraph(
|
||||
scanId: "base",
|
||||
edges: ImmutableArray<CallGraphEdge>.Empty);
|
||||
|
||||
var headGraph = CreateGraph(
|
||||
scanId: "head",
|
||||
edges: ImmutableArray.Create(new CallGraphEdge("entry", "sink", CallKind.Direct, "Demo.cs:1")));
|
||||
|
||||
var extractor = new CodeChangeFactExtractor();
|
||||
var facts = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
var guardChanges = facts
|
||||
.Where(f => f.Kind == CodeChangeKind.GuardChanged)
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(guardChanges);
|
||||
Assert.Contains(guardChanges, f => string.Equals(f.NodeId, "entry", StringComparison.Ordinal));
|
||||
|
||||
var edgeAdded = guardChanges.First(f => string.Equals(f.NodeId, "entry", StringComparison.Ordinal));
|
||||
Assert.True(edgeAdded.Details.HasValue);
|
||||
Assert.Equal("edge_added", edgeAdded.Details!.Value.GetProperty("change").GetString());
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateGraph(string scanId, ImmutableArray<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "entry",
|
||||
Symbol: "Demo.Entry",
|
||||
File: "Demo.cs",
|
||||
Line: 1,
|
||||
Package: "pkg:generic/demo@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "sink",
|
||||
Symbol: "Demo.Sink",
|
||||
File: "Demo.cs",
|
||||
Line: 2,
|
||||
Package: "pkg:generic/demo@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("entry"),
|
||||
SinkIds: ImmutableArray.Create("sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Tests;
|
||||
|
||||
public sealed class DriftCauseExplainerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = DateTimeOffset.Parse("2025-12-17T00:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void ExplainNewlyReachable_NewEntrypoint_ReturnsNewPublicRoute()
|
||||
{
|
||||
var entry = Node("E", "HomeController.Get", Visibility.Public);
|
||||
var sink = Sink("S", "System.Diagnostics.Process.Start");
|
||||
|
||||
var baseGraph = Graph(
|
||||
scanId: "base",
|
||||
entrypointIds: ImmutableArray<string>.Empty,
|
||||
nodes: new[] { entry, sink },
|
||||
edges: Array.Empty<CallGraphEdge>());
|
||||
|
||||
var headGraph = Graph(
|
||||
scanId: "head",
|
||||
entrypointIds: ImmutableArray.Create("E"),
|
||||
nodes: new[] { entry, sink },
|
||||
edges: new[] { new CallGraphEdge("E", "S", CallKind.Direct) });
|
||||
|
||||
var explainer = new DriftCauseExplainer();
|
||||
var cause = explainer.ExplainNewlyReachable(baseGraph, headGraph, "S", ImmutableArray.Create("E", "S"), Array.Empty<CodeChangeFact>());
|
||||
|
||||
Assert.Equal(DriftCauseKind.NewPublicRoute, cause.Kind);
|
||||
Assert.Contains("HomeController.Get", cause.Description, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplainNewlyReachable_VisibilityEscalation_UsesCodeChangeId()
|
||||
{
|
||||
var changed = Node("N1", "ApiController.GetSecret", Visibility.Public);
|
||||
var baseNode = changed with { Visibility = Visibility.Internal };
|
||||
|
||||
var baseGraph = Graph(
|
||||
scanId: "base",
|
||||
entrypointIds: ImmutableArray.Create("N1"),
|
||||
nodes: new[] { baseNode },
|
||||
edges: Array.Empty<CallGraphEdge>());
|
||||
|
||||
var headGraph = Graph(
|
||||
scanId: "head",
|
||||
entrypointIds: ImmutableArray.Create("N1"),
|
||||
nodes: new[] { changed },
|
||||
edges: Array.Empty<CallGraphEdge>());
|
||||
|
||||
var changeId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
var changes = new[]
|
||||
{
|
||||
new CodeChangeFact
|
||||
{
|
||||
Id = changeId,
|
||||
ScanId = "head",
|
||||
BaseScanId = "base",
|
||||
Language = "dotnet",
|
||||
NodeId = "N1",
|
||||
File = "api.cs",
|
||||
Symbol = "ApiController.GetSecret",
|
||||
Kind = CodeChangeKind.VisibilityChanged,
|
||||
Details = null,
|
||||
DetectedAt = FixedNow
|
||||
}
|
||||
};
|
||||
|
||||
var explainer = new DriftCauseExplainer();
|
||||
var cause = explainer.ExplainNewlyReachable(baseGraph, headGraph, "N1", ImmutableArray.Create("N1"), changes);
|
||||
|
||||
Assert.Equal(DriftCauseKind.VisibilityEscalated, cause.Kind);
|
||||
Assert.Equal(changeId, cause.CodeChangeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplainNewlyUnreachable_SinkRemoved_ReturnsSymbolRemoved()
|
||||
{
|
||||
var entry = Node("E", "Entry", Visibility.Public);
|
||||
var sink = Sink("S", "System.Diagnostics.Process.Start");
|
||||
|
||||
var baseGraph = Graph(
|
||||
scanId: "base",
|
||||
entrypointIds: ImmutableArray.Create("E"),
|
||||
nodes: new[] { entry, sink },
|
||||
edges: new[] { new CallGraphEdge("E", "S", CallKind.Direct) });
|
||||
|
||||
var headGraph = Graph(
|
||||
scanId: "head",
|
||||
entrypointIds: ImmutableArray.Create("E"),
|
||||
nodes: new[] { entry },
|
||||
edges: Array.Empty<CallGraphEdge>());
|
||||
|
||||
var explainer = new DriftCauseExplainer();
|
||||
var cause = explainer.ExplainNewlyUnreachable(baseGraph, headGraph, "S", ImmutableArray.Create("E", "S"), Array.Empty<CodeChangeFact>());
|
||||
|
||||
Assert.Equal(DriftCauseKind.SymbolRemoved, cause.Kind);
|
||||
Assert.Contains("System.Diagnostics.Process.Start", cause.Description, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplainNewlyUnreachable_EdgeRemoved_ReturnsGuardAdded()
|
||||
{
|
||||
var entry = Node("E", "Entry", Visibility.Public);
|
||||
var sink = Sink("S", "System.Diagnostics.Process.Start");
|
||||
|
||||
var baseGraph = Graph(
|
||||
scanId: "base",
|
||||
entrypointIds: ImmutableArray.Create("E"),
|
||||
nodes: new[] { entry, sink },
|
||||
edges: new[] { new CallGraphEdge("E", "S", CallKind.Direct) });
|
||||
|
||||
var headGraph = Graph(
|
||||
scanId: "head",
|
||||
entrypointIds: ImmutableArray.Create("E"),
|
||||
nodes: new[] { entry, sink },
|
||||
edges: Array.Empty<CallGraphEdge>());
|
||||
|
||||
var explainer = new DriftCauseExplainer();
|
||||
var cause = explainer.ExplainNewlyUnreachable(baseGraph, headGraph, "S", ImmutableArray.Create("E", "S"), Array.Empty<CodeChangeFact>());
|
||||
|
||||
Assert.Equal(DriftCauseKind.GuardAdded, cause.Kind);
|
||||
Assert.Contains("Entry", cause.Description, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot Graph(
|
||||
string scanId,
|
||||
ImmutableArray<string> entrypointIds,
|
||||
IEnumerable<CallGraphNode> nodes,
|
||||
IEnumerable<CallGraphEdge> edges)
|
||||
{
|
||||
var nodesArray = nodes.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToImmutableArray();
|
||||
var edgesArray = edges.ToImmutableArray();
|
||||
|
||||
var sinkIds = nodesArray.Where(n => n.IsSink).Select(n => n.NodeId).ToImmutableArray();
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "dotnet",
|
||||
ExtractedAt: FixedNow,
|
||||
Nodes: nodesArray,
|
||||
Edges: edgesArray,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: sinkIds);
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static CallGraphNode Node(string nodeId, string symbol, Visibility visibility)
|
||||
=> new(
|
||||
NodeId: nodeId,
|
||||
Symbol: symbol,
|
||||
File: $"{nodeId}.cs",
|
||||
Line: 1,
|
||||
Package: "app",
|
||||
Visibility: visibility,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null);
|
||||
|
||||
private static CallGraphNode Sink(string nodeId, string symbol)
|
||||
=> new(
|
||||
NodeId: nodeId,
|
||||
Symbol: symbol,
|
||||
File: $"{nodeId}.cs",
|
||||
Line: 1,
|
||||
Package: "app",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: Reachability.SinkCategory.CmdExec);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Tests;
|
||||
|
||||
public sealed class PathCompressorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compress_MarksChangedKeyNodes()
|
||||
{
|
||||
var graph = CreateGraph();
|
||||
|
||||
var change = new CodeChangeFact
|
||||
{
|
||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
ScanId = "head",
|
||||
BaseScanId = "base",
|
||||
Language = "dotnet",
|
||||
NodeId = "mid2",
|
||||
File = "Demo.cs",
|
||||
Symbol = "Demo.Mid2",
|
||||
Kind = CodeChangeKind.GuardChanged,
|
||||
Details = null,
|
||||
DetectedAt = DateTimeOffset.UnixEpoch
|
||||
};
|
||||
|
||||
var compressor = new PathCompressor(maxKeyNodes: 5);
|
||||
var compressed = compressor.Compress(
|
||||
pathNodeIds: ImmutableArray.Create("entry", "mid1", "mid2", "sink"),
|
||||
graph: graph,
|
||||
codeChanges: [change],
|
||||
includeFullPath: false);
|
||||
|
||||
Assert.Equal(2, compressed.IntermediateCount);
|
||||
Assert.Equal("entry", compressed.Entrypoint.NodeId);
|
||||
Assert.Equal("sink", compressed.Sink.NodeId);
|
||||
Assert.Null(compressed.FullPath);
|
||||
|
||||
Assert.Contains(compressed.KeyNodes, n => n.NodeId == "mid2" && n.IsChanged);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateGraph()
|
||||
{
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode("entry", "Demo.Entry", "Demo.cs", 1, "pkg:generic/demo@1.0.0", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode("mid1", "Demo.Mid1", "Demo.cs", 2, "pkg:generic/demo@1.0.0", Visibility.Internal, false, null, false, null),
|
||||
new CallGraphNode("mid2", "Demo.Mid2", "Demo.cs", 3, "pkg:generic/demo@1.0.0", Visibility.Internal, false, null, false, null),
|
||||
new CallGraphNode("sink", "Demo.Sink", "Demo.cs", 4, "pkg:generic/demo@1.0.0", Visibility.Public, false, null, true, SinkCategory.CmdExec));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new CallGraphEdge("entry", "mid1", CallKind.Direct),
|
||||
new CallGraphEdge("mid1", "mid2", CallKind.Direct),
|
||||
new CallGraphEdge("mid2", "sink", CallKind.Direct));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: "head",
|
||||
GraphDigest: string.Empty,
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("entry"),
|
||||
SinkIds: ImmutableArray.Create("sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Tests;
|
||||
|
||||
public sealed class ReachabilityDriftDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Detect_FindsNewlyReachableSinks()
|
||||
{
|
||||
var baseGraph = CreateGraph(
|
||||
scanId: "base",
|
||||
edges: ImmutableArray<CallGraphEdge>.Empty);
|
||||
|
||||
var headGraph = CreateGraph(
|
||||
scanId: "head",
|
||||
edges: ImmutableArray.Create(new CallGraphEdge("entry", "sink", CallKind.Direct, "Demo.cs:1")));
|
||||
|
||||
var extractor = new CodeChangeFactExtractor();
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
var detector = new ReachabilityDriftDetector();
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
Assert.Equal("base", drift.BaseScanId);
|
||||
Assert.Equal("head", drift.HeadScanId);
|
||||
Assert.Equal("dotnet", drift.Language);
|
||||
Assert.False(string.IsNullOrWhiteSpace(drift.ResultDigest));
|
||||
|
||||
Assert.Single(drift.NewlyReachable);
|
||||
Assert.Empty(drift.NewlyUnreachable);
|
||||
|
||||
var sink = drift.NewlyReachable[0];
|
||||
Assert.Equal(DriftDirection.BecameReachable, sink.Direction);
|
||||
Assert.Equal("sink", sink.SinkNodeId);
|
||||
Assert.Equal(DriftCauseKind.GuardRemoved, sink.Cause.Kind);
|
||||
Assert.Equal("entry", sink.Path.Entrypoint.NodeId);
|
||||
Assert.Equal("sink", sink.Path.Sink.NodeId);
|
||||
Assert.NotNull(sink.Path.FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_IsStableForSameInputs()
|
||||
{
|
||||
var baseGraph = CreateGraph(
|
||||
scanId: "base",
|
||||
edges: ImmutableArray<CallGraphEdge>.Empty);
|
||||
|
||||
var headGraph = CreateGraph(
|
||||
scanId: "head",
|
||||
edges: ImmutableArray.Create(new CallGraphEdge("entry", "sink", CallKind.Direct, "Demo.cs:1")));
|
||||
|
||||
var extractor = new CodeChangeFactExtractor();
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
var detector = new ReachabilityDriftDetector();
|
||||
var first = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
var second = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
Assert.Equal(first.Id, second.Id);
|
||||
Assert.Equal(first.ResultDigest, second.ResultDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_FindsNewlyUnreachableSinks()
|
||||
{
|
||||
var baseGraph = CreateGraph(
|
||||
scanId: "base",
|
||||
edges: ImmutableArray.Create(new CallGraphEdge("entry", "sink", CallKind.Direct, "Demo.cs:1")));
|
||||
|
||||
var headGraph = CreateGraph(
|
||||
scanId: "head",
|
||||
edges: ImmutableArray<CallGraphEdge>.Empty);
|
||||
|
||||
var extractor = new CodeChangeFactExtractor();
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
var detector = new ReachabilityDriftDetector();
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
Assert.Empty(drift.NewlyReachable);
|
||||
Assert.Single(drift.NewlyUnreachable);
|
||||
|
||||
var sink = drift.NewlyUnreachable[0];
|
||||
Assert.Equal(DriftDirection.BecameUnreachable, sink.Direction);
|
||||
Assert.Equal("sink", sink.SinkNodeId);
|
||||
Assert.Equal(DriftCauseKind.GuardAdded, sink.Cause.Kind);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateGraph(string scanId, ImmutableArray<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "entry",
|
||||
Symbol: "Demo.Entry",
|
||||
File: "Demo.cs",
|
||||
Line: 1,
|
||||
Package: "pkg:generic/demo@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "sink",
|
||||
Symbol: "Demo.Sink",
|
||||
File: "Demo.cs",
|
||||
Line: 2,
|
||||
Package: "pkg:generic/demo@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("entry"),
|
||||
SinkIds: ImmutableArray.Create("sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.ReachabilityDrift\\StellaOps.Scanner.ReachabilityDrift.csproj" />
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user