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:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user