Add unit tests for AST parsing and security sink detection
- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library. - Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX. - Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more. - Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
This commit is contained in:
675
tools/stella-callgraph-node/index.test.js
Normal file
675
tools/stella-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);
|
||||
});
|
||||
});
|
||||
236
tools/stella-callgraph-node/sink-detect.test.js
Normal file
236
tools/stella-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