feat: add stella-callgraph-node for JavaScript/TypeScript call graph extraction

- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST.
- Added command-line interface with options for JSON output and help.
- Included functionality to analyze project structure, detect functions, and build call graphs.
- Created a package.json file for dependency management.

feat: introduce stella-callgraph-python for Python call graph extraction

- Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis.
- Implemented command-line interface with options for JSON output and verbose logging.
- Added framework detection to identify popular web frameworks and their entry points.
- Created an AST analyzer to traverse Python code and extract function definitions and calls.
- Included requirements.txt for project dependencies.

chore: add framework detection for Python projects

- Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns.
- Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
This commit is contained in:
master
2025-12-19 18:11:59 +02:00
parent 951a38d561
commit 8779e9226f
130 changed files with 19011 additions and 422 deletions

View File

@@ -0,0 +1,217 @@
// -----------------------------------------------------------------------------
// DeterminismVerificationTests.cs
// Sprint: SPRINT_3610 (cross-cutting)
// Description: Tests to verify call graph extraction produces deterministic output.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Scanner.CallGraph.Tests;
/// <summary>
/// Tests to verify that call graph extraction is deterministic.
/// The same input should always produce the same output.
/// </summary>
[Trait("Category", "Determinism")]
public class DeterminismVerificationTests
{
private readonly ITestOutputHelper _output;
public DeterminismVerificationTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void CallGraphNode_HashIsStable()
{
// Arrange
var node1 = new CallGraphNode(
NodeId: "java:com.example.Service.process",
Symbol: "process",
File: "Service.java",
Line: 42,
Package: "com.example",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null);
var node2 = new CallGraphNode(
NodeId: "java:com.example.Service.process",
Symbol: "process",
File: "Service.java",
Line: 42,
Package: "com.example",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null);
// Act
var hash1 = ComputeNodeHash(node1);
var hash2 = ComputeNodeHash(node2);
// Assert
Assert.Equal(hash1, hash2);
}
[Fact]
public void CallGraphEdge_OrderingIsStable()
{
// Arrange
var edges = new List<CallGraphEdge>
{
new("a", "c", CallKind.Direct, "file.java:10"),
new("a", "b", CallKind.Direct, "file.java:5"),
new("b", "c", CallKind.Virtual, "file.java:15"),
new("a", "d", CallKind.Direct, "file.java:20"),
};
// Act - Sort multiple times
var sorted1 = SortEdges(edges);
var sorted2 = SortEdges(edges);
var sorted3 = SortEdges(edges.AsEnumerable().Reverse().ToList());
// Assert - All should produce same order
Assert.Equal(
JsonSerializer.Serialize(sorted1),
JsonSerializer.Serialize(sorted2));
Assert.Equal(
JsonSerializer.Serialize(sorted1),
JsonSerializer.Serialize(sorted3));
}
[Fact]
public void CallGraphSnapshot_SerializesToSameJson()
{
// Arrange
var snapshot = CreateTestSnapshot();
// Act - Serialize multiple times
var json1 = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true });
var json2 = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true });
// Assert
Assert.Equal(json1, json2);
}
[Fact]
public void NodeOrdering_IsDeterministic()
{
// Arrange
var nodes = new List<CallGraphNode>
{
CreateNode("c.Method", "file3.java", 30),
CreateNode("a.Method", "file1.java", 10),
CreateNode("b.Method", "file2.java", 20),
};
// Act - Sort using stable ordering
var sorted1 = nodes.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToList();
var shuffled = new List<CallGraphNode> { nodes[1], nodes[2], nodes[0] };
var sorted2 = shuffled.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToList();
// Assert
Assert.Equal(sorted1[0].NodeId, sorted2[0].NodeId);
Assert.Equal(sorted1[1].NodeId, sorted2[1].NodeId);
Assert.Equal(sorted1[2].NodeId, sorted2[2].NodeId);
}
[Theory]
[InlineData("java:com.example.Service.method")]
[InlineData("go:github.com/user/pkg.Function")]
[InlineData("py:myapp/views.handler")]
[InlineData("js:app/routes.getUser")]
[InlineData("native:libfoo.so/process")]
public void SymbolId_RoundTripsConsistently(string symbolId)
{
// Act - Hash the symbol ID multiple times
var hash1 = ComputeStringHash(symbolId);
var hash2 = ComputeStringHash(symbolId);
// Assert
Assert.Equal(hash1, hash2);
}
private static string ComputeNodeHash(CallGraphNode node)
{
var json = JsonSerializer.Serialize(node);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(bytes);
}
private static string ComputeStringHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes);
}
private static List<CallGraphEdge> SortEdges(List<CallGraphEdge> edges)
{
return edges
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ThenBy(e => e.CallSite, StringComparer.Ordinal)
.ToList();
}
private static CallGraphNode CreateNode(string symbol, string file, int line)
{
return new CallGraphNode(
NodeId: $"java:{symbol}",
Symbol: symbol,
File: file,
Line: line,
Package: "test",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: false,
SinkCategory: null);
}
private static CallGraphSnapshot CreateTestSnapshot()
{
var nodes = new List<CallGraphNode>
{
CreateNode("a.main", "a.java", 1),
CreateNode("b.helper", "b.java", 10),
}.ToImmutableList();
var edges = new List<CallGraphEdge>
{
new("java:a.main", "java:b.helper", CallKind.Direct, "a.java:5"),
}.ToImmutableList();
var entrypoints = new List<string> { "java:a.main" }.ToImmutableList();
var sinks = new List<string>().ToImmutableList();
return new CallGraphSnapshot(
ScanId: "test-scan-001",
GraphDigest: "sha256:0000",
Language: "java",
ExtractedAt: new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero),
Nodes: nodes.ToImmutableArray(),
Edges: edges.ToImmutableArray(),
EntrypointIds: entrypoints.ToImmutableArray(),
SinkIds: sinks.ToImmutableArray());
}
}
// Extension to convert to immutable list
file static class ListExtensions
{
public static System.Collections.Immutable.ImmutableList<T> ToImmutableList<T>(this List<T> list)
=> System.Collections.Immutable.ImmutableList.CreateRange(list);
public static ImmutableArray<T> ToImmutableArray<T>(this System.Collections.Immutable.ImmutableList<T> list)
=> ImmutableArray.CreateRange(list);
}

