save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

@@ -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) };
}
}

View File

@@ -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);
}

View File

@@ -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) };
}
}

View File

@@ -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) };
}
}

View File

@@ -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>