- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
341 lines
9.0 KiB
C#
341 lines
9.0 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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<ArgumentNullException>(() => 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);
|
|
}
|
|
|
|
[Fact]
|
|
public void BabelResultParser_ParsesSinks()
|
|
{
|
|
// Arrange
|
|
var json = """
|
|
{
|
|
"module": "test",
|
|
"nodes": [
|
|
{
|
|
"id": "js:test/handler.processRequest",
|
|
"package": "test",
|
|
"name": "processRequest"
|
|
}
|
|
],
|
|
"edges": [],
|
|
"entrypoints": [],
|
|
"sinks": [
|
|
{
|
|
"caller": "js:test/handler.processRequest",
|
|
"category": "command_injection",
|
|
"method": "child_process.exec",
|
|
"site": {
|
|
"file": "handler.js",
|
|
"line": 42,
|
|
"column": 8
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
var result = BabelResultParser.Parse(json);
|
|
|
|
// Assert
|
|
Assert.Single(result.Sinks);
|
|
var sink = result.Sinks[0];
|
|
Assert.Equal("js:test/handler.processRequest", sink.Caller);
|
|
Assert.Equal("command_injection", sink.Category);
|
|
Assert.Equal("child_process.exec", sink.Method);
|
|
Assert.NotNull(sink.Site);
|
|
Assert.Equal("handler.js", sink.Site.File);
|
|
Assert.Equal(42, sink.Site.Line);
|
|
}
|
|
|
|
[Fact]
|
|
public void BabelResultParser_ParsesMultipleSinkCategories()
|
|
{
|
|
// Arrange
|
|
var json = """
|
|
{
|
|
"module": "vulnerable-app",
|
|
"nodes": [],
|
|
"edges": [],
|
|
"entrypoints": [],
|
|
"sinks": [
|
|
{
|
|
"caller": "js:vulnerable-app/db.query",
|
|
"category": "sql_injection",
|
|
"method": "connection.query"
|
|
},
|
|
{
|
|
"caller": "js:vulnerable-app/api.fetch",
|
|
"category": "ssrf",
|
|
"method": "fetch"
|
|
},
|
|
{
|
|
"caller": "js:vulnerable-app/file.write",
|
|
"category": "file_write",
|
|
"method": "fs.writeFileSync"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
var result = BabelResultParser.Parse(json);
|
|
|
|
// Assert
|
|
Assert.Equal(3, result.Sinks.Count);
|
|
Assert.Contains(result.Sinks, s => s.Category == "sql_injection");
|
|
Assert.Contains(result.Sinks, s => s.Category == "ssrf");
|
|
Assert.Contains(result.Sinks, s => s.Category == "file_write");
|
|
}
|
|
|
|
[Fact]
|
|
public void BabelResultParser_ParsesEmptySinks()
|
|
{
|
|
// Arrange
|
|
var json = """
|
|
{
|
|
"module": "safe-app",
|
|
"nodes": [],
|
|
"edges": [],
|
|
"entrypoints": [],
|
|
"sinks": []
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
var result = BabelResultParser.Parse(json);
|
|
|
|
// Assert
|
|
Assert.Empty(result.Sinks);
|
|
}
|
|
|
|
[Fact]
|
|
public void BabelResultParser_ParsesMissingSinks()
|
|
{
|
|
// Arrange - sinks field omitted entirely
|
|
var json = """
|
|
{
|
|
"module": "legacy-app",
|
|
"nodes": [],
|
|
"edges": [],
|
|
"entrypoints": []
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
var result = BabelResultParser.Parse(json);
|
|
|
|
// Assert - should default to empty list
|
|
Assert.Empty(result.Sinks);
|
|
}
|
|
|
|
[Fact]
|
|
public void BabelResultParser_ParsesSinkWithoutSite()
|
|
{
|
|
// Arrange
|
|
var json = """
|
|
{
|
|
"module": "test",
|
|
"nodes": [],
|
|
"edges": [],
|
|
"entrypoints": [],
|
|
"sinks": [
|
|
{
|
|
"caller": "js:test/func",
|
|
"category": "deserialization",
|
|
"method": "eval"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
// Act
|
|
var result = BabelResultParser.Parse(json);
|
|
|
|
// Assert
|
|
Assert.Single(result.Sinks);
|
|
Assert.Null(result.Sinks[0].Site);
|
|
}
|
|
}
|