View File

@@ -0,0 +1,661 @@
// -----------------------------------------------------------------------------
// JavaCallGraphExtractorTests.cs
// Sprint: SPRINT_3610_0001_0001_java_callgraph (JCG-018)
// Description: Unit tests for the Java bytecode call graph extractor.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.Java;
using StellaOps.Scanner.Reachability;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
/// <summary>
/// Unit tests for <see cref="JavaCallGraphExtractor"/>.
/// Tests entrypoint detection, sink matching, and call graph extraction from Java bytecode.
/// </summary>
public class JavaCallGraphExtractorTests
{
private readonly JavaCallGraphExtractor _extractor;
private readonly DateTimeOffset _fixedTime = DateTimeOffset.Parse("2025-12-19T00:00:00Z");
public JavaCallGraphExtractorTests()
{
var timeProvider = new FixedTimeProvider(_fixedTime);
_extractor = new JavaCallGraphExtractor(
NullLogger<JavaCallGraphExtractor>.Instance,
timeProvider);
}
#region JavaEntrypointClassifier Tests
[Fact]
public void JavaEntrypointClassifier_SpringRequestMapping_DetectedAsHttpHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserController",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "UserController.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "getUser",
Descriptor = "(Ljava/lang/Long;)Lcom/example/User;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 42,
Annotations = ["org.springframework.web.bind.annotation.GetMapping"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Fact]
public void JavaEntrypointClassifier_SpringRestController_PublicMethodDetectedAsHttpHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserController",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["org.springframework.web.bind.annotation.RestController"],
SourceFile = "UserController.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "getAllUsers",
Descriptor = "()Ljava/util/List;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 25,
Annotations = [],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Fact]
public void JavaEntrypointClassifier_JaxRsPath_DetectedAsHttpHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserResource",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["jakarta.ws.rs.Path"],
SourceFile = "UserResource.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "getUser",
Descriptor = "(Ljava/lang/Long;)Lcom/example/User;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 30,
Annotations = ["jakarta.ws.rs.GET"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Fact]
public void JavaEntrypointClassifier_SpringScheduled_DetectedAsScheduledJob()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.SchedulerService",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "SchedulerService.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "processExpiredTokens",
Descriptor = "()V",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 45,
Annotations = ["org.springframework.scheduling.annotation.Scheduled"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.ScheduledJob, result);
}
[Fact]
public void JavaEntrypointClassifier_KafkaListener_DetectedAsMessageHandler()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.OrderEventListener",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "OrderEventListener.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "handleOrderCreated",
Descriptor = "(Lcom/example/OrderEvent;)V",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 20,
Annotations = ["org.springframework.kafka.annotation.KafkaListener"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.MessageHandler, result);
}
[Fact]
public void JavaEntrypointClassifier_GrpcService_DetectedAsGrpcMethod()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserServiceGrpc",
SuperClassName = "io.grpc.stub.AbstractStub",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "UserServiceGrpc.java",
Methods = []
};
// The gRPC service annotation on method level triggers the detection
var method = new JavaMethodInfo
{
Name = "getUser",
Descriptor = "(Lcom/example/GetUserRequest;)Lcom/example/GetUserResponse;",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 50,
Annotations = ["net.devh.boot.grpc.server.service.GrpcService"],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.GrpcMethod, result);
}
[Fact]
public void JavaEntrypointClassifier_MainMethod_DetectedAsCliCommand()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.Application",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = [],
SourceFile = "Application.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "main",
Descriptor = "([Ljava/lang/String;)V",
AccessFlags = JavaAccessFlags.Public | JavaAccessFlags.Static,
LineNumber = 10,
Annotations = [],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Equal(EntrypointType.CliCommand, result);
}
[Fact]
public void JavaEntrypointClassifier_PrivateMethod_NotDetectedAsEntrypoint()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.UserController",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["org.springframework.web.bind.annotation.RestController"],
SourceFile = "UserController.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "validateUser",
Descriptor = "(Lcom/example/User;)Z",
AccessFlags = JavaAccessFlags.Private,
LineNumber = 100,
Annotations = [],
Calls = []
};
var result = classifier.Classify(classInfo, method);
Assert.Null(result);
}
#endregion
#region JavaSinkMatcher Tests
[Fact]
public void JavaSinkMatcher_RuntimeExec_DetectedAsCmdExec()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
Assert.Equal(SinkCategory.CmdExec, result);
}
[Fact]
public void JavaSinkMatcher_ProcessBuilderInit_DetectedAsCmdExec()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.lang.ProcessBuilder", "<init>", "()V");
Assert.Equal(SinkCategory.CmdExec, result);
}
[Fact]
public void JavaSinkMatcher_StatementExecute_DetectedAsSqlRaw()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.sql.Statement", "executeQuery", "(Ljava/lang/String;)Ljava/sql/ResultSet;");
Assert.Equal(SinkCategory.SqlRaw, result);
}
[Fact]
public void JavaSinkMatcher_ObjectInputStream_DetectedAsUnsafeDeser()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.io.ObjectInputStream", "readObject", "()Ljava/lang/Object;");
Assert.Equal(SinkCategory.UnsafeDeser, result);
}
[Fact]
public void JavaSinkMatcher_HttpClientExecute_DetectedAsSsrf()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("org.apache.http.client.HttpClient", "execute", "(Lorg/apache/http/HttpHost;Lorg/apache/http/HttpRequest;)Lorg/apache/http/HttpResponse;");
Assert.Equal(SinkCategory.Ssrf, result);
}
[Fact]
public void JavaSinkMatcher_FileWriter_DetectedAsPathTraversal()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("java.io.FileWriter", "<init>", "(Ljava/lang/String;)V");
Assert.Equal(SinkCategory.PathTraversal, result);
}
[Fact]
public void JavaSinkMatcher_UnknownMethod_ReturnsNull()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("com.example.MyService", "doSomething", "()V");
Assert.Null(result);
}
[Fact]
public void JavaSinkMatcher_XxeVulnerableParsing_DetectedAsXxe()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("javax.xml.parsers.DocumentBuilderFactory", "newInstance", "()Ljavax/xml/parsers/DocumentBuilderFactory;");
Assert.Equal(SinkCategory.XxeInjection, result);
}
[Fact]
public void JavaSinkMatcher_ScriptEngineEval_DetectedAsCodeInjection()
{
var matcher = new JavaSinkMatcher();
var result = matcher.Match("javax.script.ScriptEngine", "eval", "(Ljava/lang/String;)Ljava/lang/Object;");
Assert.Equal(SinkCategory.CodeInjection, result);
}
#endregion
#region JavaBytecodeAnalyzer Tests
[Fact]
public void JavaBytecodeAnalyzer_ValidClassHeader_Parsed()
{
var analyzer = new JavaBytecodeAnalyzer(NullLogger<JavaCallGraphExtractor>.Instance);
// Minimal valid Java class file header
// Magic: 0xCAFEBABE
// Minor: 0, Major: 52 (Java 8)
// Constant pool count: 1 (minimal)
var classData = new byte[]
{
0xCA, 0xFE, 0xBA, 0xBE, // magic
0x00, 0x00, // minor version
0x00, 0x34, // major version (52 = Java 8)
// This is incomplete but tests the magic number check
};
// The parser should handle incomplete class files gracefully
var result = analyzer.ParseClass(classData);
// May return null or partial result for incomplete class
// The important thing is it doesn't throw
}
[Fact]
public void JavaBytecodeAnalyzer_InvalidMagic_ReturnsNull()
{
var analyzer = new JavaBytecodeAnalyzer(NullLogger<JavaCallGraphExtractor>.Instance);
var invalidData = new byte[] { 0x00, 0x00, 0x00, 0x00 };
var result = analyzer.ParseClass(invalidData);
Assert.Null(result);
}
[Fact]
public void JavaBytecodeAnalyzer_EmptyArray_ReturnsNull()
{
var analyzer = new JavaBytecodeAnalyzer(NullLogger<JavaCallGraphExtractor>.Instance);
var result = analyzer.ParseClass([]);
Assert.Null(result);
}
#endregion
#region Integration Tests
[Fact]
public async Task ExtractAsync_InvalidPath_ThrowsFileNotFound()
{
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "java",
TargetPath: "/nonexistent/path/to/jar");
await Assert.ThrowsAsync<FileNotFoundException>(
() => _extractor.ExtractAsync(request));
}
[Fact]
public async Task ExtractAsync_WrongLanguage_ThrowsArgumentException()
{
await using var temp = await TempDirectory.CreateAsync();
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "python", // Wrong language
TargetPath: temp.Path);
await Assert.ThrowsAsync<ArgumentException>(
() => _extractor.ExtractAsync(request));
}
[Fact]
public async Task ExtractAsync_EmptyDirectory_ProducesEmptySnapshot()
{
await using var temp = await TempDirectory.CreateAsync();
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "java",
TargetPath: temp.Path);
var snapshot = await _extractor.ExtractAsync(request);
Assert.Equal("scan-001", snapshot.ScanId);
Assert.Equal("java", snapshot.Language);
Assert.NotNull(snapshot.GraphDigest);
Assert.Empty(snapshot.Nodes);
Assert.Empty(snapshot.Edges);
Assert.Empty(snapshot.EntrypointIds);
Assert.Empty(snapshot.SinkIds);
Assert.Equal(_fixedTime, snapshot.ExtractedAt);
}
[Fact]
public void Extractor_Language_IsJava()
{
Assert.Equal("java", _extractor.Language);
}
#endregion
#region Determinism Verification Tests (JCG-020)
[Fact]
public async Task ExtractAsync_SamePath_ProducesSameDigest()
{
// Arrange: Create a temp directory
await using var temp = await TempDirectory.CreateAsync();
var request = new CallGraphExtractionRequest(
ScanId: "scan-determinism-1",
Language: "java",
TargetPath: temp.Path);
// Act: Extract twice with same input
var snapshot1 = await _extractor.ExtractAsync(request);
var snapshot2 = await _extractor.ExtractAsync(request);
// Assert: Same digest
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
}
[Fact]
public async Task ExtractAsync_DifferentScanId_SameNodesAndEdges()
{
// Arrange: Create a temp directory
await using var temp = await TempDirectory.CreateAsync();
var request1 = new CallGraphExtractionRequest(
ScanId: "scan-a",
Language: "java",
TargetPath: temp.Path);
var request2 = new CallGraphExtractionRequest(
ScanId: "scan-b",
Language: "java",
TargetPath: temp.Path);
// Act: Extract with different scan IDs
var snapshot1 = await _extractor.ExtractAsync(request1);
var snapshot2 = await _extractor.ExtractAsync(request2);
// Assert: Same graph content (nodes, edges, digests match)
Assert.Equal(snapshot1.Nodes.Length, snapshot2.Nodes.Length);
Assert.Equal(snapshot1.Edges.Length, snapshot2.Edges.Length);
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
}
[Fact]
public void BuildNodeId_SameInputs_ProducesIdenticalIds()
{
// Act: Build node IDs multiple times with same inputs
var id1 = BuildTestNodeId("com.example.Service", "doWork", "(Ljava/lang/String;)V");
var id2 = BuildTestNodeId("com.example.Service", "doWork", "(Ljava/lang/String;)V");
// Assert: Identical
Assert.Equal(id1, id2);
}
[Fact]
public void BuildNodeId_DifferentDescriptors_ProducesDifferentIds()
{
// Act: Build node IDs with different descriptors (overloaded methods)
var id1 = BuildTestNodeId("com.example.Service", "process", "(Ljava/lang/String;)V");
var id2 = BuildTestNodeId("com.example.Service", "process", "(I)V");
// Assert: Different (handles overloading)
Assert.NotEqual(id1, id2);
}
[Fact]
public void JavaEntrypointClassifier_SameInput_AlwaysSameResult()
{
var classifier = new JavaEntrypointClassifier();
var classInfo = new JavaClassInfo
{
ClassName = "com.example.Controller",
SuperClassName = "java.lang.Object",
Interfaces = [],
AccessFlags = JavaAccessFlags.Public,
Annotations = ["org.springframework.web.bind.annotation.RestController"],
SourceFile = "Controller.java",
Methods = []
};
var method = new JavaMethodInfo
{
Name = "handleRequest",
Descriptor = "()V",
AccessFlags = JavaAccessFlags.Public,
LineNumber = 10,
Annotations = [],
Calls = []
};
// Act: Classify multiple times
var result1 = classifier.Classify(classInfo, method);
var result2 = classifier.Classify(classInfo, method);
var result3 = classifier.Classify(classInfo, method);
// Assert: All results identical
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
}
[Fact]
public void JavaSinkMatcher_SameInput_AlwaysSameResult()
{
var matcher = new JavaSinkMatcher();
// Act: Match multiple times
var result1 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
var result2 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
var result3 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
// Assert: All results identical
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
Assert.Equal(SinkCategory.CmdExec, result1);
}
/// <summary>
/// Helper to match the internal BuildNodeId logic for testing.
/// </summary>
private static string BuildTestNodeId(string className, string methodName, string descriptor)
{
return $"java:{className}.{methodName}{descriptor}";
}
#endregion
#region Helpers
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _instant;
public FixedTimeProvider(DateTimeOffset instant)
{
_instant = instant;
}
public override DateTimeOffset GetUtcNow() => _instant;
}
private sealed class TempDirectory : IAsyncDisposable
{
public string Path { get; }
private TempDirectory(string path)
{
Path = path;
}
public static Task<TempDirectory> CreateAsync()
{
var root = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"stella_java_callgraph_{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
return Task.FromResult(new TempDirectory(root));
}
public ValueTask DisposeAsync()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// best effort cleanup
}
return ValueTask.CompletedTask;
}
}
#endregion
}

