@nodesecure/js-x-ray
Version:
JavaScript AST XRay analysis
229 lines • 9.88 kB
JavaScript
// Import Node.js Dependencies
import assert from "node:assert";
// Import Internal Dependencies
import unsafeVmContext from "./probes/unsafe-vm-context.js";
import logUsage from "./probes/log-usage.js";
import sqlInjection from "./probes/sql-injection.js";
import dataExfiltration from "./probes/data-exfiltration.js";
import isArrayExpression from "./probes/isArrayExpression.js";
import isBinaryExpression from "./probes/isBinaryExpression.js";
import isESMExport from "./probes/isESMExport.js";
import isFetch from "./probes/isFetch.js";
import isImportDeclaration from "./probes/isImportDeclaration.js";
import isLiteral from "./probes/isLiteral.js";
import isLiteralRegex from "./probes/isLiteralRegex.js";
import isRegexObject from "./probes/isRegexObject.js";
import isRequire from "./probes/isRequire/isRequire.js";
import isSerializeEnv from "./probes/isSerializeEnv.js";
import isSyncIO from "./probes/isSyncIO.js";
import isUnsafeCallee from "./probes/isUnsafeCallee.js";
import isUnsafeCommand from "./probes/isUnsafeCommand.js";
import isMonkeyPatch from "./probes/isMonkeyPatch.js";
import isRandom from "./probes/isRandom.js";
import isPrototypePollution from "./probes/isPrototypePollution.js";
import { isWeakAlgorithm, isWeakScrypt, isUnsafePrehash, isWeakBcrypt, isPasswordShucking } from "./probes/crypto/index.js";
import { getCallExpressionIdentifier, isCallExpression } from "./estree/index.js";
import { CALL_EXPRESSION_DATA, CALL_EXPRESSION_IDENTIFIER } from "./contants.js";
const kProbeOriginalContext = Symbol.for("ProbeOriginalContext");
export class ProbeRunner {
probes;
sourceFile;
#selectedEntryPoints = new Map();
#breakGroups = new Set();
#probeValidateFns = new Map();
#probeCtx = new Map();
#probeMainCtx = new Map();
#nodeTypeIndex = new Map();
#catchAllProbes = [];
#callExprIdentifierOptions;
static Signals = Object.freeze({
Break: Symbol.for("breakWalk"),
Skip: Symbol.for("skipWalk"),
Continue: null
});
/**
* Note:
* The order of the table has an importance/impact on the correct execution of the probes
*/
static Defaults = [
isFetch,
isRequire,
isESMExport,
isUnsafeCallee,
isLiteral,
isLiteralRegex,
isRegexObject,
isImportDeclaration,
isWeakAlgorithm,
unsafeVmContext,
isBinaryExpression,
isArrayExpression,
isUnsafeCommand,
isSerializeEnv,
dataExfiltration,
sqlInjection,
isMonkeyPatch,
isPrototypePollution
];
static Optionals = {
"synchronous-io": isSyncIO,
"log-usage": logUsage,
"insecure-random": isRandom,
"crypto.weak-scrypt": isWeakScrypt,
"crypto.unsafe-prehash": isUnsafePrehash,
"crypto.weak-bcrypt": isWeakBcrypt,
"crypto.password-shucking": isPasswordShucking
};
constructor(sourceFile, probes = ProbeRunner.Defaults) {
this.sourceFile = sourceFile;
this.#callExprIdentifierOptions = {
externalIdentifierLookup: (name) => sourceFile.tracer.literalIdentifiers.get(name)?.value ?? null
};
for (const probe of probes) {
assert(typeof probe.validateNode === "function" || Array.isArray(probe.validateNode), `Invalid probe ${probe.name}: validateNode must be a function or an array of functions`);
assert(typeof probe.main === "function" || typeof probe.main === "object", `Invalid probe ${probe.name}: main must be a function or an object with named handlers`);
if (typeof probe.main === "object") {
assert("default" in probe.main && typeof probe.main.default === "function", `Invalid probe ${probe.name}: named main handlers must provide a 'default' handler`);
}
assert(typeof probe.initialize === "function" || probe.initialize === undefined, `Invalid probe ${probe.name}: initialize must be a function or undefined`);
// Pre-build per-probe caches before calling initialize so #getProbeContext can use them.
const setEntryPoint = (handlerName) => {
if (typeof probe.main === "object") {
this.#selectedEntryPoints.set(probe, handlerName);
}
};
const ctx = {
sourceFile: this.sourceFile,
context: probe.context,
setEntryPoint
};
const mainCtx = {
sourceFile: this.sourceFile,
context: probe.context,
setEntryPoint,
signals: ProbeRunner.Signals,
data: null
};
this.#probeCtx.set(probe, ctx);
this.#probeMainCtx.set(probe, mainCtx);
this.#probeValidateFns.set(probe, Array.isArray(probe.validateNode) ? probe.validateNode : [probe.validateNode]);
if (probe.initialize) {
const isDefined = Reflect.defineProperty(probe, kProbeOriginalContext, {
enumerable: false,
value: structuredClone(probe.context),
configurable: true
});
if (!isDefined) {
throw new Error(`Failed to define original context for probe '${probe.name}'`);
}
// Pass a fresh object for initialize so any captured reference reflects
// the state at call-time (probe.context is undefined before initialize returns).
const context = probe.initialize({
sourceFile: this.sourceFile,
context: probe.context,
setEntryPoint
});
if (context) {
probe.context = structuredClone(context);
ctx.context = probe.context;
mainCtx.context = probe.context;
}
}
}
this.probes = probes;
this.#catchAllProbes = probes.filter((probe) => !probe.nodeTypes || probe.nodeTypes.length === 0);
const allNodeTypes = new Set(probes.flatMap((probe) => probe.nodeTypes ?? []));
for (const nodeType of allNodeTypes) {
const list = probes.filter((probe) => !probe.nodeTypes || probe.nodeTypes.length === 0 || probe.nodeTypes.includes(nodeType));
this.#nodeTypeIndex.set(nodeType, list);
}
}
#getProbeContext(probe) {
const ctx = this.#probeCtx.get(probe);
ctx.context = probe.context;
return ctx;
}
#getProbeHandler(probe) {
if (typeof probe.main === "function") {
return probe.main;
}
const selectedName = this.#selectedEntryPoints.get(probe);
const handlerName = (selectedName && selectedName in probe.main)
? selectedName
: "default";
return probe.main[handlerName];
}
#runProbe(probe, node) {
const validationFns = this.#probeValidateFns.get(probe);
const ctx = this.#getProbeContext(probe);
for (const validateNode of validationFns) {
const [isMatching, data = null] = validateNode(node, ctx);
if (!isMatching) {
continue;
}
const mainHandler = this.#getProbeHandler(probe);
this.#selectedEntryPoints.delete(probe);
const mainCtx = this.#probeMainCtx.get(probe);
mainCtx.context = probe.context;
mainCtx.data = data;
return mainHandler(node, mainCtx);
}
return null;
}
walk(node) {
this.#breakGroups.clear();
let tracedIdentifierReport;
let tracedIdentifier;
if (isCallExpression(node)) {
const id = getCallExpressionIdentifier(node, this.#callExprIdentifierOptions);
if (id !== null) {
tracedIdentifierReport = this.sourceFile.tracer.getDataFromIdentifier(id);
tracedIdentifier = id;
}
}
const probesForNode = this.#nodeTypeIndex.get(node.type) ?? this.#catchAllProbes;
for (const probe of probesForNode) {
if (probe.breakGroup && this.#breakGroups.has(probe.breakGroup)) {
continue;
}
try {
if (probe.context && tracedIdentifierReport) {
probe.context[CALL_EXPRESSION_IDENTIFIER] = tracedIdentifier;
probe.context[CALL_EXPRESSION_DATA] = tracedIdentifierReport;
}
const signal = this.#runProbe(probe, node);
if (signal === ProbeRunner.Signals.Continue) {
continue;
}
if (signal === ProbeRunner.Signals.Skip) {
return "skip";
}
if (signal === ProbeRunner.Signals.Break || probe.breakOnMatch) {
const breakGroup = probe.breakGroup || null;
if (breakGroup === null) {
break;
}
else {
this.#breakGroups.add(breakGroup);
}
}
}
finally {
probe.teardown?.(this.#getProbeContext(probe));
if (probe.context) {
delete probe.context[CALL_EXPRESSION_DATA];
delete probe.context[CALL_EXPRESSION_IDENTIFIER];
}
}
}
return null;
}
finalize() {
for (const probe of this.probes) {
probe.finalize?.(this.#getProbeContext(probe));
probe.context = probe[kProbeOriginalContext];
}
this.sourceFile.tracer.removeAllListeners();
}
}
//# sourceMappingURL=ProbeRunner.js.map