CD/CD consolidation

This commit is contained in:
StellaOps Bot
2025-12-26 17:32:23 +02:00
parent a866eb6277
commit c786faae84
638 changed files with 3821 additions and 181 deletions

View File

@@ -0,0 +1,178 @@
// -----------------------------------------------------------------------------
// framework-detect.js
// Framework detection patterns for JavaScript/TypeScript projects.
// -----------------------------------------------------------------------------
/**
* Framework detection patterns
*/
export const frameworkPatterns = {
express: {
packageNames: ['express'],
patterns: [
/const\s+\w+\s*=\s*require\(['"]express['"]\)/,
/import\s+\w+\s+from\s+['"]express['"]/,
/app\.(get|post|put|delete|patch)\s*\(/
],
entrypointType: 'http_handler'
},
fastify: {
packageNames: ['fastify'],
patterns: [
/require\(['"]fastify['"]\)/,
/import\s+\w+\s+from\s+['"]fastify['"]/,
/fastify\.(get|post|put|delete|patch)\s*\(/
],
entrypointType: 'http_handler'
},
koa: {
packageNames: ['koa', '@koa/router'],
patterns: [
/require\(['"]koa['"]\)/,
/import\s+\w+\s+from\s+['"]koa['"]/,
/router\.(get|post|put|delete|patch)\s*\(/
],
entrypointType: 'http_handler'
},
hapi: {
packageNames: ['@hapi/hapi'],
patterns: [
/require\(['"]@hapi\/hapi['"]\)/,
/import\s+\w+\s+from\s+['"]@hapi\/hapi['"]/,
/server\.route\s*\(/
],
entrypointType: 'http_handler'
},
nestjs: {
packageNames: ['@nestjs/core', '@nestjs/common'],
patterns: [
/@Controller\s*\(/,
/@Get\s*\(/,
/@Post\s*\(/,
/@Put\s*\(/,
/@Delete\s*\(/,
/@Patch\s*\(/
],
entrypointType: 'http_handler'
},
socketio: {
packageNames: ['socket.io'],
patterns: [
/require\(['"]socket\.io['"]\)/,
/import\s+\w+\s+from\s+['"]socket\.io['"]/,
/io\.on\s*\(\s*['"]connection['"]/,
/socket\.on\s*\(/
],
entrypointType: 'websocket_handler'
},
awsLambda: {
packageNames: ['aws-lambda', '@types/aws-lambda'],
patterns: [
/exports\.handler\s*=/,
/export\s+(const|async function)\s+handler/,
/module\.exports\.handler/,
/APIGatewayProxyHandler/,
/APIGatewayEvent/
],
entrypointType: 'lambda'
},
azureFunctions: {
packageNames: ['@azure/functions'],
patterns: [
/require\(['"]@azure\/functions['"]\)/,
/import\s+\w+\s+from\s+['"]@azure\/functions['"]/,
/app\.(http|timer|queue|blob)\s*\(/
],
entrypointType: 'cloud_function'
},
gcpFunctions: {
packageNames: ['@google-cloud/functions-framework'],
patterns: [
/require\(['"]@google-cloud\/functions-framework['"]\)/,
/functions\.(http|cloudEvent)\s*\(/
],
entrypointType: 'cloud_function'
},
electron: {
packageNames: ['electron'],
patterns: [
/require\(['"]electron['"]\)/,
/import\s+\{[^}]*\}\s+from\s+['"]electron['"]/,
/ipcMain\.on\s*\(/,
/ipcRenderer\.on\s*\(/
],
entrypointType: 'event_handler'
},
grpc: {
packageNames: ['@grpc/grpc-js', 'grpc'],
patterns: [
/require\(['"]@grpc\/grpc-js['"]\)/,
/addService\s*\(/,
/loadPackageDefinition\s*\(/
],
entrypointType: 'grpc_method'
}
};
/**
* Detect frameworks from package.json dependencies
* @param {object} packageJson
* @returns {string[]}
*/
export function detectFrameworks(packageJson) {
const detected = [];
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies
};
for (const [framework, config] of Object.entries(frameworkPatterns)) {
for (const pkgName of config.packageNames) {
if (allDeps[pkgName]) {
detected.push(framework);
break;
}
}
}
return detected;
}
/**
* Detect frameworks from source code patterns
* @param {string} content
* @returns {string[]}
*/
export function detectFrameworksFromCode(content) {
const detected = [];
for (const [framework, config] of Object.entries(frameworkPatterns)) {
for (const pattern of config.patterns) {
if (pattern.test(content)) {
detected.push(framework);
break;
}
}
}
return detected;
}
/**
* Get entrypoint type for a detected framework
* @param {string} framework
* @returns {string}
*/
export function getEntrypointType(framework) {
return frameworkPatterns[framework]?.entrypointType || 'unknown';
}

View File

@@ -0,0 +1,478 @@
#!/usr/bin/env node
// -----------------------------------------------------------------------------
// stella-callgraph-node
// Call graph extraction tool for JavaScript/TypeScript projects.
// Uses Babel AST for static analysis.
// -----------------------------------------------------------------------------
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join, extname, relative, dirname } from 'path';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import { buildSinkLookup, matchSink } from './sink-detect.js';
// Pre-build sink lookup for fast detection
const sinkLookup = buildSinkLookup();
/**
* Main entry point
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help')) {
printUsage();
process.exit(0);
}
const targetPath = args[0];
const outputFormat = args.includes('--json') ? 'json' : 'ndjson';
try {
const result = await analyzeProject(targetPath);
if (outputFormat === 'json') {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(JSON.stringify(result));
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
function printUsage() {
console.log(`
stella-callgraph-node - JavaScript/TypeScript call graph extractor
Usage:
stella-callgraph-node <project-path> [options]
Options:
--json Output formatted JSON instead of NDJSON
--help Show this help message
Example:
stella-callgraph-node ./my-express-app --json
`);
}
/**
* Analyze a JavaScript/TypeScript project
* @param {string} projectPath
* @returns {Promise<CallGraphResult>}
*/
async function analyzeProject(projectPath) {
const packageJsonPath = join(projectPath, 'package.json');
let packageInfo = { name: 'unknown', version: '0.0.0' };
if (existsSync(packageJsonPath)) {
const content = readFileSync(packageJsonPath, 'utf-8');
packageInfo = JSON.parse(content);
}
const sourceFiles = findSourceFiles(projectPath);
const nodes = [];
const edges = [];
const entrypoints = [];
const sinks = [];
for (const file of sourceFiles) {
try {
const content = readFileSync(file, 'utf-8');
const relativePath = relative(projectPath, file);
const result = analyzeFile(content, relativePath, packageInfo.name);
nodes.push(...result.nodes);
edges.push(...result.edges);
entrypoints.push(...result.entrypoints);
sinks.push(...result.sinks);
} catch (error) {
// Skip files that can't be parsed
console.error(`Warning: Could not parse ${file}: ${error.message}`);
}
}
return {
module: packageInfo.name,
version: packageInfo.version,
nodes: deduplicateNodes(nodes),
edges: deduplicateEdges(edges),
entrypoints,
sinks: deduplicateSinks(sinks)
};
}
/**
* Find all JavaScript/TypeScript source files
* @param {string} dir
* @returns {string[]}
*/
function findSourceFiles(dir) {
const files = [];
const excludeDirs = ['node_modules', 'dist', 'build', '.git', 'coverage', '__tests__'];
const extensions = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'];
function walk(currentDir) {
const entries = readdirSync(currentDir);
for (const entry of entries) {
const fullPath = join(currentDir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
if (!excludeDirs.includes(entry) && !entry.startsWith('.')) {
walk(fullPath);
}
} else if (stat.isFile()) {
const ext = extname(entry);
if (extensions.includes(ext) && !entry.includes('.test.') && !entry.includes('.spec.')) {
files.push(fullPath);
}
}
}
}
walk(dir);
return files.sort();
}
/**
* Analyze a single source file
* @param {string} content
* @param {string} relativePath
* @param {string} packageName
* @returns {{ nodes: any[], edges: any[], entrypoints: any[] }}
*/
function analyzeFile(content, relativePath, packageName) {
const nodes = [];
const edges = [];
const entrypoints = [];
const sinks = [];
const moduleBase = relativePath.replace(/\.[^.]+$/, '').replace(/\\/g, '/');
// Parse with Babel
const ast = parse(content, {
sourceType: 'module',
plugins: [
'typescript',
'jsx',
'decorators-legacy',
'classProperties',
'classPrivateProperties',
'classPrivateMethods',
'dynamicImport',
'optionalChaining',
'nullishCoalescingOperator'
],
errorRecovery: true
});
// Track current function context for edges
let currentFunction = null;
traverse.default(ast, {
// Function declarations
FunctionDeclaration(path) {
const name = path.node.id?.name;
if (!name) return;
const nodeId = `js:${packageName}/${moduleBase}.${name}`;
const isExported = path.parent.type === 'ExportNamedDeclaration' ||
path.parent.type === 'ExportDefaultDeclaration';
nodes.push({
id: nodeId,
package: packageName,
name,
signature: getFunctionSignature(path.node),
position: {
file: relativePath,
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0
},
visibility: isExported ? 'public' : 'private',
annotations: []
});
// Check for route handlers
const routeInfo = detectRouteHandler(path);
if (routeInfo) {
entrypoints.push({
id: nodeId,
type: routeInfo.type,
route: routeInfo.route,
method: routeInfo.method
});
}
currentFunction = nodeId;
},
// Arrow functions assigned to variables
VariableDeclarator(path) {
if (path.node.init?.type === 'ArrowFunctionExpression' ||
path.node.init?.type === 'FunctionExpression') {
const name = path.node.id?.name;
if (!name) return;
const nodeId = `js:${packageName}/${moduleBase}.${name}`;
const parent = path.parentPath?.parent;
const isExported = parent?.type === 'ExportNamedDeclaration';
nodes.push({
id: nodeId,
package: packageName,
name,
signature: getFunctionSignature(path.node.init),
position: {
file: relativePath,
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0
},
visibility: isExported ? 'public' : 'private',
annotations: []
});
currentFunction = nodeId;
}
},
// Class methods
ClassMethod(path) {
const className = path.parentPath?.parent?.id?.name;
const methodName = path.node.key?.name;
if (!className || !methodName) return;
const nodeId = `js:${packageName}/${moduleBase}.${className}.${methodName}`;
nodes.push({
id: nodeId,
package: packageName,
name: `${className}.${methodName}`,
signature: getFunctionSignature(path.node),
position: {
file: relativePath,
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0
},
visibility: path.node.accessibility || 'public',
annotations: getDecorators(path)
});
// Check for controller/handler patterns
if (className.endsWith('Controller') || className.endsWith('Handler')) {
entrypoints.push({
id: nodeId,
type: 'http_handler',
route: null,
method: null
});
}
currentFunction = nodeId;
},
// Call expressions (edges)
CallExpression(path) {
if (!currentFunction) return;
const callee = path.node.callee;
let targetId = null;
let objName = null;
let methodName = null;
if (callee.type === 'Identifier') {
targetId = `js:${packageName}/${moduleBase}.${callee.name}`;
methodName = callee.name;
} else if (callee.type === 'MemberExpression') {
objName = callee.object?.name || 'unknown';
methodName = callee.property?.name || 'unknown';
targetId = `js:external/${objName}.${methodName}`;
}
if (targetId) {
edges.push({
from: currentFunction,
to: targetId,
kind: 'direct',
site: {
file: relativePath,
line: path.node.loc?.start.line || 0
}
});
}
// Detect security sinks
if (methodName) {
const sinkMatch = matchSink(objName || methodName, methodName, sinkLookup);
if (sinkMatch) {
sinks.push({
caller: currentFunction,
category: sinkMatch.category,
method: `${objName ? objName + '.' : ''}${methodName}`,
site: {
file: relativePath,
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0
}
});
}
}
// Detect Express/Fastify route registration
detectRouteRegistration(path, entrypoints, packageName, moduleBase, relativePath);
}
});
return { nodes, edges, entrypoints, sinks };
}
/**
* Get function signature string
* @param {object} node
* @returns {string}
*/
function getFunctionSignature(node) {
const params = node.params?.map(p => {
if (p.type === 'Identifier') {
return p.name;
} else if (p.type === 'AssignmentPattern') {
return p.left?.name || 'arg';
} else if (p.type === 'RestElement') {
return `...${p.argument?.name || 'args'}`;
}
return 'arg';
}) || [];
const isAsync = node.async ? 'async ' : '';
return `${isAsync}(${params.join(', ')})`;
}
/**
* Get decorators from a path
* @param {object} path
* @returns {string[]}
*/
function getDecorators(path) {
const decorators = path.node.decorators || [];
return decorators.map(d => {
if (d.expression?.callee?.name) {
return `@${d.expression.callee.name}`;
} else if (d.expression?.name) {
return `@${d.expression.name}`;
}
return '@unknown';
});
}
/**
* Detect if function is a route handler
* @param {object} path
* @returns {{ type: string, route: string | null, method: string | null } | null}
*/
function detectRouteHandler(path) {
const name = path.node.id?.name?.toLowerCase();
if (!name) return null;
// Common handler naming patterns
if (name.includes('handler') || name.includes('controller')) {
return { type: 'http_handler', route: null, method: null };
}
// Lambda handler pattern
if (name === 'handler' || name === 'main') {
return { type: 'lambda', route: null, method: null };
}
return null;
}
/**
* Detect Express/Fastify route registration
* @param {object} path
* @param {any[]} entrypoints
* @param {string} packageName
* @param {string} moduleBase
* @param {string} relativePath
*/
function detectRouteRegistration(path, entrypoints, packageName, moduleBase, relativePath) {
const callee = path.node.callee;
if (callee.type !== 'MemberExpression') return;
const methodName = callee.property?.name?.toLowerCase();
const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
if (!httpMethods.includes(methodName)) return;
// Get route path from first argument
const firstArg = path.node.arguments?.[0];
let routePath = null;
if (firstArg?.type === 'StringLiteral') {
routePath = firstArg.value;
}
if (routePath) {
const handlerName = `${methodName.toUpperCase()}_${routePath.replace(/[/:{}*?]/g, '_')}`;
const nodeId = `js:${packageName}/${moduleBase}.${handlerName}`;
entrypoints.push({
id: nodeId,
type: 'http_handler',
route: routePath,
method: methodName.toUpperCase()
});
}
}
/**
* Remove duplicate nodes
* @param {any[]} nodes
* @returns {any[]}
*/
function deduplicateNodes(nodes) {
const seen = new Set();
return nodes.filter(n => {
if (seen.has(n.id)) return false;
seen.add(n.id);
return true;
});
}
/**
* Remove duplicate edges
* @param {any[]} edges
* @returns {any[]}
*/
function deduplicateEdges(edges) {
const seen = new Set();
return edges.filter(e => {
const key = `${e.from}|${e.to}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/**
* Remove duplicate sinks
* @param {any[]} sinks
* @returns {any[]}
*/
function deduplicateSinks(sinks) {
const seen = new Set();
return sinks.filter(s => {
const key = `${s.caller}|${s.category}|${s.method}|${s.site.file}:${s.site.line}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Run
main().catch(console.error);

View File

@@ -0,0 +1,675 @@
// -----------------------------------------------------------------------------
// index.test.js
// Sprint: SPRINT_3600_0004_0001 (Node.js Babel Integration)
// Tasks: NODE-017, NODE-018 - Unit tests for AST parsing and entrypoint detection
// Description: Tests for call graph extraction from JavaScript/TypeScript.
// -----------------------------------------------------------------------------
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
// Test utilities for AST parsing
function parseCode(code, options = {}) {
return parse(code, {
sourceType: 'module',
plugins: [
'typescript',
'jsx',
'decorators-legacy',
'classProperties',
'classPrivateProperties',
'classPrivateMethods',
'dynamicImport',
'optionalChaining',
'nullishCoalescingOperator'
],
errorRecovery: true,
...options
});
}
describe('Babel Parser Integration', () => {
test('parses simple JavaScript function', () => {
const code = `
function hello(name) {
return 'Hello, ' + name;
}
`;
const ast = parseCode(code);
assert.ok(ast);
assert.equal(ast.type, 'File');
assert.ok(ast.program.body.length > 0);
});
test('parses arrow function', () => {
const code = `
const greet = (name) => {
return \`Hello, \${name}\`;
};
`;
const ast = parseCode(code);
assert.ok(ast);
let foundArrow = false;
traverse.default(ast, {
ArrowFunctionExpression() {
foundArrow = true;
}
});
assert.ok(foundArrow, 'Should find arrow function');
});
test('parses async function', () => {
const code = `
async function fetchData(url) {
const response = await fetch(url);
return response.json();
}
`;
const ast = parseCode(code);
let isAsync = false;
traverse.default(ast, {
FunctionDeclaration(path) {
isAsync = path.node.async;
}
});
assert.ok(isAsync, 'Should detect async function');
});
test('parses class with methods', () => {
const code = `
class UserController {
async getUser(id) {
return this.userService.findById(id);
}
async createUser(data) {
return this.userService.create(data);
}
}
`;
const ast = parseCode(code);
const methods = [];
traverse.default(ast, {
ClassMethod(path) {
methods.push(path.node.key.name);
}
});
assert.deepEqual(methods.sort(), ['createUser', 'getUser']);
});
test('parses TypeScript with types', () => {
const code = `
interface User {
id: string;
name: string;
}
function getUser(id: string): Promise<User> {
return db.query<User>('SELECT * FROM users WHERE id = $1', [id]);
}
`;
const ast = parseCode(code);
assert.ok(ast);
let foundFunction = false;
traverse.default(ast, {
FunctionDeclaration(path) {
if (path.node.id.name === 'getUser') {
foundFunction = true;
}
}
});
assert.ok(foundFunction, 'Should parse TypeScript function');
});
test('parses JSX components', () => {
const code = `
function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
`;
const ast = parseCode(code);
let foundJSX = false;
traverse.default(ast, {
JSXElement() {
foundJSX = true;
}
});
assert.ok(foundJSX, 'Should parse JSX');
});
test('parses decorators', () => {
const code = `
@Controller('/users')
class UserController {
@Get('/:id')
async getUser(@Param('id') id: string) {
return this.userService.findById(id);
}
}
`;
const ast = parseCode(code);
const decorators = [];
traverse.default(ast, {
ClassDeclaration(path) {
if (path.node.decorators) {
decorators.push(...path.node.decorators.map(d =>
d.expression?.callee?.name || d.expression?.name
));
}
},
ClassMethod(path) {
if (path.node.decorators) {
decorators.push(...path.node.decorators.map(d =>
d.expression?.callee?.name || d.expression?.name
));
}
}
});
assert.ok(decorators.includes('Controller'));
assert.ok(decorators.includes('Get'));
});
test('parses dynamic imports', () => {
const code = `
async function loadModule(name) {
const module = await import(\`./modules/\${name}\`);
return module.default;
}
`;
const ast = parseCode(code);
let foundDynamicImport = false;
traverse.default(ast, {
Import() {
foundDynamicImport = true;
}
});
assert.ok(foundDynamicImport, 'Should detect dynamic import');
});
test('parses optional chaining', () => {
const code = `
const name = user?.profile?.name ?? 'Anonymous';
`;
const ast = parseCode(code);
let foundOptionalChain = false;
traverse.default(ast, {
OptionalMemberExpression() {
foundOptionalChain = true;
}
});
assert.ok(foundOptionalChain, 'Should parse optional chaining');
});
test('parses class private fields', () => {
const code = `
class Counter {
#count = 0;
increment() {
this.#count++;
}
get value() {
return this.#count;
}
}
`;
const ast = parseCode(code);
let foundPrivateField = false;
traverse.default(ast, {
ClassPrivateProperty() {
foundPrivateField = true;
}
});
assert.ok(foundPrivateField, 'Should parse private class field');
});
});
describe('Function Declaration Extraction', () => {
test('extracts function name', () => {
const code = `
function processRequest(req, res) {
res.json({ status: 'ok' });
}
`;
const ast = parseCode(code);
let functionName = null;
traverse.default(ast, {
FunctionDeclaration(path) {
functionName = path.node.id.name;
}
});
assert.equal(functionName, 'processRequest');
});
test('extracts function parameters', () => {
const code = `
function greet(firstName, lastName, options = {}) {
return \`Hello, \${firstName} \${lastName}\`;
}
`;
const ast = parseCode(code);
let params = [];
traverse.default(ast, {
FunctionDeclaration(path) {
params = path.node.params.map(p => {
if (p.type === 'Identifier') return p.name;
if (p.type === 'AssignmentPattern') return p.left.name;
return 'unknown';
});
}
});
assert.deepEqual(params, ['firstName', 'lastName', 'options']);
});
test('detects exported functions', () => {
const code = `
export function publicFunction() {}
function privateFunction() {}
export default function defaultFunction() {}
`;
const ast = parseCode(code);
const functions = { public: [], private: [] };
traverse.default(ast, {
FunctionDeclaration(path) {
const name = path.node.id?.name;
if (!name) return;
const isExported =
path.parent.type === 'ExportNamedDeclaration' ||
path.parent.type === 'ExportDefaultDeclaration';
if (isExported) {
functions.public.push(name);
} else {
functions.private.push(name);
}
}
});
assert.deepEqual(functions.public.sort(), ['defaultFunction', 'publicFunction']);
assert.deepEqual(functions.private, ['privateFunction']);
});
});
describe('Call Expression Extraction', () => {
test('extracts direct function calls', () => {
const code = `
function main() {
helper();
processData();
}
`;
const ast = parseCode(code);
const calls = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'Identifier') {
calls.push(path.node.callee.name);
}
}
});
assert.deepEqual(calls.sort(), ['helper', 'processData']);
});
test('extracts method calls', () => {
const code = `
function handler() {
db.query('SELECT * FROM users');
fs.readFile('./config.json');
console.log('done');
}
`;
const ast = parseCode(code);
const methodCalls = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const obj = path.node.callee.object.name;
const method = path.node.callee.property.name;
methodCalls.push(`${obj}.${method}`);
}
}
});
assert.ok(methodCalls.includes('db.query'));
assert.ok(methodCalls.includes('fs.readFile'));
assert.ok(methodCalls.includes('console.log'));
});
test('extracts chained method calls', () => {
const code = `
const result = data
.filter(x => x.active)
.map(x => x.name)
.join(', ');
`;
const ast = parseCode(code);
const methods = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name;
methods.push(method);
}
}
});
assert.ok(methods.includes('filter'));
assert.ok(methods.includes('map'));
assert.ok(methods.includes('join'));
});
});
describe('Framework Entrypoint Detection', () => {
test('detects Express route handlers', () => {
const code = `
const express = require('express');
const app = express();
app.get('/users', (req, res) => {
res.json(users);
});
app.post('/users', async (req, res) => {
const user = await createUser(req.body);
res.json(user);
});
app.delete('/users/:id', (req, res) => {
deleteUser(req.params.id);
res.sendStatus(204);
});
`;
const ast = parseCode(code);
const routes = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name?.toLowerCase();
const httpMethods = ['get', 'post', 'put', 'delete', 'patch'];
if (httpMethods.includes(method)) {
const routeArg = path.node.arguments[0];
if (routeArg?.type === 'StringLiteral') {
routes.push({ method: method.toUpperCase(), path: routeArg.value });
}
}
}
}
});
assert.equal(routes.length, 3);
assert.ok(routes.some(r => r.method === 'GET' && r.path === '/users'));
assert.ok(routes.some(r => r.method === 'POST' && r.path === '/users'));
assert.ok(routes.some(r => r.method === 'DELETE' && r.path === '/users/:id'));
});
test('detects Fastify route handlers', () => {
const code = `
const fastify = require('fastify')();
fastify.get('/health', async (request, reply) => {
return { status: 'ok' };
});
fastify.route({
method: 'POST',
url: '/items',
handler: async (request, reply) => {
return { id: 1 };
}
});
`;
const ast = parseCode(code);
const routes = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name?.toLowerCase();
if (['get', 'post', 'put', 'delete', 'patch', 'route'].includes(method)) {
const routeArg = path.node.arguments[0];
if (routeArg?.type === 'StringLiteral') {
routes.push({ method: method.toUpperCase(), path: routeArg.value });
}
}
}
}
});
assert.ok(routes.some(r => r.path === '/health'));
});
test('detects NestJS controller decorators', () => {
const code = `
@Controller('users')
export class UsersController {
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}
`;
const ast = parseCode(code);
const handlers = [];
traverse.default(ast, {
ClassMethod(path) {
const decorators = path.node.decorators || [];
for (const decorator of decorators) {
const name = decorator.expression?.callee?.name || decorator.expression?.name;
if (['Get', 'Post', 'Put', 'Delete', 'Patch'].includes(name)) {
handlers.push({
method: name.toUpperCase(),
handler: path.node.key.name
});
}
}
}
});
assert.equal(handlers.length, 3);
assert.ok(handlers.some(h => h.handler === 'findAll'));
assert.ok(handlers.some(h => h.handler === 'findOne'));
assert.ok(handlers.some(h => h.handler === 'create'));
});
test('detects Koa router handlers', () => {
const code = `
const Router = require('koa-router');
const router = new Router();
router.get('/items', async (ctx) => {
ctx.body = await getItems();
});
router.post('/items', async (ctx) => {
ctx.body = await createItem(ctx.request.body);
});
`;
const ast = parseCode(code);
const routes = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const objName = path.node.callee.object.name;
const method = path.node.callee.property.name?.toLowerCase();
if (objName === 'router' && ['get', 'post', 'put', 'delete'].includes(method)) {
const routeArg = path.node.arguments[0];
if (routeArg?.type === 'StringLiteral') {
routes.push({ method: method.toUpperCase(), path: routeArg.value });
}
}
}
}
});
assert.equal(routes.length, 2);
assert.ok(routes.some(r => r.method === 'GET' && r.path === '/items'));
assert.ok(routes.some(r => r.method === 'POST' && r.path === '/items'));
});
test('detects AWS Lambda handlers', () => {
const code = `
export const handler = async (event, context) => {
const body = JSON.parse(event.body);
return {
statusCode: 200,
body: JSON.stringify({ message: 'Success' })
};
};
export const main = async (event) => {
return { statusCode: 200 };
};
`;
const ast = parseCode(code);
const handlers = [];
traverse.default(ast, {
VariableDeclarator(path) {
const name = path.node.id?.name?.toLowerCase();
if (['handler', 'main'].includes(name)) {
if (path.node.init?.type === 'ArrowFunctionExpression') {
handlers.push(path.node.id.name);
}
}
}
});
assert.ok(handlers.includes('handler'));
assert.ok(handlers.includes('main'));
});
test('detects Hapi route handlers', () => {
const code = `
const server = Hapi.server({ port: 3000 });
server.route({
method: 'GET',
path: '/users',
handler: (request, h) => {
return getUsers();
}
});
server.route({
method: 'POST',
path: '/users',
handler: async (request, h) => {
return createUser(request.payload);
}
});
`;
const ast = parseCode(code);
let routeCount = 0;
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.type === 'MemberExpression') {
const method = path.node.callee.property.name;
if (method === 'route') {
routeCount++;
}
}
}
});
assert.equal(routeCount, 2);
});
});
describe('Module Import/Export Detection', () => {
test('detects CommonJS require', () => {
const code = `
const express = require('express');
const { Router } = require('express');
const db = require('./db');
`;
const ast = parseCode(code);
const imports = [];
traverse.default(ast, {
CallExpression(path) {
if (path.node.callee.name === 'require') {
const arg = path.node.arguments[0];
if (arg?.type === 'StringLiteral') {
imports.push(arg.value);
}
}
}
});
assert.ok(imports.includes('express'));
assert.ok(imports.includes('./db'));
});
test('detects ES module imports', () => {
const code = `
import express from 'express';
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import db from './db.js';
`;
const ast = parseCode(code);
const imports = [];
traverse.default(ast, {
ImportDeclaration(path) {
imports.push(path.node.source.value);
}
});
assert.ok(imports.includes('express'));
assert.ok(imports.includes('fs'));
assert.ok(imports.includes('./db.js'));
});
test('detects ES module exports', () => {
const code = `
export function publicFn() {}
export const publicConst = 42;
export default class MainClass {}
export { helper, utils };
`;
const ast = parseCode(code);
let exportCount = 0;
traverse.default(ast, {
ExportNamedDeclaration() { exportCount++; },
ExportDefaultDeclaration() { exportCount++; }
});
assert.ok(exportCount >= 3);
});
});

View File

@@ -0,0 +1,243 @@
{
"name": "stella-callgraph-node",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stella-callgraph-node",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@babel/parser": "^7.23.0",
"@babel/traverse": "^7.23.0",
"@babel/types": "^7.23.0"
},
"bin": {
"stella-callgraph-node": "index.js"
},
"devDependencies": {
"@types/node": "^20.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/generator": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.5",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.5",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@types/node": {
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "stella-callgraph-node",
"version": "1.0.0",
"description": "Call graph extraction tool for JavaScript/TypeScript using Babel AST",
"main": "index.js",
"type": "module",
"bin": {
"stella-callgraph-node": "./index.js"
},
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"keywords": [
"callgraph",
"ast",
"babel",
"static-analysis",
"security"
],
"license": "AGPL-3.0-or-later",
"dependencies": {
"@babel/parser": "^7.23.0",
"@babel/traverse": "^7.23.0",
"@babel/types": "^7.23.0"
},
"devDependencies": {
"@types/node": "^20.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,230 @@
// -----------------------------------------------------------------------------
// sink-detect.js
// Security sink detection patterns for JavaScript/TypeScript.
// -----------------------------------------------------------------------------
/**
* Sink detection patterns organized by category.
*/
export const sinkPatterns = {
command_injection: {
category: 'command_injection',
patterns: [
{ module: 'child_process', methods: ['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', 'execFileSync', 'fork'] },
{ module: 'shelljs', methods: ['exec', 'which', 'cat', 'sed', 'grep', 'rm', 'cp', 'mv', 'mkdir'] },
{ object: 'process', methods: ['exec'] }
]
},
sql_injection: {
category: 'sql_injection',
patterns: [
{ object: 'connection', methods: ['query', 'execute'] },
{ object: 'pool', methods: ['query', 'execute'] },
{ object: 'client', methods: ['query'] },
{ module: 'mysql', methods: ['query', 'execute'] },
{ module: 'mysql2', methods: ['query', 'execute'] },
{ module: 'pg', methods: ['query'] },
{ module: 'sqlite3', methods: ['run', 'exec', 'all', 'get'] },
{ module: 'knex', methods: ['raw', 'whereRaw', 'havingRaw', 'orderByRaw'] },
{ module: 'sequelize', methods: ['query', 'literal'] },
{ module: 'typeorm', methods: ['query', 'createQueryBuilder'] },
{ module: 'prisma', methods: ['$queryRaw', '$executeRaw', '$queryRawUnsafe', '$executeRawUnsafe'] }
]
},
file_write: {
category: 'file_write',
patterns: [
{ module: 'fs', methods: ['writeFile', 'writeFileSync', 'appendFile', 'appendFileSync', 'createWriteStream', 'rename', 'renameSync', 'unlink', 'unlinkSync', 'rmdir', 'rmdirSync', 'rm', 'rmSync'] },
{ module: 'fs/promises', methods: ['writeFile', 'appendFile', 'rename', 'unlink', 'rmdir', 'rm'] }
]
},
file_read: {
category: 'file_read',
patterns: [
{ module: 'fs', methods: ['readFile', 'readFileSync', 'createReadStream', 'readdir', 'readdirSync'] },
{ module: 'fs/promises', methods: ['readFile', 'readdir'] }
]
},
deserialization: {
category: 'deserialization',
patterns: [
{ global: true, methods: ['eval', 'Function'] },
{ object: 'JSON', methods: ['parse'] },
{ module: 'vm', methods: ['runInContext', 'runInNewContext', 'runInThisContext', 'createScript'] },
{ module: 'serialize-javascript', methods: ['deserialize'] },
{ module: 'node-serialize', methods: ['unserialize'] },
{ module: 'js-yaml', methods: ['load', 'loadAll'] }
]
},
ssrf: {
category: 'ssrf',
patterns: [
{ module: 'http', methods: ['request', 'get'] },
{ module: 'https', methods: ['request', 'get'] },
{ module: 'axios', methods: ['get', 'post', 'put', 'delete', 'patch', 'request'] },
{ module: 'node-fetch', methods: ['default'] },
{ global: true, methods: ['fetch'] },
{ module: 'got', methods: ['get', 'post', 'put', 'delete', 'patch'] },
{ module: 'superagent', methods: ['get', 'post', 'put', 'delete', 'patch'] },
{ module: 'request', methods: ['get', 'post', 'put', 'delete', 'patch'] },
{ module: 'undici', methods: ['request', 'fetch'] }
]
},
path_traversal: {
category: 'path_traversal',
patterns: [
{ module: 'path', methods: ['join', 'resolve', 'normalize'] },
{ module: 'fs', methods: ['readFile', 'readFileSync', 'writeFile', 'writeFileSync', 'access', 'accessSync', 'stat', 'statSync'] }
]
},
weak_crypto: {
category: 'weak_crypto',
patterns: [
{ module: 'crypto', methods: ['createCipher', 'createDecipher', 'createCipheriv', 'createDecipheriv'] },
{ object: 'crypto', methods: ['createHash'] } // MD5, SHA1 are weak
]
},
ldap_injection: {
category: 'ldap_injection',
patterns: [
{ module: 'ldapjs', methods: ['search', 'modify', 'add', 'del'] },
{ module: 'activedirectory', methods: ['find', 'findUser', 'findGroup'] }
]
},
nosql_injection: {
category: 'nosql_injection',
patterns: [
{ module: 'mongodb', methods: ['find', 'findOne', 'updateOne', 'updateMany', 'deleteOne', 'deleteMany', 'aggregate'] },
{ module: 'mongoose', methods: ['find', 'findOne', 'findById', 'updateOne', 'updateMany', 'deleteOne', 'deleteMany', 'where', 'aggregate'] }
]
},
xss: {
category: 'xss',
patterns: [
{ object: 'document', methods: ['write', 'writeln'] },
{ object: 'element', methods: ['innerHTML', 'outerHTML'] },
{ module: 'dangerouslySetInnerHTML', methods: ['__html'] } // React pattern
]
},
log_injection: {
category: 'log_injection',
patterns: [
{ object: 'console', methods: ['log', 'info', 'warn', 'error', 'debug'] },
{ module: 'winston', methods: ['log', 'info', 'warn', 'error', 'debug'] },
{ module: 'pino', methods: ['info', 'warn', 'error', 'debug', 'trace'] },
{ module: 'bunyan', methods: ['info', 'warn', 'error', 'debug', 'trace'] }
]
},
regex_dos: {
category: 'regex_dos',
patterns: [
{ object: 'RegExp', methods: ['test', 'exec', 'match'] },
{ global: true, methods: ['RegExp'] }
]
}
};
/**
* Build a lookup map for fast sink detection.
* @returns {Map<string, { category: string, method: string }>}
*/
export function buildSinkLookup() {
const lookup = new Map();
for (const [_, config] of Object.entries(sinkPatterns)) {
for (const pattern of config.patterns) {
for (const method of pattern.methods) {
// Key formats: "module:method", "object.method", "global:method"
if (pattern.module) {
lookup.set(`${pattern.module}:${method}`, { category: config.category, method });
}
if (pattern.object) {
lookup.set(`${pattern.object}.${method}`, { category: config.category, method });
}
if (pattern.global) {
lookup.set(`global:${method}`, { category: config.category, method });
}
}
}
}
return lookup;
}
/**
* Check if a call expression is a security sink.
* @param {string} objectOrModule - The object/module name (e.g., 'fs', 'child_process', 'connection')
* @param {string} methodName - The method being called
* @param {Map} sinkLookup - Pre-built sink lookup map
* @returns {{ category: string, method: string } | null}
*/
export function matchSink(objectOrModule, methodName, sinkLookup) {
// Check module:method pattern
const moduleKey = `${objectOrModule}:${methodName}`;
if (sinkLookup.has(moduleKey)) {
return sinkLookup.get(moduleKey);
}
// Check object.method pattern
const objectKey = `${objectOrModule}.${methodName}`;
if (sinkLookup.has(objectKey)) {
return sinkLookup.get(objectKey);
}
// Check global functions
const globalKey = `global:${objectOrModule}`;
if (sinkLookup.has(globalKey)) {
return sinkLookup.get(globalKey);
}
// Check if methodName itself is a global sink (like eval)
const directGlobal = `global:${methodName}`;
if (sinkLookup.has(directGlobal)) {
return sinkLookup.get(directGlobal);
}
return null;
}
/**
* Common dangerous patterns that indicate direct user input flow.
*/
export const taintSources = [
'req.body',
'req.query',
'req.params',
'req.headers',
'req.cookies',
'request.body',
'request.query',
'request.params',
'event.body',
'event.queryStringParameters',
'event.pathParameters',
'ctx.request.body',
'ctx.request.query',
'ctx.params',
'process.env',
'process.argv'
];
/**
* Check if an identifier is a potential taint source.
* @param {string} identifier
* @returns {boolean}
*/
export function isTaintSource(identifier) {
return taintSources.some(source => identifier.includes(source));
}

View File

@@ -0,0 +1,236 @@
// -----------------------------------------------------------------------------
// sink-detect.test.js
// Sprint: SPRINT_3600_0004_0001 (Node.js Babel Integration)
// Tasks: NODE-019 - Unit tests for sink detection (all categories)
// Description: Tests for security sink detection patterns.
// -----------------------------------------------------------------------------
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { buildSinkLookup, matchSink, sinkPatterns, isTaintSource } from './sink-detect.js';
describe('buildSinkLookup', () => {
test('builds lookup map with all patterns', () => {
const lookup = buildSinkLookup();
assert.ok(lookup instanceof Map);
assert.ok(lookup.size > 0);
});
test('includes command injection sinks', () => {
const lookup = buildSinkLookup();
assert.ok(lookup.has('child_process:exec'));
assert.ok(lookup.has('child_process:spawn'));
assert.ok(lookup.has('child_process:execSync'));
});
test('includes SQL injection sinks', () => {
const lookup = buildSinkLookup();
assert.ok(lookup.has('connection.query'));
assert.ok(lookup.has('mysql:query'));
assert.ok(lookup.has('pg:query'));
assert.ok(lookup.has('knex:raw'));
});
test('includes file write sinks', () => {
const lookup = buildSinkLookup();
assert.ok(lookup.has('fs:writeFile'));
assert.ok(lookup.has('fs:writeFileSync'));
assert.ok(lookup.has('fs:appendFile'));
});
test('includes deserialization sinks', () => {
const lookup = buildSinkLookup();
assert.ok(lookup.has('global:eval'));
assert.ok(lookup.has('global:Function'));
assert.ok(lookup.has('vm:runInContext'));
});
test('includes SSRF sinks', () => {
const lookup = buildSinkLookup();
assert.ok(lookup.has('http:request'));
assert.ok(lookup.has('https:get'));
assert.ok(lookup.has('axios:get'));
assert.ok(lookup.has('global:fetch'));
});
test('includes NoSQL injection sinks', () => {
const lookup = buildSinkLookup();
assert.ok(lookup.has('mongodb:find'));
assert.ok(lookup.has('mongoose:findOne'));
assert.ok(lookup.has('mongodb:aggregate'));
});
});
describe('matchSink', () => {
const lookup = buildSinkLookup();
test('detects command injection via child_process.exec', () => {
const result = matchSink('child_process', 'exec', lookup);
assert.ok(result);
assert.equal(result.category, 'command_injection');
assert.equal(result.method, 'exec');
});
test('detects command injection via child_process.spawn', () => {
const result = matchSink('child_process', 'spawn', lookup);
assert.ok(result);
assert.equal(result.category, 'command_injection');
});
test('detects SQL injection via connection.query', () => {
const result = matchSink('connection', 'query', lookup);
assert.ok(result);
assert.equal(result.category, 'sql_injection');
});
test('detects SQL injection via knex.raw', () => {
const result = matchSink('knex', 'raw', lookup);
assert.ok(result);
assert.equal(result.category, 'sql_injection');
});
test('detects SQL injection via prisma.$queryRaw', () => {
const result = matchSink('prisma', '$queryRaw', lookup);
assert.ok(result);
assert.equal(result.category, 'sql_injection');
});
test('detects file write via fs.writeFile', () => {
const result = matchSink('fs', 'writeFile', lookup);
assert.ok(result);
// fs.writeFile is categorized in both file_write and path_traversal
// The lookup returns path_traversal since it's processed later
assert.ok(['file_write', 'path_traversal'].includes(result.category));
});
test('detects deserialization via eval', () => {
const result = matchSink('eval', 'eval', lookup);
assert.ok(result);
assert.equal(result.category, 'deserialization');
});
test('detects SSRF via axios.get', () => {
const result = matchSink('axios', 'get', lookup);
assert.ok(result);
assert.equal(result.category, 'ssrf');
});
test('detects SSRF via fetch', () => {
const result = matchSink('fetch', 'fetch', lookup);
assert.ok(result);
assert.equal(result.category, 'ssrf');
});
test('detects NoSQL injection via mongoose.find', () => {
const result = matchSink('mongoose', 'find', lookup);
assert.ok(result);
assert.equal(result.category, 'nosql_injection');
});
test('detects weak crypto via crypto.createCipher', () => {
const result = matchSink('crypto', 'createCipher', lookup);
assert.ok(result);
assert.equal(result.category, 'weak_crypto');
});
test('detects LDAP injection via ldapjs.search', () => {
const result = matchSink('ldapjs', 'search', lookup);
assert.ok(result);
assert.equal(result.category, 'ldap_injection');
});
test('returns null for non-sink methods', () => {
const result = matchSink('console', 'clear', lookup);
assert.equal(result, null);
});
test('returns null for unknown objects', () => {
const result = matchSink('myCustomModule', 'doSomething', lookup);
assert.equal(result, null);
});
});
describe('sinkPatterns', () => {
test('has expected categories', () => {
const categories = Object.keys(sinkPatterns);
assert.ok(categories.includes('command_injection'));
assert.ok(categories.includes('sql_injection'));
assert.ok(categories.includes('file_write'));
assert.ok(categories.includes('deserialization'));
assert.ok(categories.includes('ssrf'));
assert.ok(categories.includes('nosql_injection'));
assert.ok(categories.includes('xss'));
assert.ok(categories.includes('log_injection'));
});
test('command_injection has child_process patterns', () => {
const cmdPatterns = sinkPatterns.command_injection.patterns;
const childProcessPattern = cmdPatterns.find(p => p.module === 'child_process');
assert.ok(childProcessPattern);
assert.ok(childProcessPattern.methods.includes('exec'));
assert.ok(childProcessPattern.methods.includes('spawn'));
assert.ok(childProcessPattern.methods.includes('fork'));
});
test('sql_injection covers major ORMs', () => {
const sqlPatterns = sinkPatterns.sql_injection.patterns;
const modules = sqlPatterns.map(p => p.module).filter(Boolean);
assert.ok(modules.includes('mysql'));
assert.ok(modules.includes('pg'));
assert.ok(modules.includes('knex'));
assert.ok(modules.includes('sequelize'));
assert.ok(modules.includes('prisma'));
});
test('ssrf covers HTTP clients', () => {
const ssrfPatterns = sinkPatterns.ssrf.patterns;
const modules = ssrfPatterns.map(p => p.module).filter(Boolean);
assert.ok(modules.includes('http'));
assert.ok(modules.includes('https'));
assert.ok(modules.includes('axios'));
assert.ok(modules.includes('got'));
});
});
describe('isTaintSource', () => {
test('detects req.body as taint source', () => {
assert.ok(isTaintSource('req.body'));
assert.ok(isTaintSource('req.body.username'));
});
test('detects req.query as taint source', () => {
assert.ok(isTaintSource('req.query'));
assert.ok(isTaintSource('req.query.id'));
});
test('detects req.params as taint source', () => {
assert.ok(isTaintSource('req.params'));
assert.ok(isTaintSource('req.params.userId'));
});
test('detects req.headers as taint source', () => {
assert.ok(isTaintSource('req.headers'));
assert.ok(isTaintSource('req.headers.authorization'));
});
test('detects event.body (Lambda) as taint source', () => {
assert.ok(isTaintSource('event.body'));
assert.ok(isTaintSource('event.queryStringParameters'));
});
test('detects ctx.request.body (Koa) as taint source', () => {
assert.ok(isTaintSource('ctx.request.body'));
assert.ok(isTaintSource('ctx.params'));
});
test('detects process.env as taint source', () => {
assert.ok(isTaintSource('process.env'));
assert.ok(isTaintSource('process.env.SECRET'));
});
test('does not flag safe identifiers', () => {
assert.ok(!isTaintSource('myLocalVariable'));
assert.ok(!isTaintSource('config.port'));
assert.ok(!isTaintSource('user.name'));
});
});