View File

@@ -0,0 +1,566 @@
// -----------------------------------------------------------------------------
// JavaScriptCallGraphExtractorTests.cs
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph (NCG-012)
// Description: Unit tests for JavaScriptCallGraphExtractor.
// -----------------------------------------------------------------------------
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.CallGraph.JavaScript;
using StellaOps.Scanner.Reachability;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
/// <summary>
/// Unit tests for JavaScript/TypeScript call graph extraction.
/// Tests entrypoint classification, sink matching, and extraction logic.
/// </summary>
public sealed class JavaScriptCallGraphExtractorTests : IAsyncLifetime
{
private readonly JavaScriptCallGraphExtractor _extractor;
private readonly DateTimeOffset _fixedTime = new(2025, 12, 19, 12, 0, 0, TimeSpan.Zero);
public JavaScriptCallGraphExtractorTests()
{
_extractor = new JavaScriptCallGraphExtractor(
NullLogger<JavaScriptCallGraphExtractor>.Instance,
new FixedTimeProvider(_fixedTime));
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
#region Entrypoint Classifier Tests
[Fact]
public void JsEntrypointClassifier_ExpressHandler_ReturnsHttpHandler()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::handler",
Name = "handler",
FullName = "routes.handler",
Module = "express",
IsRouteHandler = true,
Line = 10,
IsExported = true
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Fact]
public void JsEntrypointClassifier_FastifyRoute_ReturnsHttpHandler()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::getUsers",
Name = "getUsers",
FullName = "routes.getUsers",
Module = "fastify",
IsRouteHandler = true,
HttpMethod = "GET",
HttpRoute = "/users",
Line = 15,
IsExported = true
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Fact]
public void JsEntrypointClassifier_LambdaHandler_ReturnsLambda()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::handler",
Name = "handler",
FullName = "lambda.handler",
Module = "aws-lambda",
Line = 5,
IsExported = true
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.Lambda, result);
}
[Fact]
public void JsEntrypointClassifier_AzureFunction_ReturnsLambda()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::httpTrigger",
Name = "httpTrigger",
FullName = "function.httpTrigger",
Module = "@azure/functions",
Line = 10,
IsExported = true
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.Lambda, result);
}
[Fact]
public void JsEntrypointClassifier_CommanderCli_ReturnsCliCommand()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::action",
Name = "action",
FullName = "cli.action",
Module = "commander",
Line = 20,
IsExported = false
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.CliCommand, result);
}
[Fact]
public void JsEntrypointClassifier_SocketIo_ReturnsWebSocketHandler()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::onConnection",
Name = "onConnection",
FullName = "socket.onConnection",
Module = "socket.io",
Line = 30,
IsExported = false
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.WebSocketHandler, result);
}
[Fact]
public void JsEntrypointClassifier_Kafkajs_ReturnsMessageHandler()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::consumer",
Name = "consumer",
FullName = "kafka.consumer",
Module = "kafkajs",
Line = 40,
IsExported = true
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.MessageHandler, result);
}
[Fact]
public void JsEntrypointClassifier_NodeCron_ReturnsScheduledJob()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::cronJob",
Name = "cronJob",
FullName = "scheduler.cronJob",
Module = "node-cron",
Line = 50,
IsExported = false
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.ScheduledJob, result);
}
[Fact]
public void JsEntrypointClassifier_GraphQL_ReturnsHttpHandler()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::resolver",
Name = "resolver",
FullName = "graphql.resolver",
Module = "@apollo/server",
IsRouteHandler = true,
Line = 60,
IsExported = true
};
var result = classifier.Classify(func);
Assert.Equal(EntrypointType.HttpHandler, result);
}
[Fact]
public void JsEntrypointClassifier_NoMatch_ReturnsNull()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::helperFn",
Name = "helperFn",
FullName = "utils.helperFn",
Module = "utils",
Line = 100,
IsExported = false
};
var result = classifier.Classify(func);
Assert.Null(result);
}
#endregion
#region Sink Matcher Tests
[Fact]
public void JsSinkMatcher_ChildProcessExec_ReturnsCmdExec()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("child_process", "exec");
Assert.Equal(SinkCategory.CmdExec, result);
}
[Fact]
public void JsSinkMatcher_ChildProcessSpawn_ReturnsCmdExec()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("child_process", "spawn");
Assert.Equal(SinkCategory.CmdExec, result);
}
[Fact]
public void JsSinkMatcher_ChildProcessFork_ReturnsCmdExec()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("child_process", "fork");
Assert.Equal(SinkCategory.CmdExec, result);
}
[Fact]
public void JsSinkMatcher_MysqlQuery_ReturnsSqlRaw()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("mysql", "query");
Assert.Equal(SinkCategory.SqlRaw, result);
}
[Fact]
public void JsSinkMatcher_PgQuery_ReturnsSqlRaw()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("pg", "query");
Assert.Equal(SinkCategory.SqlRaw, result);
}
[Fact]
public void JsSinkMatcher_KnexRaw_ReturnsSqlRaw()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("knex", "raw");
Assert.Equal(SinkCategory.SqlRaw, result);
}
[Fact]
public void JsSinkMatcher_FsReadFile_ReturnsPathTraversal()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("fs", "readFile");
Assert.Equal(SinkCategory.PathTraversal, result);
}
[Fact]
public void JsSinkMatcher_FsWriteFile_ReturnsPathTraversal()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("fs", "writeFile");
Assert.Equal(SinkCategory.PathTraversal, result);
}
[Fact]
public void JsSinkMatcher_AxiosGet_ReturnsSsrf()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("axios", "get");
Assert.Equal(SinkCategory.Ssrf, result);
}
[Fact]
public void JsSinkMatcher_HttpRequest_ReturnsSsrf()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("http", "request");
Assert.Equal(SinkCategory.Ssrf, result);
}
[Fact]
public void JsSinkMatcher_JsYamlLoad_ReturnsUnsafeDeser()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("js-yaml", "load");
Assert.Equal(SinkCategory.UnsafeDeser, result);
}
[Fact]
public void JsSinkMatcher_Eval_ReturnsCodeInjection()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("eval", "eval");
Assert.Equal(SinkCategory.CodeInjection, result);
}
[Fact]
public void JsSinkMatcher_VmRunInContext_ReturnsCodeInjection()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("vm", "runInContext");
Assert.Equal(SinkCategory.CodeInjection, result);
}
[Fact]
public void JsSinkMatcher_EjsRender_ReturnsTemplateInjection()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("ejs", "render");
Assert.Equal(SinkCategory.TemplateInjection, result);
}
[Fact]
public void JsSinkMatcher_NoMatch_ReturnsNull()
{
var matcher = new JsSinkMatcher();
var result = matcher.Match("lodash", "map");
Assert.Null(result);
}
#endregion
#region Extractor Tests
[Fact]
public void Extractor_Language_IsJavascript()
{
Assert.Equal("javascript", _extractor.Language);
}
[Fact]
public async Task ExtractAsync_MissingPackageJson_ThrowsFileNotFound()
{
await using var temp = await TempDirectory.CreateAsync();
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "javascript",
TargetPath: temp.Path);
await Assert.ThrowsAsync<FileNotFoundException>(
() => _extractor.ExtractAsync(request));
}
[Fact]
public async Task ExtractAsync_WithPackageJson_ReturnsSnapshot()
{
await using var temp = await TempDirectory.CreateAsync();
// Create a minimal package.json
var packageJson = """
{
"name": "test-app",
"version": "1.0.0"
}
""";
await File.WriteAllTextAsync(Path.Combine(temp.Path, "package.json"), packageJson);
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "javascript",
TargetPath: temp.Path);
var snapshot = await _extractor.ExtractAsync(request);
Assert.Equal("scan-001", snapshot.ScanId);
Assert.Equal("javascript", snapshot.Language);
Assert.NotNull(snapshot.GraphDigest);
}
#endregion
#region Determinism Tests
[Fact]
public async Task ExtractAsync_SameInput_ProducesSameDigest()
{
await using var temp = await TempDirectory.CreateAsync();
var packageJson = """
{
"name": "test-app",
"version": "1.0.0"
}
""";
await File.WriteAllTextAsync(Path.Combine(temp.Path, "package.json"), packageJson);
var request = new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "javascript",
TargetPath: temp.Path);
var snapshot1 = await _extractor.ExtractAsync(request);
var snapshot2 = await _extractor.ExtractAsync(request);
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
}
[Fact]
public void JsEntrypointClassifier_SameInput_AlwaysSameResult()
{
var classifier = new JsEntrypointClassifier();
var func = new JsFunctionInfo
{
NodeId = "test::handler",
Name = "handler",
FullName = "routes.handler",
Module = "express",
IsRouteHandler = true,
Line = 10,
IsExported = true
};
var result1 = classifier.Classify(func);
var result2 = classifier.Classify(func);
var result3 = classifier.Classify(func);
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
}
[Fact]
public void JsSinkMatcher_SameInput_AlwaysSameResult()
{
var matcher = new JsSinkMatcher();
var result1 = matcher.Match("child_process", "exec");
var result2 = matcher.Match("child_process", "exec");
var result3 = matcher.Match("child_process", "exec");
Assert.Equal(result1, result2);
Assert.Equal(result2, result3);
Assert.Equal(SinkCategory.CmdExec, result1);
}
#endregion
#region Helpers
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _instant;
public FixedTimeProvider(DateTimeOffset instant)
{
_instant = instant;
}
public override DateTimeOffset GetUtcNow() => _instant;
}
private sealed class TempDirectory : IAsyncDisposable
{
public string Path { get; }
private TempDirectory(string path)
{
Path = path;
}
public static Task<TempDirectory> CreateAsync()
{
var root = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"stella_js_callgraph_{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
return Task.FromResult(new TempDirectory(root));
}
public ValueTask DisposeAsync()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// best effort cleanup
}
return ValueTask.CompletedTask;
}
}
#endregion
}

