knex-sql-injection-detector
Version:
A CLI tool to detect potential SQL injection risks in knex.js codebases by analyzing raw SQL query construction.
193 lines (174 loc) • 5.33 kB
JavaScript
import fs from 'fs/promises';
import path from 'path';
import parser from '@babel/parser';
import traverse from '@babel/traverse';
import { glob } from 'glob';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import pLimit from 'p-limit';
import { minimatch } from 'minimatch';
const rawMethods = [
'raw',
'whereRaw',
'fromRaw',
'joinRaw',
'groupByRaw',
'orderByRaw',
'havingRaw',
];
function isSafeSQLExpression(node) {
if (!node) return false;
if (node.type === 'StringLiteral' || node.type === 'NumericLiteral') return true;
if (node.type === 'BooleanLiteral' || node.type === 'NullLiteral') return true;
if (node.type === 'TemplateLiteral') {
// Safe if all interpolations are safe
return node.expressions.every(isSafeSQLExpression);
}
if (node.type === 'BinaryExpression' && node.operator === '+') {
// Concatenation: both sides must be safe
return isSafeSQLExpression(node.left) && isSafeSQLExpression(node.right);
}
if (node.type === 'ConditionalExpression') {
// Ternary: both branches must be safe
return isSafeSQLExpression(node.consequent) && isSafeSQLExpression(node.alternate);
}
return false;
}
function analyzeCode(code, filePath) {
let ast;
try {
ast = parser.parse(code, {
sourceType: 'module',
plugins: [], // Only JS
});
} catch (e) {
return [];
}
const results = [];
traverse.default(ast, {
CallExpression(path) {
const { node } = path;
if (
node.callee.type === 'MemberExpression' &&
rawMethods.includes(node.callee.property.name)
) {
const firstArg = node.arguments[0];
const loc = node.loc;
let type = isSafeSQLExpression(firstArg) ? 'info' : 'unsafe';
results.push({
type,
code: code.slice(node.start, node.end),
filePath,
line: loc ? loc.start.line : null,
column: loc ? loc.start.column + 1 : null,
});
}
},
});
return results;
}
async function getAllJsFiles(targetPath) {
let stat;
try {
stat = await fs.stat(targetPath);
} catch (e) {
return [];
}
if (stat.isDirectory()) {
return await glob(path.join(targetPath, '**/*.js'));
} else if (stat.isFile() && targetPath.endsWith('.js')) {
return [targetPath];
}
return [];
}
const argv = yargs(hideBin(process.argv))
.usage('Usage: $0 <path>')
.demandCommand(1)
.option('only-errors', {
type: 'boolean',
description: 'Print only errors (potential SQL injections)',
default: false,
})
.option('code-quotes', {
type: 'boolean',
description: 'Print code and extra spacing in output (disable for single-line output)',
default: true,
})
.option('ignore', {
type: 'array',
description: 'Glob patterns for files/folders to ignore (e.g. --ignore "**/migrations/**" "**/test/**")',
default: [],
})
.option('include-node-modules', {
type: 'boolean',
description: 'Include node_modules in scan (default: false)',
default: false,
})
.help()
.argv;
const targetPath = argv._[0];
const onlyErrors = argv['only-errors'];
const codeQuotes = argv['code-quotes'];
const ignorePatterns = argv.ignore;
const includeNodeModules = argv['include-node-modules'];
let totalRaw = 0;
let totalUnsafe = 0;
let totalSafe = 0;
let hadError = false;
const files = await getAllJsFiles(targetPath);
// Determine if user explicitly wants node_modules
const userExplicitlyWantsNodeModules = includeNodeModules || targetPath.includes('node_modules');
const limit = pLimit(256);
function quoteCode(code) {
// Add two spaces at the start of each line
return code.split('\n').map(line => ' ' + line).join('\n');
}
await Promise.all(files.map(file =>
limit(async () => {
// Skip node_modules unless explicitly requested
if (!userExplicitlyWantsNodeModules && file.includes('node_modules')) {
return;
}
// Skip files matching any ignore pattern
if (ignorePatterns.some(pattern => minimatch(file, pattern))) {
return;
}
let stat;
try {
stat = await fs.stat(file);
} catch (e) {
return; // skip unreadable
}
if (!stat.isFile()) {
return; // skip directories and non-files
}
const code = await fs.readFile(file, 'utf8');
const findings = analyzeCode(code, file);
for (const f of findings) {
totalRaw++;
if (f.type === 'unsafe') {
totalUnsafe++;
hadError = true;
if (!codeQuotes) {
console.error(`[error] Potential SQL injection at ${f.filePath}:${f.line}:${f.column}`);
} else {
console.error(`[error] Potential SQL injection:\n\n${quoteCode(f.code)}\n\nat ${f.filePath}:${f.line}:${f.column}\n`);
}
} else if (!onlyErrors) {
totalSafe++;
if (!codeQuotes) {
console.info(`[info] knex raw function call at ${f.filePath}:${f.line}:${f.column}`);
} else {
console.info(`[info] knex raw function call:\n\n${quoteCode(f.code)}\n\nat ${f.filePath}:${f.line}:${f.column}\n`);
}
}
}
})
));
console.log(`[stats]`);
console.log(` Total raw function calls: ${totalRaw}`);
console.log(` Total potential SQL injections: ${totalUnsafe}`);
if (hadError) {
process.exit(1);
}