CD/CD consolidation
This commit is contained in:
178
devops/tools/callgraph/node/framework-detect.js
Normal file
178
devops/tools/callgraph/node/framework-detect.js
Normal 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';
|
||||
}
|
||||
478
devops/tools/callgraph/node/index.js
Normal file
478
devops/tools/callgraph/node/index.js
Normal 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);
|
||||
675
devops/tools/callgraph/node/index.test.js
Normal file
675
devops/tools/callgraph/node/index.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
243
devops/tools/callgraph/node/package-lock.json
generated
Normal file
243
devops/tools/callgraph/node/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
devops/tools/callgraph/node/package.json
Normal file
33
devops/tools/callgraph/node/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
230
devops/tools/callgraph/node/sink-detect.js
Normal file
230
devops/tools/callgraph/node/sink-detect.js
Normal 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));
|
||||
}
|
||||
236
devops/tools/callgraph/node/sink-detect.test.js
Normal file
236
devops/tools/callgraph/node/sink-detect.test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user