135 lines
4.8 KiB
JavaScript
135 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
/**
|
|
* Static AST candidate analyzer for JavaScript bundles.
|
|
*
|
|
* Parses JS files using Babel (with fallback regex parser) to find function
|
|
* declarations matching crypto-related keywords.
|
|
*
|
|
* Usage:
|
|
* node scripts/ast_candidate_analyzer.js --input bundle.js --output artifacts/static_candidates.json
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
let babelParser = null;
|
|
try { babelParser = require("@babel/parser"); } catch (_) {}
|
|
|
|
function args() {
|
|
const out = {};
|
|
for (let i = 2; i < process.argv.length; i += 2)
|
|
out[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
|
|
if (!out.input || !out.output)
|
|
throw new Error("Usage: ast_candidate_analyzer.js --input bundle.js --output static_candidates.json");
|
|
return out;
|
|
}
|
|
|
|
const KEYWORDS = /encrypt|decrypt|cipher|sign|hmac|digest|hash|rsa|aes|des|sm2|sm3|sm4|md5|sha|base64|crypto/i;
|
|
|
|
function sourceSnippet(code, start, end) {
|
|
return code.slice(start || 0, Math.min(end || code.length, (start || 0) + 1200));
|
|
}
|
|
|
|
function locationFromOffset(code, offset) {
|
|
const before = code.slice(0, offset);
|
|
const lines = before.split("\n");
|
|
return { line: lines.length, column: lines[lines.length - 1].length };
|
|
}
|
|
|
|
function signalsFor(name, snippet) {
|
|
const signals = [];
|
|
if (KEYWORDS.test(name)) signals.push("name_keyword");
|
|
if (KEYWORDS.test(snippet)) signals.push("source_keyword");
|
|
if (/crypto\.subtle|CryptoJS|JSEncrypt|sm-crypto|jsrsasign|forge/i.test(snippet))
|
|
signals.push("known_library");
|
|
if (/publicKey|privateKey|BEGIN PUBLIC KEY|BEGIN RSA/i.test(snippet))
|
|
signals.push("key_material");
|
|
return signals;
|
|
}
|
|
|
|
function addCandidate(map, code, name, start, end, mode) {
|
|
const snippet = sourceSnippet(code, start, end);
|
|
const signals = signalsFor(name, snippet);
|
|
if (!signals.length) return;
|
|
const loc = locationFromOffset(code, start);
|
|
const key = `${name}:${loc.line}:${loc.column}`;
|
|
map.set(key, {
|
|
id: `static:${key}`, name, path: name, type: "function",
|
|
location: loc, signals, source_snippet: snippet, parser_mode: mode
|
|
});
|
|
}
|
|
|
|
function walk(node, parent, visit) {
|
|
if (!node || typeof node !== "object") return;
|
|
if (typeof node.type === "string") visit(node, parent);
|
|
for (const [key, value] of Object.entries(node)) {
|
|
if (["loc", "start", "end", "extra", "tokens", "comments"].includes(key)) continue;
|
|
if (Array.isArray(value)) value.forEach(child => walk(child, node, visit));
|
|
else if (value && typeof value === "object") walk(value, node, visit);
|
|
}
|
|
}
|
|
|
|
function nameOf(node, parent) {
|
|
if (node.id && node.id.name) return node.id.name;
|
|
if (parent && parent.type === "VariableDeclarator" && parent.id && parent.id.name)
|
|
return parent.id.name;
|
|
if (parent && /ObjectProperty|ObjectMethod|ClassMethod/.test(parent.type || ""))
|
|
return parent.key && (parent.key.name || parent.key.value) || "anonymous";
|
|
return "anonymous";
|
|
}
|
|
|
|
function analyzeWithBabel(code, map) {
|
|
const ast = babelParser.parse(code, {
|
|
sourceType: "unambiguous",
|
|
errorRecovery: true,
|
|
plugins: ["jsx", "typescript", "classProperties", "optionalChaining", "dynamicImport", "decorators-legacy"]
|
|
});
|
|
walk(ast, null, (node, parent) => {
|
|
if (!["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression",
|
|
"ObjectMethod", "ClassMethod"].includes(node.type)) return;
|
|
addCandidate(map, code, nameOf(node, parent), node.start, node.end, "babel");
|
|
});
|
|
}
|
|
|
|
function analyzeFallback(code, map) {
|
|
const patterns = [
|
|
/function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g,
|
|
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\s*)?\([^)]*\)\s*=>?\s*\{/g,
|
|
/([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?function\s*\([^)]*\)\s*\{/g
|
|
];
|
|
for (const pattern of patterns) {
|
|
let match;
|
|
while ((match = pattern.exec(code)))
|
|
addCandidate(map, code, match[1], match.index, Math.min(code.length, match.index + 3000), "fallback-regex");
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const opt = args();
|
|
const code = fs.readFileSync(opt.input, "utf8");
|
|
const map = new Map();
|
|
|
|
if (babelParser) analyzeWithBabel(code, map);
|
|
else analyzeFallback(code, map);
|
|
|
|
const result = {
|
|
input: path.resolve(opt.input),
|
|
parser_mode: babelParser ? "babel" : "fallback-regex",
|
|
candidates: Array.from(map.values())
|
|
};
|
|
|
|
fs.mkdirSync(path.dirname(opt.output), { recursive: true });
|
|
fs.writeFileSync(opt.output, JSON.stringify(result, null, 2) + "\n");
|
|
console.log(JSON.stringify({
|
|
status: "ok",
|
|
parser_mode: result.parser_mode,
|
|
candidates: result.candidates.length,
|
|
output: opt.output
|
|
}));
|
|
}
|
|
|
|
try { main(); }
|
|
catch (error) { console.error(String(error.stack || error)); process.exit(2); }
|