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:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -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; }
}

View File

@@ -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();