@nodesecure/js-x-ray
Version:
JavaScript AST XRay analysis
222 lines • 9.01 kB
JavaScript
// Import Node.js Dependencies
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
// 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 { ProbeRunner } from "./ProbeRunner.js";
import { SourceFile } from "./SourceFile.js";
import { isMinifiedCode, isOneLineExpressionExport } from "./utils/index.js";
import { walkEnter } from "./walker/index.js";
import { getCallExpressionIdentifier } from "./estree/index.js";
import { generateWarning } from "./warnings.js";
import { CollectableSetRegistry } from "./CollectableSetRegistry.js";
export class AstAnalyser {
static DefaultParser = new JsSourceParser();
#pipelineRunner;
probes;
#collectables;
#sensitivity;
#collectableSetRegistry;
constructor(options = {}) {
const { customProbes = [], optionalWarnings = false, skipDefaultProbes = false, pipelines = [], collectables = [], sensitivity = "conservative" } = options;
this.#pipelineRunner = new PipelineRunner(pipelines);
this.#collectables = 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 optionalProbes = Array.from(optionalWarnings ?? [])
.flatMap((warning) => ProbeRunner.Optionals[warning] ?? []);
probes = [...probes, ...optionalProbes];
}
this.probes = probes;
}
analyse(str, options = {}) {
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,
collectables: this.#collectables,
packageName
});
this.#collectableSetRegistry = source.collectablesSetRegistry;
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");
}
return {
...source.getResult(isMinified),
flags: source.flags
};
}
#walkEnter(body, probeRunner) {
const recur = this.#walkEnter.bind(this);
walkEnter(body, function walk(node) {
if (Array.isArray(node)) {
return;
}
for (const probeNode of probeRunner.sourceFile.walk(node)) {
const action = probeRunner.walk(probeNode);
if (action === "skip") {
this.skip();
}
if (probeNode.type === "CallExpression" && getCallExpressionIdentifier(probeNode, {
resolveCallExpression: true
}) === "eval" && probeNode.arguments[0].type === "Literal" && typeof probeNode.arguments[0].value === "string") {
const evalBody = AstAnalyser.DefaultParser.parse(probeNode.arguments[0].value, void 0);
recur(evalBody, probeRunner);
}
}
});
}
async analyseFile(pathToFile, options = {}) {
const filePathString = pathToFile instanceof URL ?
pathToFile.href :
pathToFile;
if (filePathString.includes("d.ts")) {
throw new Error("Declaration files are not supported");
}
try {
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 data = this.analyse(str, {
location: path.dirname(filePathString),
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");
}
return {
ok: true,
warnings: data.warnings,
flags: data.flags
};
}
catch (error) {
return {
ok: false,
warnings: [
generateWarning("parsing-error", {
value: error.message
})
]
};
}
}
analyseFileSync(pathToFile, options = {}) {
const filePathString = pathToFile instanceof URL ?
pathToFile.href :
pathToFile;
if (filePathString.includes("d.ts")) {
throw new Error("Declaration files are not supported");
}
try {
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 data = this.analyse(str, {
location: path.dirname(filePathString),
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");
}
return {
ok: true,
warnings: data.warnings,
flags: data.flags
};
}
catch (error) {
return {
ok: false,
warnings: [
generateWarning("parsing-error", {
value: error.message
})
]
};
}
}
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);
}
}
//# sourceMappingURL=AstAnalyser.js.map