Files
bentopdf/scripts/security-patterns-check.mjs

122 lines
3.8 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SCAN_DIRS = ['src/js', 'bentopdf-pymupdf-wasm/src', 'cloudflare'];
const SKIP = ['node_modules', 'dist', '.git', 'coverage', 'tests', 'test'];
const findings = [];
function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (
SKIP.some(
(s) =>
full.includes(`${path.sep}${s}${path.sep}`) ||
full.endsWith(`${path.sep}${s}`)
)
) {
continue;
}
if (e.isDirectory()) walk(full);
else if (/\.(ts|js|mjs)$/.test(e.name) && !/\.test\.|\.d\.ts$/.test(e.name))
checkFile(full);
}
}
function checkFile(file) {
const rel = path.relative(ROOT, file);
const src = fs.readFileSync(file, 'utf-8');
const lines = src.split('\n');
// Rule 1: no raw string interpolation into a Python/eval template.
const codeSink = /(runPython|\.eval)\s*\(\s*`/;
for (let i = 0; i < lines.length; i++) {
if (codeSink.test(lines[i])) {
for (let j = i; j < Math.min(i + 60, lines.length); j++) {
const interps = lines[j].match(/\$\{[^}]+\}/g) || [];
for (const it of interps) {
const danger =
/^\$\{\s*(text|name|uri|url|format|password|passwd|title|label|filename|content|search|searchText|query|font|fontname|intent|usage|replaceText)\s*(\}|\.|\))/.test(
it
);
const safe = /pyStr|JSON\.stringify|escapeHtml/.test(it);
if (danger && !safe) {
findings.push({
rule: 'code-injection',
file: rel,
line: j + 1,
detail: `Unescaped user-string interpolation ${it} into a Python/eval template — pass it via pyStr()/JSON.stringify()/globals.set().`,
});
}
}
if (lines[j].includes('`)') || lines[j].trim().endsWith('`);')) break;
}
}
}
// Rule 2: string mutation applied AFTER a sanitize call (sanitize-then-mutate).
const sanitizeLine = lines.findIndex((l) =>
/DOMPurify\.sanitize|sanitizeEmailHtml|sanitize\(/.test(l)
);
if (sanitizeLine !== -1) {
for (
let j = sanitizeLine + 1;
j < Math.min(sanitizeLine + 40, lines.length);
j++
) {
if (
/\.replace\(|innerHTML\s*=|\+=\s*`/.test(lines[j]) &&
!/escapeHtml|escapeHTML|DOMPurify/.test(lines[j])
) {
findings.push({
rule: 'sanitize-then-mutate',
file: rel,
line: j + 1,
detail: `Possible mutation of sanitized HTML. Escape/validate at the final sink, or re-sanitize after mutation.`,
});
break;
}
}
}
}
for (const d of SCAN_DIRS) walk(path.join(ROOT, d));
const blocking = findings.filter((f) => f.rule === 'code-injection');
const warnings = findings.filter((f) => f.rule !== 'code-injection');
for (const f of warnings) {
console.warn(` ⚠ [${f.rule}] ${f.file}:${f.line}\n ${f.detail}`);
}
if (warnings.length) {
console.warn(
`\n${warnings.length} sanitize-then-mutate warning(s) — review each: escape/validate at the final sink.\n`
);
}
if (blocking.length === 0) {
console.log('✅ security-patterns-check: no code-injection patterns found');
process.exit(0);
}
console.error(
`\n❌ security-patterns-check found ${blocking.length} code-injection issue(s):\n`
);
for (const f of blocking) {
console.error(` [${f.rule}] ${f.file}:${f.line}\n ${f.detail}\n`);
}
console.error(
'This pattern caused a prior CVE (pymupdf runPython injection). Pass values via pyStr()/JSON.stringify()/globals.set().\n'
);
process.exit(1);