diff --git a/package-lock.json b/package-lock.json index ddb5164..508041f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,7 @@ "dependencies": { "escodegen": "npm:@javascript-obfuscator/escodegen", "eslint-scope": "^8.2.0", - "espree": "^10.3.0", - "estraverse": "^5.3.0" + "espree": "^10.3.0" }, "devDependencies": { "eslint": "^9.16.0", @@ -114,9 +113,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.16.0.tgz", - "integrity": "sha512-tw2HxzQkrbeuvyj1tG2Yqq+0H9wGoI2IMk4EOsQeX+vmd75FtJAzf+gTA69WF+baUKRYQ3x2kbLE08js5OsTVg==", + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", "dev": true, "license": "MIT", "engines": { @@ -440,9 +439,9 @@ } }, "node_modules/eslint": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.16.0.tgz", - "integrity": "sha512-whp8mSQI4C8VXd+fLgSM0lh3UlmcFtVwUQjyKCFfsp+2ItAIYhlq/hqGahGqHE6cv9unM41VlqKk2VtKYR2TaA==", + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", "dev": true, "license": "MIT", "dependencies": { @@ -451,7 +450,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.9.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.16.0", + "@eslint/js": "9.17.0", "@eslint/plugin-kit": "^0.2.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -460,7 +459,7 @@ "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.5", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", diff --git a/package.json b/package.json index 27206de..28dc306 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "dependencies": { "escodegen": "npm:@javascript-obfuscator/escodegen", "eslint-scope": "^8.2.0", - "espree": "^10.3.0", - "estraverse": "^5.3.0" + "espree": "^10.3.0" }, "devDependencies": { "eslint": "^9.16.0", diff --git a/src/arborist.js b/src/arborist.js index f71db97..52c5bde 100644 --- a/src/arborist.js +++ b/src/arborist.js @@ -1,17 +1,17 @@ +import {logger} from './utils/logger.js'; import {generateCode, generateFlatAST} from './flast.js'; const Arborist = class { /** * @param {string|ASTNode[]} scriptOrFlatAstArr - the target script or a flat AST array - * @param {Function} logFunc - (optional) Logging function */ - constructor(scriptOrFlatAstArr, logFunc = null) { + constructor(scriptOrFlatAstArr) { this.script = ''; this.ast = []; - this.log = logFunc || (() => true); this.markedForDeletion = []; // Array of node ids. this.appliedCounter = 0; // Track the number of times changes were applied. this.replacements = []; + this.logger = logger; if (typeof scriptOrFlatAstArr === 'string') { this.script = scriptOrFlatAstArr; this.ast = generateFlatAST(scriptOrFlatAstArr); @@ -32,14 +32,13 @@ const Arborist = class { while (relevantTypes.includes(currentNode?.parentNode?.type) || (currentNode.parentNode.type === 'VariableDeclaration' && (currentNode.parentNode.declarations.length === 1 || - !currentNode.parentNode.declarations.filter(d => d !== currentNode && !d.isMarked).length) + !currentNode.parentNode.declarations.some(d => d !== currentNode && !d.isMarked)) )) currentNode = currentNode.parentNode; if (relevantClauses.includes(currentNode.parentKey)) currentNode.isEmpty = true; return currentNode; } /** - * * @returns {number} The number of changes to be applied. */ getNumberOfChanges() { @@ -49,8 +48,8 @@ const Arborist = class { /** * Replace the target node with another node or delete the target node completely, depending on whether a replacement * node is provided. - * @param targetNode The node to replace or remove. - * @param replacementNode If exists, replace the target node with this node. + * @param {ASTNode} targetNode The node to replace or remove. + * @param {object|ASTNode} replacementNode If exists, replace the target node with this node. */ markNode(targetNode, replacementNode) { if (!targetNode.isMarked) { @@ -76,34 +75,33 @@ const Arborist = class { applyChanges() { let changesCounter = 0; try { - const that = this; if (this.getNumberOfChanges() > 0) { let rootNode = this.ast[0]; - const rootNodeReplacement = this.replacements.find(n => n[0].nodeId === 0); - if (rootNodeReplacement) { + if (rootNode.isMarked) { + const rootNodeReplacement = this.replacements.find(n => n[0].nodeId === 0); ++changesCounter; - this.log(`[+] Applying changes to the root node...`); + this.logger.debug(`[+] Applying changes to the root node...`); const leadingComments = rootNode.leadingComments || []; const trailingComments = rootNode.trailingComments || []; rootNode = rootNodeReplacement[1]; if (leadingComments.length && rootNode.leadingComments !== leadingComments) rootNode.leadingComments = (rootNode.leadingComments || []).concat(leadingComments); if (trailingComments.length && rootNode.trailingComments !== trailingComments) rootNode.trailingComments = (rootNode.trailingComments || []).concat(trailingComments); } else { - for (const targetNodeId of this.markedForDeletion) { + for (let i = 0; i < this.markedForDeletion.length; i++) { + const targetNodeId = this.markedForDeletion[i]; try { let targetNode = this.ast[targetNodeId]; targetNode = targetNode.nodeId === targetNodeId ? targetNode : this.ast.find(n => n.nodeId === targetNodeId); if (targetNode) { const parent = targetNode.parentNode; if (parent[targetNode.parentKey] === targetNode) { - parent[targetNode.parentKey] = undefined; + delete parent[targetNode.parentKey]; const comments = (targetNode.leadingComments || []).concat(targetNode.trailingComments || []); if (comments.length) parent.trailingComments = (parent.trailingComments || []).concat(comments); ++changesCounter; } else if (Array.isArray(parent[targetNode.parentKey])) { const idx = parent[targetNode.parentKey].indexOf(targetNode); - parent[targetNode.parentKey][idx] = undefined; - parent[targetNode.parentKey] = parent[targetNode.parentKey].filter(n => n); + parent[targetNode.parentKey].splice(idx, 1); const comments = (targetNode.leadingComments || []).concat(targetNode.trailingComments || []); if (comments.length) { const targetParent = idx > 0 ? parent[targetNode.parentKey][idx - 1] : parent[targetNode.parentKey].length > 1 ? parent[targetNode.parentKey][idx + 1] : parent; @@ -113,10 +111,11 @@ const Arborist = class { } } } catch (e) { - that.log(`[-] Unable to delete node: ${e}`); + this.logger.debug(`[-] Unable to delete node: ${e}`); } } - for (const [targetNode, replacementNode] of this.replacements) { + for (let i = 0; i < this.replacements.length; i++) { + const [targetNode, replacementNode] = this.replacements[i]; try { if (targetNode) { const parent = targetNode.parentNode; @@ -142,7 +141,7 @@ const Arborist = class { } } } catch (e) { - that.log(`[-] Unable to replace node: ${e}`); + this.logger.debug(`[-] Unable to replace node: ${e}`); } } } @@ -158,13 +157,13 @@ const Arborist = class { this.script = script; } else { - this.log(`[-] Modified script is invalid. Reverting ${changesCounter} changes...`); + this.logger.log(`[-] Modified script is invalid. Reverting ${changesCounter} changes...`); changesCounter = 0; } } } } catch (e) { - this.log(`[-] Unable to apply changes to AST: ${e}`); + this.logger.log(`[-] Unable to apply changes to AST: ${e}`); } ++this.appliedCounter; return changesCounter; diff --git a/src/flast.js b/src/flast.js index c743751..cb7edef 100644 --- a/src/flast.js +++ b/src/flast.js @@ -1,10 +1,10 @@ import {parse} from 'espree'; -import estraverse from 'estraverse'; import {analyze} from 'eslint-scope'; import {logger} from './utils/logger.js'; import {generate, attachComments} from 'escodegen'; const ecmaVersion = 'latest'; +const currentYear = (new Date()).getFullYear(); const sourceType = 'module'; /** @@ -19,31 +19,10 @@ function parseCode(inputCode, opts = {}) { } const excludedParentKeys = [ - 'type', 'start', 'end', 'range', 'sourceType', 'comments', 'srcClosure', 'nodeId', - 'childNodes', 'parentNode', 'parentKey', 'scope', 'typeMap', 'lineage', 'allScopes', + 'type', 'start', 'end', 'range', 'sourceType', 'comments', 'srcClosure', 'nodeId', 'leadingComments', 'trailingComments', + 'childNodes', 'parentNode', 'parentKey', 'scope', 'typeMap', 'lineage', 'allScopes', 'tokens', ]; -/** - * Return the key the child node is assigned in the parent node if applicable; null otherwise. - * @param {ASTNode} node - * @returns {string|null} - */ -function getParentKey(node) { - if (node.parentNode) { - const keys = Object.keys(node.parentNode); - for (let i = 0; i < keys.length; i++) { - if (excludedParentKeys.includes(keys[i])) continue; - if (node.parentNode[keys[i]] === node) return keys[i]; - if (Array.isArray(node.parentNode[keys[i]])) { - for (let j = 0; j < node.parentNode[keys[i]]?.length; j++) { - if (node.parentNode[keys[i]][j] === node) return keys[i]; - } - } - } - } - return null; -} - const generateFlatASTDefaultOptions = { // If false, do not include any scope details detailed: true, @@ -74,16 +53,11 @@ function createSrcClosure(src) { * @return {ASTNode[]} An array of flattened AST */ function generateFlatAST(inputCode, opts = {}) { - opts = { ...generateFlatASTDefaultOptions, ...opts }; + opts = {...generateFlatASTDefaultOptions, ...opts}; let tree = []; const rootNode = generateRootNode(inputCode, opts); if (rootNode) { tree = extractNodesFromRoot(rootNode, opts); - if (opts.detailed) { - const scopes = getAllScopes(rootNode); - for (let i = 0; i < tree.length; i++) injectScopeToNode(tree[i], scopes); - tree[0].allScopes = scopes; - } } return tree; } @@ -110,8 +84,13 @@ function generateCode(rootNode, opts = {}) { return generate(rootNode, { ...generateCodeDefaultOptions, ...opts }); } +/** + * @param {string} inputCode + * @param {object} [opts] + * @return {ASTNode} + */ function generateRootNode(inputCode, opts = {}) { - opts = { ...generateFlatASTDefaultOptions, ...opts }; + opts = {...generateFlatASTDefaultOptions, ...opts}; const parseOpts = opts.parseOpts || {}; let rootNode; try { @@ -124,38 +103,68 @@ function generateRootNode(inputCode, opts = {}) { return rootNode; } +/** + * @param rootNode + * @param opts + * @return {ASTNode[]} + */ function extractNodesFromRoot(rootNode, opts) { - opts = { ...generateFlatASTDefaultOptions, ...opts }; - const tree = []; + opts = {...generateFlatASTDefaultOptions, ...opts}; let nodeId = 0; const typeMap = {}; + const allNodes = []; + const scopes = opts.detailed ? getAllScopes(rootNode) : {}; - // noinspection JSUnusedGlobalSymbols - estraverse.traverse(rootNode, { - /** - * @param {ASTNode} node - * @param {ASTNode} parentNode - */ - enter(node, parentNode) { - tree.push(node); - node.nodeId = nodeId++; - if (!typeMap[node.type]) typeMap[node.type] = [node]; - else typeMap[node.type].push(node); - node.childNodes = []; - node.parentNode = parentNode; - node.parentKey = parentNode ? getParentKey(node) : ''; - node.lineage = [...parentNode?.lineage || []]; - if (parentNode) { - node.lineage.push(parentNode.nodeId); - parentNode.childNodes.push(node); + const stack = [rootNode]; + while (stack.length) { + const node = stack.shift(); + if (node.nodeId) continue; + node.childNodes = node.childNodes || []; + const childrenLoc = {}; // Store the location of child nodes to sort them by order + node.parentKey = node.parentKey || ''; // Make sure parentKey exists + // Iterate over all keys of the node to find child nodes + const keys = Object.keys(node); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (excludedParentKeys.includes(key)) continue; + const content = node[key]; + if (content && typeof content === 'object') { + // Sort each child node by its start position + // and set the parentNode and parentKey attributes + if (Array.isArray(content)) { + for (let j = 0; j < content.length; j++) { + const childNode = content[j]; + childNode.parentNode = node; + childNode.parentKey = key; + childrenLoc[childNode.start] = childNode; + } + } else { + content.parentNode = node; + content.parentKey = key; + childrenLoc[content.start] = content; + } } - if (opts.includeSrc && !node.src) Object.defineProperty(node, 'src', { - get() { return rootNode.srcClosure(node.range[0], node.range[1]);}, - }); } - }); - if (tree?.length) tree[0].typeMap = typeMap; - return tree; + // Add the child nodes to top of the stack and populate the node's childNodes array + stack.unshift(...Object.values(childrenLoc)); + node.childNodes.push(...Object.values(childrenLoc)); + + allNodes.push(node); + node.nodeId = nodeId++; + typeMap[node.type] = typeMap[node.type] || []; + typeMap[node.type].push(node); + node.lineage = [...node.parentNode?.lineage || []]; + if (node.parentNode) { + node.lineage.push(node.parentNode.start); + } + // Add a getter for the node's source code + if (opts.includeSrc && !node.src) Object.defineProperty(node, 'src', { + get() {return rootNode.srcClosure(node.start, node.end);}, + }); + if (opts.detailed) injectScopeToNode(node, scopes); + } + if (allNodes?.length) allNodes[0].typeMap = typeMap; + return allNodes; } /** @@ -169,18 +178,30 @@ function injectScopeToNode(node, scopes) { if (node.type === 'Identifier' && !(!parentNode.computed && ['property', 'key'].includes(node.parentKey))) { // Track references and declarations // Prevent assigning declNode to member expression properties or object keys - const variables = node.scope.variables.filter(n => n.name === node.name); - if (node.parentKey === 'id' || (variables?.length && variables[0].identifiers.some(n => n === node))) { + const variables = []; + for (let i = 0; i < node.scope.variables.length; i++) { + if (node.scope.variables[i].name === node.name) variables.push(node.scope.variables[i]); + } + if (node.parentKey === 'id' || variables?.[0]?.identifiers?.includes(node)) { node.references = node.references || []; } else { // Find declaration by finding the closest declaration of the same name. let decls = []; if (variables?.length) { - decls = variables.find(n => n.name === node.name)?.identifiers; + for (let i = 0; i < variables.length; i++) { + if (variables[i].name === node.name) { + decls = variables[i].identifiers || []; + break; + } + } } else { - const scopeReference = node.scope.references.find(n => n.identifier.name === node.name); - if (scopeReference) decls = scopeReference.resolved?.identifiers || []; + for (let i = 0; i < node.scope.references.length; i++) { + if (node.scope.references[i].identifier.name === node.name) { + decls = node.scope.references[i].resolved?.identifiers || []; + break; + } + } } let declNode = decls[0]; if (decls.length > 1) { @@ -217,41 +238,29 @@ function maxSharedLength(targetArr, containedArr) { } /** - * @param {ASTNode} node - * @param {ASTScope[]} scopes - * @return {Promise} + * @param {ASTNode} rootNode + * @return {{number: ASTScope}} */ -async function injectScopeToNodeAsync(node, scopes) { - return new Promise((resolve, reject) => { - try { - injectScopeToNode(node, scopes); - resolve(); - } catch (e) { - reject(e); - } - }); -} - function getAllScopes(rootNode) { + // noinspection JSCheckFunctionSignatures const globalScope = analyze(rootNode, { optimistic: true, - ecmaVersion: (new Date()).getFullYear(), + ecmaVersion: currentYear, sourceType}).acquireAll(rootNode)[0]; const allScopes = {}; const stack = [globalScope]; - const seen = []; while (stack.length) { - let scope = stack.pop(); - if (seen.includes(scope)) continue; - seen.push(scope); - const scopeId = scope.block.nodeId; + let scope = stack.shift(); + const scopeId = scope.block.start; scope.block.isScopeBlock = true; - if (!allScopes[scopeId]) { - allScopes[scopeId] = scope; - } - stack.push(...scope.childScopes); - if (scope.type === 'module' && scope.upper?.type === 'global' && scope.variables?.length) { - for (const v of scope.variables) if (!scope.upper.variables.includes(v)) scope.upper.variables.push(v); + allScopes[scopeId] = allScopes[scopeId] || scope; + stack.unshift(...scope.childScopes); + // A single global scope is enough, so if there are variables in a module scope, add them to the global scope + if (scope.type === 'module' && scope.upper === globalScope && scope.variables?.length) { + for (let i = 0; i < scope.variables.length; i++) { + const v = scope.variables[i]; + if (!globalScope.variables.includes(v)) globalScope.variables.push(v); + } } } rootNode.allScopes = allScopes; @@ -264,49 +273,23 @@ function getAllScopes(rootNode) { * @return {ASTScope} */ function matchScopeToNode(node, allScopes) { - if (node.lineage?.length) { - for (const nid of [...node.lineage].reverse()) { - if (allScopes[nid]) { - let scope = allScopes[nid]; - if (scope.type.includes('-name') && scope?.childScopes?.length === 1) scope = scope.childScopes[0]; - return scope; - } - } - } - return allScopes[0]; // Global scope - this should never be reached -} - -/** - * - * @param {string} inputCode - * @param {object} opts - * @return {Promise} - */ -async function generateFlatASTAsync(inputCode, opts = {}) { - opts = { ...generateFlatASTDefaultOptions, ...opts }; - let tree = []; - const promises = []; - const rootNode = generateRootNode(inputCode, opts); - if (rootNode) { - tree = extractNodesFromRoot(rootNode, opts); - if (opts.detailed) { - const scopes = getAllScopes(rootNode); - for (let i = 0; i < tree.length; i++) { - promises.push(injectScopeToNodeAsync(tree[i], scopes)); - } - } + let scopeBlock = node; + while (scopeBlock && !scopeBlock.isScopeBlock) { + scopeBlock = scopeBlock.parentNode; } - return Promise.all(promises).then(() => tree); + let scope; + if (scopeBlock) { + scope = allScopes[scopeBlock.start]; + if (scope.type.includes('-name') && scope?.childScopes?.length === 1) scope = scope.childScopes[0]; + } else scope = allScopes[0]; // Global scope - this should never be reached + return scope; } export { - estraverse, extractNodesFromRoot, generateCode, generateFlatAST, - generateFlatASTAsync, generateRootNode, injectScopeToNode, - injectScopeToNodeAsync, parseCode, }; diff --git a/src/utils/applyIteratively.js b/src/utils/applyIteratively.js index e6e16bb..13877f5 100644 --- a/src/utils/applyIteratively.js +++ b/src/utils/applyIteratively.js @@ -23,11 +23,12 @@ function applyIteratively(script, funcs, maxIterations = 500) { while (arborist.ast?.length && scriptSnapshot !== script && currentIteration < maxIterations) { const iterationStartTime = Date.now(); scriptSnapshot = script; - // Mark each node with the script hash to distinguish cache of different scripts. - for (let i = 0; i < arborist.ast.length; i++) arborist.ast[i].scriptHash = scriptHash; + + // Mark the root node with the script hash to distinguish cache of different scripts. + arborist.ast[0].scriptHash = scriptHash; for (let i = 0; i < funcs.length; i++) { const func = funcs[i]; - const funcStartTime = +new Date(); + const funcStartTime = Date.now(); try { logger.debug(`\t[!] Running ${func.name}...`); arborist = func(arborist); @@ -40,13 +41,13 @@ function applyIteratively(script, funcs, maxIterations = 500) { arborist.applyChanges(); script = arborist.script; scriptHash = generateHash(script); - for (let j = 0; j < arborist.ast.length; j++) arborist.ast[j].scriptHash = scriptHash; + arborist.ast[0].scriptHash = scriptHash; } } catch (e) { logger.error(`[-] Error in ${func.name} (iteration #${iterationsCounter}): ${e}\n${e.stack}`); } finally { logger.debug(`\t\t[!] Running ${func.name} completed in ` + - `${((+new Date() - funcStartTime) / 1000).toFixed(3)} seconds`); + `${((Date.now() - funcStartTime) / 1000).toFixed(3)} seconds`); } } ++currentIteration; diff --git a/tests/functionality.test.js b/tests/functionality.test.js index 369969b..2914f7a 100644 --- a/tests/functionality.test.js +++ b/tests/functionality.test.js @@ -21,7 +21,7 @@ describe('Functionality tests', () => { expectedBreakdown.forEach(node => { const parsedNode = ast[node.nodeId]; for (const [k, v] of Object.entries(node)) { - assert.equal(v, parsedNode[k], `Node #${parsedNode[k]} parsed wrong on key '${k}'`); + assert.equal(v, parsedNode[k], `Node #${node.nodeId} parsed wrong on key '${k}'`); } }); }); @@ -30,7 +30,6 @@ describe('Functionality tests', () => { 'Arborist', 'ASTNode', 'ASTScope', - 'estraverse', 'generateCode', 'generateFlatAST', 'parseCode',