@nodesecure/js-x-ray
Version:
JavaScript AST XRay analysis
263 lines • 10.6 kB
JavaScript
// Import Node.js Dependencies
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { EventEmitter } from "node:events";
import { performance } from "node:perf_hooks";
// Import Internal Dependencies
import { JsSourceParser } from "./parsers/JsSourceParser.js";
import { TsSourceParser } from "./parsers/TsSourceParser.js";
import * as trojan from "./obfuscators/trojan-source.js";
import { PipelineRunner } from "./pipelines/index.js";
import { Inline } from "./pipelines/inline.js";
import { ProbeRunner } from "./ProbeRunner.js";
import { SourceFile } from "./SourceFile.js";
import { isMinifiedCode, isOneLineExpressionExport } from "./utils/index.js";
import { walkEnter } from "./walker/index.js";
import { getCallExpressionIdentifier, isCallExpression, isStringLiteral } from "./estree/index.js";
import { generateWarning } from "./warnings.js";
import { CollectableSetRegistry } from "./CollectableSetRegistry.js";
export class AstAnalyser extends EventEmitter {
static ParsingError = Symbol("ParsingError");
static DefaultParser = new JsSourceParser();
#pipelineRunner;
probes;
#sensitivity;
#collectableSetRegistry;
constructor(options = {}) {
super();
const { customProbes = [], optionalWarnings = false, skipDefaultProbes = false, pipelines = [], collectables = [], sensitivity = "conservative" } = options;
this.#pipelineRunner = new PipelineRunner([...pipelines, new Inline()]);
this.#collectableSetRegistry = new CollectableSetRegistry(collectables ?? []);
this.#sensitivity = sensitivity;
let probes = ProbeRunner.Defaults;
if (Array.isArray(customProbes) &&
customProbes.length > 0) {
probes = skipDefaultProbes === true ?
customProbes :
[...probes, ...customProbes];
}
if (typeof optionalWarnings === "boolean") {
if (optionalWarnings) {
probes = [...probes, ...Object.values(ProbeRunner.Optionals)];
}
}
else {
const allOptionalKeys = Object.keys(ProbeRunner.Optionals);
const optionalProbes = Array.from(optionalWarnings ?? [])
.flatMap((warning) => {
if (isPattern(warning)) {
const prefix = warning.slice(0, -2);
return allOptionalKeys
.filter((key) => key.startsWith(`${prefix}.`))
.map((key) => ProbeRunner.Optionals[key]);
}
return ProbeRunner.Optionals[warning] ?? [];
});
probes = [...probes, ...optionalProbes];
}
this.probes = probes;
}
analyse(str, options = {}) {
const startTime = performance.now();
const { packageName, location, isMinified = false, removeHTMLComments = false, initialize, finalize, metadata } = options;
const parser = options.customParser ?? AstAnalyser.DefaultParser;
const body = parser.parse(this.prepareSource(str, { removeHTMLComments }), void 0);
const source = new SourceFile(location, {
metadata,
packageName,
collectableRegistry: this.#collectableSetRegistry
});
source.sensitivity = this.#sensitivity;
if (trojan.verify(str)) {
source.warnings.push(generateWarning("obfuscated-code", { value: "trojan-source" }));
}
const probeRunner = new ProbeRunner(source, this.probes);
if (initialize) {
if (typeof initialize !== "function") {
throw new TypeError("options.initialize must be a function");
}
initialize(source);
}
// we walk each AST Nodes, this is a purely synchronous I/O
const reducedBody = this.#pipelineRunner.reduce(body);
this.#walkEnter(reducedBody, probeRunner);
if (finalize) {
if (typeof finalize !== "function") {
throw new TypeError("options.finalize must be a function");
}
finalize(source);
}
probeRunner.finalize();
// Add oneline-require flag if this is a one-line require expression
if (isOneLineExpressionExport(body)) {
source.flags.add("oneline-require");
}
const executionTime = performance.now() - startTime;
return {
...source.getResult(isMinified),
flags: source.flags,
executionTime
};
}
#walkEnter(body, probeRunner) {
const recursiveWalkEnter = this.#walkEnter.bind(this);
walkEnter(body, function walk(node) {
if (Array.isArray(node)) {
return;
}
probeRunner.sourceFile.walk(node, (probeNode) => {
const action = probeRunner.walk(probeNode);
if (action === "skip") {
this.skip();
}
if (isEvalCallExpr(probeNode) &&
isStringLiteral(probeNode.arguments[0])) {
const evalBody = AstAnalyser.DefaultParser.parse(probeNode.arguments[0].value, void 0);
recursiveWalkEnter(evalBody, probeRunner);
}
});
});
}
async analyseFile(pathToFile, options = {}) {
const startTime = performance.now();
const filePathString = pathToFile instanceof URL ?
pathToFile.href :
pathToFile;
if (filePathString.includes("d.ts")) {
throw new Error("Declaration files are not supported");
}
const { packageName, removeHTMLComments = false, initialize, finalize, customParser, metadata } = options;
let customParserToUse = customParser;
if (!customParser && path.extname(filePathString) === ".ts") {
customParserToUse = new TsSourceParser();
}
const str = await fs.readFile(pathToFile, "utf-8");
const isMin = filePathString.includes(".min") || isMinifiedCode(str);
const location = path.dirname(filePathString);
try {
const data = this.analyse(str, {
location,
isMinified: isMin,
removeHTMLComments,
initialize,
finalize,
customParser: customParserToUse,
metadata,
packageName
});
// Add is-minified flag if the file is minified and not a one-line require
if (!data.flags.has("oneline-require") && isMin) {
data.flags.add("is-minified");
}
const executionTime = performance.now() - startTime;
return {
ok: true,
warnings: data.warnings,
flags: data.flags,
executionTime
};
}
catch (error) {
this.emit(AstAnalyser.ParsingError, {
error,
file: filePathString
});
const executionTime = performance.now() - startTime;
return {
ok: false,
warnings: [
generateWarning("parsing-error", {
value: error.message
})
],
executionTime
};
}
}
analyseFileSync(pathToFile, options = {}) {
const startTime = performance.now();
const filePathString = pathToFile instanceof URL ?
pathToFile.href :
pathToFile;
if (filePathString.includes("d.ts")) {
throw new Error("Declaration files are not supported");
}
const { packageName, removeHTMLComments = false, initialize, finalize, customParser, metadata } = options;
let customParserToUse = customParser;
if (!customParser && path.extname(filePathString) === ".ts") {
customParserToUse = new TsSourceParser();
}
const str = fsSync.readFileSync(pathToFile, "utf-8");
const isMin = filePathString.includes(".min") || isMinifiedCode(str);
const location = path.dirname(filePathString);
try {
const data = this.analyse(str, {
location,
isMinified: isMin,
removeHTMLComments,
initialize,
finalize,
customParser: customParserToUse,
metadata,
packageName
});
// Add is-minified flag if the file is minified and not a one-line require
if (!data.flags.has("oneline-require") && isMin) {
data.flags.add("is-minified");
}
const executionTime = performance.now() - startTime;
return {
ok: true,
warnings: data.warnings,
flags: data.flags,
executionTime
};
}
catch (error) {
this.emit(AstAnalyser.ParsingError, {
error,
file: filePathString
});
const executionTime = performance.now() - startTime;
return {
ok: false,
warnings: [
generateWarning("parsing-error", {
value: error.message
})
],
executionTime
};
}
}
prepareSource(source, options = {}) {
if (typeof source !== "string") {
throw new TypeError("source must be a string");
}
const { removeHTMLComments = false } = options;
/**
* if the file start with a shebang then we remove it because meriyah.parseScript fail to parse it.
* @example
* #!/usr/bin/env node
*/
const rawNoShebang = source.startsWith("#") ?
source.slice(source.indexOf("\n") + 1) : source;
return removeHTMLComments ?
this.#removeHTMLComment(rawNoShebang) : rawNoShebang;
}
#removeHTMLComment(str) {
return str.replaceAll(/<!--[\s\S]*?(?:-->)/g, "");
}
getCollectableSet(type) {
return this.#collectableSetRegistry?.get(type);
}
}
function isEvalCallExpr(node) {
return (isCallExpression(node) &&
getCallExpressionIdentifier(node, { resolveCallExpression: true }) === "eval");
}
function isPattern(warning) {
return warning.endsWith(".*");
}
//# sourceMappingURL=AstAnalyser.js.map