feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ScannerSignals.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the Reachability Drift Detection pipeline.
|
||||
/// Tests the end-to-end flow from call graph extraction through drift detection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Task: RDRIFT-MASTER-0002
|
||||
/// Sprint: SPRINT_3600_0001_0001_reachability_drift_master
|
||||
/// </remarks>
|
||||
public sealed class ReachabilityDriftIntegrationTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTime = new FakeTimeProvider(
|
||||
new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Drift Detection Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WhenPathBecomesReachable_ReportsNewlyReachableSink()
|
||||
{
|
||||
// Arrange: unreachable -> reachable (guard removed)
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.BaseScanId.Should().Be("scan-v1");
|
||||
drift.HeadScanId.Should().Be("scan-v2");
|
||||
drift.Language.Should().Be("java");
|
||||
drift.HasMaterialDrift.Should().BeTrue();
|
||||
|
||||
drift.NewlyReachable.Should().HaveCount(1);
|
||||
drift.NewlyUnreachable.Should().BeEmpty();
|
||||
|
||||
var sink = drift.NewlyReachable[0];
|
||||
sink.Direction.Should().Be(DriftDirection.BecameReachable);
|
||||
sink.SinkNodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.SinkCategory.Should().Be(SinkCategory.CmdExec);
|
||||
sink.Cause.Kind.Should().Be(DriftCauseKind.GuardRemoved);
|
||||
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
|
||||
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WhenPathBecomesUnreachable_ReportsNewlyUnreachableSink()
|
||||
{
|
||||
// Arrange: reachable -> unreachable (guard added)
|
||||
var baseGraph = CreateReachableGraph("scan-v1");
|
||||
var headGraph = CreateUnreachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.BaseScanId.Should().Be("scan-v1");
|
||||
drift.HeadScanId.Should().Be("scan-v2");
|
||||
drift.HasMaterialDrift.Should().BeFalse();
|
||||
|
||||
drift.NewlyReachable.Should().BeEmpty();
|
||||
drift.NewlyUnreachable.Should().HaveCount(1);
|
||||
|
||||
var sink = drift.NewlyUnreachable[0];
|
||||
sink.Direction.Should().Be(DriftDirection.BecameUnreachable);
|
||||
sink.SinkNodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.Cause.Kind.Should().Be(DriftCauseKind.GuardAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WhenNoChange_ReportsNoDrift()
|
||||
{
|
||||
// Arrange: same graph, no changes
|
||||
var baseGraph = CreateReachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.HasMaterialDrift.Should().BeFalse();
|
||||
drift.NewlyReachable.Should().BeEmpty();
|
||||
drift.NewlyUnreachable.Should().BeEmpty();
|
||||
drift.TotalDriftCount.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_IsDeterministic_SameInputsProduceSameOutputs()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift1 = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
var drift2 = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift1.Id.Should().Be(drift2.Id);
|
||||
drift1.ResultDigest.Should().Be(drift2.ResultDigest);
|
||||
drift1.DetectedAt.Should().Be(drift2.DetectedAt);
|
||||
drift1.NewlyReachable.Length.Should().Be(drift2.NewlyReachable.Length);
|
||||
|
||||
for (var i = 0; i < drift1.NewlyReachable.Length; i++)
|
||||
{
|
||||
drift1.NewlyReachable[i].Id.Should().Be(drift2.NewlyReachable[i].Id);
|
||||
drift1.NewlyReachable[i].SinkNodeId.Should().Be(drift2.NewlyReachable[i].SinkNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_ResultDigest_IsStableAcrossRuns()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
// Act: Create multiple detectors and run independently
|
||||
var detector1 = new ReachabilityDriftDetector(_fixedTime);
|
||||
var detector2 = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
var drift1 = detector1.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
var drift2 = detector2.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
// Assert
|
||||
drift1.ResultDigest.Should().NotBeNullOrWhiteSpace();
|
||||
drift1.ResultDigest.Should().Be(drift2.ResultDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CodeChangeFact Extraction Tests
|
||||
|
||||
[Fact]
|
||||
public void CodeChangeFactExtractor_DetectsAddedEdge()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
|
||||
// Act
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
// Assert - The extractor reports edge changes as GuardChanged with details
|
||||
codeChanges.Should().NotBeEmpty();
|
||||
codeChanges.Should().Contain(c =>
|
||||
c.Kind == CodeChangeKind.GuardChanged &&
|
||||
c.Details.HasValue &&
|
||||
c.Details.Value.GetRawText().Contains("edge_added", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CodeChangeFactExtractor_DetectsRemovedEdge()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateReachableGraph("scan-v1");
|
||||
var headGraph = CreateUnreachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
|
||||
// Act
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
|
||||
// Assert - The extractor reports edge changes as GuardChanged with details
|
||||
codeChanges.Should().NotBeEmpty();
|
||||
codeChanges.Should().Contain(c =>
|
||||
c.Kind == CodeChangeKind.GuardChanged &&
|
||||
c.Details.HasValue &&
|
||||
c.Details.Value.GetRawText().Contains("edge_removed", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Sink Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithMultipleSinks_ReportsAllDriftedSinks()
|
||||
{
|
||||
// Arrange: Multiple sinks become reachable
|
||||
var baseGraph = CreateMultiSinkUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateMultiSinkReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.Should().NotBeNull();
|
||||
drift.HasMaterialDrift.Should().BeTrue();
|
||||
drift.NewlyReachable.Should().HaveCount(2);
|
||||
drift.NewlyUnreachable.Should().BeEmpty();
|
||||
|
||||
var sinkIds = drift.NewlyReachable.Select(s => s.SinkNodeId).OrderBy(s => s).ToList();
|
||||
sinkIds.Should().Contain("jndi-lookup-sink");
|
||||
sinkIds.Should().Contain("file-write-sink");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_OrderingSinks_IsStableAndDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateMultiSinkUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateMultiSinkReachableGraph("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act: Run multiple times
|
||||
var results = Enumerable.Range(0, 5)
|
||||
.Select(_ => detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false))
|
||||
.ToList();
|
||||
|
||||
// Assert: All results should have same ordering
|
||||
var expectedOrder = results[0].NewlyReachable.Select(s => s.SinkNodeId).ToList();
|
||||
foreach (var result in results.Skip(1))
|
||||
{
|
||||
var actualOrder = result.NewlyReachable.Select(s => s.SinkNodeId).ToList();
|
||||
actualOrder.Should().Equal(expectedOrder);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Compression Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithFullPath_IncludesIntermediateNodes()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraphWithIntermediates("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
|
||||
|
||||
// Assert
|
||||
drift.NewlyReachable.Should().HaveCount(1);
|
||||
var sink = drift.NewlyReachable[0];
|
||||
|
||||
sink.Path.Should().NotBeNull();
|
||||
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
|
||||
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.Path.FullPath.Should().NotBeNullOrEmpty();
|
||||
sink.Path.FullPath!.Value.Length.Should().BeGreaterThan(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithoutFullPath_OmitsIntermediateNodes()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var headGraph = CreateReachableGraphWithIntermediates("scan-v2");
|
||||
|
||||
var extractor = new CodeChangeFactExtractor(_fixedTime);
|
||||
var codeChanges = extractor.Extract(baseGraph, headGraph);
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act
|
||||
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
|
||||
|
||||
// Assert
|
||||
drift.NewlyReachable.Should().HaveCount(1);
|
||||
var sink = drift.NewlyReachable[0];
|
||||
|
||||
sink.Path.Should().NotBeNull();
|
||||
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
|
||||
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
|
||||
sink.Path.FullPath.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithLanguageMismatch_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateGraph("scan-v1", "java", ImmutableArray<CallGraphEdge>.Empty);
|
||||
var headGraph = CreateGraph("scan-v2", "dotnet", ImmutableArray<CallGraphEdge>.Empty);
|
||||
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => detector.Detect(baseGraph, headGraph, ImmutableArray<CodeChangeFact>.Empty.ToList(), includeFullPath: false);
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*Language mismatch*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithNullBaseGraph_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var headGraph = CreateReachableGraph("scan-v2");
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => detector.Detect(null!, headGraph, Array.Empty<CodeChangeFact>(), includeFullPath: false);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectDrift_WithNullHeadGraph_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var baseGraph = CreateUnreachableGraph("scan-v1");
|
||||
var detector = new ReachabilityDriftDetector(_fixedTime);
|
||||
|
||||
// Act & Assert
|
||||
var act = () => detector.Detect(baseGraph, null!, Array.Empty<CodeChangeFact>(), includeFullPath: false);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static CallGraphSnapshot CreateUnreachableGraph(string scanId)
|
||||
{
|
||||
// Graph with no edges - sink is unreachable
|
||||
return CreateGraph(scanId, "java", ImmutableArray<CallGraphEdge>.Empty);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateReachableGraph(string scanId)
|
||||
{
|
||||
// Graph with edge from entry to sink - sink is reachable
|
||||
var edges = ImmutableArray.Create(
|
||||
new CallGraphEdge("http-handler-entry", "jndi-lookup-sink", CallKind.Direct, "Logger.java:42"));
|
||||
return CreateGraph(scanId, "java", edges);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateReachableGraphWithIntermediates(string scanId)
|
||||
{
|
||||
// Graph with intermediate nodes: entry -> logger -> substitutor -> sink
|
||||
var edges = ImmutableArray.Create(
|
||||
new CallGraphEdge("http-handler-entry", "logger-method", CallKind.Direct, "App.java:10"),
|
||||
new CallGraphEdge("logger-method", "pattern-converter", CallKind.Direct, "Logger.java:15"),
|
||||
new CallGraphEdge("pattern-converter", "str-substitutor", CallKind.Direct, "PatternConverter.java:20"),
|
||||
new CallGraphEdge("str-substitutor", "jndi-lookup-sink", CallKind.Direct, "StrSubstitutor.java:25"));
|
||||
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "logger-method",
|
||||
Symbol: "org.apache.logging.log4j.Logger.info",
|
||||
File: "Logger.java",
|
||||
Line: 15,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "pattern-converter",
|
||||
Symbol: "org.apache.logging.log4j.core.pattern.MessagePatternConverter.format",
|
||||
File: "PatternConverter.java",
|
||||
Line: 20,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Internal,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "str-substitutor",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.StrSubstitutor.replace",
|
||||
File: "StrSubstitutor.java",
|
||||
Line: 25,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Internal,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "java",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMultiSinkUnreachableGraph(string scanId)
|
||||
{
|
||||
// Graph with multiple sinks, none reachable
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec),
|
||||
new CallGraphNode(
|
||||
NodeId: "file-write-sink",
|
||||
Symbol: "java.io.FileOutputStream.write",
|
||||
File: "FileOutputStream.java",
|
||||
Line: 100,
|
||||
Package: "pkg:maven/java/jdk@17",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.FileWrite));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "java",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink", "file-write-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateMultiSinkReachableGraph(string scanId)
|
||||
{
|
||||
// Graph with multiple sinks, all reachable
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec),
|
||||
new CallGraphNode(
|
||||
NodeId: "file-write-sink",
|
||||
Symbol: "java.io.FileOutputStream.write",
|
||||
File: "FileOutputStream.java",
|
||||
Line: 100,
|
||||
Package: "pkg:maven/java/jdk@17",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.FileWrite));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new CallGraphEdge("http-handler-entry", "jndi-lookup-sink", CallKind.Direct, "App.java:15"),
|
||||
new CallGraphEdge("http-handler-entry", "file-write-sink", CallKind.Direct, "App.java:20"));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: "java",
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink", "file-write-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateGraph(string scanId, string language, ImmutableArray<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = ImmutableArray.Create(
|
||||
new CallGraphNode(
|
||||
NodeId: "http-handler-entry",
|
||||
Symbol: "com.example.App.handleRequest",
|
||||
File: "App.java",
|
||||
Line: 10,
|
||||
Package: "pkg:maven/com.example/app@1.0.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null),
|
||||
new CallGraphNode(
|
||||
NodeId: "jndi-lookup-sink",
|
||||
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
|
||||
File: "JndiLookup.java",
|
||||
Line: 30,
|
||||
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: SinkCategory.CmdExec));
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: language,
|
||||
ExtractedAt: DateTimeOffset.UnixEpoch,
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
|
||||
SinkIds: ImmutableArray.Create("jndi-lookup-sink"));
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FakeTimeProvider
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -69,6 +69,7 @@ public sealed class ScannerToSignalsReachabilityTests
|
||||
callgraphRepo,
|
||||
reachabilityStore,
|
||||
new CallgraphNormalizationService(),
|
||||
new NullCallGraphSyncService(),
|
||||
Options.Create(new SignalsOptions()),
|
||||
TimeProvider.System,
|
||||
NullLogger<CallgraphIngestionService>.Instance);
|
||||
@@ -205,6 +206,21 @@ public sealed class ScannerToSignalsReachabilityTests
|
||||
storage[document.SubjectKey] = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken)
|
||||
=> Task.FromResult((IReadOnlyList<ReachabilityFactDocument>)Array.Empty<ReachabilityFactDocument>());
|
||||
|
||||
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var removed = storage.Remove(subjectKey);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityCache : IReachabilityCache
|
||||
@@ -240,6 +256,28 @@ public sealed class ScannerToSignalsReachabilityTests
|
||||
|
||||
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<string>)Array.Empty<string>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
|
||||
UnknownsBand band,
|
||||
int limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(
|
||||
UnknownsBand? band,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<UnknownSymbolDocument?>(null);
|
||||
}
|
||||
|
||||
private sealed class NullEventsPublisher : IEventsPublisher
|
||||
@@ -247,6 +285,15 @@ public sealed class ScannerToSignalsReachabilityTests
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullCallGraphSyncService : ICallGraphSyncService
|
||||
{
|
||||
public Task<CallGraphSyncResult> SyncAsync(Guid scanId, string artifactDigest, CallgraphDocument document, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new CallGraphSyncResult(scanId, 0, 0, 0, false, 0L));
|
||||
|
||||
public Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryCallgraphArtifactStore : ICallgraphArtifactStore
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> artifacts = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../src/Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
<ProjectReference Include="../../../src/Scanner/__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="../../../src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/StellaOps.Scanner.ReachabilityDrift.csproj" />
|
||||
<ProjectReference Include="../../../src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\fixtures\**\*">
|
||||
|
||||
Reference in New Issue
Block a user