feat: add security sink detection patterns for JavaScript/TypeScript
- 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.
This commit is contained in:
@@ -101,6 +101,11 @@ public sealed record JsCallGraphResult
|
||||
/// Detected entrypoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JsEntrypointInfo> Entrypoints { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Detected security sinks.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JsSinkInfo> Sinks { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -216,3 +221,29 @@ public sealed record JsEntrypointInfo
|
||||
/// </summary>
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A security sink from the JavaScript call graph.
|
||||
/// </summary>
|
||||
public sealed record JsSinkInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Node ID of the caller function that invokes the sink.
|
||||
/// </summary>
|
||||
public required string Caller { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink category (command_injection, sql_injection, ssrf, etc.).
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method being called (e.g., exec, query, fetch).
|
||||
/// </summary>
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call site position.
|
||||
/// </summary>
|
||||
public JsPositionInfo? Site { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Node;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder Node.js call graph extractor.
|
||||
/// Babel integration is planned; this implementation is intentionally minimal.
|
||||
/// Node.js call graph extractor using Babel AST analysis.
|
||||
/// Invokes stella-callgraph-node tool for JavaScript/TypeScript projects.
|
||||
/// </summary>
|
||||
public sealed class NodeCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NodeCallGraphExtractor>? _logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public NodeCallGraphExtractor(TimeProvider? timeProvider = null)
|
||||
/// <summary>
|
||||
/// Path to the stella-callgraph-node tool (configurable).
|
||||
/// </summary>
|
||||
public string ToolPath { get; init; } = "stella-callgraph-node";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for tool execution.
|
||||
/// </summary>
|
||||
public TimeSpan ToolTimeout { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public NodeCallGraphExtractor(TimeProvider? timeProvider = null, ILogger<NodeCallGraphExtractor>? logger = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Language => "node";
|
||||
@@ -28,6 +42,25 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
|
||||
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
|
||||
}
|
||||
|
||||
// Try to extract using Babel tool first
|
||||
var targetDir = ResolveProjectDirectory(request.TargetPath);
|
||||
if (targetDir is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await InvokeToolAsync(targetDir, cancellationToken).ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
{
|
||||
return BuildFromBabelResult(request.ScanId, result);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Babel tool invocation failed for {Path}, falling back to trace file", targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try legacy trace file
|
||||
var tracePath = ResolveTracePath(request.TargetPath);
|
||||
if (tracePath is not null && File.Exists(tracePath))
|
||||
{
|
||||
@@ -42,10 +75,11 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
// fall through to empty snapshot
|
||||
_logger?.LogDebug(ex, "Failed to read trace file at {Path}", tracePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty snapshot
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: request.ScanId,
|
||||
@@ -61,6 +95,238 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private async Task<JsCallGraphResult?> InvokeToolAsync(string projectPath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ToolPath,
|
||||
Arguments = $"\"{projectPath}\" --json",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
_logger?.LogDebug("Invoking stella-callgraph-node on {Path}", projectPath);
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
|
||||
// Read output asynchronously
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
using var timeoutCts = new CancellationTokenSource(ToolTimeout);
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||
|
||||
await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
|
||||
|
||||
var output = await outputTask.ConfigureAwait(false);
|
||||
var errors = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
_logger?.LogWarning("stella-callgraph-node exited with code {ExitCode}: {Errors}", process.ExitCode, errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
_logger?.LogDebug("stella-callgraph-node produced no output");
|
||||
return null;
|
||||
}
|
||||
|
||||
return BabelResultParser.Parse(output);
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception)
|
||||
{
|
||||
_logger?.LogDebug(ex, "stella-callgraph-node not found at {Path}", ToolPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildFromBabelResult(string scanId, JsCallGraphResult result)
|
||||
{
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Build entrypoint set for quick lookup
|
||||
var entrypointIds = result.Entrypoints
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Build sink lookup by caller
|
||||
var sinksByNode = result.Sinks
|
||||
.GroupBy(s => s.Caller, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.First().Category,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Convert nodes
|
||||
var nodes = result.Nodes.Select(n =>
|
||||
{
|
||||
var isEntrypoint = entrypointIds.Contains(n.Id);
|
||||
var isSink = sinksByNode.ContainsKey(n.Id);
|
||||
var sinkCategory = isSink ? sinksByNode[n.Id] : null;
|
||||
|
||||
// Determine entrypoint type
|
||||
EntrypointType? entrypointType = null;
|
||||
if (isEntrypoint)
|
||||
{
|
||||
var ep = result.Entrypoints.FirstOrDefault(e => e.Id == n.Id);
|
||||
entrypointType = MapEntrypointType(ep?.Type);
|
||||
}
|
||||
|
||||
return new CallGraphNode(
|
||||
NodeId: CallGraphNodeIds.Compute(n.Id),
|
||||
Symbol: n.Name,
|
||||
File: n.Position?.File ?? string.Empty,
|
||||
Line: n.Position?.Line ?? 0,
|
||||
Package: n.Package,
|
||||
Visibility: MapVisibility(n.Visibility),
|
||||
IsEntrypoint: isEntrypoint,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: isSink,
|
||||
SinkCategory: MapSinkCategory(sinkCategory));
|
||||
}).ToList();
|
||||
|
||||
// Convert edges
|
||||
var edges = result.Edges.Select(e => new CallGraphEdge(
|
||||
CallGraphNodeIds.Compute(e.From),
|
||||
CallGraphNodeIds.Compute(e.To),
|
||||
MapCallKind(e.Kind)
|
||||
)).ToList();
|
||||
|
||||
// Create sink nodes for detected sinks (these may not be in the nodes list)
|
||||
foreach (var sink in result.Sinks)
|
||||
{
|
||||
var sinkNodeId = CallGraphNodeIds.Compute($"js:sink:{sink.Category}:{sink.Method}");
|
||||
|
||||
// Check if we already have this sink node
|
||||
if (!nodes.Any(n => n.NodeId == sinkNodeId))
|
||||
{
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: sinkNodeId,
|
||||
Symbol: sink.Method,
|
||||
File: sink.Site?.File ?? string.Empty,
|
||||
Line: sink.Site?.Line ?? 0,
|
||||
Package: "external",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: sink.Category));
|
||||
|
||||
// Add edge from caller to sink
|
||||
var callerNodeId = CallGraphNodeIds.Compute(sink.Caller);
|
||||
edges.Add(new CallGraphEdge(callerNodeId, sinkNodeId, CallKind.Direct));
|
||||
}
|
||||
}
|
||||
|
||||
var distinctNodes = nodes
|
||||
.GroupBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(g => g.First())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var distinctEdges = edges
|
||||
.Distinct(CallGraphEdgeStructuralComparer.Instance)
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointNodeIds = distinctNodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkNodeIds = distinctNodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: distinctNodes,
|
||||
Edges: distinctEdges,
|
||||
EntrypointIds: entrypointNodeIds,
|
||||
SinkIds: sinkNodeIds);
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static Visibility MapVisibility(string? visibility) => visibility?.ToLowerInvariant() switch
|
||||
{
|
||||
"public" => Visibility.Public,
|
||||
"private" => Visibility.Private,
|
||||
"protected" => Visibility.Protected,
|
||||
_ => Visibility.Public
|
||||
};
|
||||
|
||||
private static EntrypointType MapEntrypointType(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"http_handler" => EntrypointType.HttpHandler,
|
||||
"lambda" => EntrypointType.Lambda,
|
||||
"websocket_handler" => EntrypointType.WebSocketHandler,
|
||||
"grpc_handler" or "grpc_method" => EntrypointType.GrpcMethod,
|
||||
"message_handler" => EntrypointType.MessageHandler,
|
||||
_ => EntrypointType.HttpHandler
|
||||
};
|
||||
|
||||
private static CallKind MapCallKind(string? kind) => kind?.ToLowerInvariant() switch
|
||||
{
|
||||
"direct" => CallKind.Direct,
|
||||
"dynamic" => CallKind.Dynamic,
|
||||
"virtual" => CallKind.Virtual,
|
||||
"callback" or "delegate" => CallKind.Delegate,
|
||||
_ => CallKind.Direct
|
||||
};
|
||||
|
||||
private static SinkCategory? MapSinkCategory(string? category) => category?.ToLowerInvariant() switch
|
||||
{
|
||||
"command_injection" or "cmd_exec" => SinkCategory.CmdExec,
|
||||
"sql_injection" or "sql_raw" => SinkCategory.SqlRaw,
|
||||
"deserialization" or "unsafe_deser" => SinkCategory.UnsafeDeser,
|
||||
"ssrf" => SinkCategory.Ssrf,
|
||||
"file_write" => SinkCategory.FileWrite,
|
||||
"file_read" or "path_traversal" => SinkCategory.PathTraversal,
|
||||
"weak_crypto" or "crypto_weak" => SinkCategory.CryptoWeak,
|
||||
"ldap_injection" => SinkCategory.LdapInjection,
|
||||
"nosql_injection" or "nosql" => SinkCategory.NoSqlInjection,
|
||||
"xss" or "template_injection" => SinkCategory.TemplateInjection,
|
||||
"log_injection" or "log_forging" => SinkCategory.LogForging,
|
||||
"regex_dos" or "redos" => SinkCategory.ReDos,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? ResolveProjectDirectory(string targetPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = Path.GetFullPath(targetPath);
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
// Check for package.json to verify it's a Node.js project
|
||||
if (File.Exists(Path.Combine(path, "package.json")))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildFromTrace(string scanId, TraceDocument trace)
|
||||
{
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
Reference in New Issue
Block a user