fix: parse dangerous constructs with AST

This commit is contained in:
kuekhaoyang
2026-08-01 03:30:36 +08:00
parent 43c634cbc9
commit 231265c837
2 changed files with 33 additions and 5 deletions
+22 -5
View File
@@ -1,7 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { parse } from '@typescript-eslint/typescript-estree';
import { finding } from '../core/finding.mjs';
import { relative, walk, writeJson } from '../core/files.mjs';
import { children } from './ast-walk.mjs';
const textExt = new Set(['.ts', '.tsx', '.js', '.mjs', '.json', '.yml', '.yaml', '.toml', '.md']);
const patterns = [
@@ -11,6 +13,25 @@ const patterns = [
['generic-secret-assignment', /(?:secret|token|password|api[_-]?key)\s*[:=]\s*['"][^'"\n]{12,}['"]/i],
];
export function findDangerousConstructs(file, text) {
const ast = parse(text, {
jsx: file.endsWith('.tsx') || file.endsWith('.jsx'),
errorOnUnknownASTType: false,
});
const constructs = new Set();
const visit = (node) => {
if (node.type === 'JSXAttribute' && node.name?.name === 'dangerouslySetInnerHTML') {
constructs.add('dangerouslySetInnerHTML');
}
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'eval') {
constructs.add('eval');
}
for (const child of children(node)) visit(child);
};
visit(ast);
return [...constructs].map((construct) => ({ file, construct }));
}
export async function checkSecurityScan(ctx) {
const files = walk(ctx.config.root, (file) => textExt.has(path.extname(file)) && !file.includes('/verification/artifacts/'));
const hits = [];
@@ -30,11 +51,7 @@ export async function checkSecurityScan(ctx) {
evidence: [target], remediation: 'Remove and rotate confirmed credentials; replace false positives with safe fixtures.',
});
const dangerous = files.filter((file) => ['.ts', '.tsx', '.js', '.mjs'].includes(path.extname(file))).flatMap((file) => {
const text = fs.readFileSync(file, 'utf8');
return [
...(text.includes('dangerouslySetInnerHTML') ? [{ file: relative(ctx.config.root, file), construct: 'dangerouslySetInnerHTML' }] : []),
...(text.match(/\beval\s*\(/) ? [{ file: relative(ctx.config.root, file), construct: 'eval' }] : []),
];
return findDangerousConstructs(relative(ctx.config.root, file), fs.readFileSync(file, 'utf8'));
});
finding(ctx, {
id: 'security.dangerous-constructs', category: 'security', title: 'Dangerous runtime constructs are inventoried',
+11
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { numericCandidate, prepareActionState } from '../src/browser/action-state.mjs';
import { getConfig } from '../src/config.mjs';
import { latestDeployment } from '../src/checks/deployment.mjs';
import { findDangerousConstructs } from '../src/checks/security-scan.mjs';
import { redact, redactText } from '../src/core/redact.mjs';
import { escapeXml } from '../src/core/xml.mjs';
@@ -51,3 +52,13 @@ test('selects the newest Cloudflare production deployment', () => {
assert.equal(latestDeployment(output)?.Source, 'abcdef1');
assert.equal(latestDeployment('not json'), null);
});
test('dangerous construct scan ignores matcher text and finds runtime use', () => {
const scanner = "const name = 'dangerouslySetInnerHTML'; const matcher = /eval\\s*\\(/;";
assert.deepEqual(findDangerousConstructs('scanner.mjs', scanner), []);
const runtime = "export const View = () => <div dangerouslySetInnerHTML={{ __html: 'x' }} />; eval('x');";
assert.deepEqual(findDangerousConstructs('view.tsx', runtime), [
{ file: 'view.tsx', construct: 'dangerouslySetInnerHTML' },
{ file: 'view.tsx', construct: 'eval' },
]);
});