View File

@@ -0,0 +1,186 @@
// -----------------------------------------------------------------------------
// NodeCallGraphExtractorTests.cs
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph
// Description: Unit tests for Node.js/JavaScript call graph extraction.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.CallGraph.Node;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
public class NodeCallGraphExtractorTests
{
[Fact]
public void BabelResultParser_ParsesValidJson()
{
// Arrange
var json = """
{
"module": "my-app",
"version": "1.0.0",
"nodes": [
{
"id": "js:my-app/src/index.handleRequest",
"package": "my-app",
"name": "handleRequest",
"visibility": "public"
}
],
"edges": [
{
"from": "js:my-app/src/index.handleRequest",
"to": "js:external/express.Router",
"kind": "direct"
}
],
"entrypoints": [
{
"id": "js:my-app/src/index.handleRequest",
"type": "http_handler",
"route": "/api/users",
"method": "GET"
}
]
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Equal("my-app", result.Module);
Assert.Equal("1.0.0", result.Version);
Assert.Single(result.Nodes);
Assert.Single(result.Edges);
Assert.Single(result.Entrypoints);
}
[Fact]
public void BabelResultParser_ParsesNodeWithPosition()
{
// Arrange
var json = """
{
"module": "test",
"nodes": [
{
"id": "js:test/app.main",
"package": "test",
"name": "main",
"position": {
"file": "app.js",
"line": 10,
"column": 5
}
}
],
"edges": [],
"entrypoints": []
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Single(result.Nodes);
var node = result.Nodes[0];
Assert.NotNull(node.Position);
Assert.Equal("app.js", node.Position.File);
Assert.Equal(10, node.Position.Line);
Assert.Equal(5, node.Position.Column);
}
[Fact]
public void BabelResultParser_ParsesEdgeWithSite()
{
// Arrange
var json = """
{
"module": "test",
"nodes": [],
"edges": [
{
"from": "js:test/a.foo",
"to": "js:test/b.bar",
"kind": "callback",
"site": {
"file": "a.js",
"line": 25
}
}
],
"entrypoints": []
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Single(result.Edges);
var edge = result.Edges[0];
Assert.Equal("callback", edge.Kind);
Assert.NotNull(edge.Site);
Assert.Equal("a.js", edge.Site.File);
Assert.Equal(25, edge.Site.Line);
}
[Fact]
public void BabelResultParser_ThrowsOnEmptyInput()
{
// Arrange & Act & Assert
Assert.Throws<ArgumentException>(() => BabelResultParser.Parse(""));
Assert.Throws<ArgumentException>(() => BabelResultParser.Parse(null!));
}
[Fact]
public void BabelResultParser_ParsesNdjson()
{
// Arrange
var ndjson = """
{"type": "progress", "percent": 50}
{"type": "progress", "percent": 100}
{"module": "app", "nodes": [], "edges": [], "entrypoints": []}
""";
// Act
var result = BabelResultParser.ParseNdjson(ndjson);
// Assert
Assert.Equal("app", result.Module);
}
[Fact]
public void JsEntrypointInfo_HasCorrectProperties()
{
// Arrange
var json = """
{
"module": "api",
"nodes": [],
"edges": [],
"entrypoints": [
{
"id": "js:api/routes.getUsers",
"type": "http_handler",
"route": "/users/:id",
"method": "GET"
}
]
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Single(result.Entrypoints);
var ep = result.Entrypoints[0];
Assert.Equal("js:api/routes.getUsers", ep.Id);
Assert.Equal("http_handler", ep.Type);
Assert.Equal("/users/:id", ep.Route);
Assert.Equal("GET", ep.Method);
}
}