- 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.
676 lines
21 KiB
JavaScript
676 lines
21 KiB
JavaScript
// -----------------------------------------------------------------------------
|
|
// 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);
|
|
});
|
|
});
|