diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index ac847da..a5c625c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [20.x, 22.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: @@ -26,5 +26,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y python3 g++ build-essential - run: npm install - run: npm run test diff --git a/.gitignore b/.gitignore index 6742e06..415539d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules/ *.log *tmp*/ .DS_Store -*.heapsnapshot \ No newline at end of file +*.heapsnapshot +.cursor \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index ba1c353..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,150 +0,0 @@ -# Contributing - -When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the owners of this repository before making a change. - -Please note we have a code of conduct, please follow it in all your interactions with the project. - -## Pull Request Process - -1. Ensure any install or build dependencies are removed before the end of the layer when doing a - build. -2. Update the README.md with details of changes to the interface, this includes new environment - variables, exposed ports, useful file locations and container parameters. -3. Increase the version numbers in any examples files and the README.md to the new version that this - Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). -4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you - do not have permission to do that, you may request the second reviewer to merge it for you. - -## Code of Conduct - -### Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -### Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -### Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -### Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -### Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[ben.baryo@humansecurity.com](mailto:ben.baryo@humansecurity.com). -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -### Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -#### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -#### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -#### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -#### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -### Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available -at [https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations \ No newline at end of file diff --git a/LICENSE b/LICENSE index 8c6b670..b1f185e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 PerimeterX +Copyright (c) 2025 HUMAN Security Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3375dd2..bc5dc4e 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,352 @@ -# Restringer -[![Node.js CI](https://github.com/PerimeterX/restringer/actions/workflows/node.js.yml/badge.svg?branch=main)](https://github.com/PerimeterX/restringer/actions/workflows/node.js.yml) +# REstringer + +[![Node.js CI](https://github.com/HumanSecurity/restringer/actions/workflows/node.js.yml/badge.svg?branch=main)](https://github.com/HumanSecurity/restringer/actions/workflows/node.js.yml) [![Downloads](https://img.shields.io/npm/dm/restringer.svg?maxAge=43200)](https://www.npmjs.com/package/restringer) +[![npm version](https://badge.fury.io/js/restringer.svg)](https://badge.fury.io/js/restringer) + +**A JavaScript deobfuscation tool that reconstructs strings and simplifies complex logic.** + +REstringer automatically detects obfuscation patterns and applies targeted deobfuscation techniques to restore readable JavaScript code. It handles various obfuscation methods while respecting scope limitations and maintaining code functionality. -Deobfuscate Javascript and reconstruct strings. -Simplify cumbersome logic where possible while adhering to scope limitations. +🌐 **Try it online**: [restringer.tech](https://restringer.tech) -Try it online @ [restringer.tech](https://restringer.tech). +πŸ“§ **Contact**: For questions and suggestions, open an issue or find me on Twitter / X - Ben Baryo - [@ctrl__esc](https://twitter.com/ctrl__esc) -For comments and suggestions feel free to open an issue or find me on Twitter - [@ctrl__esc](https://twitter.com/ctrl__esc) +--- ## Table of Contents -* [Installation](#installation) - * [npm](#npm) - * [Clone The Repo](#clone-the-repo) -* [Usage](#usage) - * [Command-Line Usage](#command-line-usage) - * [Use as a Module](#use-as-a-module) -* [Create Custom Deobfuscators](#create-custom-deobfuscators) - * [Boilerplate Code for Starting from Scratch](#boilerplate-code-for-starting-from-scratch) -* [Read More](#read-more) -*** - -## Installation -### npm -```shell + +- [Features](#features) +- [Installation](#installation) +- [Usage](#usage) + - [Command-Line Usage](#command-line-usage) + - [Module Usage](#module-usage) +- [Advanced Usage](#advanced-usage) + - [Custom Deobfuscators](#custom-deobfuscators) + - [Targeted Processing](#targeted-processing) + - [Custom Method Integration](#custom-method-integration) +- [Architecture](#architecture) +- [Development](#development) +- [Contributing](#contributing) +- [Resources](#resources) + +--- + +## Features + +✨ **Automatic Obfuscation Detection**: Uses [Obfuscation Detector](https://github.com/HumanSecurity/obfuscation-detector) to identify specific obfuscation types + +πŸ”§ **Modular Architecture**: 40+ deobfuscation modules organized into safe and unsafe categories + +πŸ›‘οΈ **Safe Execution**: Unsafe modules use a sandbox [isolated-vm](https://www.npmjs.com/package/isolated-vm) for secure code evaluation + +🎯 **Targeted Processing**: Specialized processors for common obfuscators (obfuscator.io, Caesar Plus, etc.) + +⚑ **Performance Optimized**: Match/transform patterns and performance improvements throughout + +πŸ” **Comprehensive Coverage**: Handles string reconstruction, dead code removal, control flow simplification, and more + +--- + +## Installation + +### Requirements +- **Node.js v20+** (v22+ recommended) + +### Global Installation (CLI) +```bash npm install -g restringer ``` -### Clone The Repo -Requires Node 16 or newer. -```shell -git clone git@github.com:PerimeterX/restringer.git +### Local Installation (Module) +```bash +npm install restringer +``` + +### Development Installation +```bash +git clone https://github.com/HumanSecurity/restringer.git cd restringer npm install ``` -*** +--- ## Usage -The [restringer.js](src/restringer.js) uses generic deobfuscation methods that reconstruct and restore obfuscated strings and simplifies redundant logic meant only to encumber. -REstringer employs the [Obfuscation Detector](https://github.com/PerimeterX/obfuscation-detector/blob/main/README.md) to identify specific types of obfuscation for which -there's a need to apply specific deobfuscation methods in order to circumvent anti-debugging mechanisms or other code traps -preventing the script from being deobfuscated. ### Command-Line Usage + ``` Usage: restringer input_filename [-h] [-c] [-q | -v] [-m M] [-o [output_filename]] positional arguments: - input_filename The obfuscated JS file + input_filename The obfuscated JavaScript file optional arguments: - -h, --help Show this help message and exit. - -c, --clean Remove dead nodes from script after deobfuscation is complete (unsafe). - -q, --quiet Suppress output to stdout. Output result only to stdout if the -o option is not set. - Does not go with the -v option. - -m, --max-iterations M Run at most M iterations - -v, --verbose Show more debug messages while deobfuscating. Does not go with the -q option. - -o, --output [output_filename] Write deobfuscated script to output_filename. - -deob.js is used if no filename is provided. -``` -Examples: -- Print the deobfuscated script to stdout. - ```shell - restringer [target-file.js] - ``` -- Save the deobfuscated script to output.js. - ```shell - restringer [target-file.js] -o output.js - ``` -- Deobfuscate and print debug info. - ```shell - restringer [target-file.js] -v - ``` -- Deobfuscate without printing anything but the deobfuscated output. - ```shell - restringer [target-file.js] -q - ``` - - -### Use as a Module + -h, --help Show this help message and exit + -c, --clean Remove dead nodes after deobfuscation (unsafe) + -q, --quiet Suppress output to stdout + -v, --verbose Show debug messages during deobfuscation + -m, --max-iterations M Maximum deobfuscation iterations (must be > 0) + -o, --output [filename] Write output to file (default: -deob.js) +``` + +#### Examples +**Basic deobfuscation** (print to stdout): +```bash +restringer obfuscated.js +``` + +**Save to specific file**: +```bash +restringer obfuscated.js -o clean-code.js +``` + +**Verbose output with iteration limit**: +```bash +restringer obfuscated.js -v -m 10 -o output.js +``` + +**Quiet mode** (no console output): +```bash +restringer obfuscated.js -q -o output.js +``` + +**Remove dead code** (potentially unsafe): +```bash +restringer obfuscated.js -c -o output.js +``` + +### Module Usage + +#### Basic Example ```javascript import {REstringer} from 'restringer'; -const restringer = new REstringer('"RE" + "stringer"'); +const obfuscatedCode = ` +const _0x4c2a = ['hello', 'world']; +const _0x3f1b = _0x4c2a[0] + ' ' + _0x4c2a[1]; +console.log(_0x3f1b); +`; + +const restringer = new REstringer(obfuscatedCode); + if (restringer.deobfuscate()) { + console.log('βœ… Deobfuscation successful!'); console.log(restringer.script); + // Output: console.log('hello world'); } else { - console.log('Nothing was deobfuscated :/'); + console.log('❌ No changes made'); } -// Output: 'REstringer'; ``` -*** -## Create Custom Deobfuscators -REstringer is highly modularized. It exposes modules that allow creating custom deobfuscators -that can solve specific problems. +--- + +## Advanced Usage -The basic structure of such a deobfuscator would be an array of deobfuscation modules -(either [safe](src/modules/safe) or [unsafe](src/modules/unsafe)), run via flAST's applyIteratively utility function. +### Custom Deobfuscators -Unsafe modules run code through `eval` (using [isolated-vm](https://www.npmjs.com/package/isolated-vm) to be on the safe side) while safe modules do not. +Create targeted deobfuscators using REstringer's modular system: ```javascript import {applyIteratively} from 'flast'; import {safe, unsafe} from 'restringer'; -const {normalizeComputed} = safe; -const {resolveDefiniteBinaryExpressions, resolveLocalCalls} = unsafe; -let script = 'obfuscated JS here'; -const deobModules = [ - resolveDefiniteBinaryExpressions, - resolveLocalCalls, - normalizeComputed, + +// Import specific modules +const normalizeComputed = safe.normalizeComputed.default; +const removeRedundantBlockStatements = safe.removeRedundantBlockStatements.default; +const resolveDefiniteBinaryExpressions = unsafe.resolveDefiniteBinaryExpressions.default; +const resolveLocalCalls = unsafe.resolveLocalCalls.default; + +let script = 'your obfuscated code here'; + +// Define custom deobfuscation pipeline +const customModules = [ + resolveDefiniteBinaryExpressions, // Resolve literal math operations + resolveLocalCalls, // Inline function calls + normalizeComputed, // Convert obj['prop'] to obj.prop + removeRedundantBlockStatements, // Clean up unnecessary blocks ]; -script = applyIteratively(script, deobModules); -console.log(script); // Deobfuscated script + +// Apply modules iteratively +script = applyIteratively(script, customModules); +console.log(script); ``` -With the additional `candidateFilter` function argument, it's possible to narrow down the targeted nodes: +### Targeted Processing + +Use candidate filters to target specific nodes: + ```javascript import {unsafe} from 'restringer'; -const {resolveLocalCalls} = unsafe; import {applyIteratively} from 'flast'; -let script = 'obfuscated JS here'; -// It's better to define a function with a meaningful name that can show up in the log -function resolveLocalCallsInGlobalScope(arb) { +const {resolveLocalCalls} = unsafe; + +function resolveGlobalScopeCalls(arb) { + // Only process calls in global scope return resolveLocalCalls(arb, n => n.parentNode?.type === 'Program'); } -script = applyIteratively(script, [resolveLocalCallsInGlobalScope]); -console.log(script); // Deobfuscated script + +function resolveSpecificFunctions(arb) { + // Only process calls to functions with specific names + return resolveLocalCalls(arb, n => { + const callee = n.callee; + return callee.type === 'Identifier' && + ['decode', 'decrypt', 'transform'].includes(callee.name); + }); +} + +const script = applyIteratively(code, [ + resolveGlobalScopeCalls, + resolveSpecificFunctions +]); ``` -You can also customize any deobfuscation method while still using REstringer without running the loop yourself: +### Custom Method Integration + +Replace or customize built-in methods: + ```javascript import fs from 'node:fs'; import {REstringer} from 'restringer'; -const inputFilename = process.argv[2]; -const code = fs.readFileSync(inputFilename, 'utf-8'); -const res = new REstringer(code); +const code = fs.readFileSync('obfuscated.js', 'utf-8'); +const restringer = new REstringer(code); + +// Find and replace a specific method +const targetMethod = restringer.unsafeMethods.find(m => + m.name === 'resolveLocalCalls' +); + +if (targetMethod) { + let processedCount = 0; + const maxProcessing = 5; + + // Custom implementation with limits + const customMethod = function limitedResolveLocalCalls(arb) { + return targetMethod(arb, () => processedCount++ < maxProcessing); + }; + + // Replace the method + const index = restringer.unsafeMethods.indexOf(targetMethod); + restringer.unsafeMethods[index] = customMethod; +} -// res.logger.setLogLevelDebug(); -res.detectObfuscationType = false; // Skip obfuscation type detection, including any pre and post processors +restringer.deobfuscate(); +``` -const targetFunc = res.unsafeMethods.find(m => m.name === 'resolveLocalCalls'); -let changes = 0; // Resolve only the first 5 calls -res.safeMethods[res.unsafeMethods.indexOf(targetFunc)] = function customResolveLocalCalls(n) {return targetFunc(n, () => changes++ < 5)} +--- -res.deobfuscate(); +## Architecture -if (res.script !== code) { - console.log('[+] Deob successful'); - fs.writeFileSync(`${inputFilename}-deob.js`, res.script, 'utf-8'); -} else console.log('[-] Nothing deobfuscated :/'); -``` +### Module Categories -*** +**Safe Modules** (`src/modules/safe/`): +- Perform transformations without code evaluation +- No risk of executing malicious code +- Examples: String normalization, syntax simplification, dead code removal -### Boilerplate code for starting from scratch -```javascript -import {applyIteratively, logger} from 'flast'; -// Optional loading from file -// import fs from 'node:fs'; -// const inputFilename = process.argv[2] || 'target.js'; -// const code = fs.readFileSync(inputFilename, 'utf-8'); -const code = `(function() { - function createMessage() {return 'Hello' + ' ' + 'there!';} - function print(msg) {console.log(msg);} - print(createMessage()); -})();`; - -logger.setLogLevelDebug(); - -/** - * Replace specific strings with other strings - * @param {Arborist} arb - * @return {Arborist} - */ -function replaceSpecificLiterals(arb) { - const replacements = { - 'Hello': 'General', - 'there!': 'Kenobi!', - }; - // Iterate over only the relevant nodes by targeting specific types using the typeMap property on the root node - const relevantNodes = [ - ...(arb.ast[0].typeMap.Literal || []), - // ...(arb.ast.typeMap.TemplateLiteral || []), // unnecessary for this example, but this is how to add more types - ]; - for (const n of relevantNodes) { - if (replacements[n.value]) { - // dynamically define a replacement node by creating an object with a type and value properties - // markNode(n) would delete the node, while markNode(n, {...}) would replace the node with the supplied node. - arb.markNode(n, {type: 'Literal', value: replacements[n.value]}); - } - } - return arb; -} +**Unsafe Modules** (`src/modules/unsafe/`): +- Use `eval()` in an isolated sandbox for dynamic analysis +- Can resolve complex expressions and function calls +- Secured using [isolated-vm](https://www.npmjs.com/package/isolated-vm) -let script = code; +### Processing Pipeline -script = applyIteratively(script, [ - replaceSpecificLiterals, -]); +1. **Detection**: Identify obfuscation type using pattern recognition +2. **Preprocessing**: Apply obfuscation-specific preparations +3. **Core Deobfuscation**: Run safe and unsafe modules iteratively +4. **Postprocessing**: Clean up and optimize the result +5. **Validation**: Ensure output correctness + +### Processor Architecture + +Specialized processors handle specific obfuscation patterns: +- **Match/Transform Pattern**: Separate identification and modification logic +- **Performance Optimized**: Pre-compiled patterns and efficient algorithms +- **Configurable**: Support for custom filtering and targeting -if (code !== script) { - console.log(script); - // fs.writeFileSync(inputFilename + '-deob.js', script, 'utf-8'); -} else console.log(`No changes`); +--- + +## Development + +### Project Structure +``` +restringer/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ modules/ +β”‚ β”‚ β”œβ”€β”€ safe/ # Safe deobfuscation modules +β”‚ β”‚ β”œβ”€β”€ unsafe/ # Unsafe deobfuscation modules +β”‚ β”‚ └── utils/ # Utility functions +β”‚ β”œβ”€β”€ processors/ # Obfuscation-specific processors +β”‚ └── restringer.js # Main REstringer class +β”œβ”€β”€ tests/ # Comprehensive test suites +└── docs/ # Documentation ``` -*** -## Read More -* [Processors](src/processors/README.md) -* [Contribution guide](CONTRIBUTING.md) -* [Obfuscation Detector](https://github.com/PerimeterX/obfuscation-detector/blob/main/README.md) -* [flAST](https://github.com/PerimeterX/flast/blob/main/README.md) +### Running Tests +```bash +# Quick test suite (without testing against samples) +npm run test:quick + +# Watch mode for development (quick tests) +npm run test:quick:watch + +# Full test suite with samples +npm test +``` + +--- + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](docs/CONTRIBUTING.md) for detailed guidelines on: + +- Setting up the development environment +- Code standards and best practices +- Module and processor development +- Testing requirements +- Pull request process + +--- + +## Resources + +### Documentation +- πŸ“– [Processors Guide](src/processors/README.md) - Detailed processor documentation +- 🀝 [Contributing Guide](docs/CONTRIBUTING.md) - How to contribute to REstringer + +### Related Projects +- πŸ” [Obfuscation Detector](https://github.com/HumanSecurity/obfuscation-detector) - Automatic obfuscation detection +- 🌳 [flAST](https://github.com/HumanSecurity/flast) - AST manipulation utilities + +### Research & Blog Posts + +**The REstringer Tri(b)logy**: +- πŸ“ [The Far Point of a Static Encounter](https://www.humansecurity.com/tech-engineering-blog/the-far-point-of-a-static-encounter/) - Part 1: Understanding static analysis challenges +- πŸ”§ [Automating Skimmer Deobfuscation](https://www.humansecurity.com/tech-engineering-blog/automating-skimmer-deobfuscation/) - Part 2: Automated deobfuscation techniques +- πŸ›‘οΈ [Defeating JavaScript Obfuscation](https://www.humansecurity.com/tech-engineering-blog/defeating-javascript-obfuscation/) - Part 3: The story of REstringer + +**Additional Resources**: +- πŸ” [Caesar Plus Deobfuscation](https://www.humansecurity.com/tech-engineering-blog/deobfuscating-caesar/) - Deep dive into Caesar cipher obfuscation + +### Community +- πŸ’¬ [GitHub Issues](https://github.com/HumanSecurity/restringer/issues) - Bug reports and feature requests +- 🐦 [Twitter @ctrl__esc](https://twitter.com/ctrl__esc) - Updates and discussions +- 🌐 [Online Tool](https://restringer.tech) - Try REstringer in your browser + +--- + +## License + +This project is licensed under the [MIT License](LICENSE). + +--- + +
+ +**Made with ❀️ by [HUMAN Security](https://www.HumanSecurity.com/)** + +
\ No newline at end of file diff --git a/bin/deobfuscate.js b/bin/deobfuscate.js index 7853819..1ec79b3 100755 --- a/bin/deobfuscate.js +++ b/bin/deobfuscate.js @@ -1,12 +1,15 @@ #!/usr/bin/env node import {REstringer} from '../src/restringer.js'; -import {argsAreValid, parseArgs} from'../src/utils/parseArgs.js'; +import {parseArgs} from '../src/utils/parseArgs.js'; try { const args = parseArgs(process.argv.slice(2)); - if (argsAreValid(args)) { - const fs = await import('node:fs'); - let content = fs.readFileSync(args.inputFilename, 'utf-8'); + + // Skip processing if help was displayed + if (args.help) process.exit(0); + + const fs = await import('node:fs'); + let content = fs.readFileSync(args.inputFilename, 'utf-8'); const startTime = Date.now(); const restringer = new REstringer(content); @@ -24,7 +27,6 @@ try { if (args.outputToFile) fs.writeFileSync(args.outputFilename, restringer.script, {encoding: 'utf-8'}); else console.log(restringer.script); } else restringer.logger.log(`[-] Nothing was deobfuscated Β―\\_(ツ)_/Β―`); - } } catch (e) { console.error(`[-] Critical Error: ${e}`); } diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..cc5c5d7 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,444 @@ +# Contributing to REstringer + +Thank you for your interest in contributing to REstringer! This guide covers everything you need to know about contributing to the project. + +## Table of Contents + +- [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Development Setup](#development-setup) + - [Running Tests](#running-tests) +- [Contribution Process](#contribution-process) + - [General Guidelines](#general-guidelines) + - [Code Standards](#code-standards) + - [Testing Requirements](#testing-requirements) +- [Module Development](#module-development) + - [Module Architecture](#module-architecture) + - [Match/Transform Pattern](#matchtransform-pattern) + - [Performance Requirements](#performance-requirements) + - [Documentation Standards](#documentation-standards) +- [Processor Development](#processor-development) + - [Processor Architecture](#processor-architecture) + - [Development Guidelines](#development-guidelines) + - [Testing Processors](#testing-processors) +- [Code Quality](#code-quality) + - [Naming Conventions](#naming-conventions) + - [Error Handling](#error-handling) + - [Memory Management](#memory-management) +- [Testing Guidelines](#testing-guidelines) + - [Test Categories](#test-categories) + - [Test Organization](#test-organization) + - [Running Tests](#running-tests-1) +- [Documentation](#documentation) + - [JSDoc Requirements](#jsdoc-requirements) + - [README Updates](#readme-updates) +- [Submission Guidelines](#submission-guidelines) + - [Pull Request Process](#pull-request-process) + - [Review Checklist](#review-checklist) + +--- + +## Getting Started + +### Prerequisites + +- **Node.js v20+** (v22+ recommended) +- **npm** (latest stable version) +- **Git** for version control + +### Development Setup + +1. Fork the repository on GitHub +2. Clone your fork locally: + ```bash + git clone https://github.com/your-username/restringer.git + cd restringer + ``` +3. Install dependencies: + ```bash + npm install + ``` +4. Create a feature branch: + ```bash + git checkout -b feature-name + ``` + +### Running Tests + +```bash +# Full test suite with sample files +npm test + +# Quick test suite (recommended for development) +npm run test:quick + +# Watch mode during development (quick tests) +npm run test:quick:watch +``` + +--- + +## Contribution Process + +### General Guidelines + +1. **Follow project conventions** - Maintain consistency with existing code style and patterns +2. **Focus on quality over quantity** - Well-tested, documented improvements are preferred over large changes +3. **Be respectful** - Follow the code of conduct and be considerate in discussions +4. **Start small** - Begin with small improvements to familiarize yourself with the codebase +5. **Ask questions** - Don't hesitate to open an issue for clarification or discussion before starting work +6. **Test thoroughly** - Use the `test:quick` option to validate code while working, but always run the full test suite and add tests for new functionality before proceeding to submit the code + +### Code Standards + +- **Prefer `const` and `let`** - Avoid using `var` as much as possible +- **Single quotes** - Use single quotes for strings (use backticks if string contains single quotes) +- **2 spaces for indentation** - If file uses tabs, maintain tabs +- **Match existing style** - Always try to match existing style when adding or changing code + +### Testing Requirements + +- **Add tests for new functionality** - Include both positive (TP) and negative (TN) test cases +- **Maintain test coverage** - Ensure comprehensive coverage for edge cases +- **Run appropriate test suite** - Use `npm run test:quick` during development, `npm test` for full validation +- **Watch for regressions** - Changes to one module could affect other parts of the system + +--- + +## Module Development + +### Module Architecture + +All modules must follow the **match/transform pattern**: + +```javascript +// Match function - identifies target nodes +export function moduleNameMatch(arb, candidateFilter = () => true) { + const matches = []; + const candidates = arb.ast[0].typeMap.TargetNodeType + .concat(arb.ast[0].typeMap.AnotherTargetNodeType); + + for (let i = 0; i < candidates.length; i++) { + const node = candidates[i]; + if (matchesCriteria(node) && candidateFilter(node)) { + matches.push(node); + } + } + return matches; +} + +// Transform function - modifies matched nodes +export function moduleNameTransform(arb, node) { + // Apply transformations + performTransformation(node); + return arb; // Must explicitly return arb +} + +// Main function - orchestrates match and transform +export default function moduleName(arb, candidateFilter = () => true) { + const matches = moduleNameMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = moduleNameTransform(arb, matches[i]); // Capture returned arb + } + return arb; +} +``` + +### Match/Transform Pattern + +- **Separate matching logic** - Create `moduleNameMatch(arb, candidateFilter = () => true)` function +- **Separate transformation logic** - Create `moduleNameTransform(arb, node)` function +- **Main function orchestration** - Main function calls match, then iterates and transforms +- **Explicit arb returns** - All transform functions must return `arb` explicitly, even though the transformation can be considered a side-effect +- **Capture returned arb** - Main functions must use `arb = transformFunction(arb, node)` + +### Performance Requirements + +#### Loop Optimization +- **Traditional for loops** - Prefer `for (let i = 0; i < length; i++)` over `for..of` or `for..in` +- **Use 'i' variable** - Use `i` for iteration variable unless inside nested scope + +#### Memory & Allocation Optimization +- **Extract static arrays/sets** - Move static collections outside functions to avoid recreation overhead +- **Array operations** - Use `.concat()` for array concatenation and `.slice()` for array copying +- **Object cloning** - Use spread operators `{ ...obj }` for AST node cloning + +#### Static Array Guidelines +- **Small collections** - For arrays with ≀10 elements, prefer arrays over Sets for simplicity +- **Large collections** - For larger collections, consider Sets for O(1) lookup performance +- **Semantic clarity** - Choose the data structure that best represents the intent + +#### Common Patterns to Fix + +**Performance Anti-patterns**: +```javascript +// ❌ Bad - recreated every call +function someFunction() { + const types = ['Type1', 'Type2']; + const relevantNodes = [...(arb.ast[0].typeMap.NodeType || [])]; + // ... +} + +// βœ… Good - static extraction and direct access +const ALLOWED_TYPES = ['Type1', 'Type2']; +function someFunction() { + const relevantNodes = arb.ast[0].typeMap.NodeType; + // ... +} +``` + +**Structure Anti-patterns**: +```javascript +// ❌ Bad - everything mixed together +function moduleMainFunc(arb) { + // matching logic mixed with transformation logic +} + +// βœ… Good - separated concerns +export function moduleMainFuncMatch(arb) { /* matching */ } +export function moduleMainFuncTransform(arb, node) { /* transformation */ } +export default function moduleMainFunc(arb) { /* orchestration */ } +``` + +### Documentation Standards + +#### JSDoc Requirements +- **Comprehensive function docs** - All exported functions need full JSDoc +- **Specific types** - Use `{ASTNode}` and `{ASTNode[]}` instead of generic `{Object}` and `{Array}` +- **Custom object types** - Use `{Object[]}` for arrays of custom objects +- **Parameter documentation** - Document all parameters with types +- **Return value documentation** - Document what functions return +- **Algorithm explanations** - Explain complex algorithms and their purpose + +#### Inline Comments +- **NON-TRIVIAL ONLY** - Only add comments that explain complex logic and reason, never obvious statements +- **Algorithm steps** - Break down multi-step processes +- **Safety warnings** - Note any potential issues or limitations +- **Examples** - Include before/after transformation examples where helpful + +--- + +## Processor Development + +### Processor Architecture + +Processors export **preprocessors** and **postprocessors** arrays: + +```javascript +// Processor function - can be written as a single function +function myProcessorLogic(arb, candidateFilter = () => true) { + const candidates = arb.ast[0].typeMap.TargetNodeType + .concat(arb.ast[0].typeMap.AnotherTargetNodeType); + + for (let i = 0; i < candidates.length; i++) { + const node = candidates[i]; + if (matchesCriteria(node) && candidateFilter(node)) { + // Apply transformation directly + performTransformation(node); + } + } + return arb; +} + +// Processors export arrays of functions, not a default export +export const preprocessors = [myProcessorLogic]; +export const postprocessors = []; +``` + +### Development Guidelines + +1. **Follow match/transform pattern** for consistency (optional for processors) +2. **Extract static patterns** for performance +3. **Add comprehensive tests** (TP/TN cases) +4. **Document obfuscation patterns** in code comments +5. **Use performance optimizations** (typeMap access, efficient loops) + +### Testing Processors + +#### Test Structure +**NOTE**: Preprocessors and postprocessors must be applied separatelyβ€”never run preprocessors after postprocessors. Do not combine both arrays in a single `applyIteratively` call, as this would incorrectly apply preprocessors after postprocessors. + +```javascript +import assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {applyIteratively} from 'flast'; + +describe('Custom Processor Tests', async () => { + const targetProcessors = await import('./customProcessor.js'); + + it('TP-1: Should transform basic pattern', () => { + const code = `/* obfuscated pattern */`; + const expected = `/* expected result */`; + + // Apply preprocessors + let script = applyIteratively(code, targetProcessors.preprocessors); + // Apply postprocessors + script = applyIteratively(script, targetProcessors.postprocessors); + + assert.strictEqual(script, expected); + }); + + it('TN-1: Should not transform invalid pattern', () => { + const code = `/* non-matching pattern */`; + const originalScript = code; + + // Apply preprocessors + let script = applyIteratively(code, targetProcessors.preprocessors); + // Apply postprocessors + script = applyIteratively(script, targetProcessors.postprocessors); + + assert.strictEqual(script, originalScript); + }); +}); +``` + +--- + +## Code Quality + +### Naming Conventions + +- **Variable naming** - Prefer `n` over `node` for AST node variables +- **Iteration variables** - Use `i` for loop iteration unless already used in nested scope +- **Constants** - Use ALL_CAPS for static constants +- **Function names** - Clear, descriptive names that indicate purpose + +### Error Handling + +- **Input validation** - Add appropriate null/undefined checks +- **Infinite loop protection** - Implement safeguards for recursive operations +- **Graceful degradation** - Handle edge cases without breaking functionality + +### Memory Management + +- **Cache management** - Implement appropriate caching strategies +- **Static extractions** - Extract static arrays/sets outside functions +- **Efficient data structures** - Use Sets for large collections, arrays for small ones + +--- + +## Testing Guidelines + +### Test Categories + +- **TP (True Positive)** - Cases where transformation should occur +- **TN (True Negative)** - Cases where transformation should NOT occur +- **Edge Cases** - Boundary conditions and unusual inputs +- **Different operand types** - Test all relevant AST node types as operands + +### Test Organization + +- **Clear naming** - Use descriptive test names that explain what's being tested +- **Comprehensive scenarios** - Cover simple cases, complex cases, and edge cases +- **Proper assertions** - Ensure expected results match actual behavior + +### Running Tests + +- **Full test suite** - Always run complete test suite +- **Review all output** - Changes to one module could affect other parts of the system +- **Watch for regressions** - Ensure no existing functionality is broken + +--- + +## Documentation + +### JSDoc Requirements + +- **Function documentation** - All exported functions need comprehensive JSDoc +- **Type specifications** - Use specific types like `{ASTNode}` instead of generic `{Object}` +- **Parameter descriptions** - Document all parameters with types and purpose +- **Return documentation** - Clearly describe what functions return +- **Examples** - Include usage examples for complex functions + +### README Updates + +- **Keep documentation current** - Update relevant READMEs when adding new features +- **Add examples** - Include practical usage examples +- **Link to related documentation** - Reference other relevant docs + +--- + +## Submission Guidelines + +### Pull Request Process + +1. **Fork the repository** +2. **Create a feature branch**: `git checkout -b feature-name` +3. **Make changes** following the coding standards outlined above +4. **Add comprehensive tests** for new functionality +5. **Update documentation** as needed +6. **Run the full test suite**: `npm test` +7. **Submit a pull request** with a clear description + +### Review Checklist + +#### Code Review (for Modules): +- [ ] Identify and fix any bugs +- [ ] Split into match/transform functions +- [ ] Extract static arrays/sets outside functions +- [ ] Use traditional for loops with `i` variable +- [ ] Add comprehensive JSDoc documentation with specific types +- [ ] Add non-trivial inline comments only (avoid obvious comments) +- [ ] Ensure explicit `arb` returns +- [ ] Use `arb = transform(arb, node)` pattern + +#### Test Review (for Modules): +- [ ] Review existing tests for relevance and correctness +- [ ] Identify missing test cases +- [ ] Add positive test cases (TP) +- [ ] Add negative test cases (TN) +- [ ] Add edge case tests +- [ ] Ensure test names are descriptive +- [ ] Verify expected results match actual behavior + +#### For Processors: +- [ ] Exports `preprocessors` and `postprocessors` arrays +- [ ] Follows architectural patterns (when applicable) +- [ ] Comprehensive test coverage added +- [ ] JSDoc documentation for all functions +- [ ] Performance optimizations implemented +- [ ] Integration tests with main pipeline + +#### General Requirements: +- [ ] All tests pass without failures +- [ ] No regressions in existing functionality +- [ ] Code follows project style guidelines +- [ ] Documentation is updated appropriately + +## Success Criteria + +A successfully refactored module should: +1. **Function identically** to the original (all tests pass) +2. **Have better structure** (match/transform separation) +3. **Perform better** (optimized loops, static extractions) +4. **Be well documented** (comprehensive JSDoc and comments) +5. **Have comprehensive tests** (positive, negative, edge cases) +6. **Follow established patterns** (consistent with other refactored modules) +- [ ] Commit messages are clear and descriptive + +### Commit Message Guidelines + +- **Focus on changes** - Describe what was changed, improved, or added +- **Be concise** - Keep commit messages focused and descriptive + +--- + +## Getting Help + +- πŸ’¬ **GitHub Issues** - Ask questions or report issues +- 🐦 **Twitter / X** - Reach out to Ben Baryo [@ctrl__esc](https://twitter.com/ctrl__esc) +- πŸ“– **Documentation** - Check the [main README](README.md) and [processors guide](src/processors/README.md) + +--- + +## Resources + +- πŸ” [Obfuscation Detector](https://github.com/HumanSecurity/obfuscation-detector) - Pattern recognition system +- 🌳 [flAST Documentation](https://github.com/HumanSecurity/flast) - AST manipulation utilities +- πŸ“– [Main README](README.md) - Complete project documentation +- πŸ“– [Processors Guide](src/processors/README.md) - Detailed processor documentation + +--- + +**Made with ❀️ by [HUMAN Security](https://www.HumanSecurity.com/)** diff --git a/package-lock.json b/package-lock.json index 827b58d..9741578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,36 +9,22 @@ "version": "2.0.8", "license": "MIT", "dependencies": { + "commander": "^14.0.2", "flast": "2.2.5", - "isolated-vm": "^5.0.3", - "obfuscation-detector": "^2.0.5" + "isolated-vm": "5.0.4", + "obfuscation-detector": "^2.0.7" }, "bin": { "restringer": "bin/deobfuscate.js" }, "devDependencies": { - "@babel/eslint-parser": "^7.25.9", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "eslint": "^9.16.0", - "globals": "^15.13.0", + "@babel/eslint-parser": "^7.28.5", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "eslint": "^9.39.1", + "globals": "^16.5.0", "husky": "^9.1.7" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -56,9 +42,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "dev": true, "license": "MIT", "peer": true, @@ -67,23 +53,23 @@ } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -99,9 +85,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.25.9.tgz", - "integrity": "sha512-5UXfgpK0j0Xr/xIdgdLEhOFxaDZ0bRPWJJchRpqOSur/3rZoPbqqki5mm0p4NE2cs28krBEiSM2MB7//afRSQQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.5.tgz", + "integrity": "sha512-fcdRcWahONYo+JRnJg1/AekOacGvKx12Gu0qXJXFi2WBqQA1i7+O5PaxRB7kxE/Op94dExnCiiar6T09pvdHpA==", "dev": true, "license": "MIT", "dependencies": { @@ -118,17 +104,17 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -136,15 +122,15 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -153,32 +139,43 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -188,9 +185,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { @@ -209,9 +206,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "peer": true, @@ -220,9 +217,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "peer": true, @@ -231,29 +228,29 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", - "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -263,13 +260,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", - "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -295,55 +292,44 @@ } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -373,9 +359,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -383,13 +369,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -398,19 +384,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -458,9 +447,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", - "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "dev": true, "license": "MIT", "engines": { @@ -471,9 +460,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -481,13 +470,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -505,33 +494,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -570,36 +545,33 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "peer": true, - "engines": { - "node": ">=6.0.0" + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "peer": true, @@ -608,17 +580,17 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "peer": true, @@ -638,9 +610,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -739,6 +711,17 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -762,9 +745,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", - "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", "dev": true, "funding": [ { @@ -783,10 +766,11 @@ "license": "MIT", "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" @@ -830,9 +814,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001690", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", - "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "dev": true, "funding": [ { @@ -894,6 +878,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -925,9 +918,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -973,26 +966,26 @@ "license": "MIT" }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/electron-to-chromium": { - "version": "1.5.75", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz", - "integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==", + "version": "1.5.260", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz", + "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==", "dev": true, "license": "ISC", "peer": true }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -1092,25 +1085,24 @@ } }, "node_modules/eslint": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", - "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.33.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -1431,9 +1423,9 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -1474,9 +1466,9 @@ } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -1612,9 +1604,9 @@ "license": "ISC" }, "node_modules/isolated-vm": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-5.0.3.tgz", - "integrity": "sha512-GNqX0j7dkwdaNQfFogLLb/tSuPZbXtKlk5ldaJ084ngjaW9/bn34x9FQFL856p20KSZoubIIummmiJf+2hzhCw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-5.0.4.tgz", + "integrity": "sha512-RYUf/JC4ldWz/oi2BVs8a1XIprQ71q6eQPBwySaF5Apu0KMyf2gIpElbCyPh2OEmRT+FYw1GOKSdkv7jw2KLxw==", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -1633,9 +1625,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -1800,9 +1792,9 @@ "license": "MIT" }, "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, "node_modules/natural-compare": { @@ -1813,9 +1805,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "3.71.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.71.0.tgz", - "integrity": "sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==", + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -1825,9 +1817,9 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1837,20 +1829,20 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT", "peer": true }, "node_modules/obfuscation-detector": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/obfuscation-detector/-/obfuscation-detector-2.0.5.tgz", - "integrity": "sha512-g4VLMZronO5ZTZUWzTY9k8YfevkT1YGp0go514WStGAvHSQ6Yh2d9LzG1q4rJ+goDqYo0+tPOrP/xKLxptqkUw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/obfuscation-detector/-/obfuscation-detector-2.0.7.tgz", + "integrity": "sha512-OhMPPDwx9s7SCavwK0E5Mg4zugbV9+o4NnhMgKRdds7YdvNkHaxNTkTZGpSaReaa8QHQB4UVihjdTAQ+KuQWgg==", "license": "MIT", "dependencies": { - "flast": "^2.2.1" + "flast": "^2.2.5" }, "bin": { "obfuscation-detector": "bin/obfuscation-detector.js" @@ -1957,9 +1949,9 @@ "peer": true }, "node_modules/prebuild-install": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", - "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -1967,7 +1959,7 @@ "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", + "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", @@ -1993,9 +1985,9 @@ } }, "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -2204,9 +2196,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -2257,9 +2249,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "dev": true, "funding": [ { @@ -2279,7 +2271,7 @@ "peer": true, "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" diff --git a/package.json b/package.json index 4fa52fa..cec1fa6 100644 --- a/package.json +++ b/package.json @@ -19,17 +19,20 @@ "test": "tests" }, "dependencies": { + "commander": "^14.0.2", "flast": "2.2.5", - "isolated-vm": "^5.0.3", - "obfuscation-detector": "^2.0.5" + "isolated-vm": "5.0.4", + "obfuscation-detector": "^2.0.7" }, "scripts": { "test": "node --test --trace-warnings --no-node-snapshot", - "test:coverage": "node --test --trace-warnings --no-node-snapshot --experimental-test-coverage" + "test:coverage": "node --test --trace-warnings --no-node-snapshot --experimental-test-coverage", + "test:quick": "node --test --trace-warnings --no-node-snapshot tests/functionality.test.js tests/modules.*.test.js tests/processors.test.js tests/utils.test.js tests/deobfuscation.test.js", + "test:quick:watch": "node --test --trace-warnings --no-node-snapshot --watch tests/functionality.test.js tests/modules.*.test.js tests/processors.test.js tests/utils.test.js tests/deobfuscation.test.js" }, "repository": { "type": "git", - "url": "git+https://github.com/PerimeterX/restringer.git" + "url": "git+https://github.com/HumanSecurity/restringer.git" }, "keywords": [ "obfuscation", @@ -42,14 +45,14 @@ "author": "Ben Baryo (ben.baryo@humansecurity.com)", "license": "MIT", "bugs": { - "url": "https://github.com/PerimeterX/Restringer/issues" + "url": "https://github.com/HumanSecurity/restringer/issues" }, - "homepage": "https://github.com/PerimeterX/Restringer#readme", + "homepage": "https://github.com/HumanSecurity/restringer#readme", "devDependencies": { - "@babel/eslint-parser": "^7.25.9", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "eslint": "^9.16.0", - "globals": "^15.13.0", + "@babel/eslint-parser": "^7.28.5", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "eslint": "^9.39.1", + "globals": "^16.5.0", "husky": "^9.1.7" } } diff --git a/src/modules/config.js b/src/modules/config.js index 77fb23e..e78b9dd 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,52 +1,27 @@ -// Arguments that shouldn't be touched since the context may not be inferred during deobfuscation. -const badArgumentTypes = ['ThisExpression']; - -// A string that tests true for this regex cannot be used as a variable name. -const badIdentifierCharsRegex = /([:!@#%^&*(){}[\]\\|/`'"]|[^\da-zA-Z_$])/; - // Internal value used to indicate eval failed -const badValue = '--BAD-VAL--'; +export const BAD_VALUE = '--BAD-VAL--'; // Do not repeate more than this many iterations. // Behaves like a number, but decrements each time it's used. -// Use defaultMaxIterations.value = 300 to set a new value. -const defaultMaxIterations = { +// Use DEFAULT_MAX_ITERATIONS.value = 300 to set a new value. +export const DEFAULT_MAX_ITERATIONS = { value: 500, valueOf() {return this.value--;}, }; -const propertiesThatModifyContent = [ - 'push', 'forEach', 'pop', 'insert', 'add', 'set', 'delete', 'shift', 'unshift', 'splice' -]; - -// Builtin functions that shouldn't be resolved in the deobfuscation context. -const skipBuiltinFunctions = [ - 'Function', 'eval', 'Array', 'Object', 'fetch', 'XMLHttpRequest', 'Promise', 'console', 'performance', '$', +export const PROPERTIES_THAT_MODIFY_CONTENT = [ + 'push', 'forEach', 'pop', 'insert', 'add', 'set', 'delete', 'shift', 'unshift', 'splice', + 'sort', 'reverse', 'fill', 'copyWithin' ]; // Identifiers that shouldn't be touched since they're either session-based or resolve inconsisstently. -const skipIdentifiers = [ +export const SKIP_IDENTIFIERS = [ 'window', 'this', 'self', 'document', 'module', '$', 'jQuery', 'navigator', 'typeof', 'new', 'Date', 'Math', - 'Promise', 'Error', 'fetch', 'XMLHttpRequest', 'performance', + 'Promise', 'Error', 'fetch', 'XMLHttpRequest', 'performance', 'globalThis', ]; // Properties that shouldn't be resolved since they're either based on context which can't be determined or resolve inconsistently. -const skipProperties = [ +export const SKIP_PROPERTIES = [ 'test', 'exec', 'match', 'length', 'freeze', 'call', 'apply', 'create', 'getTime', 'now', - 'getMilliseconds', ...propertiesThatModifyContent, -]; - -// A regex for a valid identifier name. -const validIdentifierBeginning = /^[A-Za-z$_]/; - -export { - badArgumentTypes, - badIdentifierCharsRegex, - badValue, - defaultMaxIterations, - propertiesThatModifyContent, - skipBuiltinFunctions, - skipIdentifiers, - skipProperties, - validIdentifierBeginning, -}; \ No newline at end of file + 'getMilliseconds', ...PROPERTIES_THAT_MODIFY_CONTENT, +]; \ No newline at end of file diff --git a/src/modules/safe/normalizeComputed.js b/src/modules/safe/normalizeComputed.js index 2360665..8b7cb09 100644 --- a/src/modules/safe/normalizeComputed.js +++ b/src/modules/safe/normalizeComputed.js @@ -1,56 +1,98 @@ -import {badIdentifierCharsRegex, validIdentifierBeginning} from '../config.js'; +// A string that tests true for this regex cannot be used as a variable name. +const BAD_IDENTIFIER_CHARS_REGEX = /([:!@#%^&*(){}[\]\\|/`'"]|[^\da-zA-Z_$])/; +// A regex for a valid identifier name. +const VALID_IDENTIFIER_BEGINNING = /^[A-Za-z$_]/; /** - * Change all member expressions and class methods which has a property which can support it - to non-computed. - * E.g. - * console['log'] -> console.log - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Find all computed member expressions, method definitions, and properties that can be converted to dot notation. + * @param {Arborist} arb An Arborist instance + * @param {Function} [candidateFilter] a filter to apply on the candidates list + * @return {ASTNode[]} Array of nodes that match the criteria for normalization */ -function normalizeComputed(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.MemberExpression || []), - ...(arb.ast[0].typeMap.MethodDefinition || []), - ...(arb.ast[0].typeMap.Property || []), - ]; - for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (n.computed && // Filter for only member expressions using bracket notation - // Ignore member expressions with properties which can't be non-computed, like arr[2] or window['!obj'] - // or those having another variable reference as their property like window[varHoldingFuncName] - (n.type === 'MemberExpression' && - n.property.type === 'Literal' && - validIdentifierBeginning.test(n.property.value) && - !badIdentifierCharsRegex.test(n.property.value)) || - /** - * Ignore the same cases for method names and object properties, for example - * class A { - * ['!hello']() {} // Can't change the name of this method - * ['miao']() {} // This can be changed to 'miao() {}' - * } - * const obj = { - * ['!hello']: 1, // Will be ignored - * ['miao']: 4 // Will be changed to 'miao: 4' - * }; - */ - (['MethodDefinition', 'Property'].includes(n.type) && - n.key.type === 'Literal' && - validIdentifierBeginning.test(n.key.value) && - !badIdentifierCharsRegex.test(n.key.value)) && +export function normalizeComputedMatch(arb, candidateFilter = () => true) { + const matchingNodes = []; + + // Process MemberExpression nodes: obj['prop'] -> obj.prop + const memberExpressions = arb.ast[0].typeMap.MemberExpression; + for (let i = 0; i < memberExpressions.length; i++) { + const n = memberExpressions[i]; + if (n.computed && + n.property.type === 'Literal' && + VALID_IDENTIFIER_BEGINNING.test(n.property.value) && + !BAD_IDENTIFIER_CHARS_REGEX.test(n.property.value) && + candidateFilter(n)) { + matchingNodes.push(n); + } + } + + // Process MethodDefinition nodes: ['method']() {} -> method() {} + const methodDefinitions = arb.ast[0].typeMap.MethodDefinition; + for (let i = 0; i < methodDefinitions.length; i++) { + const n = methodDefinitions[i]; + if (n.computed && + n.key.type === 'Literal' && + VALID_IDENTIFIER_BEGINNING.test(n.key.value) && + !BAD_IDENTIFIER_CHARS_REGEX.test(n.key.value) && candidateFilter(n)) { - const relevantProperty = n.type === 'MemberExpression' ? 'property' : 'key'; - arb.markNode(n, { - ...n, - computed: false, - [relevantProperty]: { - type: 'Identifier', - name: n[relevantProperty].value, - }, - }); + matchingNodes.push(n); } } + + // Process Property nodes: {['prop']: value} -> {prop: value}, and also {'string': value} -> {string: value} + const properties = arb.ast[0].typeMap.Property; + for (let i = 0; i < properties.length; i++) { + const n = properties[i]; + if (n.key.type === 'Literal' && + VALID_IDENTIFIER_BEGINNING.test(n.key.value) && + !BAD_IDENTIFIER_CHARS_REGEX.test(n.key.value) && + candidateFilter(n)) { + matchingNodes.push(n); + } + } + + return matchingNodes; +} + +/** + * Transform a computed property access node to use dot notation. + * @param {Arborist} arb + * @param {Object} n The AST node to transform + * @return {Arborist} + */ +export function normalizeComputedTransform(arb, n) { + const relevantProperty = n.type === 'MemberExpression' ? 'property' : 'key'; + arb.markNode(n, { + ...n, + computed: false, + [relevantProperty]: { + type: 'Identifier', + name: n[relevantProperty].value, + }, + }); return arb; } -export default normalizeComputed; \ No newline at end of file +/** + * Convert computed property access to dot notation where the property is a valid identifier. + * This normalizes bracket notation to more readable dot notation. + * + * Transforms: + * console['log'] -> console.log + * obj['methodName']() -> obj.methodName() + * {['propName']: value} -> {propName: value} + * + * Only applies to string literals that form valid JavaScript identifiers + * (start with letter/$/_, contain only alphanumeric/_/$ characters). + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list + * @return {Arborist} + */ +export default function normalizeComputed(arb, candidateFilter = () => true) { + const matchingNodes = normalizeComputedMatch(arb, candidateFilter); + + for (let i = 0; i < matchingNodes.length; i++) { + arb = normalizeComputedTransform(arb, matchingNodes[i]); + } + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/normalizeEmptyStatements.js b/src/modules/safe/normalizeEmptyStatements.js index dc24d7f..e8e3039 100644 --- a/src/modules/safe/normalizeEmptyStatements.js +++ b/src/modules/safe/normalizeEmptyStatements.js @@ -1,23 +1,69 @@ +// Control flow statement types where empty statements must be preserved as statement bodies +const CONTROL_FLOW_STATEMENT_TYPES = ['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement', 'IfStatement']; + /** - * Remove unrequired empty statements. + * Find all empty statements that can be safely removed. * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {ASTNode[]} Array of empty statement nodes that can be safely removed */ -function normalizeEmptyStatements(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.EmptyStatement || []), - ]; +export function normalizeEmptyStatementsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.EmptyStatement; + + const matchingNodes = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; if (candidateFilter(n)) { - // A loop can be used to execute code even without providing a loop body, just an empty statement. + // Control flow statements can have empty statements as their body. // If we delete that empty statement the syntax breaks - // e.g. for (var i = 0, b = 8;;); - this is a valid for statement. - if (!/(For.*|(Do)?While)Statement/.test(n.parentNode.type)) arb.markNode(n); + // e.g. for (var i = 0, b = 8;;); - valid for statement + // e.g. if (condition); - valid if statement with empty consequent + if (!CONTROL_FLOW_STATEMENT_TYPES.includes(n.parentNode.type)) { + matchingNodes.push(n); + } } } + return matchingNodes; +} + +/** + * Remove an empty statement node. + * @param {Arborist} arb + * @param {Object} node The empty statement node to remove + * @return {Arborist} + */ +export function normalizeEmptyStatementsTransform(arb, node) { + arb.markNode(node); return arb; } -export default normalizeEmptyStatements; \ No newline at end of file +/** + * Remove empty statements that are not required for syntax correctness. + * + * Empty statements (just semicolons) can be safely removed in most contexts, + * but must be preserved in control flow statements where they serve as the statement body: + * - for (var i = 0; i < 10; i++); // The semicolon is the empty loop body + * - while (condition); // The semicolon is the empty loop body + * - if (condition); else;// The semicolon is the empty if consequent and the empty else alternate + * + * Safe to remove: + * - Standalone empty statements: "var x = 1;;" + * - Empty statements in blocks: "if (true) {;}" + * + * Must preserve: + * - Control flow body empty statements: "for(;;);", "while(true);", "if(condition);" + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {Arborist} + */ +export default function normalizeEmptyStatements(arb, candidateFilter = () => true) { + const matchingNodes = normalizeEmptyStatementsMatch(arb, candidateFilter); + + for (let i = 0; i < matchingNodes.length; i++) { + arb = normalizeEmptyStatementsTransform(arb, matchingNodes[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/parseTemplateLiteralsIntoStringLiterals.js b/src/modules/safe/parseTemplateLiteralsIntoStringLiterals.js index 8ff7a1e..df27457 100644 --- a/src/modules/safe/parseTemplateLiteralsIntoStringLiterals.js +++ b/src/modules/safe/parseTemplateLiteralsIntoStringLiterals.js @@ -1,29 +1,72 @@ import {createNewNode} from '../utils/createNewNode.js'; /** - * E.g. - * `hello ${'world'}!`; // <-- will be parsed into 'hello world!' + * Find all template literals that contain only literal expressions and can be converted to string literals. * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {ASTNode[]} Array of template literal nodes that can be converted to string literals */ -function parseTemplateLiteralsIntoStringLiterals(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.TemplateLiteral || []), - ]; +export function parseTemplateLiteralsIntoStringLiteralsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.TemplateLiteral; + const matchingNodes = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + // Only process template literals where all expressions are literals (not variables or function calls) if (!n.expressions.some(exp => exp.type !== 'Literal') && candidateFilter(n)) { - let newStringLiteral = ''; - for (let j = 0; j < n.expressions.length; j++) { - newStringLiteral += n.quasis[j].value.raw + n.expressions[j].value; - } - newStringLiteral += n.quasis.slice(-1)[0].value.raw; - arb.markNode(n, createNewNode(newStringLiteral)); + matchingNodes.push(n); } } + return matchingNodes; +} + +/** + * Convert a template literal with only literal expressions into a plain string literal. + * @param {Arborist} arb + * @param {Object} node The template literal node to transform + * @return {Arborist} + */ +export function parseTemplateLiteralsIntoStringLiteralsTransform(arb, node) { + // Template literals have alternating quasis (string parts) and expressions + // e.g. `hello ${name}!` has quasis=["hello ", "!"] and expressions=[name] + // The build process is: quasi[0] + expr[0] + quasi[1] + expr[1] + ... + final_quasi + let newStringLiteral = ''; + + // Process all expressions, adding the preceding quasi each time + for (let i = 0; i < node.expressions.length; i++) { + newStringLiteral += node.quasis[i].value.raw + node.expressions[i].value; + } + + // Add the final quasi (there's always one more quasi than expressions) + newStringLiteral += node.quasis.slice(-1)[0].value.raw; + + arb.markNode(node, createNewNode(newStringLiteral)); return arb; } -export default parseTemplateLiteralsIntoStringLiterals; \ No newline at end of file +/** + * Convert template literals that contain only literal expressions into regular string literals. + * This simplifies expressions by replacing template syntax with plain strings when no dynamic content exists. + * + * Transforms: + * `hello ${'world'}!` -> 'hello world!' + * `static ${42} text` -> 'static 42 text' + * `just text` -> 'just text' + * + * Only processes template literals where all interpolated expressions are literals (strings, numbers, booleans), + * not variables or function calls which could change at runtime. + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {Arborist} + */ +export default function parseTemplateLiteralsIntoStringLiterals(arb, candidateFilter = () => true) { + const matchingNodes = parseTemplateLiteralsIntoStringLiteralsMatch(arb, candidateFilter); + + for (let i = 0; i < matchingNodes.length; i++) { + arb = parseTemplateLiteralsIntoStringLiteralsTransform(arb, matchingNodes[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/rearrangeSequences.js b/src/modules/safe/rearrangeSequences.js index 231471f..e0cfcb9 100644 --- a/src/modules/safe/rearrangeSequences.js +++ b/src/modules/safe/rearrangeSequences.js @@ -1,65 +1,111 @@ /** - * Moves up all expressions except the last one of a returned sequence or in an if statement. - * E.g. return a(), b(); -> a(); return b(); - * if (a(), b()); -> a(); if (b()); + * Find all return statements and if statements that contain sequence expressions. * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {ASTNode[]} Array of nodes with sequence expressions that can be rearranged */ -function rearrangeSequences(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.ReturnStatement || []), - ...(arb.ast[0].typeMap.IfStatement || []), - ]; +export function rearrangeSequencesMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.ReturnStatement + .concat(arb.ast[0].typeMap.IfStatement); + const matchingNodes = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (( - n.type === 'ReturnStatement' && n.argument?.type === 'SequenceExpression' || - n.type === 'IfStatement' && n.test.type === 'SequenceExpression' - ) && candidateFilter(n)) { - const parent = n.parentNode; - const { expressions } = n.argument || n.test; + // Check if node has a sequence expression that can be rearranged + const hasSequenceExpression = + (n.type === 'ReturnStatement' && n.argument?.type === 'SequenceExpression') || + (n.type === 'IfStatement' && n.test?.type === 'SequenceExpression'); + + if (hasSequenceExpression && candidateFilter(n)) { + matchingNodes.push(n); + } + } + return matchingNodes; +} + +/** + * Transform a statement with sequence expressions by extracting all but the last expression + * into separate expression statements. + * @param {Arborist} arb + * @param {Object} node The statement node to transform + * @return {Arborist} + */ +export function rearrangeSequencesTransform(arb, node) { + const parent = node.parentNode; + // Get the sequence expression from either return argument or if test + const sequenceExpression = node.argument || node.test; + const { expressions } = sequenceExpression; - const statements = expressions.slice(0, -1).map(e => ({ - type: 'ExpressionStatement', - expression: e - })); + // Create expression statements for all but the last expression + const extractedStatements = expressions.slice(0, -1).map(expr => ({ + type: 'ExpressionStatement', + expression: expr + })); - const replacementNode = n.type === 'IfStatement' ? { - type: 'IfStatement', - test: expressions[expressions.length - 1], - consequent: n.consequent, - alternate: n.alternate - } : { - type: 'ReturnStatement', - argument: expressions[expressions.length - 1] - }; + // Create the replacement node with only the last expression + const replacementNode = node.type === 'IfStatement' ? { + type: 'IfStatement', + test: expressions[expressions.length - 1], + consequent: node.consequent, + alternate: node.alternate + } : { + type: 'ReturnStatement', + argument: expressions[expressions.length - 1] + }; - if (parent.type === 'BlockStatement') { - const currentIdx = parent.body.indexOf(n); - const replacementParent = { - type: 'BlockStatement', - body: [ - ...parent.body.slice(0, currentIdx), - ...statements, - replacementNode, - ...parent.body.slice(currentIdx + 1) - ], - }; - arb.markNode(parent, replacementParent); - } else { - const replacementParent = { - type: 'BlockStatement', - body: [ - ...statements, - replacementNode - ] - }; - arb.markNode(n, replacementParent); - } - } + // Handle different parent contexts + if (parent.type === 'BlockStatement') { + // Insert extracted statements before the current statement in the block + const currentIdx = parent.body.indexOf(node); + const newBlockBody = [ + ...parent.body.slice(0, currentIdx), + ...extractedStatements, + replacementNode, + ...parent.body.slice(currentIdx + 1) + ]; + + arb.markNode(parent, { + type: 'BlockStatement', + body: newBlockBody, + }); + } else { + // Wrap in a new block statement if parent is not a block + arb.markNode(node, { + type: 'BlockStatement', + body: [ + ...extractedStatements, + replacementNode + ] + }); } return arb; } -export default rearrangeSequences; \ No newline at end of file +/** + * Rearrange sequence expressions in return statements and if conditions by extracting + * all expressions except the last one into separate expression statements. + * + * This improves code readability by converting: + * return a(), b(), c(); -> a(); b(); return c(); + * if (x(), y(), z()) {...} -> x(); y(); if (z()) {...} + * + * Algorithm: + * 1. Find return statements with sequence expression arguments + * 2. Find if statements with sequence expression tests + * 3. Extract all but the last expression into separate expression statements + * 4. Replace the original statement with one containing only the last expression + * 5. Handle both block statement parents and single statement contexts + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {Arborist} + */ +export default function rearrangeSequences(arb, candidateFilter = () => true) { + const matchingNodes = rearrangeSequencesMatch(arb, candidateFilter); + + for (let i = 0; i < matchingNodes.length; i++) { + arb = rearrangeSequencesTransform(arb, matchingNodes[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/rearrangeSwitches.js b/src/modules/safe/rearrangeSwitches.js index 0942aeb..4719a05 100644 --- a/src/modules/safe/rearrangeSwitches.js +++ b/src/modules/safe/rearrangeSwitches.js @@ -1,71 +1,135 @@ import {getDescendants} from '../utils/getDescendants.js'; -const maxRepetition = 50; +const MAX_REPETITION = 50; /** - * + * Find switch statements that can be linearized into sequential code. + * + * Identifies switch statements that use a discriminant variable which: + * - Is an identifier with literal initialization + * - Has deterministic flow through cases via assignments + * * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {ASTNode[]} Array of matching switch statement nodes */ -function rearrangeSwitches(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.SwitchStatement || []), - ]; +export function rearrangeSwitchesMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.SwitchStatement; + const matchingNodes = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + // Check if switch discriminant is an identifier with literal initialization if (n.discriminant.type === 'Identifier' && - n?.discriminant.declNode?.parentNode?.init?.type === 'Literal' && - candidateFilter(n)) { - let ordered = []; - const cases = n.cases; - let currentVal = n.discriminant.declNode.parentNode.init.value; - let counter = 0; - while (currentVal !== undefined && counter < maxRepetition) { - // A matching case or the default case - let currentCase; - for (let j = 0; j < cases.length; j++) { - if (cases[j].test?.value === currentVal || !cases[j].test) { - currentCase = cases[j]; - break; - } - } - if (!currentCase) break; - for (let j = 0; j < currentCase.consequent.length; j++) { - if (currentCase.consequent[j].type !== 'BreakStatement') { - ordered.push(currentCase.consequent[j]); - } - } - let allDescendants = []; - for (let j = 0; j < currentCase.consequent.length; j++) { - allDescendants.push(...getDescendants(currentCase.consequent[j])); - } - const assignments2Next = []; - for (let j = 0; j < allDescendants.length; j++) { - const d = allDescendants[j]; - if (d.declNode === n.discriminant.declNode && - d.parentKey === 'left' && - d.parentNode.type === 'AssignmentExpression') { - assignments2Next.push(d); - } - } - if (assignments2Next.length === 1) { - currentVal = assignments2Next[0].parentNode.right.value; - } else { - // TODO: Handle more complex cases - currentVal = undefined; - } - ++counter; + n?.discriminant.declNode?.parentNode?.init?.type === 'Literal' && + candidateFilter(n)) { + matchingNodes.push(n); + } + } + return matchingNodes; +} + +/** + * Transform a switch statement into a sequential block of statements. + * + * Algorithm: + * 1. Start with the initial discriminant value from variable initialization + * 2. Find the matching case (or default case) for current value + * 3. Collect all statements from that case (except break statements) + * 4. Look for assignments to the discriminant variable to find next case + * 5. Repeat until no more valid transitions found or max iterations reached + * 6. Replace switch with sequential block of collected statements + * + * @param {Arborist} arb + * @param {Object} switchNode - The switch statement node to transform + * @return {Arborist} + */ +export function rearrangeSwitchesTransform(arb, switchNode) { + const ordered = []; + const cases = switchNode.cases; + let currentVal = switchNode.discriminant.declNode.parentNode.init.value; + let counter = 0; + + // Trace execution path through switch cases + while (currentVal !== undefined && counter < MAX_REPETITION) { + // Find the matching case for current value (or default case) + let currentCase; + for (let i = 0; i < cases.length; i++) { + if (cases[i].test?.value === currentVal || !cases[i].test) { + currentCase = cases[i]; + break; } - if (ordered.length) { - arb.markNode(n, { - type: 'BlockStatement', - body: ordered, - }); + } + if (!currentCase) break; + + // Collect all statements from this case (except break statements) + for (let i = 0; i < currentCase.consequent.length; i++) { + if (currentCase.consequent[i].type !== 'BreakStatement') { + ordered.push(currentCase.consequent[i]); } } + + // Find assignments to discriminant variable to determine next case + let allDescendants = []; + for (let i = 0; i < currentCase.consequent.length; i++) { + allDescendants.push(...getDescendants(currentCase.consequent[i])); + } + + // Look for assignments to the switch discriminant variable + const assignments2Next = allDescendants.filter(d => + d.declNode === switchNode.discriminant.declNode && + d.parentKey === 'left' && + d.parentNode.type === 'AssignmentExpression' + ); + + if (assignments2Next.length === 1) { + // Single assignment found - use its value for next iteration + currentVal = assignments2Next[0].parentNode.right.value; + } else { + // Multiple or no assignments - can't determine next case reliably + currentVal = undefined; + } + ++counter; + } + + // Replace switch with sequential block if we collected any statements + if (ordered.length) { + arb.markNode(switchNode, { + type: 'BlockStatement', + body: ordered, + }); } return arb; } -export default rearrangeSwitches; \ No newline at end of file +/** + * Rearrange switch statements with deterministic flow into sequential code blocks. + * + * Converts switch statements that use a control variable to sequence operations + * into a linear sequence of statements. This is commonly seen in obfuscated code + * where a simple sequence of operations is disguised as a switch statement. + * + * Example transformation: + * var state = 0; + * switch (state) { + * case 0: doFirst(); state = 1; break; + * case 1: doSecond(); state = 2; break; + * case 2: doThird(); break; + * } + * + * Becomes: + * doFirst(); + * doSecond(); + * doThird(); + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {Arborist} + */ +export default function rearrangeSwitches(arb, candidateFilter = () => true) { + const matchingNodes = rearrangeSwitchesMatch(arb, candidateFilter); + for (let i = 0; i < matchingNodes.length; i++) { + arb = rearrangeSwitchesTransform(arb, matchingNodes[i]); + } + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/removeDeadNodes.js b/src/modules/safe/removeDeadNodes.js index aefbc84..912dda4 100644 --- a/src/modules/safe/removeDeadNodes.js +++ b/src/modules/safe/removeDeadNodes.js @@ -1,4 +1,4 @@ -const relevantParents = [ +const RELEVANT_PARENTS = [ 'VariableDeclarator', 'AssignmentExpression', 'FunctionDeclaration', @@ -6,28 +6,87 @@ const relevantParents = [ ]; /** - * Remove nodes code which is only declared but never used. - * NOTE: This is a dangerous operation which shouldn't run by default, invokations of the so-called dead code - * may be dynamically built during execution. Handle with care. + * Find identifiers that are declared but never referenced (dead code). + * + * Identifies identifiers in declaration contexts that have no references, + * indicating they are declared but never used anywhere in the code. + * * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * @param {Function} [candidateFilter] a filter to apply on the candidates list + * @return {ASTNode[]} Array of dead identifier nodes */ -function removeDeadNodes(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.Identifier || []), - ]; +function removeDeadNodesMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.Identifier; + const matchingNodes = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (relevantParents.includes(n.parentNode.type) && - (!n?.declNode?.references?.length && !n?.references?.length) && - candidateFilter(n)) { + // Check if identifier is in a declaration context and has no references + if (RELEVANT_PARENTS.includes(n.parentNode.type) && + (!n?.declNode?.references?.length && !n?.references?.length) && + candidateFilter(n)) { const parent = n.parentNode; - // Do not remove root nodes as they might be referenced in another script + // Skip root-level declarations as they might be referenced externally if (parent.parentNode.type === 'Program') continue; - arb.markNode(parent?.parentNode?.type === 'ExpressionStatement' ? parent.parentNode : parent); + matchingNodes.push(n); } } + return matchingNodes; +} + +/** + * Remove a dead code declaration node. + * + * Determines the appropriate node to remove based on the declaration type: + * - For expression statements: removes the entire expression statement + * - For other declarations: removes the declaration itself + * + * @param {Arborist} arb + * @param {Object} identifierNode - The dead identifier node + * @return {Arborist} + */ +function removeDeadNodesTransform(arb, identifierNode) { + const parent = identifierNode.parentNode; + // Remove expression statement wrapper if present, otherwise remove the declaration + const nodeToRemove = parent?.parentNode?.type === 'ExpressionStatement' + ? parent.parentNode + : parent; + arb.markNode(nodeToRemove); + return arb; +} + +/** + * Remove declared but unused code (dead code elimination). + * + * This function identifies and removes variables, functions, and classes that are + * declared but never referenced in the code. This helps clean up obfuscated code + * that may contain many unused declarations. + * + * ⚠️ **WARNING**: This is a potentially dangerous operation that should be used with caution. + * Dynamic references (e.g., `eval`, `window[varName]`) cannot be detected statically, + * so removing "dead" code might break functionality that relies on dynamic access. + * + * Algorithm: + * 1. Find all identifiers in declaration contexts (variables, functions, classes) + * 2. Check if they have any references in the AST + * 3. Skip root-level declarations (might be used by external scripts) + * 4. Remove unreferenced declarations + * + * Handles these declaration types: + * - Variable declarations: `var unused = 5;` + * - Function declarations: `function unused() {}` + * - Class declarations: `class Unused {}` + * - Assignment expressions: `unused = value;` (if unused is unreferenced) + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list + * @return {Arborist} + */ +function removeDeadNodes(arb, candidateFilter = () => true) { + const matchingNodes = removeDeadNodesMatch(arb, candidateFilter); + for (let i = 0; i < matchingNodes.length; i++) { + arb = removeDeadNodesTransform(arb, matchingNodes[i]); + } return arb; } diff --git a/src/modules/safe/removeRedundantBlockStatements.js b/src/modules/safe/removeRedundantBlockStatements.js index 0e90a0b..8786373 100644 --- a/src/modules/safe/removeRedundantBlockStatements.js +++ b/src/modules/safe/removeRedundantBlockStatements.js @@ -1,41 +1,109 @@ +// Parent types that indicate a block statement is redundant (creates unnecessary nesting) +const REDUNDANT_BLOCK_PARENT_TYPES = ['BlockStatement', 'Program']; + /** - * Remove redundant block statements which either have another block statement as their body, - * or are a direct child of the Program node. - * E.g. - * if (a) {{do_a();}} ===> if (a) {do_a();} + * Find all block statements that are redundant and can be flattened. + * + * Identifies block statements that create unnecessary nesting by being + * direct children of other block statements or the Program node. + * * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {ASTNode[]} Array of redundant block statement nodes */ -function removeRedundantBlockStatements(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.BlockStatement || []), - ]; +export function removeRedundantBlockStatementsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.BlockStatement; + const matchingNodes = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (['BlockStatement', 'Program'].includes(n.parentNode.type) && - candidateFilter(n)) { - const parent = n.parentNode; - if (parent.body?.length > 1) { - if (n.body.length === 1) arb.markNode(n, n.body[0]); - else { - const currentIdx = parent.body.indexOf(n); - const replacementNode = { - type: parent.type, - body: [ - ...parent.body.slice(0, currentIdx), - ...n.body, - ...parent.body.slice(currentIdx + 1) - ], - }; - arb.markNode(parent, replacementNode); - } - } - else arb.markNode(parent, n); - if (parent.type === 'Program') break; // No reason to continue if the root node will be replaced + // Block statements are redundant if: + // 1. Their parent is a node type that creates unnecessary nesting + // 2. They pass the candidate filter + if (REDUNDANT_BLOCK_PARENT_TYPES.includes(n.parentNode.type) && candidateFilter(n)) { + matchingNodes.push(n); + } + } + return matchingNodes; +} + +/** + * Transform a redundant block statement by flattening it into its parent. + * + * Handles three transformation scenarios: + * 1. Single child replacement: parent becomes this block + * 2. Single statement replacement: block becomes its single statement + * 3. Content flattening: block's contents spread into parent's body + * + * @param {Arborist} arb + * @param {Object} blockNode The redundant block statement node to flatten + * @return {Arborist} + */ +export function removeRedundantBlockStatementsTransform(arb, blockNode) { + const parent = blockNode.parentNode; + + // Case 1: Parent has only one child (this block) - replace parent with this block + if (parent.body?.length === 1) { + arb.markNode(parent, blockNode); + } + // Case 2: Parent has multiple children - need to flatten this block's contents + else if (parent.body?.length > 1) { + // If this block has only one statement, replace it directly + if (blockNode.body.length === 1) { + arb.markNode(blockNode, blockNode.body[0]); + } else { + // Flatten this block's contents into the parent's body + const currentIdx = parent.body.indexOf(blockNode); + const replacementNode = { + type: parent.type, + body: [ + ...parent.body.slice(0, currentIdx), + ...blockNode.body, + ...parent.body.slice(currentIdx + 1) + ], + }; + arb.markNode(parent, replacementNode); } } + return arb; } -export default removeRedundantBlockStatements; \ No newline at end of file +/** + * Remove redundant block statements by flattening unnecessarily nested blocks. + * + * This module eliminates redundant block statements that create unnecessary nesting: + * 1. Block statements that are direct children of other block statements + * 2. Block statements that are direct children of the Program node + * + * Transformations: + * if (a) {{do_a();}} β†’ if (a) {do_a();} + * if (a) {{do_a();}{do_b();}} β†’ if (a) {do_a(); do_b();} + * {{{{{statement;}}}}} β†’ statement; + * + * Algorithm: + * 1. Find all block statements whose parent is BlockStatement or Program + * 2. For each redundant block: + * - If parent has single child: replace parent with the block + * - If block has single statement: replace block with the statement + * - Otherwise: flatten block's contents into parent's body + * + * Note: Processing stops after Program node replacement since the root changes. + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {Arborist} + */ +export default function removeRedundantBlockStatements(arb, candidateFilter = () => true) { + const matchingNodes = removeRedundantBlockStatementsMatch(arb, candidateFilter); + + for (let i = 0; i < matchingNodes.length; i++) { + arb = removeRedundantBlockStatementsTransform(arb, matchingNodes[i]); + + // Stop processing if we replaced the Program node since the AST structure changed + if (matchingNodes[i].parentNode.type === 'Program') { + break; + } + } + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceBooleanExpressionsWithIf.js b/src/modules/safe/replaceBooleanExpressionsWithIf.js index 773e67f..3887ca3 100644 --- a/src/modules/safe/replaceBooleanExpressionsWithIf.js +++ b/src/modules/safe/replaceBooleanExpressionsWithIf.js @@ -1,46 +1,109 @@ +// Logical operators that can be converted to if statements +const LOGICAL_OPERATORS = ['&&', '||']; + /** - * Logical expressions which only consist of && and || will be replaced with an if statement. - * E.g. x && y(); -> if (x) y(); - * x || y(); -> if (!x) y(); + * Find all expression statements containing logical expressions that can be converted to if statements. + * + * Identifies expression statements where the expression is a logical operation (&&, ||) + * that can be converted from short-circuit evaluation to explicit if statements. + * * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {ASTNode[]} Array of expression statement nodes with logical expressions */ -function replaceBooleanExpressionsWithIf(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.ExpressionStatement || []), - ]; +export function replaceBooleanExpressionsWithIfMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.ExpressionStatement; + const matchingNodes = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (['&&', '||'].includes(n.expression.operator) && candidateFilter(n)) { - // || requires inverted logic (only execute the consequent if all operands are false) - const testExpression = - n.expression.operator === '||' - ? { - type: 'UnaryExpression', - operator: '!', - argument: n.expression.left, - } - : n.expression.left; - // wrap expression in block statement so it results in e.g. if (x) { y(); } instead of if (x) (y()); - const consequentStatement = { - type: 'BlockStatement', - body: [ - { - type: 'ExpressionStatement', - expression: n.expression.right - } - ], - }; - const ifStatement = { - type: 'IfStatement', - test: testExpression, - consequent: consequentStatement, - }; - arb.markNode(n, ifStatement); + // Check if the expression statement contains a logical expression with && or || + if (n.expression?.type === 'LogicalExpression' && + LOGICAL_OPERATORS.includes(n.expression.operator) && + candidateFilter(n)) { + matchingNodes.push(n); } } + return matchingNodes; +} + +/** + * Transform a logical expression into an if statement. + * + * Converts logical expressions using short-circuit evaluation into explicit if statements: + * - For &&: if (left) { right; } + * - For ||: if (!left) { right; } (inverted logic) + * + * The transformation preserves the original semantics where: + * - && only executes right side if left is truthy + * - || only executes right side if left is falsy + * + * @param {Arborist} arb + * @param {Object} expressionStatementNode The expression statement node to transform + * @return {Arborist} + */ +export function replaceBooleanExpressionsWithIfTransform(arb, expressionStatementNode) { + const logicalExpr = expressionStatementNode.expression; + + // For ||, we need to invert the test condition since || executes right side when left is falsy + const testExpression = logicalExpr.operator === '||' + ? { + type: 'UnaryExpression', + operator: '!', + argument: logicalExpr.left, + } + : logicalExpr.left; + + // Create the if statement with the right operand as the consequent + const ifStatement = { + type: 'IfStatement', + test: testExpression, + consequent: { + type: 'BlockStatement', + body: [{ + type: 'ExpressionStatement', + expression: logicalExpr.right + }] + }, + }; + + arb.markNode(expressionStatementNode, ifStatement); return arb; } -export default replaceBooleanExpressionsWithIf; +/** + * Replace logical expressions with equivalent if statements for better readability. + * + * This module converts short-circuit logical expressions into explicit if statements, + * making the control flow more obvious and easier to understand. + * + * Transformations: + * x && y(); β†’ if (x) { y(); } + * x || y(); β†’ if (!x) { y(); } + * a && b && c(); β†’ if (a && b) { c(); } + * a || b || c(); β†’ if (!(a || b)) { c(); } + * + * Algorithm: + * 1. Find expression statements containing logical expressions (&& or ||) + * 2. Extract the rightmost operand as the consequent action + * 3. Use the left operand(s) as the test condition + * 4. For ||, invert the test condition to preserve semantics + * 5. Wrap the consequent in a block statement for proper syntax + * + * Note: This transformation maintains the original short-circuit evaluation semantics + * where && executes the right side only if left is truthy, and || executes the right + * side only if left is falsy. + * + * @param {Arborist} arb + * @param {Function} [candidateFilter] a filter to apply on the candidates list. Defaults to true. + * @return {Arborist} + */ +export default function replaceBooleanExpressionsWithIf(arb, candidateFilter = () => true) { + const matchingNodes = replaceBooleanExpressionsWithIfMatch(arb, candidateFilter); + + for (let i = 0; i < matchingNodes.length; i++) { + arb = replaceBooleanExpressionsWithIfTransform(arb, matchingNodes[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceCallExpressionsWithUnwrappedIdentifier.js b/src/modules/safe/replaceCallExpressionsWithUnwrappedIdentifier.js index a242e91..ce8e78f 100644 --- a/src/modules/safe/replaceCallExpressionsWithUnwrappedIdentifier.js +++ b/src/modules/safe/replaceCallExpressionsWithUnwrappedIdentifier.js @@ -1,39 +1,136 @@ +// Static arrays extracted outside functions to avoid recreation overhead +const FUNCTION_EXPRESSION_TYPES = ['FunctionExpression', 'ArrowFunctionExpression']; + /** - * Calls to functions which only return an identifier will be replaced with the identifier itself. - * E.g. - * function a() {return String} - * a()(val) // <-- will be replaced with String(val) - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Find all call expressions that can be replaced with unwrapped identifiers. + * + * This function identifies call expressions where the callee is a function that + * only returns an identifier or a call expression with no arguments. Such calls + * can be safely replaced with the returned value directly. + * + * Algorithm: + * 1. Find all CallExpression nodes in the AST + * 2. Check if the callee references a function declaration or function expression + * 3. Analyze the function body to determine if it only returns an identifier + * 4. Return matching nodes for transformation + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {ASTNode[]} Array of call expression nodes that can be unwrapped */ -function replaceCallExpressionsWithUnwrappedIdentifier(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; +export function replaceCallExpressionsWithUnwrappedIdentifierMatch(arb, candidateFilter = () => true) { + // Direct access to typeMap without spread operator for better performance + const relevantNodes = arb.ast[0].typeMap.CallExpression; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (((n.callee?.declNode?.parentNode?.type === 'VariableDeclarator' && - /FunctionExpression/.test(n.callee.declNode.parentNode?.init?.type)) || - (n.callee?.declNode?.parentNode?.type === 'FunctionDeclaration' && - n.callee.declNode.parentKey === 'id')) && - candidateFilter(n)) { - const declBody = n.callee.declNode.parentNode?.init?.body || n.callee.declNode.parentNode?.body; - if (!Array.isArray(declBody)) { - // Cases where an arrow function has no block statement - if (declBody.type === 'Identifier' || (declBody.type === 'CallExpression' && !declBody.arguments.length)) { - for (const ref of n.callee.declNode.references) { - arb.markNode(ref.parentNode, declBody); - } - } else if (declBody.type === 'BlockStatement' && declBody.body.length === 1 && declBody.body[0].type === 'ReturnStatement') { - const arg = declBody.body[0].argument; - if (arg.type === 'Identifier' || (arg.type === 'CallExpression' && !arg.arguments?.length)) { - arb.markNode(n, arg); - } - } + const node = relevantNodes[i]; + + // Check if the callee references a function declaration or expression + const calleeDecl = node.callee?.declNode; + if (!calleeDecl || !candidateFilter(node)) continue; + + const parentNode = calleeDecl.parentNode; + const parentType = parentNode?.type; + + // Check if callee is from a variable declarator with function expression + const isVariableFunction = parentType === 'VariableDeclarator' && + FUNCTION_EXPRESSION_TYPES.includes(parentNode.init?.type); + + // Check if callee is from a function declaration + const isFunctionDeclaration = parentType === 'FunctionDeclaration' && + calleeDecl.parentKey === 'id'; + + if (isVariableFunction || isFunctionDeclaration) { + matches.push(node); + } + } + + return matches; +} + +/** + * Transform call expressions by replacing them with their unwrapped identifiers. + * + * This function analyzes the function body referenced by each call expression + * and replaces the call with the identifier or call expression that the function + * returns, effectively unwrapping the function shell. + * + * @param {Arborist} arb - The arborist instance to modify + * @param {Object} node - The call expression node to transform + * @return {Arborist} The modified arborist instance + */ +export function replaceCallExpressionsWithUnwrappedIdentifierTransform(arb, node) { + const calleeDecl = node.callee.declNode; + const parentNode = calleeDecl.parentNode; + + // Get the function body (either from init for expressions or body for declarations) + const declBody = parentNode.init?.body || parentNode.body; + + // Handle function bodies (arrow functions without blocks or block statements) + if (!Array.isArray(declBody)) { + // Case 1: Arrow function with direct return (no block statement) + if (isUnwrappableExpression(declBody)) { + // Mark all references to this function for replacement + for (const ref of calleeDecl.references) { + arb.markNode(ref.parentNode, declBody); } } + // Case 2: Block statement with single return statement + else if (declBody.type === 'BlockStatement' && + declBody.body.length === 1 && + declBody.body[0].type === 'ReturnStatement') { + + const returnArg = declBody.body[0].argument; + if (isUnwrappableExpression(returnArg)) { + arb.markNode(node, returnArg); + } + } + } + + return arb; +} + +/** + * Check if an expression can be safely unwrapped. + * + * An expression is unwrappable if it's: + * - An identifier (variable reference) + * - A call expression with no arguments + * + * @param {Object} expr - The expression node to check + * @return {boolean} True if the expression can be unwrapped + */ +function isUnwrappableExpression(expr) { + return expr.type === 'Identifier' || + (expr.type === 'CallExpression' && !expr.arguments?.length); +} + +/** + * Replace call expressions with unwrapped identifiers when the called function + * only returns an identifier or parameterless call expression. + * + * This transformation removes unnecessary function wrappers that only return + * simple values, effectively flattening the call chain for better readability + * and potential performance improvements. + * + * Examples: + * - function a() {return String} a()(val) β†’ String(val) + * - const b = () => btoa; b()('data') β†’ btoa('data') + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified arborist instance + */ +function replaceCallExpressionsWithUnwrappedIdentifier(arb, candidateFilter = () => true) { + // Find all matching call expressions + const matches = replaceCallExpressionsWithUnwrappedIdentifierMatch(arb, candidateFilter); + + // Transform each matching node + for (let i = 0; i < matches.length; i++) { + arb = replaceCallExpressionsWithUnwrappedIdentifierTransform(arb, matches[i]); } + return arb; } diff --git a/src/modules/safe/replaceEvalCallsWithLiteralContent.js b/src/modules/safe/replaceEvalCallsWithLiteralContent.js index d841bf6..485d744 100644 --- a/src/modules/safe/replaceEvalCallsWithLiteralContent.js +++ b/src/modules/safe/replaceEvalCallsWithLiteralContent.js @@ -3,66 +3,173 @@ import {generateFlatAST, logger} from 'flast'; import {generateHash} from '../utils/generateHash.js'; /** - * Extract string values of eval call expressions, and replace calls with the actual code, without running it through eval. - * E.g. - * eval('console.log("hello world")'); // <-- will be replaced with console.log("hello world"); - * eval('a(); b();'); // <-- will be replaced with '{a(); b();}' - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Parse the string argument of an eval call into an AST node. + * + * This function takes the string content from an eval() call and converts it + * into the appropriate AST representation, handling single statements, + * multiple statements, and expression statements appropriately. + * + * @param {string} code - The code string to parse + * @return {ASTNode} The parsed AST node */ -function replaceEvalCallsWithLiteralContent(arb, candidateFilter = () => true) { - const cache = getCache(arb.ast[0].scriptHash); - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; +function parseEvalArgument(code) { + let body = generateFlatAST(code, {detailed: false, includeSrc: false})[0].body; + + // Multiple statements become a block statement + if (body.length > 1) { + return { + type: 'BlockStatement', + body, + }; + } + + // Single statement processing + body = body[0]; + + // Unwrap expression statements to just the expression when appropriate + if (body.type === 'ExpressionStatement') { + body = body.expression; + } + + return body; +} + +/** + * Handle replacement when eval is used as a callee in a call expression. + * + * This handles the edge case where eval returns a function that is immediately + * called, such as eval('Function')('alert("hacked!")'). + * + * @param {ASTNode} evalNode - The original eval call node + * @param {ASTNode} replacementNode - The parsed replacement AST node + * @return {ASTNode} The modified call expression with eval replaced + */ +function handleCalleeReplacement(evalNode, replacementNode) { + // Unwrap expression statement if needed + if (replacementNode.type === 'ExpressionStatement') { + replacementNode = replacementNode.expression; + } + + // Create new call expression with eval replaced by the parsed content + return { + ...evalNode.parentNode, + callee: replacementNode + }; +} + +/** + * Find all eval call expressions that can be replaced with their literal content. + * + * This function identifies eval() calls where the argument is a string literal + * that can be safely parsed and replaced with the actual AST nodes without + * executing the eval. + * + * Algorithm: + * 1. Find all CallExpression nodes in the AST + * 2. Check if callee is 'eval' and first argument is a string literal + * 3. Apply candidate filter for additional constraints + * 4. Return matching nodes for transformation + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {ASTNode[]} Array of eval call expression nodes that can be replaced + */ +export function replaceEvalCallsWithLiteralContentMatch(arb, candidateFilter = () => true) { + // Direct access to typeMap without spread operator for better performance + const relevantNodes = arb.ast[0].typeMap.CallExpression; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (n.callee?.name === 'eval' && - n.arguments[0]?.type === 'Literal' && - candidateFilter(n)) { - const cacheName = `replaceEval-${generateHash(n.src)}`; - try { - if (!cache[cacheName]) { - let body; - body = generateFlatAST(n.arguments[0].value, {detailed: false, includeSrc: false})[0].body; - if (body.length > 1) { - body = { - type: 'BlockStatement', - body, - }; - } else { - body = body[0]; - if (body.type === 'ExpressionStatement') body = body.expression; - } - cache[cacheName] = body; - } - let replacementNode = cache[cacheName]; - let targetNode = n; - // Edge case where the eval call renders an identifier which is then used in a call expression: - // eval('Function')('alert("hacked!")'); - if (n.parentKey === 'callee') { - targetNode = n.parentNode; - if (replacementNode.type === 'ExpressionStatement') { - replacementNode = replacementNode.expression; - } - replacementNode = {...n.parentNode, callee: replacementNode}; - } - if (targetNode.parentNode.type === 'ExpressionStatement' && replacementNode.type === 'BlockStatement') { - targetNode = targetNode.parentNode; - } - // Edge case where the eval call renders an expression statement which is then used as an expression: - // console.log(eval('1;')) --> console.log(1) - if (targetNode.parentNode.type !== 'ExpressionStatement' && replacementNode.type === 'ExpressionStatement') { - replacementNode = replacementNode.expression; - } - arb.markNode(targetNode, replacementNode); - } catch (e) { - logger.debug(`[-] Unable to replace eval's body with call expression: ${e}`); - } + const node = relevantNodes[i]; + + // Check if this is an eval call with a literal string argument + if (node.callee?.name === 'eval' && + node.arguments[0]?.type === 'Literal' && + candidateFilter(node)) { + matches.push(node); + } + } + + return matches; +} + +/** + * Transform eval call expressions by replacing them with their parsed content. + * + * This function takes an eval() call with a string literal and replaces it with + * the actual AST nodes that the string represents. It handles various edge cases + * including block statements, expression statements, and nested call expressions. + * + * @param {Arborist} arb - The arborist instance to modify + * @param {ASTNode} node - The eval call expression node to transform + * @return {Arborist} The modified arborist instance + */ +export function replaceEvalCallsWithLiteralContentTransform(arb, node) { + const cache = getCache(arb.ast[0].scriptHash); + const cacheName = `replaceEval-${generateHash(node.src)}`; + + try { + // Generate or retrieve cached AST for the eval argument + if (!cache[cacheName]) { + cache[cacheName] = parseEvalArgument(node.arguments[0].value); + } + + let replacementNode = cache[cacheName]; + let targetNode = node; + + // Handle edge case: eval used as callee in call expression + // Example: eval('Function')('alert("hacked!")'); + if (node.parentKey === 'callee') { + targetNode = node.parentNode; + replacementNode = handleCalleeReplacement(node, replacementNode); } + + // Handle block statement placement + if (targetNode.parentNode.type === 'ExpressionStatement' && + replacementNode.type === 'BlockStatement') { + targetNode = targetNode.parentNode; + } + + // Handle expression statement unwrapping + // Example: console.log(eval('1;')) β†’ console.log(1) + if (targetNode.parentNode.type !== 'ExpressionStatement' && + replacementNode.type === 'ExpressionStatement') { + replacementNode = replacementNode.expression; + } + + arb.markNode(targetNode, replacementNode); + } catch (e) { + logger.debug(`[-] Unable to replace eval's body with call expression: ${e}`); } + return arb; } -export default replaceEvalCallsWithLiteralContent; \ No newline at end of file +/** + * Replace eval call expressions with their literal content without executing eval. + * + * This transformation safely replaces eval() calls that contain string literals + * with the actual AST nodes that the strings represent. This improves code + * readability and removes the security concerns associated with eval. + * + * Examples: + * - eval('console.log("hello")') β†’ console.log("hello") + * - eval('a; b;') β†’ {a; b;} + * - console.log(eval('1;')) β†’ console.log(1) + * - eval('Function')('code') β†’ Function('code') + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified arborist instance + */ +export default function replaceEvalCallsWithLiteralContent(arb, candidateFilter = () => true) { + // Find all matching eval call expressions + const matches = replaceEvalCallsWithLiteralContentMatch(arb, candidateFilter); + + // Transform each matching node + for (let i = 0; i < matches.length; i++) { + arb = replaceEvalCallsWithLiteralContentTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceFunctionShellsWithWrappedValue.js b/src/modules/safe/replaceFunctionShellsWithWrappedValue.js index 81bd1e1..cea1090 100644 --- a/src/modules/safe/replaceFunctionShellsWithWrappedValue.js +++ b/src/modules/safe/replaceFunctionShellsWithWrappedValue.js @@ -1,28 +1,111 @@ +const RETURNABLE_TYPES = ['Literal', 'Identifier']; + /** - * Functions which only return a single literal or identifier will have their references replaced with the actual return value. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Find all function declarations that only return a simple literal or identifier. + * + * This function identifies function declarations that act as "shells" around simple + * values, containing only a single return statement that returns either a literal + * or an identifier. Such functions can be optimized by replacing calls to them + * with their return values directly. + * + * Algorithm: + * 1. Find all function declarations in the AST + * 2. Check if function body contains exactly one return statement + * 3. Verify the return argument is a literal or identifier + * 4. Apply candidate filter for additional constraints + * 5. Return matching function declaration nodes + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {ASTNode[]} Array of function declaration nodes that can be replaced */ -function replaceFunctionShellsWithWrappedValue(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.FunctionDeclaration || []), - ]; +export function replaceFunctionShellsWithWrappedValueMatch(arb, candidateFilter = () => true) { + // Direct access to typeMap without spread operator for better performance + const relevantNodes = arb.ast[0].typeMap.FunctionDeclaration; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (n.body.body?.[0]?.type === 'ReturnStatement' && - ['Literal', 'Identifier'].includes(n.body.body[0]?.argument?.type) && - candidateFilter(n)) { - const replacementNode = n.body.body[0].argument; - for (const ref of (n.id?.references || [])) { - // Make sure the function is called and not just referenced in another call expression - if (ref.parentNode.type === 'CallExpression' && ref.parentNode.callee === ref) { - arb.markNode(ref.parentNode, replacementNode); - } - } + const node = relevantNodes[i]; + + // Check if function has exactly one return statement with simple argument + if (node.body.body?.[0]?.type === 'ReturnStatement' && + RETURNABLE_TYPES.includes(node.body.body[0]?.argument?.type) && + candidateFilter(node)) { + matches.push(node); } } + + return matches; +} + +/** + * Transform function shell calls by replacing them with their wrapped values. + * + * This function replaces call expressions to function shells with the actual + * values they return. It only transforms actual function calls (not references) + * to ensure the transformation maintains the original semantics. + * + * Safety considerations: + * - Only replaces call expressions where the function is the callee + * - Preserves function references that are not called + * - Maintains original execution semantics + * + * @param {Arborist} arb - The arborist instance to modify + * @param {Object} node - The function declaration node to process + * @return {Arborist} The modified arborist instance + */ +export function replaceFunctionShellsWithWrappedValueTransform(arb, node) { + // Extract the return value from the function body + const replacementNode = node.body.body[0].argument; + + // Process all references to this function + for (const ref of (node.id?.references || [])) { + // Only replace call expressions, not function references + // This ensures we don't break code that passes the function around + if (ref.parentNode.type === 'CallExpression' && ref.parentNode.callee === ref) { + arb.markNode(ref.parentNode, replacementNode); + } + } + return arb; } -export default replaceFunctionShellsWithWrappedValue; \ No newline at end of file +/** + * Replace function shells with their wrapped values for optimization. + * + * This module identifies and optimizes "function shells" - functions that serve + * no purpose other than wrapping a simple literal or identifier value. Such + * functions are common in obfuscated code where simple values are hidden + * behind function calls. + * + * Transformations: + * function a() { return 42; } β†’ (calls to a() become 42) + * function b() { return String; } β†’ (calls to b() become String) + * function c() { return x; } β†’ (calls to c() become x) + * + * Safety features: + * - Only processes functions with exactly one return statement + * - Only replaces function calls, not function references + * - Preserves functions passed as arguments or assigned to properties + * - Only handles simple return types (literals and identifiers) + * + * Performance benefits: + * - Eliminates unnecessary function call overhead + * - Reduces code size by removing wrapper functions + * - Improves readability by exposing actual values + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified arborist instance + */ +export default function replaceFunctionShellsWithWrappedValue(arb, candidateFilter = () => true) { + // Find all matching function declaration nodes + const matches = replaceFunctionShellsWithWrappedValueMatch(arb, candidateFilter); + + // Transform each matching function by replacing its calls + for (let i = 0; i < matches.length; i++) { + arb = replaceFunctionShellsWithWrappedValueTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceFunctionShellsWithWrappedValueIIFE.js b/src/modules/safe/replaceFunctionShellsWithWrappedValueIIFE.js index e1a967f..5831d1a 100644 --- a/src/modules/safe/replaceFunctionShellsWithWrappedValueIIFE.js +++ b/src/modules/safe/replaceFunctionShellsWithWrappedValueIIFE.js @@ -1,24 +1,114 @@ +// Static arrays extracted outside functions to avoid recreation overhead +const RETURNABLE_TYPES = ['Literal', 'Identifier']; + /** - * Functions which only return a single literal or identifier will have their references replaced with the actual return value. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Find all IIFE function expressions that only return a simple literal or identifier. + * + * This function identifies Immediately Invoked Function Expressions (IIFEs) that act + * as "shells" around simple values. These are function expressions that are immediately + * called with no arguments and contain only a single return statement returning either + * a literal or an identifier. + * + * Algorithm: + * 1. Find all function expressions in the AST + * 2. Check if they are used as callees (IIFE pattern) + * 3. Verify the call has no arguments + * 4. Check if function body contains exactly one return statement + * 5. Verify the return argument is a literal or identifier + * 6. Apply candidate filter for additional constraints + * 7. Return matching function expression nodes + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {ASTNode[]} Array of function expression nodes that can be replaced */ -function replaceFunctionShellsWithWrappedValueIIFE(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.FunctionExpression || []), - ]; +export function replaceFunctionShellsWithWrappedValueIIFEMatch(arb, candidateFilter = () => true) { + // Direct access to typeMap without spread operator for better performance + const relevantNodes = arb.ast[0].typeMap.FunctionExpression; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (n.parentKey === 'callee' && - !n.parentNode.arguments.length && - n.body.body?.[0]?.type === 'ReturnStatement' && - ['Literal', 'Identifier'].includes(n.body.body[0].argument?.type) && - candidateFilter(n)) { - arb.markNode(n.parentNode, n.parentNode.callee.body.body[0].argument); + const node = relevantNodes[i]; + + // Optimized condition ordering: cheapest checks first for better performance + // Also added safety checks to prevent potential runtime errors + if (candidateFilter(node) && + node.parentKey === 'callee' && + node.parentNode && + !node.parentNode.arguments.length && + node.body?.body?.[0]?.type === 'ReturnStatement' && + RETURNABLE_TYPES.includes(node.body.body[0].argument?.type)) { + matches.push(node); } } + + return matches; +} + +/** + * Transform IIFE function shells by replacing them with their wrapped values. + * + * This function replaces Immediately Invoked Function Expression (IIFE) calls + * that only return simple values with the actual values themselves. This removes + * the overhead of function creation and invocation for simple value wrapping. + * + * The transformation changes patterns like (function(){return value})() to just value. + * + * @param {Arborist} arb - The arborist instance to modify + * @param {Object} node - The function expression node to process + * @return {Arborist} The modified arborist instance + */ +export function replaceFunctionShellsWithWrappedValueIIFETransform(arb, node) { + // Extract the return value from the function body + const replacementNode = node.body.body[0].argument; + + // Replace the entire IIFE call expression with the return value + // node.parentNode is the call expression (function(){...})(), we replace it with just the value + arb.markNode(node.parentNode, replacementNode); + return arb; } -export default replaceFunctionShellsWithWrappedValueIIFE; \ No newline at end of file +/** + * Replace IIFE function shells with their wrapped values for optimization. + * + * This module identifies and optimizes Immediately Invoked Function Expression (IIFE) + * "shells" - function expressions that are immediately called with no arguments and + * serve no purpose other than wrapping a simple literal or identifier value. Such + * patterns are common in obfuscated code where simple values are hidden behind + * function calls. + * + * Transformations: + * (function() { return 42; })() β†’ 42 + * (function() { return String; })() β†’ String + * (function() { return x; })() β†’ x + * (function() { return "test"; })() β†’ "test" + * + * Safety features: + * - Only processes function expressions used as callees (IIFE pattern) + * - Only handles calls with no arguments to preserve semantics + * - Only processes functions with exactly one return statement + * - Only handles simple return types (literals and identifiers) + * - Preserves execution order and side effects + * + * Performance benefits: + * - Eliminates unnecessary function creation and invocation overhead + * - Reduces code size by removing wrapper functions + * - Improves readability by exposing actual values + * - Enables further optimization opportunities + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified arborist instance + */ +export default function replaceFunctionShellsWithWrappedValueIIFE(arb, candidateFilter = () => true) { + // Find all matching IIFE function expression nodes + const matches = replaceFunctionShellsWithWrappedValueIIFEMatch(arb, candidateFilter); + + // Transform each matching IIFE by replacing it with its return value + for (let i = 0; i < matches.length; i++) { + arb = replaceFunctionShellsWithWrappedValueIIFETransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceIdentifierWithFixedAssignedValue.js b/src/modules/safe/replaceIdentifierWithFixedAssignedValue.js index fe1316d..2f9ec3e 100644 --- a/src/modules/safe/replaceIdentifierWithFixedAssignedValue.js +++ b/src/modules/safe/replaceIdentifierWithFixedAssignedValue.js @@ -1,30 +1,124 @@ import {areReferencesModified} from '../utils/areReferencesModified.js'; /** - * When an identifier holds a static literal value, replace all references to it with the value. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Check if an identifier is a property name in an object expression. + * + * This helper function determines if an identifier node is being used as a property + * name in an object literal, which should not be replaced with its literal value + * as that would change the object's structure. + * + * @param {Object} n - The identifier node to check + * @return {boolean} True if the identifier is a property name in an object expression */ -function replaceIdentifierWithFixedAssignedValue(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.Identifier || []), - ]; +function isObjectPropertyName(n) { + return n.parentKey === 'property' && + n.parentNode?.type === 'ObjectExpression'; +} + +/** + * Find all identifiers with fixed literal assigned values that can be replaced. + * + * This function identifies identifier nodes that: + * - Have a declaration with a literal initializer (e.g., const x = 42) + * - Are not used as property names in object expressions + * - Have references that are not modified elsewhere in the code + * + * Algorithm: + * 1. Find all identifier nodes in the AST + * 2. Check if they have a declaration with a literal init value + * 3. Verify they're not object property names + * 4. Ensure their references aren't modified + * 5. Apply candidate filter for additional constraints + * 6. Return matching identifier nodes + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {ASTNode[]} Array of identifier nodes that can have their references replaced + */ +export function replaceIdentifierWithFixedAssignedValueMatch(arb, candidateFilter = () => true) { + // Direct access to typeMap without spread operator for better performance + const relevantNodes = arb.ast[0].typeMap.Identifier; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n?.declNode?.parentNode?.init?.type === 'Literal' && - !(n.parentKey === 'property' && n.parentNode.type === 'ObjectExpression') && - candidateFilter(n)) { - const valueNode = n.declNode.parentNode.init; - const refs = n.declNode.references; - if (!areReferencesModified(arb.ast, refs)) { - for (const ref of refs) { - arb.markNode(ref, valueNode); - } - } + + // Optimized condition ordering: cheapest checks first for better performance + // Added safety checks to prevent potential runtime errors + if (candidateFilter(n) && + !isObjectPropertyName(n) && + n.declNode?.parentNode?.init?.type === 'Literal' && + n.declNode.references && + !areReferencesModified(arb.ast, n.declNode.references)) { + matches.push(n); } } + + return matches; +} + +/** + * Transform identifier references by replacing them with their fixed literal values. + * + * This function replaces all references to an identifier with its literal value, + * effectively performing constant propagation optimization. It ensures that the + * original declaration and its literal value are preserved while replacing only + * the references. + * + * @param {Arborist} arb - The arborist instance to modify + * @param {Object} n - The identifier node whose references should be replaced + * @return {Arborist} The modified arborist instance + */ +export function replaceIdentifierWithFixedAssignedValueTransform(arb, n) { + // Extract the literal value from the declaration + const valueNode = n.declNode.parentNode.init; + const refs = n.declNode.references; + + // Replace all references with the literal value + // Note: We use traditional for loop for better performance + for (let i = 0; i < refs.length; i++) { + arb.markNode(refs[i], valueNode); + } + return arb; } -export default replaceIdentifierWithFixedAssignedValue; \ No newline at end of file +/** + * Replace identifier references with their fixed assigned literal values. + * + * This module performs constant propagation by identifying variables that are + * assigned literal values and never modified, then replacing all references to + * those variables with their literal values directly. This optimization improves + * code readability and enables further optimizations. + * + * Transformations: + * const x = 42; y = x + 1; β†’ const x = 42; y = 42 + 1; + * let msg = "hello"; console.log(msg); β†’ let msg = "hello"; console.log("hello"); + * var flag = true; if (flag) {...} β†’ var flag = true; if (true) {...} + * + * Safety features: + * - Only processes identifiers with literal initializers + * - Skips identifiers used as object property names to preserve structure + * - Uses reference analysis to ensure variables are never modified + * - Preserves original declaration for debugging and readability + * + * Performance benefits: + * - Eliminates variable lookups at runtime + * - Enables further optimization opportunities (dead code elimination, etc.) + * - Improves code clarity by making values explicit + * - Reduces memory usage by eliminating variable references + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified arborist instance + */ +export default function replaceIdentifierWithFixedAssignedValue(arb, candidateFilter = () => true) { + // Find all matching identifier nodes + const matches = replaceIdentifierWithFixedAssignedValueMatch(arb, candidateFilter); + + // Transform each matching identifier by replacing its references + for (let i = 0; i < matches.length; i++) { + arb = replaceIdentifierWithFixedAssignedValueTransform(arb, matches[i]); + } + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js b/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js index 3f3fe68..b97b594 100644 --- a/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js +++ b/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js @@ -1,51 +1,193 @@ import {areReferencesModified} from '../utils/areReferencesModified.js'; import {getMainDeclaredObjectOfMemberExpression} from '../utils/getMainDeclaredObjectOfMemberExpression.js'; +const FOR_STATEMENT_REGEX = /For.*Statement/; + +/** + * Check if a reference is used in a for-loop left side (iterator variable). + * + * This prevents replacement of variables that are loop iterators, such as: + * let a; for (a in obj) { ... } or for (a of arr) { ... } + * + * @param {Object} ref - The reference node to check + * @return {boolean} True if reference is a for-loop iterator + */ +function isForLoopIterator(ref) { + return FOR_STATEMENT_REGEX.test(ref.parentNode.type) && ref.parentKey === 'left'; +} + +/** + * Check if a reference is within a conditional expression context. + * + * This prevents replacement in complex conditional scenarios like: + * let a; b === c ? (b++, a = 1) : a = 2 + * where the assignment context matters for execution flow. + * + * @param {Object} ref - The reference node to check + * @return {boolean} True if reference is in conditional context + */ +function isInConditionalContext(ref) { + // Check up to 3 levels up the AST for ConditionalExpression + let currentNode = ref.parentNode; + for (let depth = 0; depth < 3 && currentNode; depth++) { + if (currentNode.type === 'ConditionalExpression') { + return true; + } + currentNode = currentNode.parentNode; + } + return false; +} + +/** + * Get the single assignment reference for an identifier. + * + * Finds the one reference that assigns a value to the identifier after + * its declaration. Returns null if there isn't exactly one assignment. + * + * @param {Object} n - The identifier node + * @return {Object|null} The assignment reference or null + */ +function getSingleAssignmentReference(n) { + if (!n.references?.length) return null; + + const assignmentRefs = n.references.filter(r => + r.parentNode.type === 'AssignmentExpression' && + getMainDeclaredObjectOfMemberExpression(r.parentNode.left) === r + ); + + return assignmentRefs.length === 1 ? assignmentRefs[0] : null; +} + /** - * When an identifier holds a static value which is assigned after declaration but doesn't change afterwards, - * replace all references to it with the value. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Find all identifiers declared without initialization that have exactly one + * literal assignment afterwards and are safe to replace. + * + * This function identifies variables that follow the pattern: + * let a; a = 3; // later uses of 'a' can be replaced with 3 + * + * Algorithm: + * 1. Find all Identifier nodes in the AST + * 2. Check if identifier is declared without initial value + * 3. Verify exactly one assignment to a literal value exists + * 4. Ensure no unsafe usage patterns (for-loops, conditionals) + * 5. Apply candidate filter for additional constraints + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {ASTNode[]} Array of identifier nodes that can be safely replaced */ -function replaceIdentifierWithFixedValueNotAssignedAtDeclaration(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.Identifier || []), - ]; +export function replaceIdentifierWithFixedValueNotAssignedAtDeclarationMatch(arb, candidateFilter = () => true) { + // Direct access to typeMap without spread operator for better performance + const relevantNodes = arb.ast[0].typeMap.Identifier; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.parentNode?.type === 'VariableDeclarator' && - !n.parentNode.init && - n.references?.filter(r => - r.parentNode.type === 'AssignmentExpression' && - getMainDeclaredObjectOfMemberExpression(r.parentNode.left) === r).length === 1 && - !n.references.some(r => - (/For.*Statement/.test(r.parentNode.type) && - r.parentKey === 'left') || - // This covers cases like: - // let a; b === c ? (b++, a = 1) : a = 2 - [ - r.parentNode.parentNode.type, - r.parentNode.parentNode?.parentNode?.type, - r.parentNode.parentNode?.parentNode?.parentNode?.type, - ].includes('ConditionalExpression')) && - candidateFilter(n)) { - const assignmentNode = n.references.find(r => - r.parentNode.type === 'AssignmentExpression' && - getMainDeclaredObjectOfMemberExpression(r.parentNode.left) === r); - const valueNode = assignmentNode.parentNode.right; - if (valueNode.type === 'Literal') { - const refs = n.references.filter(r => r !== assignmentNode); - if (!areReferencesModified(arb.ast, refs)) { - for (const ref of refs) { - if (ref.parentNode.type === 'CallExpression' && ref.parentKey === 'callee') continue; - arb.markNode(ref, valueNode); - } + + // Optimized condition ordering: cheapest checks first for better performance + if (candidateFilter(n) && + n.parentNode?.type === 'VariableDeclarator' && + !n.parentNode.init && // Variable declared without initial value + n.references.length) { + + // Check for exactly one assignment to a literal value + const assignmentRef = getSingleAssignmentReference(n); + if (assignmentRef && assignmentRef.parentNode.right.type === 'Literal') { + + // Ensure no unsafe usage patterns exist + const hasUnsafeReferences = n.references.some(r => + isForLoopIterator(r) || isInConditionalContext(r) + ); + + if (!hasUnsafeReferences) { + matches.push(n); + } + } + } + } + + return matches; +} + +/** + * Transform identifier references by replacing them with their assigned literal values. + * + * This function takes an identifier that was declared without initialization but + * later assigned a literal value, and replaces all safe references with that value. + * The assignment itself is preserved. + * + * @param {Arborist} arb - The arborist instance to modify + * @param {Object} n - The identifier node whose references should be replaced + * @return {Arborist} The modified arborist instance + */ +export function replaceIdentifierWithFixedValueNotAssignedAtDeclarationTransform(arb, n) { + // Get the single assignment reference (validated in match function) + const assignmentRef = getSingleAssignmentReference(n); + const valueNode = assignmentRef.parentNode.right; + + // Get all references except the assignment itself + const referencesToReplace = n.references.filter(r => r !== assignmentRef); + + // Additional safety check: ensure references aren't modified in complex ways + if (!areReferencesModified(arb.ast, referencesToReplace)) { + for (let i = 0; i < referencesToReplace.length; i++) { + const ref = referencesToReplace[i]; + + // Skip function calls where identifier is the callee + // Example: let func; func = someFunction; func(); // Don't replace func() + if (ref.parentNode.type === 'CallExpression' && ref.parentKey === 'callee') { + continue; + } + + // Check if the reference is in the same scope as the assignment + let scopesMatches = true; + for (let j = 0; j < assignmentRef.lineage.length; j++) { + if (assignmentRef.lineage[j] !== ref.lineage[j]) { + scopesMatches = false; + break; } } + + if (scopesMatches) { + // Replace the reference with the literal value + arb.markNode(ref, valueNode); + } + } } + return arb; } -export default replaceIdentifierWithFixedValueNotAssignedAtDeclaration; \ No newline at end of file +/** + * Replace identifier references with their fixed assigned values when safe to do so. + * + * This transformation handles variables that are declared without initialization + * but are later assigned a single literal value and never modified afterwards. + * It replaces all safe references to such variables with their literal values. + * + * Examples: + * - let a; a = 3; console.log(a); β†’ let a; a = 3; console.log(3); + * - let x; x = "hello"; alert(x); β†’ let x; x = "hello"; alert("hello"); + * + * Safety constraints: + * - Only works with exactly one assignment to a literal value + * - Skips variables used as for-loop iterators + * - Avoids replacement in complex conditional contexts + * - Preserves function calls where variable is the callee + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified arborist instance + */ +export default function replaceIdentifierWithFixedValueNotAssignedAtDeclaration(arb, candidateFilter = () => true) { + // Find all matching identifier nodes + const matches = replaceIdentifierWithFixedValueNotAssignedAtDeclarationMatch(arb, candidateFilter); + + // Transform each matching node + for (let i = 0; i < matches.length; i++) { + arb = replaceIdentifierWithFixedValueNotAssignedAtDeclarationTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js b/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js index f9b8c16..2517201 100644 --- a/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js +++ b/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js @@ -1,64 +1,180 @@ import {getCache} from '../utils/getCache.js'; -import {generateHash} from '../utils/generateHash.js'; import {generateFlatAST, logger} from 'flast'; +import {generateHash} from '../utils/generateHash.js'; /** - * Extract string values of eval call expressions, and replace calls with the actual code, without running it through eval. - * E.g. - * new Function('!function() {console.log("hello world")}()')(); - * will be replaced with - * !function () {console.log("hello world")}(); - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Parse a JavaScript code string into an AST body with appropriate normalization. + * + * This function handles various code string formats: + * - Empty strings become literal nodes + * - Single expressions are unwrapped from ExpressionStatement + * - Multiple statements become BlockStatement + * + * @param {string} codeStr - The JavaScript code string to parse + * @return {ASTNode} The parsed AST node */ -function replaceNewFuncCallsWithLiteralContent(arb, candidateFilter = () => true) { - const cache = getCache(arb.ast[0].scriptHash); - const relevantNodes = [ - ...(arb.ast[0].typeMap.NewExpression || []), - ]; +function parseCodeStringToAST(codeStr) { + if (!codeStr) { + return { + type: 'Literal', + value: codeStr, + }; + } + + const body = generateFlatAST(codeStr, {detailed: false, includeSrc: false})[0].body; + + if (body.length > 1) { + return { + type: 'BlockStatement', + body, + }; + } + + const singleStatement = body[0]; + + // Unwrap single expressions from ExpressionStatement wrapper + if (singleStatement.type === 'ExpressionStatement') { + return singleStatement.expression; + } + + // For immediately-executed functions, unwrap single return statements + if (singleStatement.type === 'ReturnStatement' && singleStatement.argument) { + return singleStatement.argument; + } + + return singleStatement; +} + +/** + * Determine the appropriate target node for replacement based on context. + * + * When replacing `new Function(code)()` with a BlockStatement, we need to + * replace the entire ExpressionStatement that contains the call, not just + * the call expression itself. For variable assignments and other contexts, + * we replace just the call expression. + * + * @param {ASTNode} callNode - The call expression node (parent of NewExpression) + * @param {ASTNode} replacementNode - The AST node that will replace the call + * @return {ASTNode} The node that should be replaced + */ +function getReplacementTarget(callNode, replacementNode) { + // For BlockStatement replacements in standalone expressions, replace the entire ExpressionStatement + if (callNode.parentNode.type === 'ExpressionStatement' && + replacementNode.type === 'BlockStatement') { + return callNode.parentNode; + } + + // For all other cases (including variable assignments), replace just the call expression + return callNode; +} + +/** + * Find all NewExpression nodes that represent immediately-called Function constructors + * with single string arguments. + * + * This function identifies patterns like: + * new Function("code")() - Function constructor called immediately + * + * Algorithm: + * 1. Find all NewExpression nodes in the AST + * 2. Check if used as callee in immediate call (parentKey === 'callee') + * 3. Verify the immediate call has no arguments + * 4. Confirm callee is 'Function' constructor + * 5. Ensure exactly one literal string argument + * 6. Apply candidate filter for additional constraints + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {ASTNode[]} Array of NewExpression nodes that can be safely replaced + */ +export function replaceNewFuncCallsWithLiteralContentMatch(arb, candidateFilter = () => true) { + // Direct access to typeMap without spread operator for better performance + const relevantNodes = arb.ast[0].typeMap.NewExpression || []; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.parentKey === 'callee' && - !n.parentNode?.arguments?.length && - n.callee?.name === 'Function' && - n.arguments?.length === 1 && - n.arguments[0].type === 'Literal' && - candidateFilter(n)) { - const targetCodeStr = n.arguments[0].value; - const cacheName = `replaceEval-${generateHash(targetCodeStr)}`; - try { - if (!cache[cacheName]) { - let body; - if (targetCodeStr) { - body = generateFlatAST(targetCodeStr, {detailed: false, includeSrc: false})[0].body; - if (body.length > 1) { - body = { - type: 'BlockStatement', - body, - }; - } else { - body = body[0]; - if (body.type === 'ExpressionStatement') body = body.expression; - } - } else body = { - type: 'Literal', - value: targetCodeStr, - }; - cache[cacheName] = body; - } - let replacementNode = cache[cacheName]; - let targetNode = n.parentNode; - if (targetNode.parentNode.type === 'ExpressionStatement' && replacementNode.type === 'BlockStatement') { - targetNode = targetNode.parentNode; - } - arb.markNode(targetNode, replacementNode); - } catch (e) { - logger.debug(`[-] Unable to replace new function's body with call expression: ${e}`); - } + + // Optimized condition ordering: cheapest checks first for better performance + if (candidateFilter(n) && + n.parentKey === 'callee' && // Used as callee in immediate call + n.callee?.name === 'Function' && // Constructor is 'Function' + n.arguments?.length === 1 && // Exactly one argument + n.arguments[0].type === 'Literal' && // Argument is a literal string + !n.parentNode?.arguments?.length) { // Immediate call has no arguments + + matches.push(n); } } + + return matches; +} + +/** + * Transform a NewExpression node by replacing the entire Function constructor call + * with the parsed content of its string argument. + * + * This function takes a `new Function(code)()` pattern and replaces it with + * the actual parsed JavaScript code, effectively "unwrapping" the dynamic + * function creation and execution. + * + * @param {Arborist} arb - The arborist instance to modify + * @param {ASTNode} n - The NewExpression node to transform + * @return {Arborist} The modified arborist instance + */ +export function replaceNewFuncCallsWithLiteralContentTransform(arb, n) { + const cache = getCache(arb.ast[0].scriptHash); + const targetCodeStr = n.arguments[0].value; + const cacheName = `replaceEval-${generateHash(targetCodeStr)}`; + + try { + // Use cache to avoid re-parsing identical code strings + if (!cache[cacheName]) { + cache[cacheName] = parseCodeStringToAST(targetCodeStr); + } + + const replacementNode = cache[cacheName]; + const targetNode = getReplacementTarget(n.parentNode, replacementNode); + + arb.markNode(targetNode, replacementNode); + } catch (e) { + // Log parsing failures but don't crash the transformation + logger.debug(`[-] Unable to replace new function's body with call expression: ${e}`); + } + return arb; } -export default replaceNewFuncCallsWithLiteralContent; \ No newline at end of file +/** + * Replace Function constructor calls with their literal content when safe to do so. + * + * This transformation handles patterns where JavaScript code is dynamically created + * using the Function constructor and immediately executed. It replaces such patterns + * with the actual parsed code, eliminating the dynamic construction overhead. + * + * Examples: + * - new Function("console.log('hello')")() β†’ console.log('hello') + * - new Function("x = 1; y = 2;")() β†’ { x = 1; y = 2; } + * - new Function("return 42")() β†’ 42 + * + * Safety constraints: + * - Only works with literal string arguments to Function constructor + * - Only processes immediately-called Function constructors + * - Skips constructions that can't be parsed as valid JavaScript + * - Uses caching to avoid re-parsing identical code strings + * + * @param {Arborist} arb - The arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified arborist instance + */ +export default function replaceNewFuncCallsWithLiteralContent(arb, candidateFilter = () => true) { + // Find all matching NewExpression nodes + const matches = replaceNewFuncCallsWithLiteralContentMatch(arb, candidateFilter); + + // Transform each matching node + for (let i = 0; i < matches.length; i++) { + arb = replaceNewFuncCallsWithLiteralContentTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/replaceSequencesWithExpressions.js b/src/modules/safe/replaceSequencesWithExpressions.js index 41ce784..25fba8e 100644 --- a/src/modules/safe/replaceSequencesWithExpressions.js +++ b/src/modules/safe/replaceSequencesWithExpressions.js @@ -1,47 +1,156 @@ /** - * All expressions within a sequence will be replaced by their own expression statement. - * E.g. if (a) (b(), c()); -> if (a) { b(); c(); } - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Creates individual expression statements from each expression in a sequence expression. + * + * This helper function takes an array of expressions from a SequenceExpression + * and converts each one into a standalone ExpressionStatement AST node. + * + * @param {Array} expressions - Array of expression AST nodes from SequenceExpression + * @return {ASTNode[]} Array of ExpressionStatement AST nodes + */ +function createExpressionStatements(expressions) { + const statements = []; + for (let i = 0; i < expressions.length; i++) { + statements.push({ + type: 'ExpressionStatement', + expression: expressions[i] + }); + } + return statements; +} + +/** + * Creates a new BlockStatement body by replacing a target statement with multiple statements. + * + * This optimized implementation avoids spread operators and builds the new array + * incrementally for better performance with large parent bodies. + * + * @param {Array} parentBody - Original body array from BlockStatement + * @param {number} targetIndex - Index of statement to replace + * @param {Array} replacementStatements - Array of statements to insert + * @return {ASTNode[]} New body array with replacements + */ +function createReplacementBody(parentBody, targetIndex, replacementStatements) { + const newBody = []; + let newIndex = 0; + + // Copy statements before target + for (let i = 0; i < targetIndex; i++) { + newBody[newIndex++] = parentBody[i]; + } + + // Insert replacement statements + for (let i = 0; i < replacementStatements.length; i++) { + newBody[newIndex++] = replacementStatements[i]; + } + + // Copy statements after target + for (let i = targetIndex + 1; i < parentBody.length; i++) { + newBody[newIndex++] = parentBody[i]; + } + + return newBody; +} + +/** + * Identifies ExpressionStatement nodes that contain SequenceExpressions suitable for transformation. + * + * A sequence expression is a candidate for transformation when: + * 1. The node is an ExpressionStatement + * 2. Its expression property is a SequenceExpression + * 3. The SequenceExpression contains multiple expressions to expand + * 4. The node passes the candidate filter + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of nodes that can be transformed */ -function replaceSequencesWithExpressions(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.ExpressionStatement || []), - ]; +export function replaceSequencesWithExpressionsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.ExpressionStatement || []; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.expression.type === 'SequenceExpression' && - candidateFilter(n)) { - const parent = n.parentNode; - const statements = n.expression.expressions.map(e => ({ - type: 'ExpressionStatement', - expression: e - })); - if (parent.type === 'BlockStatement') { - // Insert between other statements - const currentIdx = parent.body.indexOf(n); - /** @type {ASTNode} */ - const replacementNode = { - type: 'BlockStatement', - body: [ - ...parent.body.slice(0, currentIdx), - ...statements, - ...parent.body.slice(currentIdx + 1) - ], - }; - arb.markNode(parent, replacementNode); - } else { - // Replace expression with new block statement - const blockStatement = { - type: 'BlockStatement', - body: statements - }; - arb.markNode(n, blockStatement); - } + + // Check if this ExpressionStatement contains a SequenceExpression + if (n.expression && + n.expression.type === 'SequenceExpression' && + n.expression.expressions && + n.expression.expressions.length > 1 && + candidateFilter(n)) { + matches[matches.length] = n; + } + } + + return matches; +} + +/** + * Transforms a SequenceExpression into individual ExpressionStatements. + * + * The transformation strategy depends on the parent context: + * - If parent is BlockStatement: Replace within the existing block by creating + * a new BlockStatement with the sequence expanded into individual statements + * - If parent is not BlockStatement: Replace the ExpressionStatement with a + * new BlockStatement containing the individual statements + * + * This ensures proper AST structure while expanding sequence expressions into + * separate executable statements. + * + * @param {Arborist} arb - The Arborist instance to mark changes on + * @param {Object} n - The ExpressionStatement node containing SequenceExpression + * @return {Arborist} The modified Arborist instance + */ +export function replaceSequencesWithExpressionsTransform(arb, n) { + const parent = n.parentNode; + const statements = createExpressionStatements(n.expression.expressions); + + if (parent && parent.type === 'BlockStatement') { + // Find target statement position within parent block + const currentIdx = parent.body.indexOf(n); + + if (currentIdx !== -1) { + // Create new BlockStatement with sequence expanded inline + const replacementNode = { + type: 'BlockStatement', + body: createReplacementBody(parent.body, currentIdx, statements) + }; + arb.markNode(parent, replacementNode); } + } else { + // Replace ExpressionStatement with BlockStatement containing individual statements + const blockStatement = { + type: 'BlockStatement', + body: statements + }; + arb.markNode(n, blockStatement); } + return arb; } -export default replaceSequencesWithExpressions; \ No newline at end of file +/** + * All expressions within a sequence will be replaced by their own expression statement. + * + * This transformation converts SequenceExpressions into individual ExpressionStatements + * to improve code readability and enable better analysis. For example: + * + * Input: if (a) (b(), c()); + * Output: if (a) { b(); c(); } + * + * The transformation handles both cases where the sequence is: + * 1. Already within a BlockStatement (inserts statements inline) + * 2. Not within a BlockStatement (creates new BlockStatement) + * + * @param {Arborist} arb - The Arborist instance containing the AST to transform + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified Arborist instance + */ +export default function replaceSequencesWithExpressions(arb, candidateFilter = () => true) { + const matches = replaceSequencesWithExpressionsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = replaceSequencesWithExpressionsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveDeterministicIfStatements.js b/src/modules/safe/resolveDeterministicIfStatements.js index 2c63bec..d663395 100644 --- a/src/modules/safe/resolveDeterministicIfStatements.js +++ b/src/modules/safe/resolveDeterministicIfStatements.js @@ -1,30 +1,183 @@ /** - * Replace if statements which will always resolve the same way with their relevant consequent or alternative. - * E.g. - * if (true) do_a(); else do_b(); if (false) do_c(); else do_d(); - * ==> - * do_a(); do_d(); - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Determines whether a literal value is truthy in JavaScript context. + * + * This helper evaluates literal values according to JavaScript truthiness rules: + * - false, 0, -0, 0n, "", null, undefined, NaN are falsy + * - All other values are truthy + * + * @param {*} value - The literal value to evaluate + * @return {boolean} Whether the value is truthy + */ +function isLiteralTruthy(value) { + // Handle special JavaScript falsy values + if (value === false || value === 0 || value === -0 || value === 0n || + value === '' || value === null || value === undefined) { + return false; + } + + // Handle NaN (NaN !== NaN is true) + if (typeof value === 'number' && value !== value) { + return false; + } + + return true; +} + +/** + * Evaluates a test condition to get its literal value for truthiness testing. + * + * Handles both direct literals and unary expressions with literal arguments: + * - Literal nodes: return the literal value directly + * - UnaryExpression nodes: evaluate the unary operation and return result + * + * @param {ASTNode} testNode - The test condition AST node (Literal or UnaryExpression) + * @return {*} The evaluated literal value + */ +function evaluateTestValue(testNode) { + if (testNode.type === 'Literal') { + return testNode.value; + } + + if (testNode.type === 'UnaryExpression' && testNode.argument.type === 'Literal') { + const argument = testNode.argument.value; + const operator = testNode.operator; + + switch (operator) { + case '-': + return -argument; + case '+': + return +argument; + case '!': + return !argument; + case '~': + return ~argument; + default: + // For any other unary operators, return the original argument + return argument; + } + } + + // Fallback (should not reach here if match function works correctly) + return testNode.value; +} + +/** + * Gets the appropriate replacement node for a resolved if statement. + * + * When an if statement can be resolved deterministically: + * - If test is truthy: return consequent (or null if no consequent) + * - If test is falsy: return alternate (or null if no alternate) + * + * Returning null indicates the if statement should be removed entirely. + * Handles both Literal and UnaryExpression test conditions. + * + * @param {ASTNode} ifNode - The IfStatement AST node to resolve + * @return {ASTNode|null} The replacement node or null to remove */ -function resolveDeterministicIfStatements(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.IfStatement || []), - ]; +function getReplacementNode(ifNode) { + const testValue = evaluateTestValue(ifNode.test); + const isTestTruthy = isLiteralTruthy(testValue); + + if (isTestTruthy) { + // Test condition is truthy - use consequent + return ifNode.consequent || null; + } else { + // Test condition is falsy - use alternate + return ifNode.alternate || null; + } +} + +/** + * Identifies IfStatement nodes with literal test conditions that can be resolved deterministically. + * + * An if statement is a candidate for resolution when: + * 1. The node is an IfStatement + * 2. The test condition is a Literal (constant value) or UnaryExpression with literal argument + * 3. The node passes the candidate filter + * + * These conditions ensure the if statement's outcome is known at static analysis time. + * Handles cases like: if (true), if (false), if (1), if (0), if (""), if (-1), etc. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of IfStatement nodes that can be resolved + */ +export function resolveDeterministicIfStatementsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.IfStatement || []; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.test.type === 'Literal' && candidateFilter(n)) { - if (n.test.value) { - if (n.consequent) arb.markNode(n, n.consequent); - else arb.markNode(n); - } else { - if (n.alternate) arb.markNode(n, n.alternate); - else arb.markNode(n); - } + + if (!n.test || !candidateFilter(n)) { + continue; + } + + // Check if test condition is a literal + if (n.test.type === 'Literal') { + matches.push(n); + } + // Check if test condition is a unary expression with literal argument (e.g., -1, +5) + else if (n.test.type === 'UnaryExpression' && + n.test.argument && + n.test.argument.type === 'Literal') { + matches.push(n); } } + + return matches; +} + +/** + * Transforms an IfStatement with a literal test condition into its resolved form. + * + * The transformation logic: + * - If test value is truthy: replace with consequent (if exists) or remove entirely + * - If test value is falsy: replace with alternate (if exists) or remove entirely + * + * This transformation eliminates dead code by resolving conditional branches + * that will always take the same path at runtime. + * + * @param {Arborist} arb - The Arborist instance to mark changes on + * @param {ASTNode} n - The IfStatement node to transform + * @return {Arborist} The modified Arborist instance + */ +export function resolveDeterministicIfStatementsTransform(arb, n) { + const replacementNode = getReplacementNode(n); + + if (replacementNode) { + // Replace if statement with the appropriate branch + arb.markNode(n, replacementNode); + } else { + // Remove if statement entirely (no consequent/alternate to execute) + arb.markNode(n); + } + return arb; } -export default resolveDeterministicIfStatements; \ No newline at end of file +/** + * Replace if statements which will always resolve the same way with their relevant consequent or alternative. + * + * This transformation eliminates deterministic conditional statements where the test condition + * is a literal value, allowing static resolution of the control flow. For example: + * + * Input: if (true) do_a(); else do_b(); if (false) do_c(); else do_d(); + * Output: do_a(); do_d(); + * + * The transformation handles all JavaScript falsy values correctly (false, 0, "", null, etc.) + * and ensures proper cleanup of dead code branches. + * + * @param {Arborist} arb - The Arborist instance containing the AST to transform + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified Arborist instance + */ +export default function resolveDeterministicIfStatements(arb, candidateFilter = () => true) { + const matches = resolveDeterministicIfStatementsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveDeterministicIfStatementsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveFunctionConstructorCalls.js b/src/modules/safe/resolveFunctionConstructorCalls.js index 396db60..4760ebf 100644 --- a/src/modules/safe/resolveFunctionConstructorCalls.js +++ b/src/modules/safe/resolveFunctionConstructorCalls.js @@ -1,43 +1,165 @@ import {generateFlatAST} from 'flast'; /** - * Typical for packers, function constructor calls where the last argument - * is a code snippet, should be replaced with the code nodes. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Builds the function arguments string from constructor arguments. + * + * When Function.constructor is called with multiple arguments, all but the last + * are parameter names, and the last is the function body. This helper extracts + * and formats the parameter names properly. + * + * @param {Array} args - Array of literal argument values + * @return {string} Comma-separated parameter names + */ +function buildArgumentsString(args) { + if (args.length <= 1) { + return ''; + } + + // All arguments except the last are parameter names + const paramNames = []; + for (let i = 0; i < args.length - 1; i++) { + paramNames.push(args[i]); + } + + return paramNames.join(', '); +} + +/** + * Generates a function expression AST node from constructor arguments. + * + * This function recreates the same behavior as Function.constructor by: + * 1. Taking all but the last argument as parameter names + * 2. Using the last argument as the function body + * 3. Wrapping in a function expression for valid syntax + * 4. Generating AST without nodeIds to avoid conflicts + * + * @param {Array} argumentValues - Array of literal values from constructor call + * @return {ASTNode|null} Function expression AST node or null if generation fails */ -function resolveFunctionConstructorCalls(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; - nodeLoop: for (let i = 0; i < relevantNodes.length; i++) { +function generateFunctionExpression(argumentValues) { + const argsString = buildArgumentsString(argumentValues); + const code = argumentValues[argumentValues.length - 1]; + + try { + // Create function expression string matching Function.constructor behavior + const functionCode = `(function (${argsString}) {${code}})`; + + // Generate AST without nodeIds to avoid duplicates with existing code + const ast = generateFlatAST(functionCode, {detailed: false, includeSrc: false}); + + // Return the function expression node (index 2 in the generated AST) + return ast[2] || null; + } catch { + // Return null if code generation fails (invalid syntax, etc.) + return null; + } +} + +/** + * Identifies CallExpression nodes that are Function.constructor calls with literal arguments. + * + * A call expression is a candidate for transformation when: + * 1. It's a call to Function.constructor (member expression with 'constructor' property) + * 2. All arguments are literal values (required for static analysis) + * 3. Has at least one argument (the function body) + * 4. Passes the candidate filter + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of CallExpression nodes that can be transformed + */ +export function resolveFunctionConstructorCallsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.CallExpression || []; + const matches = []; + + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.callee?.type === 'MemberExpression' && - (n.callee.property?.name || n.callee.property?.value) === 'constructor' && - candidateFilter(n)) { - let args = ''; - let code = ''; - if (n.arguments.length > 1) { - for (let j = 0; j < n.arguments.length; j++) { - if (n.arguments[j].type !== 'Literal') continue nodeLoop; - if (code) args += (args.length ? ', ' : '') + code; - code = n.arguments[j].value; - } - } else code = n.arguments[0].value; - // Wrap the code in a valid anonymous function in the same way Function.constructor would. - // Give the anonymous function any arguments it may require. - // Wrap the function in an expression to make it a valid code (since it's anonymous). - // Generate an AST without nodeIds (to avoid duplicates with the rest of the code). - // Extract just the function expression from the AST. - try { - const codeNode = generateFlatAST(`(function (${args}) {${code}})`, - {detailed: false, includeSrc: false})[2]; - if (codeNode) arb.markNode(n, codeNode); - } catch {} + + // Check if this is a .constructor call + if (!n.callee || + n.callee.type !== 'MemberExpression' || + !n.callee.property || + (n.callee.property.name !== 'constructor' && n.callee.property.value !== 'constructor')) { + continue; + } + + // Must have at least one argument (the function body) + if (!n.arguments || n.arguments.length === 0) { + continue; } + + // All arguments must be literals for static evaluation + let allLiterals = true; + for (let j = 0; j < n.arguments.length; j++) { + if (n.arguments[j].type !== 'Literal') { + allLiterals = false; + break; + } + } + + if (allLiterals && candidateFilter(n)) { + matches.push(n); + } + } + + return matches; +} + +/** + * Transforms a Function.constructor call into a function expression. + * + * The transformation process: + * 1. Extract literal values from constructor arguments + * 2. Generate equivalent function expression AST + * 3. Replace constructor call with function expression + * + * This transformation is safe because all arguments are literals, ensuring + * the function can be statically analyzed and transformed. + * + * @param {Arborist} arb - The Arborist instance to mark changes on + * @param {Object} n - The CallExpression node to transform + * @return {Arborist} The modified Arborist instance + */ +export function resolveFunctionConstructorCallsTransform(arb, n) { + // Extract literal values from arguments + const argumentValues = []; + for (let i = 0; i < n.arguments.length; i++) { + argumentValues.push(n.arguments[i].value); } + + // Generate equivalent function expression + const functionExpression = generateFunctionExpression(argumentValues); + + if (functionExpression) { + arb.markNode(n, functionExpression); + } + return arb; } -export default resolveFunctionConstructorCalls; \ No newline at end of file +/** + * Typical for packers, function constructor calls where the last argument + * is a code snippet, should be replaced with the code nodes. + * + * This transformation converts Function.constructor calls into equivalent function expressions + * when all arguments are literal values. For example: + * + * Input: Function.constructor('a', 'b', 'return a + b') + * Output: function (a, b) { return a + b } + * + * The transformation preserves the exact semantics of Function.constructor while + * making the code more readable and enabling further static analysis. + * + * @param {Arborist} arb - The Arborist instance containing the AST to transform + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified Arborist instance + */ +export default function resolveFunctionConstructorCalls(arb, candidateFilter = () => true) { + const matches = resolveFunctionConstructorCallsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveFunctionConstructorCallsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveMemberExpressionReferencesToArrayIndex.js b/src/modules/safe/resolveMemberExpressionReferencesToArrayIndex.js index 6aa189b..cb2de63 100644 --- a/src/modules/safe/resolveMemberExpressionReferencesToArrayIndex.js +++ b/src/modules/safe/resolveMemberExpressionReferencesToArrayIndex.js @@ -1,40 +1,179 @@ import {logger} from 'flast'; -const minArrayLength = 20; +const MIN_ARRAY_LENGTH = 20; /** - * Resolve member expressions to their targeted index in an array. - * E.g. - * const a = [1, 2, 3]; b = a[0]; c = a[2]; - * ==> - * const a = [1, 2, 3]; b = 1; c = 3; - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Validates if a property access represents a valid numeric array index. + * + * Checks that the property is a literal, represents a valid integer, + * and is within the bounds of the array. Non-numeric properties like + * 'length' or 'indexOf' are excluded. + * + * @param {ASTNode} memberExpr - The MemberExpression node + * @param {number} arrayLength - Length of the array being accessed + * @return {boolean} True if this is a valid numeric index access + */ +function isValidArrayIndex(memberExpr, arrayLength) { + if (!memberExpr.property || memberExpr.property.type !== 'Literal') { + return false; + } + + const value = memberExpr.property.value; + + // Must be a number (not string like 'indexOf' or 'length') + if (typeof value !== 'number') { + return false; + } + + // Must be a valid integer within array bounds + const index = Math.floor(value); + return index >= 0 && index < arrayLength && index === value; +} + +/** + * Checks if a reference is a valid candidate for array index resolution. + * + * Valid candidates are MemberExpression nodes that: + * 1. Are not on the left side of assignments (not being modified) + * 2. Have numeric literal properties within array bounds + * 3. Are not accessing array methods or properties + * + * @param {ASTNode} ref - Reference node to check + * @param {number} arrayLength - Length of the array being accessed + * @return {boolean} True if reference can be resolved to array element */ -function resolveMemberExpressionReferencesToArrayIndex(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.VariableDeclarator || []), - ]; +function isResolvableReference(ref, arrayLength) { + // Must be a member expression (array[index] access) + if (ref.type !== 'MemberExpression') { + return false; + } + + // Skip if this reference is being assigned to (left side of assignment) + if (ref.parentNode.type === 'AssignmentExpression' && ref.parentKey === 'left') { + return false; + } + + // Must be a valid numeric array index + return isValidArrayIndex(ref, arrayLength); +} + +/** + * Identifies VariableDeclarator nodes with large array initializers that can have their references resolved. + * + * A variable declarator is a candidate when: + * 1. It's initialized with an ArrayExpression + * 2. The array has more than MIN_ARRAY_LENGTH elements (performance threshold) + * 3. The identifier has references that can be resolved + * 4. It passes the candidate filter + * + * Large arrays are targeted because this optimization is most beneficial for + * obfuscated code that uses large lookup tables. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of VariableDeclarator nodes that can be processed + */ +export function resolveMemberExpressionReferencesToArrayIndexMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.VariableDeclarator || []; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.init?.type === 'ArrayExpression' && - n.id?.references && - n.init.elements.length > minArrayLength && - candidateFilter(n)) { - const refs = n.id.references.map(n => n.parentNode); - for (const ref of refs) { - if ((ref.parentNode.type === 'AssignmentExpression' && ref.parentKey === 'left') || ref.type !== 'MemberExpression') continue; - if ((ref.property && ref.property.type !== 'Literal') || Number.isNaN(parseInt(ref.property?.value))) continue; + + // Must be array initialization with sufficient length + if (!n.init || + n.init.type !== 'ArrayExpression' || + n.init.elements.length <= MIN_ARRAY_LENGTH) { + continue; + } + + // Must have identifier with references to resolve + if (!n.id || !n.id.references || n.id.references.length === 0) { + continue; + } + + if (candidateFilter(n)) { + matches.push(n); + } + } + + return matches; +} + +/** + * Transforms array index references into their literal values. + * + * For each reference to the array variable, if it's a valid numeric index access, + * replace the member expression with the corresponding array element. + * + * This transformation is safe because: + * - Only literal numeric indices are replaced + * - Array bounds are validated + * - Assignment targets are excluded + * + * @param {Arborist} arb - The Arborist instance to mark changes on + * @param {ASTNode} n - The VariableDeclarator node with array initialization + * @return {Arborist} The modified Arborist instance + */ +export function resolveMemberExpressionReferencesToArrayIndexTransform(arb, n) { + const arrayElements = n.init.elements; + const arrayLength = arrayElements.length; + + // Get parent nodes of all references (the actual member expressions) + const memberExpressions = []; + for (let i = 0; i < n.id.references.length; i++) { + memberExpressions.push(n.id.references[i].parentNode); + } + + // Process each member expression reference + for (let i = 0; i < memberExpressions.length; i++) { + const memberExpr = memberExpressions[i]; + + if (isResolvableReference(memberExpr, arrayLength)) { + const index = memberExpr.property.value; + const arrayElement = arrayElements[index]; + + // Only replace if the array element exists (handle sparse arrays) + if (arrayElement) { try { - arb.markNode(ref, n.init.elements[parseInt(ref.property.value)]); + arb.markNode(memberExpr, arrayElement); } catch (e) { logger.debug(`[-] Unable to mark node for replacement: ${e}`); } } } } + return arb; } -export default resolveMemberExpressionReferencesToArrayIndex; \ No newline at end of file +/** + * Resolve member expressions to their targeted index in an array. + * + * This transformation replaces array index access with the literal values + * for large arrays (> 20 elements). This is particularly useful for deobfuscating + * code that uses large lookup tables. + * + * Example transformation: + * Input: const a = [1, 2, 3, ...]; b = a[0]; c = a[2]; + * Output: const a = [1, 2, 3, ...]; b = 1; c = 3; + * + * Only safe transformations are performed: + * - Numeric literal indices only + * - Within array bounds + * - Not modifying assignments + * - Array methods/properties excluded + * + * @param {Arborist} arb - The Arborist instance containing the AST to transform + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified Arborist instance + */ +export default function resolveMemberExpressionReferencesToArrayIndex(arb, candidateFilter = () => true) { + const matches = resolveMemberExpressionReferencesToArrayIndexMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveMemberExpressionReferencesToArrayIndexTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveMemberExpressionsWithDirectAssignment.js b/src/modules/safe/resolveMemberExpressionsWithDirectAssignment.js index 12ac68e..4ecb7b2 100644 --- a/src/modules/safe/resolveMemberExpressionsWithDirectAssignment.js +++ b/src/modules/safe/resolveMemberExpressionsWithDirectAssignment.js @@ -1,46 +1,240 @@ /** - * Resolve the value of member expressions to objects which hold literals that were directly assigned to the expression. - * E.g. - * function a() {} - * a.b = 3; - * a.c = '5'; - * console.log(a.b + a.c); // a.b + a.c will be replaced with '35' - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Gets the property name from a MemberExpression, handling both computed and non-computed access. + * + * For computed access (obj['prop']), uses the value of the property only if it's a literal. + * For non-computed access (obj.prop), uses the name of the property. + * + * This function is conservative about computed access - it only resolves when the property + * is a direct literal, not a variable that happens to have a literal value. + * + * @param {ASTNode} memberExpr - The MemberExpression node + * @return {string|number|null} The property name/value, or null if not determinable */ -function resolveMemberExpressionsWithDirectAssignment(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.MemberExpression || []), - ]; - rnLoop: for (let i = 0; i < relevantNodes.length; i++) { +function getPropertyName(memberExpr) { + if (!memberExpr.property) { + return null; + } + + if (memberExpr.computed) { + // For computed access, only allow direct literals like obj['prop'] or obj[0] + // Do not allow variables like obj[key] even if key has a literal value + if (memberExpr.property.type === 'Literal') { + return memberExpr.property.value; + } else { + // Conservative approach: don't resolve computed access with variables + return null; + } + } else { + // For dot notation access like obj.prop + return memberExpr.property.name; + } +} + +/** + * Checks if a member expression reference represents a modification (assignment or update). + * + * Identifies cases where the member expression is being modified rather than read: + * - Assignment expressions where the member expression is on the left side + * - Update expressions like ++obj.prop or obj.prop++ + * + * @param {ASTNode} memberExpr - The MemberExpression node to check + * @return {boolean} True if this is a modification, false if it's a read access + */ +function isModifyingReference(memberExpr) { + const parent = memberExpr.parentNode; + + if (!parent) { + return false; + } + + // Check for update expressions (++obj.prop, obj.prop++, --obj.prop, obj.prop--) + if (parent.type === 'UpdateExpression') { + return true; + } + + // Check for assignment expressions where member expression is on the left side + if (parent.type === 'AssignmentExpression' && memberExpr.parentKey === 'left') { + return true; + } + + return false; +} + +/** + * Finds all references to a specific property on an object that can be replaced with a literal value. + * + * Searches through all references to the object's declaration and identifies member expressions + * that access the same property. Excludes references that modify the property to ensure + * the transformation is safe. + * + * @param {ASTNode} objectDeclNode - The declaration node of the object + * @param {string|number} propertyName - The name/value of the property to find + * @param {Object} assignmentMemberExpr - The original assignment member expression to exclude + * @return {Object[]} Array of reference nodes that can be replaced + */ +function findReplaceablePropertyReferences(objectDeclNode, propertyName, assignmentMemberExpr) { + const replaceableRefs = []; + + if (!objectDeclNode.references) { + return replaceableRefs; + } + + for (let i = 0; i < objectDeclNode.references.length; i++) { + const ref = objectDeclNode.references[i]; + const memberExpr = ref.parentNode; + + // Skip if not a member expression or if it's the original assignment + if (!memberExpr || + memberExpr.type !== 'MemberExpression' || + memberExpr === assignmentMemberExpr) { + continue; + } + + // Check if this member expression accesses the same property + const refPropertyName = getPropertyName(memberExpr); + if (refPropertyName !== propertyName) { + continue; + } + + // Don't replace any reference if any of them are modifying the property + if (isModifyingReference(memberExpr)) { + return []; + } + + if (ref.scope !== assignmentMemberExpr.scope) { + return []; + } + + replaceableRefs.push(ref); + } + + return replaceableRefs; +} + +/** + * Identifies MemberExpression nodes that are being assigned literal values and can have their references resolved. + * + * A member expression is a candidate when: + * 1. It's on the left side of an assignment expression + * 2. The right side is a literal value + * 3. The object has a declaration node with references + * 4. There are other references to the same property that can be replaced + * 5. No references modify the property (ensuring safe transformation) + * + * This transformation is useful for resolving simple object property assignments + * like `obj.prop = 'value'` where `obj.prop` is later accessed. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {Object[]} Array of objects with memberExpr, propertyName, replacementNode, and references + */ +export function resolveMemberExpressionsWithDirectAssignmentMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.MemberExpression; + const matches = []; + + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.object.declNode && - n.parentNode.type === 'AssignmentExpression' && - n.parentNode.right.type === 'Literal' && - candidateFilter(n)) { - const prop = n.property?.value || n.property?.name; - const valueUses = []; - for (let j = 0; j < n.object.declNode.references.length; j++) { - /** @type {ASTNode} */ - const ref = n.object.declNode.references[j]; - if (ref.parentNode !== n && ref.parentNode.type === 'MemberExpression' && - prop === ref.parentNode.property[ref.parentNode.property.computed ? 'value' : 'name']) { - // Skip if the value is reassigned - if (ref.parentNode.parentNode.type === 'UpdateExpression' || - (ref.parentNode.parentNode.type === 'AssignmentExpression' && ref.parentNode.parentKey === 'left')) continue rnLoop; - valueUses.push(ref); - } - } - if (valueUses.length) { - const replacementNode = n.parentNode.right; - for (let j = 0; j < valueUses.length; j++) { - arb.markNode(valueUses[j].parentNode, replacementNode); - } - } + + // Must be a member expression with an object that has a declaration + if (!n.object || !n.object.declNode) { + continue; + } + + // Must be on the left side of an assignment expression + if (!n.parentNode || + n.parentNode.type !== 'AssignmentExpression' || + n.parentKey !== 'left') { + continue; + } + + // The assigned value must be a literal + if (!n.parentNode.right || n.parentNode.right.type !== 'Literal') { + continue; + } + + // Must pass the candidate filter + if (!candidateFilter(n)) { + continue; + } + + const propertyName = getPropertyName(n); + if (propertyName === null) { + continue; + } + + // Find all references to this property that can be replaced + const replaceableRefs = findReplaceablePropertyReferences( + n.object.declNode, + propertyName, + n + ); + + // Only add as candidate if there are references to replace + if (replaceableRefs.length) { + matches.push({ + memberExpr: n, + propertyName: propertyName, + replacementNode: n.parentNode.right, + references: replaceableRefs + }); + } + } + + return matches; +} + +/** + * Transforms member expression references by replacing them with their assigned literal values. + * + * For each match, replaces all found references to the property with the literal value + * that was assigned to it. This is safe because the match function ensures no + * modifications occur to the property after assignment. + * + * @param {Arborist} arb - The Arborist instance to mark changes on + * @param {Object} match - Match object containing memberExpr, propertyName, replacementNode, and references + * @return {Arborist} The modified Arborist instance + */ +export function resolveMemberExpressionsWithDirectAssignmentTransform(arb, match) { + const {replacementNode, references} = match; + + // Replace each reference with the literal value + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const memberExpr = ref.parentNode; + + if (memberExpr && memberExpr.type === 'MemberExpression') { + arb.markNode(memberExpr, replacementNode); } } + return arb; } -export default resolveMemberExpressionsWithDirectAssignment; \ No newline at end of file +/** + * Resolve the value of member expressions to objects which hold literals that were directly assigned to the expression. + * + * This transformation replaces property access with literal values when the property + * has been directly assigned a literal value and is not modified elsewhere. + * + * Example transformation: + * Input: function a() {} a.b = 3; a.c = '5'; console.log(a.b + a.c); + * Output: function a() {} a.b = 3; a.c = '5'; console.log(3 + '5'); + * + * Safety constraints: + * - Only replaces when assigned value is a literal + * - Skips if property is modified (assigned or updated) elsewhere + * - Ensures all references are read-only accesses + * + * @param {Arborist} arb - The Arborist instance containing the AST to transform + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified Arborist instance + */ +export default function resolveMemberExpressionsWithDirectAssignment(arb, candidateFilter = () => true) { + const matches = resolveMemberExpressionsWithDirectAssignmentMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveMemberExpressionsWithDirectAssignmentTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveProxyCalls.js b/src/modules/safe/resolveProxyCalls.js index a5b27f7..b380c23 100644 --- a/src/modules/safe/resolveProxyCalls.js +++ b/src/modules/safe/resolveProxyCalls.js @@ -1,52 +1,173 @@ /** - * Remove redundant call expressions which only pass the arguments to other call expression. - * E.g. - * function call1(a, b) { - * return a + b; - * } - * function call2(c, d) { - * return call1(c, d); // will be changed to call1(c, d); - * } - * function call3(e, f) { - * return call2(e, f); // will be changed to call1(e, f); - * } - * const three = call3(1, 2); // will be changed to call1(1, 2); - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Checks if a function contains only a single return statement with no other code. + * + * A proxy function candidate must have exactly one statement in its body, + * and that statement must be a return statement. This ensures the function + * doesn't perform any side effects beyond passing through arguments. + * + * @param {ASTNode} funcNode - The FunctionDeclaration node to check + * @return {boolean} True if function has only a return statement + */ +function hasOnlyReturnStatement(funcNode) { + if (!funcNode.body || + !funcNode.body.body || + funcNode?.body?.body?.length !== 1) { + return false; + } + + return funcNode?.body?.body[0]?.type === 'ReturnStatement'; +} + +/** + * Validates that parameter names are passed through in the same order to the target function. + * + * For a valid proxy function, each parameter must be passed to the target function + * in the exact same order and position. This ensures the proxy doesn't modify, + * reorder, or omit any arguments. + * + * @param {Array} params - Function parameters array + * @param {Array} callArgs - Arguments passed to the target function call + * @return {boolean} True if all parameters are passed through correctly + */ +function areParametersPassedThrough(params, callArgs) { + // Must have same number of parameters and arguments + if (!params || !callArgs || params.length !== callArgs.length) { + return false; + } + + // Each parameter must match corresponding argument by name + for (let i = 0; i < params.length; i++) { + const param = params[i]; + const arg = callArgs[i]; + + // Both must be identifiers with matching names + if (param?.type !== 'Identifier' || + arg?.type !== 'Identifier' || + param?.name !== arg?.name) { + return false; + } + } + + return true; +} + +/** + * Identifies FunctionDeclaration nodes that act as proxy calls to other functions. + * + * A proxy function is one that: + * 1. Contains only a single return statement + * 2. Returns a call expression + * 3. The call target is an identifier (not a complex expression) + * 4. All parameters are passed through to the target in the same order + * 5. No parameters are modified, reordered, or omitted + * + * This pattern is common in obfuscated code where simple wrapper functions + * are used to indirect function calls. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {Object[]} Array of objects with funcNode, targetCallee, and references */ -function resolveProxyCalls(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.FunctionDeclaration || []), - ]; +export function resolveProxyCallsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.FunctionDeclaration; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n?.body?.body?.[0]?.type === 'ReturnStatement' && - n.body.body[0].argument?.type === 'CallExpression' && - n.body.body[0].argument.arguments?.length === n.params?.length && - n.body.body[0].argument.callee.type === 'Identifier' && - candidateFilter(n)) { - const funcName = n.id; - const ret = n.body.body[0].argument; - let transitiveArguments = true; - try { - for (let j = 0; j < n.params.length; j++) { - if (n.params[j]?.name !== ret?.arguments[j]?.name) { - transitiveArguments = false; - break; - } - } - } catch { - transitiveArguments = false; - } - if (transitiveArguments) { - for (const ref of funcName.references || []) { - arb.markNode(ref, ret.callee); - } - } + + // Must pass the candidate filter + if (!candidateFilter(n)) { + continue; + } + + // Must have only a return statement + if (!hasOnlyReturnStatement(n)) { + continue; } + + const returnStmt = n.body.body[0]; + const returnArg = returnStmt.argument; + + // Must return a call expression + if (returnArg?.type !== 'CallExpression') { + continue; + } + + // Call target must be a simple identifier + if (returnArg.callee?.type !== 'Identifier') { + continue; + } + + // Must have a function name with references to replace + if (!n.id?.references?.length) { + continue; + } + + // All parameters must be passed through correctly + if (!areParametersPassedThrough(n.params, returnArg.arguments)) { + continue; + } + + matches.push({ + funcNode: n, + targetCallee: returnArg.callee, + references: n.id.references + }); + } + + return matches; +} + +/** + * Transforms proxy function calls by replacing them with direct calls to the target function. + * + * For each reference to the proxy function, replaces it with a reference to the + * target function that the proxy was calling. This eliminates the unnecessary + * indirection and simplifies the call chain. + * + * @param {Arborist} arb - The Arborist instance to mark changes on + * @param {Object} match - Match object containing funcNode, targetCallee, and references + * @return {Arborist} The modified Arborist instance + */ +export function resolveProxyCallsTransform(arb, match) { + const {targetCallee, references} = match; + + // Replace each reference to the proxy function with the target function + for (let i = 0; i < references.length; i++) { + arb.markNode(references[i], targetCallee); } + return arb; } -export default resolveProxyCalls; \ No newline at end of file +/** + * Remove redundant call expressions which only pass the arguments to other call expression. + * + * This transformation identifies proxy functions that simply pass their arguments + * to another function and replaces calls to the proxy with direct calls to the target. + * This is particularly useful for deobfuscating code that uses wrapper functions + * to indirect function calls. + * + * Example transformation: + * Input: function call2(c, d) { return call1(c, d); } call2(1, 2); + * Output: function call2(c, d) { return call1(c, d); } call1(1, 2); + * + * Safety constraints: + * - Only processes functions with single return statements + * - Target must be a simple identifier (not complex expression) + * - All parameters must be passed through in exact order + * - No parameter modification, reordering, or omission allowed + * + * @param {Arborist} arb - The Arborist instance containing the AST to transform + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified Arborist instance + */ +export default function resolveProxyCalls(arb, candidateFilter = () => true) { + const matches = resolveProxyCallsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveProxyCallsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveProxyReferences.js b/src/modules/safe/resolveProxyReferences.js index cb4cc8a..5d30f1b 100644 --- a/src/modules/safe/resolveProxyReferences.js +++ b/src/modules/safe/resolveProxyReferences.js @@ -2,41 +2,195 @@ import {areReferencesModified} from '../utils/areReferencesModified.js'; import {doesDescendantMatchCondition} from '../utils/doesDescendantMatchCondition.js'; import {getMainDeclaredObjectOfMemberExpression} from '../utils/getMainDeclaredObjectOfMemberExpression.js'; +// Static array for supported node types to avoid recreation overhead +const SUPPORTED_REFERENCE_TYPES = ['Identifier', 'MemberExpression']; + +// Static regex for detecting loop statements to avoid recreation overhead +const LOOP_STATEMENT_REGEX = /(For.*Statement|WhileStatement|DoWhileStatement)/; + /** - * Replace variables which only point at other variables and do not change, with their target. - * E.g. - * const a = [...]; - * const b = a; - * const c = b[0]; // <-- will be replaced with `const c = a[0];` - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Checks if a variable declarator represents a proxy reference. + * + * A proxy reference is a variable that simply points to another variable + * without modification. For example: `const b = a;` where `b` is a proxy to `a`. + * + * @param {ASTNode} declaratorNode - The VariableDeclarator node to check + * @return {boolean} True if this is a valid proxy reference pattern + */ +function isProxyReferencePattern(declaratorNode) { + // The variable being declared must be an Identifier or MemberExpression + if (!SUPPORTED_REFERENCE_TYPES.includes(declaratorNode.id?.type)) { + return false; + } + + // CRITICAL: The value being assigned must also be Identifier or MemberExpression + // This prevents transforming cases like: const b = getValue(); where getValue() is a CallExpression + if (!SUPPORTED_REFERENCE_TYPES.includes(declaratorNode.init?.type)) { + return false; + } + + // Avoid proxy variables in loop contexts (for, while, do-while) + // This prevents breaking loop semantics where variables may be modified during iteration + if (LOOP_STATEMENT_REGEX.test(declaratorNode.parentNode?.parentNode?.type)) { + return false; + } + + return true; +} + +/** + * Validates that a proxy reference replacement is safe to perform. + * + * Ensures that replacing the proxy with its target won't create circular + * references or other problematic scenarios. This includes checking for + * self-references and ensuring the proxy variable isn't used in its own + * initialization. + * + * @param {ASTNode} proxyIdentifier - The main identifier being proxied + * @param {ASTNode} replacementNode - The node that will replace the proxy + * @return {boolean} True if the replacement is safe + */ +function isReplacementSafe(proxyIdentifier, replacementNode) { + // Get the main identifier from the replacement to check for circular references + const replacementMainIdentifier = getMainDeclaredObjectOfMemberExpression(replacementNode)?.declNode; + + // Prevent circular references: proxy can't point to itself + // Example: const a = b; const b = a; (circular - not safe) + if (replacementMainIdentifier && replacementMainIdentifier === proxyIdentifier) { + return false; + } + + // Prevent self-reference in initialization + // Example: const a = someFunction(a); (not safe - uses itself in init) + if (doesDescendantMatchCondition(replacementNode, n => n === proxyIdentifier)) { + return false; + } + + return true; +} + + + +/** + * Identifies VariableDeclarator nodes that represent proxy references to other variables. + * + * A proxy reference is a variable declaration where the variable simply points to + * another variable without any modification. This pattern is common in obfuscated + * code to create indirection layers. + * + * Examples of proxy references: + * const b = a; // Simple identifier proxy + * const d = obj.prop; // Member expression proxy + * const e = b; // Chained proxy (b -> a, e -> b) + * + * Safety constraints: + * - Both variable and value must be Identifier or MemberExpression + * - Not in For statement context (to avoid breaking loop semantics) + * - No circular references allowed + * - References must not be modified after declaration + * - Target must not be modified either + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {Object[]} Array of objects with proxyNode, targetNode, and references */ -function resolveProxyReferences(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.VariableDeclarator || []), - ]; +export function resolveProxyReferencesMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.VariableDeclarator; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (['Identifier', 'MemberExpression'].includes(n.id.type) && - ['Identifier', 'MemberExpression'].includes(n.init?.type) && - !/For.*Statement/.test(n.parentNode?.parentNode?.type) && - candidateFilter(n)) { - const relevantIdentifier = getMainDeclaredObjectOfMemberExpression(n.id)?.declNode || n.id; - const refs = relevantIdentifier.references || []; - const replacementNode = n.init; - const replacementMainIdentifier = getMainDeclaredObjectOfMemberExpression(n.init)?.declNode; - if (replacementMainIdentifier && replacementMainIdentifier === relevantIdentifier) continue; - // Exclude changes in the identifier's own init - if (doesDescendantMatchCondition(n.init, n => n === relevantIdentifier)) continue; - if (refs.length && !areReferencesModified(arb.ast, refs) && !areReferencesModified(arb.ast, [replacementNode])) { - for (const ref of refs) { - arb.markNode(ref, replacementNode); - } - } + + // Must pass the candidate filter + if (!candidateFilter(n)) { + continue; + } + + // Must follow the proxy reference pattern + if (!isProxyReferencePattern(n)) { + continue; + } + + // Get the main identifier that will be replaced + const proxyIdentifier = getMainDeclaredObjectOfMemberExpression(n.id)?.declNode || n.id; + const refs = proxyIdentifier.references || []; + + // Must have references to replace + if (!refs.length) { + continue; } + + // Must be safe to replace + if (!isReplacementSafe(proxyIdentifier, n.init)) { + continue; + } + + // Both the proxy and target must not be modified + if (areReferencesModified(arb.ast, refs) || areReferencesModified(arb.ast, [n.init])) { + continue; + } + + matches.push({ + declaratorNode: n, + proxyIdentifier, + targetNode: n.init, + references: refs + }); + } + + return matches; +} + +/** + * Transforms proxy references by replacing them with direct references to their targets. + * + * For each reference to the proxy variable, replaces it with the target node + * that the proxy was pointing to. This eliminates unnecessary indirection + * in the code. + * + * @param {Arborist} arb - The Arborist instance to mark changes on + * @param {Object} match - Match object containing proxyIdentifier, targetNode, and references + * @return {Arborist} The modified Arborist instance + */ +export function resolveProxyReferencesTransform(arb, match) { + const {targetNode, references} = match; + + // Replace each reference to the proxy with the target + for (let i = 0; i < references.length; i++) { + arb.markNode(references[i], targetNode); } + return arb; } -export default resolveProxyReferences; \ No newline at end of file +/** + * Replace variables which only point at other variables and do not change, with their target. + * + * This transformation identifies proxy references where a variable simply points to + * another variable without modification and replaces all references to the proxy + * with direct references to the target. This is particularly useful for deobfuscating + * code that uses multiple layers of variable indirection. + * + * Example transformation: + * Input: const a = ['hello']; const b = a; const c = b[0]; + * Output: const a = ['hello']; const b = a; const c = a[0]; + * + * Safety constraints: + * - Only processes simple variable-to-variable assignments + * - Avoids loop iterator variables to prevent breaking loop semantics + * - Prevents circular references and self-references + * - Ensures neither proxy nor target variables are modified after declaration + * + * @param {Arborist} arb - The Arborist instance containing the AST to transform + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified Arborist instance + */ +export default function resolveProxyReferences(arb, candidateFilter = () => true) { + const matches = resolveProxyReferencesMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveProxyReferencesTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveProxyVariables.js b/src/modules/safe/resolveProxyVariables.js index a5e7c63..95236df 100644 --- a/src/modules/safe/resolveProxyVariables.js +++ b/src/modules/safe/resolveProxyVariables.js @@ -1,31 +1,130 @@ import {areReferencesModified} from '../utils/areReferencesModified.js'; /** - * Replace proxied variables with their intended target. - * E.g. - * const a2b = atob; // This line will be removed - * console.log(a2b('NDI=')); // This will be replaced with `console.log(atob('NDI='));` - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Validates that a VariableDeclarator represents a proxy variable assignment. + * + * A proxy variable is one that simply assigns another identifier without modification. + * For example: `const alias = originalVar;` where `alias` is a proxy to `originalVar`. + * + * @param {ASTNode} declaratorNode - The VariableDeclarator node to check + * @param {Function} candidateFilter - Filter function to apply additional criteria + * @return {boolean} True if this is a valid proxy variable pattern + */ +function isProxyVariablePattern(declaratorNode, candidateFilter) { + // Must have an identifier as the initialization value + if (!declaratorNode.init || declaratorNode.init.type !== 'Identifier') { + return false; + } + + // Must pass the candidate filter + if (!candidateFilter(declaratorNode)) { + return false; + } + + return true; +} + +/** + * Identifies VariableDeclarator nodes that represent proxy variables to other identifiers. + * + * A proxy variable is a declaration like `const alias = originalVar;` where the variable + * simply points to another identifier. These can either be removed (if unused) or have + * all their references replaced with the target identifier. + * + * This function finds all such proxy variables and returns them along with their + * reference information for transformation. + * + * @param {Arborist} arb - The AST tree manager + * @param {Function} candidateFilter - Filter to apply on candidate nodes + * @return {Object[]} Array of match objects containing proxy info */ -function resolveProxyVariables(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.VariableDeclarator || []), - ]; +export function resolveProxyVariablesMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.VariableDeclarator; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n?.init?.type === 'Identifier' && candidateFilter(n)) { - const refs = n.id.references || []; - // Remove proxy assignments if there are no more references - if (!refs.length) arb.markNode(n); - else if (areReferencesModified(arb.ast, refs)) continue; - else for (const ref of refs) { - arb.markNode(ref, n.init); - } + + // Must be a valid proxy variable pattern + if (!isProxyVariablePattern(n, candidateFilter)) { + continue; } + + // Get references to this proxy variable + const refs = n.id?.references || []; + + // Add to matches - we'll handle both removal and replacement in transform + matches.push({ + declaratorNode: n, + targetIdentifier: n.init, + references: refs, + shouldRemove: refs.length === 0 + }); } + + return matches; +} + +/** + * Transforms proxy variable declarations by either removing them or replacing references. + * + * For proxy variables with no references, removes the entire declaration. + * For proxy variables with references, replaces all references with the target identifier + * if the references are not modified elsewhere. + * + * @param {Arborist} arb - The AST tree manager + * @param {Object} match - Match object from resolveProxyVariablesMatch + * @return {Arborist} The modified AST tree manager + */ +export function resolveProxyVariablesTransform(arb, match) { + const {declaratorNode, targetIdentifier, references, shouldRemove} = match; + + if (shouldRemove) { + // Remove the proxy assignment if there are no references + arb.markNode(declaratorNode); + } else { + // Check if references are modified - if so, skip transformation + if (areReferencesModified(arb.ast, references)) { + return arb; + } + + // Replace all references with the target identifier + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + arb.markNode(ref, targetIdentifier); + } + } + return arb; } -export default resolveProxyVariables; \ No newline at end of file +/** + * Replace proxied variables with their intended target. + * + * This module handles simple variable assignments where one identifier is assigned + * to another identifier, creating a "proxy" relationship. It either removes unused + * proxy assignments or replaces all references to the proxy with the original identifier. + * + * Examples of transformations: + * - `const alias = original; console.log(alias);` β†’ `console.log(original);` + * - `const unused = original;` β†’ (removed entirely) + * - `const a2b = atob; console.log(a2b('test'));` β†’ `console.log(atob('test'));` + * + * Safety considerations: + * - Only transforms when references are not modified (no assignments or updates) + * - Preserves program semantics by ensuring proxy and target are equivalent + * - Removes unused declarations to clean up dead code + * + * @param {Arborist} arb - The AST tree manager + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The modified AST tree manager + */ +export default function resolveProxyVariables(arb, candidateFilter = () => true) { + const matches = resolveProxyVariablesMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveProxyVariablesTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/resolveRedundantLogicalExpressions.js b/src/modules/safe/resolveRedundantLogicalExpressions.js index 9be5b40..e0f15aa 100644 --- a/src/modules/safe/resolveRedundantLogicalExpressions.js +++ b/src/modules/safe/resolveRedundantLogicalExpressions.js @@ -1,52 +1,194 @@ +// Static arrays to avoid recreation overhead +const LOGICAL_OPERATORS = ['&&', '||']; +const TRUTHY_NODE_TYPES = ['ArrayExpression', 'ObjectExpression', 'FunctionExpression', 'ArrowFunctionExpression']; + /** - * Remove redundant logical expressions which will always resolve in the same way. - * E.g. - * if (false && ...) do_a(); else do_b(); ==> do_b(); - * if (... || true) do_c(); else do_d(); ==> do_c(); - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Evaluates the truthiness of an AST node according to JavaScript rules. + * + * In JavaScript, these are always truthy: + * - Arrays (even empty: []) + * - Objects (even empty: {}) + * - Functions + * - Regular expressions + * + * For literals, these values are falsy: false, 0, -0, 0n, "", null, undefined, NaN + * All other literal values are truthy. + * + * @param {ASTNode} node - The AST node to evaluate + * @return {boolean|null} True if truthy, false if falsy, null if indeterminate + */ +function isNodeTruthy(node) { + // Arrays, objects, functions, and regex are always truthy + if (TRUTHY_NODE_TYPES.includes(node.type) || (node.type === 'Literal' && node.regex)) { + return true; + } + + // For literal values, evaluate using JavaScript truthiness rules + if (node.type === 'Literal') { + // JavaScript falsy values: false, 0, -0, 0n, "", null, undefined, NaN + return Boolean(node.value); + } + + // For other node types, we can't determine truthiness statically + return null; +} + +/** + * Determines the replacement node for a redundant logical expression. + * + * Uses JavaScript's short-circuit evaluation rules. See truth table below: + * + * AND (&&) operator - returns first falsy value or last value: + * | Left | Right | Result | + * |--------|--------|--------| + * | truthy | any | right | + * | falsy | any | left | + * + * OR (||) operator - returns first truthy value or last value: + * | Left | Right | Result | + * |--------|--------|--------| + * | truthy | any | left | + * | falsy | any | right | + * + * @param {ASTNode} logicalExpr - The LogicalExpression node to simplify + * @return {ASTNode|null} The replacement node or null if no simplification possible */ -function resolveRedundantLogicalExpressions(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.IfStatement || []), - ]; +function getSimplifiedLogicalExpression(logicalExpr) { + const {left, right, operator} = logicalExpr; + + // Check if left operand has deterministic truthiness + const leftTruthiness = isNodeTruthy(left); + if (leftTruthiness !== null) { + if (operator === '&&') { + // Apply AND truth table: truthy left β†’ right, falsy left β†’ left + return leftTruthiness ? right : left; + } else if (operator === '||') { + // Apply OR truth table: truthy left β†’ left, falsy left β†’ right + return leftTruthiness ? left : right; + } + } + + // Check if right operand has deterministic truthiness + const rightTruthiness = isNodeTruthy(right); + if (rightTruthiness !== null) { + if (operator === '&&') { + // Apply AND truth table: truthy right β†’ left, falsy right β†’ right + return rightTruthiness ? left : right; + } else if (operator === '||') { + // Apply OR truth table: truthy right β†’ right, falsy right β†’ left + return rightTruthiness ? right : left; + } + } + + return null; // No simplification possible +} + +/** + * Finds IfStatement nodes with redundant logical expressions that can be simplified. + * + * Identifies if statements where the test condition is a logical expression (&&, ||) + * with at least one operand that has deterministic truthiness, allowing the expression + * to be simplified based on JavaScript's short-circuit evaluation rules. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of IfStatement nodes that can be simplified + */ +export function resolveRedundantLogicalExpressionsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.IfStatement; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.test.type === 'LogicalExpression' && - candidateFilter(n)) { - if (n.test.operator === '&&') { - if (n.test.left.type === 'Literal') { - if (n.test.left.value) { - arb.markNode(n.test, n.test.right); - } else { - arb.markNode(n.test, n.test.left); - } - } else if (n.test.right.type === 'Literal') { - if (n.test.right.value) { - arb.markNode(n.test, n.test.left); - } else { - arb.markNode(n.test, n.test.right); - } - } - } else if (n.test.operator === '||') { - if (n.test.left.type === 'Literal') { - if (n.test.left.value) { - arb.markNode(n.test, n.test.left); - } else { - arb.markNode(n.test, n.test.right); - } - } else if (n.test.right.type === 'Literal') { - if (n.test.right.value) { - arb.markNode(n.test, n.test.right); - } else { - arb.markNode(n.test, n.test.left); - } - } - } + + // Must have a LogicalExpression with supported operator and pass candidate filter + if (n.test?.type !== 'LogicalExpression' || + !LOGICAL_OPERATORS.includes(n.test.operator) || + !candidateFilter(n)) { + continue; + } + + // Check if this logical expression can be simplified + if (getSimplifiedLogicalExpression(n.test) !== null) { + matches.push(n); } } + + return matches; +} + +/** + * Transforms an IfStatement by simplifying its redundant logical expression. + * + * Replaces the test condition with the simplified expression determined by + * JavaScript's logical operator short-circuit evaluation rules. + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The IfStatement node to transform + * @return {Arborist} The Arborist instance for chaining + */ +export function resolveRedundantLogicalExpressionsTransform(arb, n) { + const simplifiedExpr = getSimplifiedLogicalExpression(n.test); + + if (simplifiedExpr !== null) { + arb.markNode(n.test, simplifiedExpr); + } + return arb; } -export default resolveRedundantLogicalExpressions; \ No newline at end of file +/** + * Remove redundant logical expressions which will always resolve in the same way. + * + * This function simplifies logical expressions in if statement conditions where + * one operand has deterministic truthiness, making the result predictable based on + * JavaScript's short-circuit evaluation rules. + * + * Handles literals, arrays, objects, functions, and regular expressions: + * - `if (false && expr)` becomes `if (false)` (AND with falsy literal) + * - `if ([] || expr)` becomes `if ([])` (OR with truthy array) + * - `if (expr && {})` becomes `if (expr)` (AND with truthy object) + * - `if (function() {} || expr)` becomes `if (function() {})` (OR with truthy function) + * - `if (true && expr)` becomes `if (expr)` (AND with truthy literal) + * - `if (0 || expr)` becomes `if (expr)` (OR with falsy literal) + * + * ⚠️ EDGE CASES WHERE THIS OPTIMIZATION COULD BREAK CODE: + * + * 1. Getter side effects: Properties with getters that have side effects + * - `if (obj.prop && true)` β†’ `if (obj.prop)` may change when getter is called + * - `if (false && obj.sideEffectProp)` β†’ `if (false)` prevents getter execution + * + * 2. Function call side effects: When expr contains function calls with side effects + * - `if (true && doSomething())` β†’ `if (doSomething())` (still executes) + * - `if (false && doSomething())` β†’ `if (false)` (skips execution entirely) + * + * 3. Proxy object traps: Objects wrapped in Proxy with get/has trap side effects + * - Accessing properties can trigger custom proxy handlers + * + * 4. Type coercion side effects: Objects with custom valueOf/toString methods + * - `if (customObj && true)` might trigger valueOf() during evaluation + * + * 5. Reactive/Observable systems: Frameworks like Vue, MobX, or RxJS + * - Property access can trigger reactivity or subscription side effects + * + * 6. Temporal dead zone: Variables accessed before declaration in let/const + * - May throw ReferenceError that gets prevented by short-circuiting + * + * This optimization is SAFE for obfuscated code analysis because: + * - Obfuscated code typically avoids complex side effects for reliability + * - We only transform when operands are deterministically truthy/falsy + * - The logic outcome remains semantically equivalent for pure expressions + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function resolveRedundantLogicalExpressions(arb, candidateFilter = () => true) { + const matches = resolveRedundantLogicalExpressionsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveRedundantLogicalExpressionsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/separateChainedDeclarators.js b/src/modules/safe/separateChainedDeclarators.js index 47dbd30..b6da1ea 100644 --- a/src/modules/safe/separateChainedDeclarators.js +++ b/src/modules/safe/separateChainedDeclarators.js @@ -1,53 +1,135 @@ +// Static regex to avoid recreation overhead +const FOR_STATEMENT_REGEX = /For.*Statement/; + /** - * Separate multiple variable declarators under the same variable declaration into single variable declaration->variable declarator pairs. - * E.g. - * const foo = 5, bar = 7; - * // will be separated into - * const foo = 5; const foo = 7; - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Creates individual VariableDeclaration nodes from a single declarator. + * + * @param {ASTNode} originalDeclaration - The original VariableDeclaration node + * @param {ASTNode} declarator - The individual VariableDeclarator to wrap + * @return {ASTNode} New VariableDeclaration node with single declarator + */ +function createSingleDeclaration(originalDeclaration, declarator) { + return { + type: 'VariableDeclaration', + kind: originalDeclaration.kind, + declarations: [declarator], + }; +} + +/** + * Creates a replacement parent node with separated declarations. + * + * Handles two cases: + * 1. Parent accepts arrays - splice in separated declarations + * 2. Parent accepts single nodes - wrap in BlockStatement + * + * @param {ASTNode} n - The VariableDeclaration node to replace + * @param {ASTNode[]} separatedDeclarations - Array of separated declaration nodes + * @return {ASTNode} The replacement parent node + */ +function createReplacementParent(n, separatedDeclarations) { + let replacementValue; + + if (Array.isArray(n.parentNode[n.parentKey])) { + // Parent accepts multiple nodes - splice in the separated declarations + const replacedArr = n.parentNode[n.parentKey]; + const idx = replacedArr.indexOf(n); + replacementValue = [ + ...replacedArr.slice(0, idx), + ...separatedDeclarations, + ...replacedArr.slice(idx + 1) + ]; + } else { + // Parent accepts single node - wrap in BlockStatement + replacementValue = { + type: 'BlockStatement', + body: separatedDeclarations, + }; + } + + return { + ...n.parentNode, + [n.parentKey]: replacementValue, + }; +} + +/** + * Finds VariableDeclaration nodes with multiple declarators that can be separated. + * + * Identifies variable declarations with multiple declarators, excluding those inside + * for-loop statements where multiple declarations serve a specific purpose. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of VariableDeclaration nodes that can be separated */ -function separateChainedDeclarators(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.VariableDeclaration || []), - ]; +export function separateChainedDeclaratorsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.VariableDeclaration; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + + // Must have multiple declarations, not be in a for-loop, and pass filter if (n.declarations.length > 1 && - !n.parentNode.type.match(/For.*Statement/) && - candidateFilter(n)) { - const decls = []; - for (const d of n.declarations) { - decls.push({ - type: 'VariableDeclaration', - kind: n.kind, - declarations: [d], - }); - } - // Since we're inserting new nodes, we'll need to replace the parent node - let replacementNode; - if (Array.isArray(n.parentNode[n.parentKey])) { - const replacedArr = n.parentNode[n.parentKey]; - const idx = replacedArr.indexOf(n); - replacementNode = { - ...n.parentNode, - [n.parentKey]: replacedArr.slice(0, idx).concat(decls).concat(replacedArr.slice(idx + 1)), - }; - } else { - // If the parent node isn't ready to accept multiple nodes, inject a block statement to hold them. - replacementNode = { - ...n.parentNode, - [n.parentKey]: { - type: 'BlockStatement', - body: decls, - }, - }; - } - arb.markNode(n.parentNode, replacementNode); + !FOR_STATEMENT_REGEX.test(n.parentNode.type) && + candidateFilter(n)) { + matches.push(n); } } + + return matches; +} + +/** + * Transforms a VariableDeclaration by separating its multiple declarators. + * + * Converts a single VariableDeclaration with multiple declarators into + * multiple VariableDeclaration nodes each with a single declarator. + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The VariableDeclaration node to transform + * @return {Arborist} The Arborist instance for chaining + */ +export function separateChainedDeclaratorsTransform(arb, n) { + // Create individual declarations for each declarator + const separatedDeclarations = []; + for (let i = 0; i < n.declarations.length; i++) { + separatedDeclarations.push(createSingleDeclaration(n, n.declarations[i])); + } + + // Create replacement parent node and mark for transformation + const replacementParent = createReplacementParent(n, separatedDeclarations); + arb.markNode(n.parentNode, replacementParent); + return arb; } -export default separateChainedDeclarators; \ No newline at end of file +/** + * Separate multiple variable declarators under the same variable declaration into single variable declaration->variable declarator pairs. + * + * This function improves code readability and simplifies analysis by converting + * chained variable declarations into individual declaration statements. + * + * Examples: + * - `const foo = 5, bar = 7;` becomes `const foo = 5; const bar = 7;` + * - `let a, b = 2, c = 3;` becomes `let a; let b = 2; let c = 3;` + * - `var x = 1, y = 2;` becomes `var x = 1; var y = 2;` + * + * Special handling: + * - Preserves for-loop declarations: `for (let i = 0, len = arr.length; ...)` (unchanged) + * - Wraps in BlockStatement when parent expects single node: `if (x) var a, b;` becomes `if (x) { var a; var b; }` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function separateChainedDeclarators(arb, candidateFilter = () => true) { + const matches = separateChainedDeclaratorsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = separateChainedDeclaratorsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/simplifyCalls.js b/src/modules/safe/simplifyCalls.js index 5b7e5ab..a6c0f72 100644 --- a/src/modules/safe/simplifyCalls.js +++ b/src/modules/safe/simplifyCalls.js @@ -1,30 +1,124 @@ +const CALL_APPLY_METHODS = ['apply', 'call']; +const ALLOWED_CONTEXT_VARIABLE_TYPES = ['ThisExpression', 'Literal']; + +/** + * Extracts arguments for the simplified call based on method type. + * + * For 'apply': extracts elements from the array argument + * For 'call': extracts arguments after the first (this) argument + * + * @param {ASTNode} n - The CallExpression node + * @param {string} methodName - Either 'apply' or 'call' + * @return {ASTNode[]} Array of argument nodes for the simplified call + */ +function extractSimplifiedArguments(n, methodName) { + if (methodName === 'apply') { + // For apply: func.apply(this, [arg1, arg2]) -> get elements from array + const arrayArg = n.arguments?.[1]; + return Array.isArray(arrayArg?.elements) ? arrayArg.elements : []; + } else { + // For call: func.call(this, arg1, arg2) -> get args after 'this' + return n.arguments?.slice(1) || []; + } +} + /** - * Remove unnecessary usage of '.call(this' or '.apply(this' when calling a function - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Finds CallExpression nodes that use .call(this) or .apply(this) patterns. + * + * Identifies function calls that can be simplified by removing unnecessary + * .call(this) or .apply(this) wrappers when the context is 'this'. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of CallExpression nodes that can be simplified */ -function simplifyCalls(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; +export function simplifyCallsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.CallExpression; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.arguments?.[0]?.type === 'ThisExpression' && - n.callee.type === 'MemberExpression' && - ['apply', 'call'].includes(n.callee.property?.name || n.callee.property?.value) && - (n.callee.object?.name || n.callee?.value) !== 'Function' && - !/function/i.test(n.callee.object.type) && - candidateFilter(n)) { - const args = (n.callee.property?.name || n.callee.property?.value) === 'apply' ? n.arguments?.[1]?.elements : n.arguments?.slice(1); - arb.markNode(n, { - type: 'CallExpression', - callee: n.callee.object, - arguments: Array.isArray(args) ? args : (args ? [args] : []), - }); + + // Must be a call/apply on a member expression with 'this' as first argument + if (!ALLOWED_CONTEXT_VARIABLE_TYPES.includes(n.arguments?.[0]?.type) || + (n.arguments?.[0]?.type === 'Literal' && n.arguments?.[0]?.value !== null) || + n.callee.type !== 'MemberExpression' || + !candidateFilter(n)) { + continue; + } + + const propertyName = n.callee.property?.name || n.callee.property?.value; + + // Must be 'apply' or 'call' method + if (!CALL_APPLY_METHODS.includes(propertyName)) { + continue; + } + + // Exclude Function constructor calls and function expressions + const objectName = n.callee.object?.name || n.callee?.value; + if (objectName === 'Function' || n.callee.object.type.includes('unction')) { + continue; } + + matches.push(n); } + + return matches; +} + +/** + * Transforms a .call(this) or .apply(this) call into a direct function call. + * + * Converts patterns like: + * - func.call(this, arg1, arg2) -> func(arg1, arg2) + * - func.apply(this, [arg1, arg2]) -> func(arg1, arg2) + * - func.apply(this) -> func() + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The CallExpression node to transform + * @return {Arborist} The Arborist instance for chaining + */ +export function simplifyCallsTransform(arb, n) { + const propertyName = n.callee.property?.name || n.callee.property?.value; + const simplifiedArgs = extractSimplifiedArguments(n, propertyName); + + const simplifiedCall = { + type: 'CallExpression', + callee: n.callee.object, + arguments: simplifiedArgs, + }; + + arb.markNode(n, simplifiedCall); return arb; } -export default simplifyCalls; \ No newline at end of file +/** + * Remove unnecessary usage of .call(this) or .apply(this) when calling a function. + * + * This function simplifies function calls that use .call(this, ...) or .apply(this, [...]) + * by converting them to direct function calls, improving code readability and performance. + * + * Examples: + * - `func.call(this, arg1, arg2)` becomes `func(arg1, arg2)` + * - `func.apply(this, [arg1, arg2])` becomes `func(arg1, arg2)` + * - `func.apply(this)` becomes `func()` + * - `func.call(this)` becomes `func()` + * + * Restrictions: + * - Only transforms calls where first argument is exactly 'this' + * - Does not transform Function constructor calls + * - Does not transform calls on function expressions + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function simplifyCalls(arb, candidateFilter = () => true) { + const matches = simplifyCallsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = simplifyCallsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/simplifyIfStatements.js b/src/modules/safe/simplifyIfStatements.js index fbe32f3..f3ac736 100644 --- a/src/modules/safe/simplifyIfStatements.js +++ b/src/modules/safe/simplifyIfStatements.js @@ -1,46 +1,138 @@ /** - * - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Checks if an AST node represents an empty statement or block. + * + * @param {ASTNode} node - The AST node to check + * @return {boolean} True if the node is empty, false otherwise */ -function simplifyIfStatements(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.IfStatement || []), - ]; +function isEmpty(node) { + if (!node) return true; + if (node.type === 'EmptyStatement') return true; + if (node.type === 'BlockStatement' && !node.body.length) return true; + return false; +} + +/** + * Creates an inverted test expression wrapped in UnaryExpression with '!' operator. + * + * @param {ASTNode} test - The original test expression + * @return {ASTNode} UnaryExpression node with '!' operator + */ +function createInvertedTest(test) { + return { + type: 'UnaryExpression', + operator: '!', + prefix: true, + argument: test, + }; +} + +/** + * Finds IfStatement nodes that can be simplified by removing empty branches. + * + * Identifies if statements where: + * - Both consequent and alternate are empty (convert to expression) + * - Consequent is empty but alternate has content (invert and swap) + * - Alternate is empty but consequent has content (remove alternate) + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of IfStatement nodes that can be simplified + */ +export function simplifyIfStatementsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.IfStatement; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (candidateFilter(n)) { - // Empty consequent - if (n.consequent.type === 'EmptyStatement' || (n.consequent.type === 'BlockStatement' && !n.consequent.body.length)) { - // Populated alternate - if (n.alternate && n.alternate.type !== 'EmptyStatement' && !(n.alternate.type === 'BlockStatement' && !n.alternate.body.length)) { - // Wrap the test clause in a logical NOT, replace the consequent with the alternate, and remove the now empty alternate. - arb.markNode(n, { - type: 'IfStatement', - test: { - type: 'UnaryExpression', - operator: '!', - prefix: true, - argument: n.test, - }, - consequent: n.alternate, - alternate: null, - }); - } else arb.markNode(n, { - type: 'ExpressionStatement', - expression: n.test, - }); // Empty alternate - } else if (n.alternate && (n.alternate.type === 'EmptyStatement' || (n.alternate.type === 'BlockStatement' && !n.alternate.body.length))) { - // Remove the empty alternate clause - arb.markNode(n, { - ...n, - alternate: null, - }); - } + + if (!candidateFilter(n)) { + continue; + } + + const consequentEmpty = isEmpty(n.consequent); + const alternateEmpty = isEmpty(n.alternate); + + // Can simplify if: both empty, or consequent empty with populated alternate, or alternate empty with populated consequent + if ((consequentEmpty && alternateEmpty) || + (consequentEmpty && !alternateEmpty) || + (!consequentEmpty && alternateEmpty)) { + matches.push(n); + } + } + + return matches; +} + +/** + * Transforms an IfStatement by simplifying empty branches. + * + * Applies one of three transformations: + * 1. Both branches empty: Convert to ExpressionStatement with test only + * 2. Empty consequent with populated alternate: Invert test and move alternate to consequent + * 3. Empty alternate with populated consequent: Remove the alternate clause + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The IfStatement node to transform + * @return {Arborist} The Arborist instance for chaining + */ +export function simplifyIfStatementsTransform(arb, n) { + const isConsequentEmpty = isEmpty(n.consequent); + const isAlternateEmpty = isEmpty(n.alternate); + let replacementNode; + + if (isConsequentEmpty) { + if (isAlternateEmpty) { + // Both branches empty - convert to expression statement + replacementNode = { + type: 'ExpressionStatement', + expression: n.test, + }; + } else { + // Empty consequent with populated alternate - invert test and swap + replacementNode = { + type: 'IfStatement', + test: createInvertedTest(n.test), + consequent: n.alternate, + alternate: null, + }; } + } else if (isAlternateEmpty && n.alternate !== null) { + // Populated consequent with empty alternate - remove alternate + replacementNode = { + ...n, + alternate: null, + }; } + + if (replacementNode) { + arb.markNode(n, replacementNode); + } + return arb; } -export default simplifyIfStatements; \ No newline at end of file +/** + * Simplify if statements by removing or restructuring empty branches. + * + * This function optimizes if statements that have empty consequents or alternates, + * improving code readability and reducing unnecessary branching. + * + * Transformations applied: + * - `if (test) {} else {}` becomes `test;` + * - `if (test) {} else action()` becomes `if (!test) action()` + * - `if (test) action() else {}` becomes `if (test) action()` + * - `if (test);` becomes `test;` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function simplifyIfStatements(arb, candidateFilter = () => true) { + const matches = simplifyIfStatementsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = simplifyIfStatementsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/unwrapFunctionShells.js b/src/modules/safe/unwrapFunctionShells.js index 56f4fec..d5ad61b 100644 --- a/src/modules/safe/unwrapFunctionShells.js +++ b/src/modules/safe/unwrapFunctionShells.js @@ -1,36 +1,141 @@ +const FUNCTION_TYPES = ['FunctionDeclaration', 'FunctionExpression']; + /** - * Remove functions which only return another function. - * If params or id on the outer scope are used in the inner function - replace them on the inner function. - * E.g. - * function a(x) { - * return function() {return x + 3} - * } - * // will be replaced with - * function a(x) {return x + 3} - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Gets the property name from a member expression property. + * + * @param {ASTNode} property - The property node from MemberExpression + * @return {string} The property name or an empty string if not extractable */ -function unwrapFunctionShells(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.FunctionExpression || []), - ...(arb.ast[0].typeMap.FunctionDeclaration || []), - ]; +function getPropertyName(property) { + return property?.name || property?.value || ''; +} + +/** + * Creates a replacement function by transferring outer function properties to inner function. + * + * Preserves the inner function while adding: + * - Outer function's identifier if inner function is anonymous + * - Outer function's parameters if inner function has no parameters + * + * @param {ASTNode} outerFunction - The outer function shell to unwrap + * @param {ASTNode} innerFunction - The inner function to enhance + * @return {ASTNode} The enhanced inner function node + */ +function createUnwrappedFunction(outerFunction, innerFunction) { + const replacementNode = { ...innerFunction }; + + // Transfer identifier from outer function if inner function is anonymous + if (outerFunction.id && !replacementNode.id) { + replacementNode.id = outerFunction.id; + } + + // Transfer parameters from outer function if inner function has no parameters + if (outerFunction.params.length && !replacementNode.params.length) { + replacementNode.params = outerFunction.params.slice(); + } + + return replacementNode; +} + +/** + * Finds function shells that can be unwrapped. + * + * Identifies functions that: + * - Only contain a single return statement + * - Return the result of calling another function with .apply(this, arguments) + * - Have a FunctionExpression as the callee object + * + * Pattern: `function outer() { return inner().apply(this, arguments); }` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of function nodes that can be unwrapped + */ +export function unwrapFunctionShellsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.FunctionExpression + .concat(arb.ast[0].typeMap.FunctionDeclaration); + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (['FunctionDeclaration', 'FunctionExpression'].includes(n.type) && - n.body?.body?.[0]?.type === 'ReturnStatement' && - (n.body.body[0].argument?.callee?.property?.name || n.body.body[0].argument?.callee?.property?.value) === 'apply' && - n.body.body[0].argument.arguments?.length === 2 && - n.body.body[0].argument.callee.object.type === 'FunctionExpression' && - candidateFilter(n)) { - const replacementNode = n.body.body[0].argument.callee.object; - if (n.id && !replacementNode.id) replacementNode.id = n.id; - if (n.params.length && !replacementNode.params.length) replacementNode.params.push(...n.params); - arb.markNode(n, replacementNode); + + if (!candidateFilter(n) || + !FUNCTION_TYPES.includes(n.type) || + n.body?.body?.length !== 1) { + continue; + } + + const returnStmt = n.body.body[0]; + if (returnStmt?.type !== 'ReturnStatement') { + continue; + } + + const callExpr = returnStmt.argument; + if (callExpr?.type !== 'CallExpression' || + callExpr.arguments?.length !== 2 || + callExpr.callee?.type !== 'MemberExpression' || + callExpr.callee.object?.type !== 'FunctionExpression') { + continue; + } + + const propertyName = getPropertyName(callExpr.callee.property); + if (propertyName === 'apply') { + matches.push(n); } } + + return matches; +} + +/** + * Transforms a function shell by unwrapping it to reveal the inner function. + * + * The transformation preserves the outer function's identifier and parameters + * by transferring them to the inner function when appropriate. + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The function shell node to unwrap + * @return {Arborist} The Arborist instance for chaining + */ +export function unwrapFunctionShellsTransform(arb, n) { + const innerFunction = n.body.body[0].argument.callee.object; + const replacementNode = createUnwrappedFunction(n, innerFunction); + + arb.markNode(n, replacementNode); return arb; } -export default unwrapFunctionShells; \ No newline at end of file +/** + * Remove functions which only return another function via .apply(this, arguments). + * + * This optimization unwraps function shells that serve no purpose other than + * forwarding calls to an inner function. The outer function's identifier and + * parameters are preserved by transferring them to the inner function. + * + * Transforms: + * ```javascript + * function outer(x) { + * return function inner() { return x + 3; }.apply(this, arguments); + * } + * ``` + * + * Into: + * ```javascript + * function inner(x) { + * return x + 3; + * } + * ``` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function unwrapFunctionShells(arb, candidateFilter = () => true) { + const matches = unwrapFunctionShellsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = unwrapFunctionShellsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/unwrapIIFEs.js b/src/modules/safe/unwrapIIFEs.js index dbd0983..f21947a 100644 --- a/src/modules/safe/unwrapIIFEs.js +++ b/src/modules/safe/unwrapIIFEs.js @@ -1,57 +1,148 @@ +const IIFE_FUNCTION_TYPES = ['ArrowFunctionExpression', 'FunctionExpression']; + /** - * Replace IIFEs that are unwrapping a function with the unwraped function. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Determines if a node represents an unwrappable IIFE. + * + * @param {ASTNode} n - The CallExpression node to check + * @return {boolean} True if the node is an unwrappable IIFE */ -function unwrapIIFEs(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; - candidatesLoop: for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (!n.arguments.length && - ['ArrowFunctionExpression', 'FunctionExpression'].includes(n.callee.type) && +function isUnwrappableIIFE(n) { + return !n.arguments.length && + IIFE_FUNCTION_TYPES.includes(n.callee.type) && !n.callee.id && - // IIFEs with a single return statement - ((( - n.callee.body.type !== 'BlockStatement' || - ( - n.callee.body.body.length === 1 && - n.callee.body.body[0].type === 'ReturnStatement') - ) && + // IIFEs with a single return statement for variable initialization + (((n.callee.body.type !== 'BlockStatement' || + (n.callee.body.body.length === 1 && + n.callee.body.body[0].type === 'ReturnStatement')) && n.parentKey === 'init') || - // Generic IIFE wrappers + // Generic IIFE wrappers for statement unwrapping (n.parentKey === 'ExpressionStatement' || - n.parentKey === 'argument' && - n.parentNode.type === 'UnaryExpression')) && - candidateFilter(n)) { - let targetNode = n; - let replacementNode = n.callee.body; - if (replacementNode.type === 'BlockStatement') { - let targetChild = replacementNode; - // IIFEs with a single return statement - if (replacementNode.body?.[0]?.argument) replacementNode = replacementNode.body[0].argument; - // IIFEs with multiple statements or expressions - else while (targetNode && !targetNode.body) { - // Skip cases where IIFE is used to initialize or set a value - if (targetNode.parentKey === 'init' || targetNode.type === 'AssignmentExpression' ) continue candidatesLoop; - targetChild = targetNode; - targetNode = targetNode.parentNode; - } - if (!targetNode?.body?.filter) targetNode = n; - else { - // Place the wrapped code instead of the wrapper node - replacementNode = { - ...targetNode, - body: [...targetNode.body.filter(t => t !== targetChild), ...replacementNode.body], - }; + (n.parentKey === 'argument' && n.parentNode.type === 'UnaryExpression'))); +} + +/** + * Computes target and replacement nodes for IIFE unwrapping. + * + * @param {ASTNode} n - The IIFE CallExpression node + * @return {Object|null} Object with targetNode and replacementNode, or null if unwrapping should be skipped + */ +function computeUnwrappingNodes(n) { + let targetNode = n; + let replacementNode = n.callee.body; + + if (replacementNode.type === 'BlockStatement') { + let targetChild = replacementNode; + + // IIFEs with a single return statement + if (replacementNode.body?.[0]?.argument) { + replacementNode = replacementNode.body[0].argument; + } + // IIFEs with multiple statements or expressions + else { + while (targetNode && !targetNode.body) { + // Skip cases where IIFE is used to initialize or set a value + if (targetNode.parentKey === 'init' || targetNode.type === 'AssignmentExpression') { + return null; // Signal to skip this candidate } + targetChild = targetNode; + targetNode = targetNode.parentNode; + } + + if (!targetNode?.body?.filter) { + targetNode = n; + } else { + // Place the wrapped code instead of the wrapper node + replacementNode = { + ...targetNode, + body: targetNode.body.filter(t => t !== targetChild).concat(replacementNode.body), + }; + } + } + } + + return { targetNode, replacementNode }; +} + +/** + * Finds IIFE nodes that can be unwrapped. + * + * Identifies Immediately Invoked Function Expressions (IIFEs) that: + * - Have no arguments + * - Use anonymous functions (arrow or function expressions) + * - Are used for variable initialization or statement wrapping + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of IIFE CallExpression nodes that can be unwrapped + */ +export function unwrapIIFEsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.CallExpression; + const matches = []; + + for (let i = 0; i < relevantNodes.length; i++) { + const n = relevantNodes[i]; + + if (isUnwrappableIIFE(n) && candidateFilter(n)) { + // Verify that unwrapping is actually possible + const unwrappingNodes = computeUnwrappingNodes(n); + if (unwrappingNodes !== null) { + matches.push(n); } - arb.markNode(targetNode, replacementNode); } } + + return matches; +} + +/** + * Transforms an IIFE by unwrapping it to reveal its content. + * + * Handles two main transformation patterns: + * 1. Variable initialization: Replace IIFE with returned function/value + * 2. Statement unwrapping: Replace IIFE with its body statements + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The IIFE CallExpression node to unwrap + * @return {Arborist} The Arborist instance for chaining + */ +export function unwrapIIFEsTransform(arb, n) { + const unwrappingNodes = computeUnwrappingNodes(n); + + if (unwrappingNodes !== null) { + const { targetNode, replacementNode } = unwrappingNodes; + arb.markNode(targetNode, replacementNode); + } + return arb; } -export default unwrapIIFEs; \ No newline at end of file +/** + * Replace IIFEs that are unwrapping a function with the unwrapped function. + * + * This optimization removes unnecessary IIFE wrappers around functions or statements + * that serve no purpose other than immediate execution. The transformation handles + * both variable initialization patterns and statement unwrapping scenarios. + * + * Transforms: + * ```javascript + * var a = (() => { return b => c(b - 40); })(); + * ``` + * + * Into: + * ```javascript + * var a = b => c(b - 40); + * ``` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function unwrapIIFEs(arb, candidateFilter = () => true) { + const matches = unwrapIIFEsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = unwrapIIFEsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/safe/unwrapSimpleOperations.js b/src/modules/safe/unwrapSimpleOperations.js index fc6eb01..db5320b 100644 --- a/src/modules/safe/unwrapSimpleOperations.js +++ b/src/modules/safe/unwrapSimpleOperations.js @@ -1,14 +1,18 @@ -const operators = ['+', '-', '*', '/', '%', '&', '|', '&&', '||', '**', '^', '<=', '>=', '<', '>', '==', '===', '!=', +const BINARY_OPERATORS = ['+', '-', '*', '/', '%', '&', '|', '&&', '||', '**', '^', '<=', '>=', '<', '>', '==', '===', '!=', '!==', '<<', '>>', '>>>', 'in', 'instanceof', '??']; -const fixes = ['!', '~', '-', '+', 'typeof', 'void', 'delete', '--', '++']; // as in prefix and postfix operators +const UNARY_OPERATORS = ['!', '~', '-', '+', 'typeof', 'void', 'delete', '--', '++']; +const BINARY_EXPRESSION_TYPES = ['LogicalExpression', 'BinaryExpression']; +const UNARY_EXPRESSION_TYPES = ['UnaryExpression', 'UpdateExpression']; /** - * @param {ASTNode} n - * @return {boolean} + * Determines if a node is a simple binary or logical operation within a function wrapper. + * + * @param {ASTNode} n - The expression node to check + * @return {boolean} True if the node is a binary/logical operation in a simple function wrapper */ -function matchBinaryOrLogical(n) { - return ['LogicalExpression', 'BinaryExpression'].includes(n.type) && - operators.includes(n.operator) && +function isBinaryOrLogicalWrapper(n) { + return BINARY_EXPRESSION_TYPES.includes(n.type) && + BINARY_OPERATORS.includes(n.operator) && n.parentNode.type === 'ReturnStatement' && n.parentNode.parentNode?.body?.length === 1 && n.left?.declNode?.parentKey === 'params' && @@ -16,78 +20,147 @@ function matchBinaryOrLogical(n) { } /** - * @param {ASTNode} c - * @param {Arborist} arb + * Determines if a node is a simple unary or update operation within a function wrapper. + * + * @param {ASTNode} n - The expression node to check + * @return {boolean} True if the node is a unary/update operation in a simple function wrapper */ -function handleBinaryOrLogical(c, arb) { - const refs = (c.scope.block?.id?.references || []).map(r => r.parentNode); - for (const ref of refs) { - if (ref.type === 'CallExpression' && ref.arguments.length === 2) arb.markNode(ref, { - type: c.type, - operator: c.operator, - left: ref.arguments[0], - right: ref.arguments[1], - }); - } +function isUnaryOrUpdateWrapper(n) { + return UNARY_EXPRESSION_TYPES.includes(n.type) && + UNARY_OPERATORS.includes(n.operator) && + n.parentNode.type === 'ReturnStatement' && + n.parentNode.parentNode?.body?.length === 1 && + n.argument?.declNode?.parentKey === 'params'; } /** - * @param {ASTNode} n - * @return {boolean} + * Creates a binary or logical expression node from the original operation. + * + * @param {ASTNode} operationNode - The original binary/logical expression node + * @param {ASTNode[]} args - The function call arguments to use as operands + * @return {ASTNode} New binary or logical expression node */ -function matchUnaryOrUpdate(n) { - return ['UnaryExpression', 'UpdateExpression'].includes(n.type) && - fixes.includes(n.operator) && - n.parentNode.type === 'ReturnStatement' && - n.parentNode.parentNode?.body?.length === 1 && - n.argument?.declNode?.parentKey === 'params'; +function createBinaryOrLogicalExpression(operationNode, args) { + return { + type: operationNode.type, + operator: operationNode.operator, + left: args[0], + right: args[1], + }; } /** - * @param {ASTNode} c - * @param {Arborist} arb + * Creates a unary or update expression node from the original operation. + * + * @param {ASTNode} operationNode - The original unary/update expression node + * @param {ASTNode[]} args - The function call arguments to use as operands + * @return {ASTNode} New unary or update expression node */ -function handleUnaryAndUpdate(c, arb) { - const refs = (c.scope.block?.id?.references || []).map(r => r.parentNode); - for (const ref of refs) { - if (ref.type === 'CallExpression' && ref.arguments.length === 1) arb.markNode(ref, { - type: c.type, - operator: c.operator, - prefix: c.prefix, - argument: ref.arguments[0], - }); - } +function createUnaryOrUpdateExpression(operationNode, args) { + return { + type: operationNode.type, + operator: operationNode.operator, + prefix: operationNode.prefix, + argument: args[0], + }; } /** - * Replace calls to functions that wrap simple operations with the actual operations - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Finds nodes representing simple operations wrapped in functions. + * + * Identifies operation expressions (binary, logical, unary, update) that are: + * - Single statements in function return statements + * - Use function parameters as operands + * - Can be safely unwrapped to direct operation calls + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of operation nodes that can be unwrapped */ -function unwrapSimpleOperations(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.BinaryExpression || []), - ...(arb.ast[0].typeMap.LogicalExpression || []), - ...(arb.ast[0].typeMap.UnaryExpression || []), - ...(arb.ast[0].typeMap.UpdateExpression || []), - ]; +export function unwrapSimpleOperationsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.BinaryExpression + .concat(arb.ast[0].typeMap.LogicalExpression) + .concat(arb.ast[0].typeMap.UnaryExpression) + .concat(arb.ast[0].typeMap.UpdateExpression); + + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if ((matchBinaryOrLogical(n) || matchUnaryOrUpdate(n)) && candidateFilter(n)) { - switch (n.type) { - case 'BinaryExpression': - case 'LogicalExpression': - handleBinaryOrLogical(n, arb); - break; - case 'UnaryExpression': - case 'UpdateExpression': - handleUnaryAndUpdate(n, arb); - break; + + if ((isBinaryOrLogicalWrapper(n) || isUnaryOrUpdateWrapper(n)) && candidateFilter(n)) { + matches.push(n); + } + } + + return matches; +} + +/** + * Transforms a simple operation wrapper by replacing function calls with direct operations. + * + * Replaces function calls that wrap simple operations with the actual operation. + * For example, `add(1, 2)` where `add` is `function add(a,b) { return a + b; }` + * becomes `1 + 2`. + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The operation expression node within the function wrapper + * @return {Arborist} The Arborist instance for chaining + */ +export function unwrapSimpleOperationsTransform(arb, n) { + const references = n.scope.block?.id?.references || []; + + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const callExpression = ref.parentNode; + + if (callExpression.type === 'CallExpression') { + let replacementNode = null; + + if (BINARY_EXPRESSION_TYPES.includes(n.type) && callExpression.arguments.length === 2) { + replacementNode = createBinaryOrLogicalExpression(n, callExpression.arguments); + } else if (UNARY_EXPRESSION_TYPES.includes(n.type) && callExpression.arguments.length === 1) { + replacementNode = createUnaryOrUpdateExpression(n, callExpression.arguments); + } + + if (replacementNode) { + arb.markNode(callExpression, replacementNode); } } } + return arb; } -export default unwrapSimpleOperations; \ No newline at end of file +/** + * Replace calls to functions that wrap simple operations with the actual operations. + * + * This optimization identifies function wrappers around simple operations (binary, logical, + * unary, and update expressions) and replaces function calls with direct operations. + * This removes unnecessary function call overhead for basic operations. + * + * Transforms: + * ```javascript + * function add(a, b) { return a + b; } + * add(1, 2); + * ``` + * + * Into: + * ```javascript + * function add(a, b) { return a + b; } + * 1 + 2; + * ``` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function unwrapSimpleOperations(arb, candidateFilter = () => true) { + const matches = unwrapSimpleOperationsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = unwrapSimpleOperationsTransform(arb, matches[i]); + } + + return arb; +} \ No newline at end of file diff --git a/src/modules/unsafe/normalizeRedundantNotOperator.js b/src/modules/unsafe/normalizeRedundantNotOperator.js index 63228c4..278d016 100644 --- a/src/modules/unsafe/normalizeRedundantNotOperator.js +++ b/src/modules/unsafe/normalizeRedundantNotOperator.js @@ -1,34 +1,134 @@ -import {badValue} from '../config.js'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; -import {canUnaryExpressionBeResolved} from '../utils/canUnaryExpressionBeResolved.js'; -const relevantNodeTypes = ['Literal', 'ArrayExpression', 'ObjectExpression', 'UnaryExpression']; +const RESOLVABLE_ARGUMENT_TYPES = ['Literal', 'ArrayExpression', 'ObjectExpression', 'Identifier', 'TemplateLiteral', 'UnaryExpression']; /** - * Replace redundant not operators with actual value (e.g. !true -> false) - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Determines if a NOT operator's argument can be safely resolved to a boolean value. + * All supported argument types (literals, arrays, objects, template literals, identifiers) + * can be evaluated to determine their truthiness without side effects. + * @param {ASTNode} argument - The argument node of the NOT operator to check + * @return {boolean} True if the argument can be resolved independently, false otherwise */ -function normalizeRedundantNotOperator(arb, candidateFilter = () => true) { - let sharedSB; - const relevantNodes = [ - ...(arb.ast[0].typeMap.UnaryExpression || []), - ]; +function canNotOperatorArgumentBeResolved(argument) { + switch (argument.type) { + case 'Literal': + return true; // All literals: !true, !"hello", !42, !null + + case 'ArrayExpression': + // All arrays evaluate to truthy (even empty ones), so all are resolvable + // E.g. ![] -> false, ![1, 2, 3] -> false + return true; + + case 'ObjectExpression': + // All objects evaluate to truthy (even empty ones), so all are resolvable + // E.g. !{} -> false, !{a: 1} -> false + return true; + + case 'Identifier': + // Only the undefined identifier has predictable truthiness + return argument.name === 'undefined'; + + case 'TemplateLiteral': + // Template literals with no dynamic expressions can be evaluated + // E.g. !`hello` -> false, !`` -> true, but not !`hello ${variable}` + return !argument.expressions.length; + + case 'UnaryExpression': + // Nested unary expressions: !!true, +!false, etc. + return canNotOperatorArgumentBeResolved(argument.argument); + } + + // Conservative approach: other expression types require runtime evaluation + return false; +} + +/** + * Finds UnaryExpression nodes with redundant NOT operators that can be normalized. + * + * Identifies NOT operators (!expr) where the expression can be safely evaluated + * to determine the boolean result. This includes NOT operations on: + * - Literals (numbers, strings, booleans, null) + * - Array expressions (empty or with literal elements) + * - Object expressions (empty or with literal properties) + * - Nested unary expressions + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of UnaryExpression nodes with redundant NOT operators + */ +export function normalizeRedundantNotOperatorMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.UnaryExpression; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + if (n.operator === '!' && - relevantNodeTypes.includes(n.argument.type) && - candidateFilter(n)) { - if (canUnaryExpressionBeResolved(n.argument)) { - sharedSB = sharedSB || new Sandbox(); - const replacementNode = evalInVm(n.src, sharedSB); - if (replacementNode !== badValue) arb.markNode(n, replacementNode); - } + RESOLVABLE_ARGUMENT_TYPES.includes(n.argument.type) && + canNotOperatorArgumentBeResolved(n.argument) && + candidateFilter(n)) { + matches.push(n); } } + + return matches; +} + +/** + * Transforms a redundant NOT operator by evaluating it to its boolean result. + * + * Evaluates the NOT expression in a sandbox environment and replaces it with + * the computed boolean literal. This normalizes expressions like `!true` to `false`, + * `!0` to `true`, `![]` to `false`, etc. + * + * @param {Arborist} arb - The Arborist instance to mark nodes for transformation + * @param {ASTNode} n - The UnaryExpression node with redundant NOT operator + * @param {Sandbox} sharedSandbox - Shared sandbox instance for evaluation + * @return {Arborist} The Arborist instance for chaining + */ +export function normalizeRedundantNotOperatorTransform(arb, n, sharedSandbox) { + const replacementNode = evalInVm(n.src, sharedSandbox); + + if (replacementNode !== evalInVm.BAD_VALUE) { + arb.markNode(n, replacementNode); + } + return arb; } -export default normalizeRedundantNotOperator; \ No newline at end of file +/** + * Replace redundant NOT operators with their actual boolean values. + * + * This optimization evaluates NOT expressions that can be safely computed at + * transformation time, replacing them with boolean literals. This includes + * expressions like `!true`, `!0`, `![]`, `!{}`, etc. + * + * The evaluation is performed in a secure sandbox environment to prevent + * code execution side effects. + * + * Transforms: + * ```javascript + * !true || !false || !0 || !1 + * ``` + * + * Into: + * ```javascript + * false || true || true || false + * ``` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function normalizeRedundantNotOperator(arb, candidateFilter = () => true) { + const matches = normalizeRedundantNotOperatorMatch(arb, candidateFilter); + + if (matches.length) { + let sharedSandbox = new Sandbox(); + for (let i = 0; i < matches.length; i++) { + arb = normalizeRedundantNotOperatorTransform(arb, matches[i], sharedSandbox); + } + } + return arb; +} \ No newline at end of file diff --git a/src/modules/unsafe/resolveAugmentedFunctionWrappedArrayReplacements.js b/src/modules/unsafe/resolveAugmentedFunctionWrappedArrayReplacements.js index 1aa51f2..6613313 100644 --- a/src/modules/unsafe/resolveAugmentedFunctionWrappedArrayReplacements.js +++ b/src/modules/unsafe/resolveAugmentedFunctionWrappedArrayReplacements.js @@ -1,82 +1,217 @@ -import {badValue} from '../config.js'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; import {getDescendants} from '../utils/getDescendants.js'; import {doesDescendantMatchCondition} from '../utils/doesDescendantMatchCondition.js'; + + /** - * A special case of function array replacement where the function is wrapped in another function, the array is - * sometimes wrapped in its own function, and is also augmented. - * TODO: Add example code - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Resolves array reference from array candidate node by finding the assignment expression + * where an array is assigned to a variable. + * + * This function returns the actual assignment/declaration node (e.g., `var arr = [1,2,3]` + * or `arr = someFunction()`). Having this assignment is crucial because it provides: + * - The variable name that holds the array + * - The ability to find all references to that array variable throughout the code + * - The assignment expression needed for the sandbox evaluation context + * + * Handles both: + * - Global scope array declarations: `var arr = [1,2,3]` + * - Call expression array initializations: `var arr = someArrayFunction()` + * + * @param {ASTNode} ac - Array candidate node (Identifier) to resolve reference for + * @return {ASTNode|null} The assignment/declaration node containing the array, or null if not found */ -export default function resolveAugmentedFunctionWrappedArrayReplacements(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.FunctionDeclaration || []), - ]; +function resolveArrayReference(ac) { + if (!ac.declNode) return null; + + if (ac.declNode.scope.type === 'global') { + if (ac.declNode.parentNode?.init?.type === 'ArrayExpression') { + return ac.declNode.parentNode?.parentNode || ac.declNode.parentNode; + } + } else if (ac.declNode.parentNode?.init?.type === 'CallExpression') { + return ac.declNode.parentNode.init.callee?.declNode?.parentNode; + } + + return null; +} + +/** + * Finds matching expression statement that calls a function with the array candidate. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {ASTNode} ac - Array candidate node to match + * @return {ASTNode|null} The matching expression statement or null if not found + */ +function findMatchingExpressionStatement(arb, ac) { + const expressionStatements = arb.ast[0].typeMap.ExpressionStatement; + for (let i = 0; i < expressionStatements.length; i++) { + const exp = expressionStatements[i]; + if (exp.expression.type === 'CallExpression' && + exp.expression.callee.type === 'FunctionExpression' && + exp.expression.arguments.length && + exp.expression.arguments[0].type === 'Identifier' && + exp.expression.arguments[0].declNode === ac.declNode) { + return exp; + } + } + return null; +} + +/** + * Finds call expressions that reference the decryptor function and are candidates for replacement. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {ASTNode} arrDecryptor - The function that decrypts array values + * @param {ASTNode[]} skipScopes - Array of scopes to skip when searching + * @return {ASTNode[]} Array of call expression nodes that are replacement candidates + */ +function findReplacementCandidates(arb, arrDecryptor, skipScopes) { + const callExpressions = arb.ast[0].typeMap.CallExpression; + const replacementCandidates = []; + + for (let i = 0; i < callExpressions.length; i++) { + const c = callExpressions[i]; + if (c.callee?.name === arrDecryptor.id.name && + !skipScopes.includes(c.scope)) { + replacementCandidates.push(c); + } + } + + return replacementCandidates; +} + +/** + * Finds FunctionDeclaration nodes that are potentially augmented functions. + * + * Performs initial filtering for functions that: + * - Are named (have an identifier) + * - Contains assignment expressions that modify the function itself + * + * Additional validation (checking if the function is used as an array decryptor) + * is performed in the transform function since it's computationally expensive + * and the results are needed for the actual transformation logic. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} candidateFilter - Filter function to apply to candidates + * @return {ASTNode[]} Array of FunctionDeclaration nodes that are potentially augmented + */ +export function resolveAugmentedFunctionWrappedArrayReplacementsMatch(arb, candidateFilter = () => true) { + const relevantNodes = arb.ast[0].typeMap.FunctionDeclaration; + const matches = []; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.id && + if (n.id?.name && candidateFilter(n) && doesDescendantMatchCondition(n, d => d.type === 'AssignmentExpression' && - d.left?.name === n.id?.name) && - candidateFilter(n)) { - const descendants = getDescendants(n); - const arrDecryptor = n; - const arrCandidates = []; - for (let q = 0; q < descendants.length; q++) { - const c = descendants[q]; - if (c.type === 'MemberExpression' && c.object.type === 'Identifier') arrCandidates.push(c.object); - } - for (let j = 0; j < arrCandidates.length; j++) { - const ac = arrCandidates[j]; - // If a direct reference to a global variable pointing at an array - let arrRef; - if (!ac.declNode) continue; - if (ac.declNode.scope.type === 'global') { - if (ac.declNode.parentNode?.init?.type === 'ArrayExpression') { - arrRef = ac.declNode.parentNode?.parentNode || ac.declNode.parentNode; - } - } else if (ac.declNode.parentNode?.init?.type === 'CallExpression') { - arrRef = ac.declNode.parentNode.init.callee?.declNode?.parentNode; - } - if (arrRef) { - const expressionStatements = arb.ast[0].typeMap.ExpressionStatement || []; - for (let k = 0; k < expressionStatements.length; k++) { - const exp = expressionStatements[k]; - if (exp.expression.type === 'CallExpression' && - exp.expression.callee.type === 'FunctionExpression' && - exp.expression.arguments.length && - exp.expression.arguments[0].type === 'Identifier' && - exp.expression.arguments[0].declNode === ac.declNode) { - const context = [arrRef.src, arrDecryptor.src, exp.src].join('\n'); - const skipScopes = [arrRef.scope, arrDecryptor.scope, exp.expression.callee.scope]; - const callExpressions = arb.ast[0].typeMap.CallExpression || []; - const replacementCandidates = []; - for (let r = 0; r < callExpressions.length; r++) { - const c = callExpressions[r]; - if (c.callee?.name === arrDecryptor.id.name && - !skipScopes.includes(c.scope)) { - replacementCandidates.push(c); - } - } - const sb = new Sandbox(); - sb.run(context); - for (let p = 0; p < replacementCandidates.length; p++) { - const rc = replacementCandidates[p]; - const replacementNode = evalInVm(`\n${rc.src}`, sb); - if (replacementNode !== badValue) { - arb.markNode(rc, replacementNode); - } - } - break; + d.left?.name === n.id.name)) { + matches.push(n); + } + } + + return matches; +} + +/** + * Transforms augmented function declarations by resolving array-wrapped function calls. + * + * This handles a complex obfuscation pattern where: + * 1. Array data is stored in variables (global or function-scoped) + * 2. A decryptor function processes array indices to return string values + * 3. The decryptor function is modified/augmented through assignment expressions + * 4. Function expressions are used to set up the array-decryptor relationship + * 5. Call expressions to the decryptor function are replaced with literal values + * + * The transformation creates a sandbox environment containing the array definition, + * decryptor function, and setup expression, then evaluates calls to replace them + * with their computed literal values. + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {ASTNode} n - The FunctionDeclaration node to transform + * @return {Arborist} The Arborist instance for chaining + */ +export function resolveAugmentedFunctionWrappedArrayReplacementsTransform(arb, n) { + const descendants = getDescendants(n); + const arrDecryptor = n; + + // Find and process MemberExpression nodes with Identifier objects as array candidates + for (let i = 0; i < descendants.length; i++) { + const d = descendants[i]; + if (d.type === 'MemberExpression' && d.object.type === 'Identifier') { + const ac = d.object; + const arrRef = resolveArrayReference(ac); + + if (arrRef) { + const exp = findMatchingExpressionStatement(arb, ac); + + if (exp) { + const context = [arrRef.src, arrDecryptor.src, exp.src].join('\n;'); + const skipScopes = [arrRef.scope, arrDecryptor.scope, exp.expression.callee.scope]; + const replacementCandidates = findReplacementCandidates(arb, arrDecryptor, skipScopes); + + if (!replacementCandidates.length) continue; + + const sb = new Sandbox(); + sb.run(context); + + for (let j = 0; j < replacementCandidates.length; j++) { + const rc = replacementCandidates[j]; + const replacementNode = evalInVm(`\n${rc.src}`, sb); + if (replacementNode !== evalInVm.BAD_VALUE) { + arb.markNode(rc, replacementNode); } } + break; } } } } + + return arb; +} + +/** + * Resolves augmented function-wrapped array replacements in obfuscated code. + * + * This transformation handles a sophisticated obfuscation pattern where array + * access is disguised through function calls that decrypt array indices. The + * pattern typically involves: + * + * 1. An array of encoded strings stored in a variable + * 2. A decryptor function that takes indices and returns decoded strings + * 3. Assignment expressions that modify the decryptor function (augmentation) + * 4. Function expressions that establish the array-decryptor relationship + * 5. Call expressions throughout the code that use the decryptor + * + * This module identifies such patterns and replaces the function calls with + * their actual string literals, effectively deobfuscating the code. + * + * Example transformation: + * ```javascript + * // Before: + * var arr = ['encoded1', 'encoded2']; + * function decrypt(i) { return arr[i]; } + * decrypt = augmentFunction(decrypt, arr); + * console.log(decrypt(0)); // obfuscated call + * + * // After: + * var arr = ['encoded1', 'encoded2']; + * function decrypt(i) { return arr[i]; } + * decrypt = augmentFunction(decrypt, arr); + * console.log('decoded1'); // literal replacement + * ``` + * + * @param {Arborist} arb - The Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter to apply to candidates + * @return {Arborist} The Arborist instance for chaining + */ +export default function resolveAugmentedFunctionWrappedArrayReplacements(arb, candidateFilter = () => true) { + const matches = resolveAugmentedFunctionWrappedArrayReplacementsMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = resolveAugmentedFunctionWrappedArrayReplacementsTransform(arb, matches[i]); + } + return arb; } \ No newline at end of file diff --git a/src/modules/unsafe/resolveBuiltinCalls.js b/src/modules/unsafe/resolveBuiltinCalls.js index 2c91d59..3081470 100644 --- a/src/modules/unsafe/resolveBuiltinCalls.js +++ b/src/modules/unsafe/resolveBuiltinCalls.js @@ -1,77 +1,112 @@ import {logger} from 'flast'; -import {badValue} from '../config.js'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; import {createNewNode} from '../utils/createNewNode.js'; import * as safeImplementations from '../utils/safeImplementations.js'; -import {skipBuiltinFunctions, skipIdentifiers, skipProperties} from '../config.js'; +import {SKIP_IDENTIFIERS, SKIP_PROPERTIES} from '../config.js'; -const availableSafeImplementations = Object.keys(safeImplementations); - -function isCallWithOnlyLiteralArguments(node) { - return node.type === 'CallExpression' && !node.arguments.find(a => a.type !== 'Literal'); -} - -function isBuiltinIdentifier(node) { - return node.type === 'Identifier' && !node.declNode && !skipBuiltinFunctions.includes(node.name); -} - -function isSafeCall(node) { - return node.type === 'CallExpression' && availableSafeImplementations.includes((node.callee.name)); -} - -function isBuiltinMemberExpression(node) { - return node.type === 'MemberExpression' && - !node.object.declNode && - !skipBuiltinFunctions.includes(node.object?.name) && - !skipIdentifiers.includes(node.object?.name) && - !skipProperties.includes(node.property?.name || node.property?.value); -} - -function isUnwantedNode(node) { - return Boolean(node.callee?.declNode || node?.callee?.object?.declNode || - 'ThisExpression' === (node.callee?.object?.type || node.callee?.type) || - 'constructor' === (node.callee?.property?.name || node.callee?.property?.value)); -} +const AVAILABLE_SAFE_IMPLEMENTATIONS = Object.keys(safeImplementations); +// Builtin functions that shouldn't be resolved in the deobfuscation context. +const SKIP_BUILTIN_FUNCTIONS = [ + 'Function', 'eval', 'Array', 'Object', 'fetch', 'XMLHttpRequest', 'Promise', 'console', 'performance', '$', +]; /** - * Resolve calls to builtin functions (like atob or String(), etc...). - * Use safe implmentations of known functions when available. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies builtin function calls that can be resolved to literal values. + * Matches CallExpressions and MemberExpressions that reference builtin functions + * with only literal arguments, and Identifiers that are builtin functions. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {ASTNode[]} Array of nodes that match the criteria */ -function resolveBuiltinCalls(arb, candidateFilter = () => true) { - let sharedSb; - const relevantNodes = [ - ...(arb.ast[0].typeMap.MemberExpression || []), - ...(arb.ast[0].typeMap.CallExpression || []), - ...(arb.ast[0].typeMap.Identifier || []), - ]; +export function resolveBuiltinCallsMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.MemberExpression + .concat(arb.ast[0].typeMap.CallExpression) + .concat(arb.ast[0].typeMap.Identifier); + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (!isUnwantedNode(n) && candidateFilter(n) && (isSafeCall(n) || - (isCallWithOnlyLiteralArguments(n) && (isBuiltinIdentifier(n.callee) || isBuiltinMemberExpression(n.callee))) - )) { - try { - const safeImplementation = safeImplementations[n.callee.name]; - if (safeImplementation) { - const args = n.arguments.map(a => a.value); - const tempValue = safeImplementation(...args); - if (tempValue) { - arb.markNode(n, createNewNode(tempValue)); - } - } else { - sharedSb = sharedSb || new Sandbox(); - const replacementNode = evalInVm(n.src, sharedSb); - if (replacementNode !== badValue) arb.markNode(n, replacementNode); - } - } catch (e) { - logger.debug(e.message); + if (!candidateFilter(n)) continue; + + // Skip user-defined functions and objects, this expressions, constructor access + if (n.callee?.declNode || n?.callee?.object?.declNode || + 'ThisExpression' === (n.callee?.object?.type || n.callee?.type) || + 'constructor' === (n.callee?.property?.name || n.callee?.property?.value)) { + continue; + } + + // Check for safe implementation calls + if (n.type === 'CallExpression' && AVAILABLE_SAFE_IMPLEMENTATIONS.includes(n.callee.name)) { + matches.push(n); + } + + // Check for calls with only literal arguments + else if (n.type === 'CallExpression' && !n.arguments.some(a => a.type !== 'Literal')) { + // Check if callee is builtin identifier + if (n.callee.type === 'Identifier' && !n.callee.declNode && + !SKIP_BUILTIN_FUNCTIONS.includes(n.callee.name)) { + matches.push(n); + continue; + } + + // Check if callee is builtin member expression + if (n.callee.type === 'MemberExpression' && !n.callee.object.declNode && + !SKIP_BUILTIN_FUNCTIONS.includes(n.callee.object?.name) && + !SKIP_IDENTIFIERS.includes(n.callee.object?.name) && + !SKIP_PROPERTIES.includes(n.callee.property?.name || n.callee.property?.value)) { + matches.push(n); } } } + return matches; +} + +/** + * Transforms a builtin function call into its literal value. + * Uses safe implementations when available, otherwise evaluates in sandbox. + * @param {Arborist} arb - The Arborist instance + * @param {ASTNode} n - The node to transform + * @param {Sandbox} sharedSb - Shared sandbox instance for evaluation + * @return {Arborist} The updated Arborist instance + */ +export function resolveBuiltinCallsTransform(arb, n, sharedSb) { + try { + const safeImplementation = safeImplementations[n.callee.name]; + if (safeImplementation) { + // Use safe implementation for known functions (btoa, atob, etc.) + const args = n.arguments.map(a => a.value); + const tempValue = safeImplementation(...args); + if (tempValue) { + arb.markNode(n, createNewNode(tempValue)); + } + } else { + // Evaluate unknown builtin calls in sandbox + const replacementNode = evalInVm(n.src, sharedSb); + if (replacementNode !== evalInVm.BAD_VALUE) arb.markNode(n, replacementNode); + } + } catch (e) { + logger.debug(e.message); + } return arb; } -export default resolveBuiltinCalls; \ No newline at end of file +/** + * Resolve calls to builtin functions (like atob, btoa, String.fromCharCode, etc.). + * Replaces builtin function calls with literal arguments with their computed values. + * Uses safe implementations when available to avoid potential security issues. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter to apply on candidates + * @return {Arborist} The updated Arborist instance + */ +export default function resolveBuiltinCalls(arb, candidateFilter = () => true) { + const matches = resolveBuiltinCallsMatch(arb, candidateFilter); + let sharedSb; + + for (let i = 0; i < matches.length; i++) { + // Create sandbox only when needed to avoid overhead + sharedSb = sharedSb || new Sandbox(); + arb = resolveBuiltinCallsTransform(arb, matches[i], sharedSb); + } + return arb; +} \ No newline at end of file diff --git a/src/modules/unsafe/resolveDefiniteBinaryExpressions.js b/src/modules/unsafe/resolveDefiniteBinaryExpressions.js index 32cd95f..81cb985 100644 --- a/src/modules/unsafe/resolveDefiniteBinaryExpressions.js +++ b/src/modules/unsafe/resolveDefiniteBinaryExpressions.js @@ -1,38 +1,144 @@ -import {badValue} from '../config.js'; +import {logger} from 'flast'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; -import {doesBinaryExpressionContainOnlyLiterals} from '../utils/doesBinaryExpressionContainOnlyLiterals.js'; /** - * Resolve definite binary expressions. - * E.g. - * 5 * 3 ==> 15; - * '2' + 2 ==> '22'; - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Recursively determines if an AST expression contains only literal values. + * This is useful for identifying expressions that can be safely evaluated at compile time. + * Supports binary expressions, unary expressions, logical expressions, conditional expressions, + * sequence expressions, update expressions, and parenthesized expressions. + * + * @param {ASTNode} expression - The AST node to check for literal-only content + * @return {boolean} True if the expression contains only literals; false otherwise + * + * @example + * // Returns true + * doesBinaryExpressionContainOnlyLiterals(parseCode('1 + 2').body[0].expression); + * doesBinaryExpressionContainOnlyLiterals(parseCode('!true').body[0].expression); + * doesBinaryExpressionContainOnlyLiterals(parseCode('true ? 1 : 2').body[0].expression); + * + * // Returns false + * doesBinaryExpressionContainOnlyLiterals(parseCode('1 + x').body[0].expression); + * doesBinaryExpressionContainOnlyLiterals(parseCode('func()').body[0].expression); */ -function resolveDefiniteBinaryExpressions(arb, candidateFilter = () => true) { - let sharedSb; - const relevantNodes = [ - ...(arb.ast[0].typeMap.BinaryExpression || []), - ]; +export function doesBinaryExpressionContainOnlyLiterals(expression) { + // Early return for null/undefined to prevent errors + if (!expression || !expression.type) { + return false; + } + + switch (expression.type) { + case 'BinaryExpression': + // Both operands must contain only literals + return doesBinaryExpressionContainOnlyLiterals(expression.left) && + doesBinaryExpressionContainOnlyLiterals(expression.right); + + case 'UnaryExpression': + // Argument must contain only literals (e.g., !true, -5, +"hello") + return doesBinaryExpressionContainOnlyLiterals(expression.argument); + + case 'UpdateExpression': + // UpdateExpression requires lvalue (variable/property), never a literal + // Valid: ++x, invalid: ++5 (flast won't generate UpdateExpression for invalid syntax) + return false; + + case 'LogicalExpression': + // Both operands must contain only literals (e.g., true && false, 1 || 2) + return doesBinaryExpressionContainOnlyLiterals(expression.left) && + doesBinaryExpressionContainOnlyLiterals(expression.right); + + case 'ConditionalExpression': + // All three parts must contain only literals (e.g., true ? 1 : 2) + return doesBinaryExpressionContainOnlyLiterals(expression.test) && + doesBinaryExpressionContainOnlyLiterals(expression.consequent) && + doesBinaryExpressionContainOnlyLiterals(expression.alternate); + + case 'SequenceExpression': + // All expressions in sequence must contain only literals (e.g., (1, 2, 3)) + for (let i = 0; i < expression.expressions.length; i++) { + if (!doesBinaryExpressionContainOnlyLiterals(expression.expressions[i])) { + return false; + } + } + return true; + + case 'Literal': + // Base case: literals are always literal-only + return true; + + default: + // Any other node type (Identifier, CallExpression, etc.) is not literal-only + return false; + } +} + +/** + * Identifies BinaryExpression nodes that contain only literal values and can be safely evaluated. + * Filters candidates to those with literal operands that are suitable for sandbox evaluation. + * @param {Arborist} arb - The Arborist instance + * @param {Function} candidateFilter - Filter function to apply on candidates + * @return {ASTNode[]} Array of BinaryExpression nodes ready for evaluation + */ +export function resolveDefiniteBinaryExpressionsMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.BinaryExpression; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + if (doesBinaryExpressionContainOnlyLiterals(n) && candidateFilter(n)) { - sharedSb = sharedSb || new Sandbox(); - const replacementNode = evalInVm(n.src, sharedSb); - if (replacementNode !== badValue) { - // Fix issue where a number below zero would be replaced with a string - if (replacementNode.type === 'UnaryExpression' && typeof n?.left?.value === 'number' && typeof n?.right?.value === 'number') { - const v = parseInt(replacementNode.argument.value + ''); + matches.push(n); + } + } + return matches; +} + +/** + * Transforms matched BinaryExpression nodes by evaluating them in a sandbox and replacing + * them with their computed literal values. + * @param {Arborist} arb - The Arborist instance + * @param {ASTNode[]} matches - Array of BinaryExpression nodes to transform + * @return {Arborist} The updated Arborist instance + */ +export function resolveDefiniteBinaryExpressionsTransform(arb, matches) { + if (!matches.length) return arb; + + const sharedSb = new Sandbox(); + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + const replacementNode = evalInVm(n.src, sharedSb); + + if (replacementNode !== evalInVm.BAD_VALUE) { + try { + // Handle negative number edge case: when evaluating expressions like '5 - 10', + // the result may be a UnaryExpression with '-5' instead of a Literal with value -5. + // This ensures numeric operations remain as proper numeric literals. + if (replacementNode.type === 'UnaryExpression' && + typeof n?.left?.value === 'number' && + typeof n?.right?.value === 'number') { + const v = parseInt(replacementNode.argument.raw); replacementNode.argument.value = v; replacementNode.argument.raw = `${v}`; } arb.markNode(n, replacementNode); + } catch (e) { + logger.debug(e.message); } } } return arb; } -export default resolveDefiniteBinaryExpressions; \ No newline at end of file + +/** + * Resolves BinaryExpression nodes that contain only literal values by evaluating them + * in a sandbox and replacing them with their computed results. + * Handles expressions like: 5 * 3 β†’ 15, '2' + 2 β†’ '22', 10 - 15 β†’ -5 + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for candidates + * @return {Arborist} The updated Arborist instance + */ +export default function resolveDefiniteBinaryExpressions(arb, candidateFilter = () => true) { + const matches = resolveDefiniteBinaryExpressionsMatch(arb, candidateFilter); + return resolveDefiniteBinaryExpressionsTransform(arb, matches); +} \ No newline at end of file diff --git a/src/modules/unsafe/resolveDefiniteMemberExpressions.js b/src/modules/unsafe/resolveDefiniteMemberExpressions.js index 2a487ea..ac46d2d 100644 --- a/src/modules/unsafe/resolveDefiniteMemberExpressions.js +++ b/src/modules/unsafe/resolveDefiniteMemberExpressions.js @@ -1,36 +1,84 @@ -import {badValue} from '../config.js'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; +const VALID_OBJECT_TYPES = ['ArrayExpression', 'Literal']; + /** - * Replace definite member expressions with their intended value. - * E.g. - * '123'[0]; ==> '1'; - * 'hello'.length ==> 5; - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies MemberExpression nodes that can be safely resolved to literal values. + * Matches expressions like '123'[0], 'hello'.length, [1,2,3][0] that access + * literal properties of literal objects/arrays. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {ASTNode[]} Array of MemberExpression nodes ready for evaluation */ -function resolveDefiniteMemberExpressions(arb, candidateFilter = () => true) { - let sharedSb; - const relevantNodes = [ - ...(arb.ast[0].typeMap.MemberExpression || []), - ]; +export function resolveDefiniteMemberExpressionsMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.MemberExpression; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (!['UpdateExpression'].includes(n.parentNode.type) && // Prevent replacing (++[[]][0]) with (++1) - !(n.parentKey === 'callee') && // Prevent replacing obj.method() with undefined() - (n.property.type === 'Literal' || - (n.property.name && !n.computed)) && - ['ArrayExpression', 'Literal'].includes(n.object.type) && - (n.object?.value?.length || n.object?.elements?.length) && - candidateFilter(n)) { - sharedSb = sharedSb || new Sandbox(); - const replacementNode = evalInVm(n.src, sharedSb); - if (replacementNode !== badValue) arb.markNode(n, replacementNode); + + // Prevent unsafe transformations that could break semantics + if (n.parentNode.type === 'UpdateExpression') { + // Prevent replacing (++[[]][0]) with (++1) which changes semantics + continue; + } + + if (n.parentKey === 'callee') { + // Prevent replacing obj.method() with undefined() calls + continue; + } + + // Property must be a literal or non-computed identifier (safe to evaluate) + const hasValidProperty = n.property.type === 'Literal' || + (n.property.name && !n.computed); + if (!hasValidProperty) continue; + + // Object must be a literal or array expression (deterministic) + if (!VALID_OBJECT_TYPES.includes(n.object.type)) continue; + + // Object must have content to access (length or elements) + if (!(n.object?.value?.length || n.object?.elements?.length)) continue; + + if (candidateFilter(n)) { + matches.push(n); + } + } + return matches; +} + +/** + * Transforms matched MemberExpression nodes by evaluating them in a sandbox + * and replacing them with their computed literal values. + * @param {Arborist} arb - The Arborist instance + * @param {ASTNode[]} matches - Array of MemberExpression nodes to transform + * @return {Arborist} The updated Arborist instance + */ +export function resolveDefiniteMemberExpressionsTransform(arb, matches) { + if (!matches.length) return arb; + + const sharedSb = new Sandbox(); + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + const replacementNode = evalInVm(n.src, sharedSb); + + if (replacementNode !== evalInVm.BAD_VALUE) { + arb.markNode(n, replacementNode); } } return arb; } -export default resolveDefiniteMemberExpressions; \ No newline at end of file +/** + * Resolves MemberExpression nodes that access literal properties of literal objects/arrays. + * Transforms expressions like '123'[0] β†’ '1', 'hello'.length β†’ 5, [1,2,3][0] β†’ 1 + * Only processes safe expressions that won't change program semantics. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for candidates + * @return {Arborist} The updated Arborist instance + */ +export default function resolveDefiniteMemberExpressions(arb, candidateFilter = () => true) { + const matches = resolveDefiniteMemberExpressionsMatch(arb, candidateFilter); + return resolveDefiniteMemberExpressionsTransform(arb, matches); +} \ No newline at end of file diff --git a/src/modules/unsafe/resolveDeterministicConditionalExpressions.js b/src/modules/unsafe/resolveDeterministicConditionalExpressions.js index 8e4bc5d..7c1fd88 100644 --- a/src/modules/unsafe/resolveDeterministicConditionalExpressions.js +++ b/src/modules/unsafe/resolveDeterministicConditionalExpressions.js @@ -2,29 +2,60 @@ import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; /** - * Evaluate resolvable (independent) conditional expressions and replace them with their unchanged resolution. - * E.g. - * 'a' ? do_a() : do_b(); // <-- will be replaced with just do_a(): - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies ConditionalExpression nodes with literal test values that can be deterministically resolved. + * Matches ternary expressions like 'a' ? x : y, 0 ? x : y, true ? x : y where the test is a literal. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {ASTNode[]} Array of ConditionalExpression nodes ready for evaluation */ -function resolveDeterministicConditionalExpressions(arb, candidateFilter = () => true) { - let sharedSb; - const relevantNodes = [ - ...(arb.ast[0].typeMap.ConditionalExpression || []), - ]; +export function resolveDeterministicConditionalExpressionsMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.ConditionalExpression; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + // Only resolve conditionals where test is a literal (deterministic) if (n.test.type === 'Literal' && candidateFilter(n)) { - sharedSb = sharedSb || new Sandbox(); - const replacementNode = evalInVm(`Boolean(${n.test.src});`, sharedSb); - if (replacementNode.type === 'Literal') { - arb.markNode(n, replacementNode.value ? n.consequent : n.alternate); - } + matches.push(n); + } + } + return matches; +} + +/** + * Transforms matched ConditionalExpression nodes by evaluating their literal test values + * and replacing the entire conditional with either the consequent or alternate branch. + * @param {Arborist} arb - The Arborist instance + * @param {ASTNode[]} matches - Array of ConditionalExpression nodes to transform + * @return {Arborist} The updated Arborist instance + */ +export function resolveDeterministicConditionalExpressionsTransform(arb, matches) { + if (!matches.length) return arb; + + const sharedSb = new Sandbox(); + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + // Evaluate the literal test value to determine truthiness + const replacementNode = evalInVm(`Boolean(${n.test.src});`, sharedSb); + + if (replacementNode.type === 'Literal') { + // Replace conditional with consequent if truthy, alternate if falsy + arb.markNode(n, replacementNode.value ? n.consequent : n.alternate); } } return arb; } -export default resolveDeterministicConditionalExpressions; \ No newline at end of file +/** + * Resolves ConditionalExpression nodes with literal test values to their deterministic outcomes. + * Transforms expressions like 'a' ? do_a() : do_b() β†’ do_a() since 'a' is truthy. + * Only processes conditionals where the test is a literal for safe evaluation. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for candidates + * @return {Arborist} The updated Arborist instance + */ +export default function resolveDeterministicConditionalExpressions(arb, candidateFilter = () => true) { + const matches = resolveDeterministicConditionalExpressionsMatch(arb, candidateFilter); + return resolveDeterministicConditionalExpressionsTransform(arb, matches); +} \ No newline at end of file diff --git a/src/modules/unsafe/resolveEvalCallsOnNonLiterals.js b/src/modules/unsafe/resolveEvalCallsOnNonLiterals.js index 35ae2ed..32fa125 100644 --- a/src/modules/unsafe/resolveEvalCallsOnNonLiterals.js +++ b/src/modules/unsafe/resolveEvalCallsOnNonLiterals.js @@ -1,60 +1,105 @@ import {parseCode} from 'flast'; -import {badValue} from '../config.js'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; import {createOrderedSrc} from '../utils/createOrderedSrc.js'; import {getDeclarationWithContext} from '../utils/getDeclarationWithContext.js'; /** - * Resolve eval call expressions where the argument isn't a literal. - * E.g. - * eval(function() {return 'atob'}()); // <-- will be resolved into 'atob' - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies CallExpression nodes for eval() with non-literal arguments that can be resolved. + * Matches eval calls where the argument is an expression (function call, array access, etc.) + * rather than a direct string literal. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {ASTNode[]} Array of eval CallExpression nodes ready for resolution */ -function resolveEvalCallsOnNonLiterals(arb, candidateFilter = () => true) { - let sharedSb; - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; +export function resolveEvalCallsOnNonLiteralsMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.CallExpression; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + // Only process eval calls with exactly one non-literal argument if (n.callee.name === 'eval' && - n.arguments.length === 1 && - n.arguments[0].type !== 'Literal' && - candidateFilter(n)) { - // The code inside the eval might contain references to outside code that should be included. - const contextNodes = getDeclarationWithContext(n, true); - // In case any of the target candidate is included in the context it should be removed. - const possiblyRedundantNodes = [n, n?.parentNode, n?.parentNode?.parentNode]; - for (let i = 0; i < possiblyRedundantNodes.length; i++) { - if (contextNodes.includes(possiblyRedundantNodes[i])) contextNodes.splice(contextNodes.indexOf(possiblyRedundantNodes[i]), 1); + n.arguments.length === 1 && + n.arguments[0].type !== 'Literal' && + candidateFilter(n)) { + matches.push(n); + } + } + return matches; +} + +/** + * Transforms matched eval CallExpression nodes by evaluating their non-literal arguments + * and replacing the eval calls with the resolved content. Handles context dependencies + * and attempts to parse the result as JavaScript code. + * @param {Arborist} arb - The Arborist instance + * @param {ASTNode[]} matches - Array of eval CallExpression nodes to transform + * @return {Arborist} The updated Arborist instance + */ +export function resolveEvalCallsOnNonLiteralsTransform(arb, matches) { + if (!matches.length) return arb; + + const sharedSb = new Sandbox(); + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + + // Gather context nodes that might be referenced by the eval argument + const contextNodes = getDeclarationWithContext(n, true); + + // Remove any nodes that are part of the eval expression itself to avoid circular references + const possiblyRedundantNodes = [n, n?.parentNode, n?.parentNode?.parentNode]; + for (let j = 0; j < possiblyRedundantNodes.length; j++) { + const redundantNode = possiblyRedundantNodes[j]; + const index = contextNodes.indexOf(redundantNode); + if (index !== -1) { + contextNodes.splice(index, 1); } - const context = contextNodes.length ? createOrderedSrc(contextNodes) : ''; - const src = `${context}\n;var __a_ = ${createOrderedSrc([n.arguments[0]])}\n;__a_`; - sharedSb = sharedSb || new Sandbox(); - const newNode = evalInVm(src, sharedSb); - const targetNode = n.parentNode.type === 'ExpressionStatement' ? n.parentNode : n; - let replacementNode = newNode; - try { - if (newNode.type === 'Literal') { - try { - replacementNode = parseCode(newNode.value); - } catch { - // Edge case for broken scripts that can be solved - // by adding a newline after closing brackets except if part of a regexp - replacementNode = parseCode(newNode.value.replace(/([)}])(?!\/)/g, '$1\n')); - } finally { - // If when parsed the newNode results in an empty program - use the unparsed newNode. - if (!replacementNode.body.length) replacementNode = newNode; - } + } + + // Build evaluation context: dependencies + argument assignment + return value + const context = contextNodes.length ? createOrderedSrc(contextNodes) : ''; + const src = `${context}\n;${createOrderedSrc([n.arguments[0]])}\n;`; + + const newNode = evalInVm(src, sharedSb); + const targetNode = n.parentNode.type === 'ExpressionStatement' ? n.parentNode : n; + let replacementNode = newNode; + + // If result is a literal string, try to parse it as JavaScript code + try { + if (newNode.type === 'Literal') { + try { + replacementNode = parseCode(newNode.value); + } catch { + // Handle malformed code by adding newlines after closing brackets + // (except when part of regex patterns like "/}/") + replacementNode = parseCode(newNode.value.replace(/([)}])(?!\/)/g, '$1\n')); + } finally { + // Fallback to unparsed literal if parsing results in empty program + if (!replacementNode.body.length) replacementNode = newNode; } - } catch {} - if (replacementNode !== badValue) arb.markNode(targetNode, replacementNode); + } + } catch { + // If all parsing attempts fail, keep the original evaluated result + } + + if (replacementNode !== evalInVm.BAD_VALUE) { + arb.markNode(targetNode, replacementNode); } } return arb; } -export default resolveEvalCallsOnNonLiterals; \ No newline at end of file +/** + * Resolves eval() calls with non-literal arguments by evaluating the arguments + * and replacing the eval calls with their resolved content. Handles context dependencies + * and attempts to parse string results as JavaScript code. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for candidates + * @return {Arborist} The updated Arborist instance + */ +export default function resolveEvalCallsOnNonLiterals(arb, candidateFilter = () => true) { + const matches = resolveEvalCallsOnNonLiteralsMatch(arb, candidateFilter); + return resolveEvalCallsOnNonLiteralsTransform(arb, matches); +} \ No newline at end of file diff --git a/src/modules/unsafe/resolveFunctionToArray.js b/src/modules/unsafe/resolveFunctionToArray.js index 92dda50..a61a4b6 100644 --- a/src/modules/unsafe/resolveFunctionToArray.js +++ b/src/modules/unsafe/resolveFunctionToArray.js @@ -6,39 +6,86 @@ import utils from '../utils/index.js'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; const {createOrderedSrc, getDeclarationWithContext} = utils; -import {badValue} from '../config.js'; /** - * Run the generating function and replace it with the actual array. - * Candidates are variables which are assigned a call expression, and every reference to them is a member expression. - * E.g. - * function getArr() {return ['One', 'Two', 'Three']}; - * const a = getArr(); - * console.log(`${a[0]} + ${a[1]} = ${a[2]}`); - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies VariableDeclarator nodes with function calls that generate arrays. + * Matches variables assigned function call results where all references are member expressions + * (indicating array-like usage). + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {ASTNode[]} Array of VariableDeclarator nodes ready for array resolution */ -export default function resolveFunctionToArray(arb, candidateFilter = () => true) { - let sharedSb; - const relevantNodes = [ - ...(arb.ast[0].typeMap.VariableDeclarator || []), - ]; +export function resolveFunctionToArrayMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.VariableDeclarator; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.init?.type === 'CallExpression' && n.id?.references && - !n.id.references.some(r => r.parentNode.type !== 'MemberExpression') && - candidateFilter(n)) { - const targetNode = n.init.callee?.declNode?.parentNode || n.init; - let src = ''; - if (![n.init, n.init?.parentNode].includes(targetNode)) src += createOrderedSrc(getDeclarationWithContext(targetNode)); - src += `\n${createOrderedSrc([n.init])}`; - sharedSb = sharedSb || new Sandbox(); - const replacementNode = evalInVm(src, sharedSb); - if (replacementNode !== badValue) { - arb.markNode(n.init, replacementNode); - } + + // Must be a variable assigned a function call result + if (n.init?.type !== 'CallExpression') continue; + + // All references must be member expressions that are NOT used as function callees + // Empty references array is allowed + if (n.id.references?.some(r => { + return r.parentNode.type !== 'MemberExpression' || + (r.parentNode.parentNode?.type === 'CallExpression' && + r.parentNode.parentNode.callee === r.parentNode); + })) continue; + + if (candidateFilter(n)) { + matches.push(n); + } + } + return matches; +} + +/** + * Transforms matched VariableDeclarator nodes by evaluating their function calls + * and replacing them with the resolved array literals. Handles context dependencies + * to ensure the generating function can be properly executed. + * @param {Arborist} arb - The Arborist instance + * @param {ASTNode[]} matches - Array of VariableDeclarator nodes to transform + * @return {Arborist} The updated Arborist instance + */ +export function resolveFunctionToArrayTransform(arb, matches) { + if (!matches.length) return arb; + + const sharedSb = new Sandbox(); + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + + // Determine the target node that contains the function definition + const targetNode = n.init.callee?.declNode?.parentNode || n.init; + + // Build evaluation context - include function definition if it's separate + let src = ''; + if (![n.init, n.init?.parentNode].includes(targetNode)) { + // Function is defined elsewhere, include its context + src += createOrderedSrc(getDeclarationWithContext(targetNode)); + } + + // Add the function call to evaluate + src += `\n;${createOrderedSrc([n.init])}\n;`; + + const replacementNode = evalInVm(src, sharedSb); + if (replacementNode !== evalInVm.BAD_VALUE) { + arb.markNode(n.init, replacementNode); } } return arb; +} + +/** + * Resolves function calls that generate arrays by evaluating them and replacing + * with the actual array literals. This handles obfuscation patterns where arrays + * are dynamically generated by functions and then accessed via member expressions. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for candidates + * @return {Arborist} The updated Arborist instance + */ +export default function resolveFunctionToArray(arb, candidateFilter = () => true) { + const matches = resolveFunctionToArrayMatch(arb, candidateFilter); + return resolveFunctionToArrayTransform(arb, matches); } \ No newline at end of file diff --git a/src/modules/unsafe/resolveInjectedPrototypeMethodCalls.js b/src/modules/unsafe/resolveInjectedPrototypeMethodCalls.js index 0acf308..c4b09df 100644 --- a/src/modules/unsafe/resolveInjectedPrototypeMethodCalls.js +++ b/src/modules/unsafe/resolveInjectedPrototypeMethodCalls.js @@ -1,49 +1,101 @@ import {logger} from 'flast'; -import {badValue} from '../config.js'; import {Sandbox} from '../utils/sandbox.js'; import {evalInVm} from '../utils/evalInVm.js'; import {createOrderedSrc} from '../utils/createOrderedSrc.js'; import {getDeclarationWithContext} from '../utils/getDeclarationWithContext.js'; +// Valid right-hand side types for prototype method assignments +// Note: ArrowFunctionExpression is supported - works fine when not relying on 'this' binding +const VALID_PROTOTYPE_FUNCTION_TYPES = ['FunctionExpression', 'ArrowFunctionExpression', 'Identifier']; + /** - * Resolve call expressions which are defined on an object's prototype and are applied to an object's instance. - * E.g. - * String.prototype.secret = function() {return 'secret ' + this} - * 'hello'.secret(); // <-- will be resolved to 'secret hello'. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies AssignmentExpression nodes that assign functions to prototype properties. + * Matches patterns like `String.prototype.method = function() {...}`, `Obj.prototype.prop = () => value`, + * or `Obj.prototype.prop = identifier`. Arrow functions work fine when they don't rely on 'this' binding. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {Object[]} Array of match objects containing prototype assignments and method details */ -export default function resolveInjectedPrototypeMethodCalls(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.AssignmentExpression || []), - ]; +export function resolveInjectedPrototypeMethodCallsMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.AssignmentExpression; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (n.left.type === 'MemberExpression' && - (n.left.object.property?.name || n.left.object.property?.value) === 'prototype' && - n.operator === '=' && - (/FunctionExpression|Identifier/.test(n.right?.type)) && - candidateFilter(n)) { - try { - const methodName = n.left.property?.name || n.left.property?.value; - const context = getDeclarationWithContext(n); - const contextSb = new Sandbox(); - contextSb.run(createOrderedSrc(context)); - const rlvntNodes = arb.ast[0].typeMap.CallExpression || []; - for (let j = 0; j < rlvntNodes.length; j++) { - const ref = rlvntNodes[j]; - if (ref.type === 'CallExpression' && - ref.callee.type === 'MemberExpression' && - (ref.callee.property?.name || ref.callee.property?.value) === methodName) { - const replacementNode = evalInVm(`\n${createOrderedSrc([ref])}`, contextSb); - if (replacementNode !== badValue) arb.markNode(ref, replacementNode); + + // Must be assignment to a prototype property with a function value + if (n.left?.type === 'MemberExpression' && + n.left.object?.type === 'MemberExpression' && + 'prototype' === (n.left.object.property?.name || n.left.object.property?.value) && + n.operator === '=' && + VALID_PROTOTYPE_FUNCTION_TYPES.includes(n.right?.type) && + candidateFilter(n)) { + + const methodName = n.left.property?.name || n.left.property?.value; + if (methodName) { + matches.push({ + assignmentNode: n, + methodName: methodName + }); + } + } + } + return matches; +} + +/** + * Transforms prototype method assignments by resolving their corresponding call expressions. + * Evaluates calls to injected prototype methods in a sandbox and replaces them with results. + * @param {Arborist} arb - The Arborist instance + * @param {Object[]} matches - Array of prototype method assignments from match function + * @return {Arborist} The updated Arborist instance + */ +export function resolveInjectedPrototypeMethodCallsTransform(arb, matches) { + if (!matches.length) return arb; + + // Process each prototype method assignment + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + + try { + // Build execution context including the prototype assignment + const context = getDeclarationWithContext(match.assignmentNode); + const contextSb = new Sandbox(); + contextSb.run(createOrderedSrc(context)); + + // Find and resolve calls to this injected method + const callNodes = arb.ast[0].typeMap.CallExpression; + for (let j = 0; j < callNodes.length; j++) { + const callNode = callNodes[j]; + + // Check if this call uses the injected prototype method + if (callNode.callee?.type === 'MemberExpression' && + (callNode.callee.property?.name === match.methodName || + callNode.callee.property?.value === match.methodName)) { + + // Evaluate the method call in the prepared context + const replacementNode = evalInVm(`\n${createOrderedSrc([callNode])}`, contextSb); + if (replacementNode !== evalInVm.BAD_VALUE) { + arb.markNode(callNode, replacementNode); } } - } catch (e) { - logger.debug(`[-] Error in resolveInjectedPrototypeMethodCalls: ${e.message}`); } + } catch (e) { + logger.debug(`[-] Error resolving injected prototype method '${match.methodName}': ${e.message}`); } } return arb; +} + +/** + * Resolves call expressions that use injected prototype methods. + * Finds prototype method assignments like `String.prototype.secret = function() {...}` + * and resolves corresponding calls like `'hello'.secret()` to their literal results. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {Arborist} The updated Arborist instance + */ +export default function resolveInjectedPrototypeMethodCalls(arb, candidateFilter = () => true) { + const matches = resolveInjectedPrototypeMethodCallsMatch(arb, candidateFilter); + return resolveInjectedPrototypeMethodCallsTransform(arb, matches); } \ No newline at end of file diff --git a/src/modules/unsafe/resolveLocalCalls.js b/src/modules/unsafe/resolveLocalCalls.js index 0d2f714..dfe2646 100644 --- a/src/modules/unsafe/resolveLocalCalls.js +++ b/src/modules/unsafe/resolveLocalCalls.js @@ -4,106 +4,158 @@ import {getCache} from '../utils/getCache.js'; import {getCalleeName} from '../utils/getCalleeName.js'; import {isNodeInRanges} from '../utils/isNodeInRanges.js'; import {createOrderedSrc} from '../utils/createOrderedSrc.js'; +import {SKIP_IDENTIFIERS, SKIP_PROPERTIES} from '../config.js'; import {getDeclarationWithContext} from '../utils/getDeclarationWithContext.js'; -import {badValue, badArgumentTypes, skipIdentifiers, skipProperties} from '../config.js'; -let appearances = new Map(); -const cacheLimit = 100; +const VALID_UNWRAP_TYPES = ['Literal', 'Identifier']; +const CACHE_LIMIT = 100; + +// Module-level variables for appearance tracking +let APPEARANCES = new Map(); /** - * @param {ASTNode} a - * @param {ASTNode} b + * Sorts call expression nodes by their appearance frequency in descending order. + * @param {ASTNode} a - First call expression node + * @param {ASTNode} b - Second call expression node + * @return {number} Comparison result for sorting */ function sortByApperanceFrequency(a, b) { - return appearances.get(getCalleeName(b)) - appearances.get(getCalleeName(a)); + return APPEARANCES.get(getCalleeName(b)) - APPEARANCES.get(getCalleeName(a)); } /** - * @param {ASTNode} node - * @return {number} + * Counts and tracks the appearance frequency of a call expression's callee. + * @param {ASTNode} n - Call expression node + * @return {number} Updated appearance count */ -function countAppearances(node) { - const callee = getCalleeName(node); - const count = (appearances.get(callee) || 0) + 1; - appearances.set(callee, count); +function countAppearances(n) { + const calleeName = getCalleeName(n); + const count = (APPEARANCES.get(calleeName) || 0) + 1; + APPEARANCES.set(calleeName, count); return count; } /** - * Collect all available context on call expressions where the callee is defined in the script and attempt - * to resolve their value. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies CallExpression nodes that can be resolved through local function definitions. + * Collects call expressions where the callee has a declaration node and meets specific criteria. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {ASTNode[]} Array of call expression nodes that can be transformed */ -export default function resolveLocalCalls(arb, candidateFilter = () => true) { - appearances = new Map(); - const cache = getCache(arb.ast[0].scriptHash); - const candidates = []; - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; +export function resolveLocalCallsMatch(arb, candidateFilter = () => true) { + APPEARANCES = new Map(); + const matches = []; + const relevantNodes = arb.ast[0].typeMap.CallExpression; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; + + // Check if call expression has proper declaration context if ((n.callee?.declNode || (n.callee?.object?.declNode && - !skipProperties.includes(n.callee.property?.value || n.callee.property?.name)) || + !SKIP_PROPERTIES.includes(n.callee.property?.value || n.callee.property?.name)) || n.callee?.object?.type === 'Literal') && - countAppearances(n) && candidateFilter(n)) { - candidates.push(n); + countAppearances(n); // Count appearances during the match phase to allow sorting by appearance frequency + matches.push(n); } } - candidates.sort(sortByApperanceFrequency); + + // Sort by appearance frequency for optimization (most frequent first) + matches.sort(sortByApperanceFrequency); + return matches; +} + +/** + * Transforms call expressions by resolving them to their evaluated values using local function context. + * Uses caching and sandbox evaluation to safely determine replacement values. + * @param {Arborist} arb - The Arborist instance + * @param {ASTNode[]} matches - Array of call expression nodes to transform + * @return {Arborist} The modified Arborist instance + */ +export function resolveLocalCallsTransform(arb, matches) { + if (!matches.length) return arb; + const cache = getCache(arb.ast[0].scriptHash); const modifiedRanges = []; - candidateLoop: for (let i = 0; i < candidates.length; i++) { - const c = candidates[i]; + + candidateLoop: for (let i = 0; i < matches.length; i++) { + const c = matches[i]; + + // Skip if already modified in this iteration if (isNodeInRanges(c, modifiedRanges)) continue; + + // Skip if any argument has problematic type for (let j = 0; j < c.arguments.length; j++) { - const arg = c.arguments[j]; - if (badArgumentTypes.includes(arg.type)) continue candidateLoop; + if (c.arguments[j].type === 'ThisExpression') continue candidateLoop; } + const callee = c.callee?.object || c.callee; - const declNode = c.callee?.declNode || c.callee?.object?.declNode; + const declNode = callee?.declNode || callee?.object?.declNode; + + // Skip simple wrappers that should be handled by safe modules if (declNode?.parentNode?.body?.body?.[0]?.type === 'ReturnStatement') { - // Leave this replacement to a safe function const returnArg = declNode.parentNode.body.body[0].argument; - if (['Literal', 'Identifier'].includes(returnArg.type) || returnArg.type.includes('unction')) continue; // Unwrap identifier + // Leave simple literal/identifier returns to safe unwrapping modules + if (VALID_UNWRAP_TYPES.includes(returnArg.type) || returnArg.type.includes('unction')) continue; + // Leave function shell unwrapping to dedicated module else if (returnArg.type === 'CallExpression' && returnArg.callee?.object?.type === 'FunctionExpression' && - (returnArg.callee.property?.name || returnArg.callee.property?.value) === 'apply') continue; // Unwrap function shells + (returnArg.callee.property?.name || returnArg.callee.property?.value) === 'apply') continue; } + + // Cache management for performance const cacheName = `rlc-${callee.name || callee.value}-${declNode?.nodeId}`; if (!cache[cacheName]) { - cache[cacheName] = badValue; - // Skip call expressions with problematic values - if (skipIdentifiers.includes(callee.name) || + cache[cacheName] = evalInVm.BAD_VALUE; + + // Skip problematic callee types that shouldn't be evaluated + if (SKIP_IDENTIFIERS.includes(callee.name) || (callee.type === 'ArrayExpression' && !callee.elements.length) || - (callee.arguments || []).some(a => skipIdentifiers.includes(a) || a?.type === 'ThisExpression')) continue; + (callee.arguments || []).some(arg => SKIP_IDENTIFIERS.includes(arg) || arg?.type === 'ThisExpression')) continue; + if (declNode) { - // Verify the declNode isn't a simple wrapper for an identifier + // Skip simple function wrappers (handled by safe modules) if (declNode.parentNode.type === 'FunctionDeclaration' && - ['Identifier', 'Literal'].includes(declNode.parentNode?.body?.body?.[0]?.argument?.type)) continue; + VALID_UNWRAP_TYPES.includes(declNode.parentNode?.body?.body?.[0]?.argument?.type)) continue; + + // Build execution context in sandbox const contextSb = new Sandbox(); try { contextSb.run(createOrderedSrc(getDeclarationWithContext(declNode.parentNode))); - if (Object.keys(cache) >= cacheLimit) cache.flush(); + if (Object.keys(cache) >= CACHE_LIMIT) cache.flush(); cache[cacheName] = contextSb; } catch {} } } + + // Evaluate call expression in appropriate context const contextVM = cache[cacheName]; const nodeSrc = createOrderedSrc([c]); - const replacementNode = contextVM === badValue ? evalInVm(nodeSrc) : evalInVm(nodeSrc, contextVM); - if (replacementNode !== badValue && replacementNode.type !== 'FunctionDeclaration' && replacementNode.name !== 'undefined') { - // Prevent resolving a function's toString as it might be an anti-debugging mechanism - // which will spring if the code is beautified - if (c.callee.type === 'MemberExpression' && (c.callee.property?.name || c.callee.property?.value) === 'toString' && - replacementNode?.value.substring(0, 8) === 'function') continue; + const replacementNode = contextVM === evalInVm.BAD_VALUE ? evalInVm(nodeSrc) : evalInVm(nodeSrc, contextVM); + + if (replacementNode !== evalInVm.BAD_VALUE && replacementNode.type !== 'FunctionDeclaration' && replacementNode.name !== 'undefined') { + // Anti-debugging protection: avoid resolving function toString that might trigger detection + if (c.callee.type === 'MemberExpression' && + (c.callee.property?.name || c.callee.property?.value) === 'toString' && + replacementNode?.value?.substring(0, 8) === 'function') continue; + arb.markNode(c, replacementNode); modifiedRanges.push(c.range); } } return arb; -} \ No newline at end of file +} + +/** + * Resolves local function calls by evaluating them with their declaration context. + * This module identifies call expressions where the callee is defined locally and attempts + * to resolve their values through safe evaluation in a sandbox environment. + * @param {Arborist} arb - The Arborist instance + * @param {Function} [candidateFilter] - Optional filter for candidates + * @return {Arborist} The modified Arborist instance + */ +export default function resolveLocalCalls(arb, candidateFilter = () => true) { + const matches = resolveLocalCallsMatch(arb, candidateFilter); + return resolveLocalCallsTransform(arb, matches); +} diff --git a/src/modules/unsafe/resolveMemberExpressionsLocalReferences.js b/src/modules/unsafe/resolveMemberExpressionsLocalReferences.js index ba9dd33..69bd1e1 100644 --- a/src/modules/unsafe/resolveMemberExpressionsLocalReferences.js +++ b/src/modules/unsafe/resolveMemberExpressionsLocalReferences.js @@ -1,81 +1,125 @@ +import {SKIP_PROPERTIES} from '../config.js'; import {evalInVm} from '../utils/evalInVm.js'; -import {badValue, skipProperties} from '../config.js'; import {createOrderedSrc} from '../utils/createOrderedSrc.js'; import {areReferencesModified} from '../utils/areReferencesModified.js'; import {getDeclarationWithContext} from '../utils/getDeclarationWithContext.js'; import {getMainDeclaredObjectOfMemberExpression} from '../utils/getMainDeclaredObjectOfMemberExpression.js'; +const VALID_PROPERTY_TYPES = ['Identifier', 'Literal']; + /** - * Resolve member expressions to the value they stand for, if they're defined in the script. - * E.g. - * const a = [1, 2, 3]; - * const b = a[2]; // <-- will be resolved to 3 - * const c = 0; - * const d = a[c]; // <-- will be resolved to 1 - * --- - * const a = {hello: 'world'}; - * const b = a['hello']; // <-- will be resolved to 'world' - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies member expressions that can be resolved to their local reference values. + * Only processes member expressions with literal properties or identifiers, excluding + * assignment targets, call expression callees, function parameters, and modified references. + * @param {Arborist} arb - Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for additional candidate filtering + * @return {ASTNode[]} Array of member expression nodes that can be resolved */ -export default function resolveMemberExpressionsLocalReferences(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.MemberExpression || []), - ]; +export function resolveMemberExpressionsLocalReferencesMatch(arb, candidateFilter = () => true) { + const matches = []; + const relevantNodes = arb.ast[0].typeMap.MemberExpression; + for (let i = 0; i < relevantNodes.length; i++) { const n = relevantNodes[i]; - if (['Identifier', 'Literal'].includes(n.property.type) && - !skipProperties.includes(n.property?.name || n.property?.value) && - (!(n.parentKey === 'left' && n.parentNode.type === 'AssignmentExpression')) && + if (VALID_PROPERTY_TYPES.includes(n.property.type) && + !SKIP_PROPERTIES.includes(n.property?.name || n.property?.value) && + !(n.parentKey === 'left' && n.parentNode.type === 'AssignmentExpression') && candidateFilter(n)) { - // If this member expression is the callee of a call expression - skip it + // Skip member expressions used as call expression callees if (n.parentNode.type === 'CallExpression' && n.parentKey === 'callee') continue; - // If this member expression is a part of another member expression - get the first parentNode - // which has a declaration in the code; - // E.g. a.b[c.d] --> if candidate is c.d, the c identifier will be selected; - // a.b.c.d --> if the candidate is c.d, the 'a' identifier will be selected; - let relevantIdentifier = getMainDeclaredObjectOfMemberExpression(n); - if (relevantIdentifier && relevantIdentifier.declNode) { - // Skip if the relevant identifier is on the left side of an assignment. - if (relevantIdentifier.parentNode.parentNode.type === 'AssignmentExpression' && - relevantIdentifier.parentNode.parentKey === 'left') continue; - const declNode = relevantIdentifier.declNode; - // Skip if the identifier was declared as a function's parameter. + + // Find the main declared identifier for the member expression being processed + // E.g. processing 'c.d' in 'a.b[c.d]' -> mainObj is 'c' (declared identifier for c.d) + // E.g. processing 'data.user.name' in 'const data = {...}; data.user.name' -> mainObj is 'data' + const mainObj = getMainDeclaredObjectOfMemberExpression(n); + if (mainObj?.declNode) { + // Skip if identifier is assignment target + // E.g. const obj = {a: 1}; obj.a = 2; -> mainObj is 'obj', skip obj.a (obj on left side) + if (mainObj.parentNode.parentNode.type === 'AssignmentExpression' && + mainObj.parentNode.parentKey === 'left') continue; + + const declNode = mainObj.declNode; + // Skip function parameters as they may have dynamic values + // E.g. function test(arr) { return arr[0]; } -> mainObj is 'arr', skip arr[0] (arr is parameter) if (/Function/.test(declNode.parentNode.type) && (declNode.parentNode.params || []).find(p => p === declNode)) continue; + const prop = n.property; - if (prop.type === 'Identifier' && prop.declNode?.references && areReferencesModified(arb.ast, prop.declNode.references)) continue; - const context = createOrderedSrc(getDeclarationWithContext(relevantIdentifier.declNode.parentNode)); - if (context) { - const src = `${context}\n${n.src}`; - const replacementNode = evalInVm(src); - if (replacementNode !== badValue) { - let isEmptyReplacement = false; - switch (replacementNode.type) { - case 'ArrayExpression': - if (!replacementNode.elements.length) isEmptyReplacement = true; - break; - case 'ObjectExpression': - if (!replacementNode.properties.length) isEmptyReplacement = true; - break; - case 'Literal': - if ( - !String(replacementNode.value).length || // '' - replacementNode.raw === 'null' // null - ) isEmptyReplacement = true; - break; - case 'Identifier': - if (replacementNode.name === 'undefined') isEmptyReplacement = true; - break; - } - if (!isEmptyReplacement) { - arb.markNode(n, replacementNode); + // Skip if property identifier has modified references (not safe to resolve) + // E.g. let idx = 0; idx = 1; const val = arr[idx]; -> mainObj is 'arr', prop is 'idx', skip because idx modified + if (prop.type === 'Identifier' && prop.declNode?.references && + areReferencesModified(arb.ast, prop.declNode.references)) continue; + + matches.push(n); + } + } + } + + return matches; +} + +/** + * Transforms member expressions by resolving them to their evaluated values using local context. + * Uses sandbox evaluation to safely determine replacement values and skips empty results. + * @param {Arborist} arb - Arborist instance + * @param {ASTNode[]} matches - Array of member expression nodes to transform + * @return {Arborist} The modified Arborist instance + */ +export function resolveMemberExpressionsLocalReferencesTransform(arb, matches) { + if (!matches.length) return arb; + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + const relevantIdentifier = getMainDeclaredObjectOfMemberExpression(n); + const context = createOrderedSrc(getDeclarationWithContext(relevantIdentifier.declNode.parentNode)); + + if (context) { + const src = `${context}\n${n.src}`; + const replacementNode = evalInVm(src); + if (replacementNode !== evalInVm.BAD_VALUE) { + // Check if replacement would result in empty/meaningless values + let isEmptyReplacement = false; + switch (replacementNode.type) { + case 'ArrayExpression': + if (!replacementNode.elements.length) isEmptyReplacement = true; + break; + case 'ObjectExpression': + if (!replacementNode.properties.length) isEmptyReplacement = true; + break; + case 'Literal': + if (!String(replacementNode.value).length || replacementNode.raw === 'null') { + isEmptyReplacement = true; } - } + break; + case 'Identifier': + if (replacementNode.name === 'undefined') isEmptyReplacement = true; + break; + } + if (!isEmptyReplacement) { + arb.markNode(n, replacementNode); } } } } + return arb; +} + +/** + * Resolve member expressions to the value they stand for, if they're defined in the script. + * E.g. + * const a = [1, 2, 3]; + * const b = a[2]; // <-- will be resolved to 3 + * const c = 0; + * const d = a[c]; // <-- will be resolved to 1 + * --- + * const a = {hello: 'world'}; + * const b = a['hello']; // <-- will be resolved to 'world' + * @param {Arborist} arb - Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for additional candidate filtering + * @return {Arborist} The modified Arborist instance + */ +export default function resolveMemberExpressionsLocalReferences(arb, candidateFilter = () => true) { + const matches = resolveMemberExpressionsLocalReferencesMatch(arb, candidateFilter); + return resolveMemberExpressionsLocalReferencesTransform(arb, matches); } \ No newline at end of file diff --git a/src/modules/unsafe/resolveMinimalAlphabet.js b/src/modules/unsafe/resolveMinimalAlphabet.js index 161661d..d37fbe9 100644 --- a/src/modules/unsafe/resolveMinimalAlphabet.js +++ b/src/modules/unsafe/resolveMinimalAlphabet.js @@ -1,36 +1,80 @@ -import {badValue} from '../config.js'; import {evalInVm} from '../utils/evalInVm.js'; import {doesDescendantMatchCondition} from '../utils/doesDescendantMatchCondition.js'; + + /** - * Resolve unary expressions on values which aren't numbers such as +true, +[], +[...], etc, - * as well as binary expressions around the + operator. These usually resolve to string values, - * which can be used to obfuscate code in schemes such as JSFuck. - * @param {Arborist} arb - * @param {Function} candidateFilter (optional) a filter to apply on the candidates list - * @return {Arborist} + * Identifies unary and binary expressions that can be resolved to simplified values. + * Targets JSFuck-style obfuscation patterns using non-numeric operands and excludes + * expressions containing ThisExpression for safe evaluation. + * @param {Arborist} arb - Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for additional candidate filtering + * @return {ASTNode[]} Array of expression nodes that can be resolved */ -export default function resolveMinimalAlphabet(arb, candidateFilter = () => true) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.UnaryExpression || []), - ...(arb.ast[0].typeMap.BinaryExpression || []), - ]; - for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if ((n.type === 'UnaryExpression' && - ((n.argument.type === 'Literal' && /^\D/.test(n.argument.raw[0])) || - n.argument.type === 'ArrayExpression')) || - (n.type === 'BinaryExpression' && - n.operator === '+' && - (n.left.type !== 'MemberExpression' && Number.isNaN(parseFloat(n.left?.value))) && - ![n.left?.type, n.right?.type].includes('ThisExpression')) && +export function resolveMinimalAlphabetMatch(arb, candidateFilter = () => true) { + const matches = []; + const unaryNodes = arb.ast[0].typeMap.UnaryExpression; + const binaryNodes = arb.ast[0].typeMap.BinaryExpression; + + // Process unary expressions: +true, +[], -false, ~[], etc. + for (let i = 0; i < unaryNodes.length; i++) { + const n = unaryNodes[i]; + if (((n.argument.type === 'Literal' && /^\D/.test(n.argument.raw[0])) || + n.argument.type === 'ArrayExpression') && candidateFilter(n)) { - if (doesDescendantMatchCondition(n, n => n.type === 'ThisExpression')) continue; - const replacementNode = evalInVm(n.src); - if (replacementNode !== badValue) { - arb.markNode(n, replacementNode); - } + // Skip expressions containing ThisExpression for safe evaluation + if (doesDescendantMatchCondition(n, descendant => descendant.type === 'ThisExpression')) continue; + matches.push(n); + } + } + + // Process binary expressions: [] + [], [+[]], etc. + for (let i = 0; i < binaryNodes.length; i++) { + const n = binaryNodes[i]; + if (n.operator === '+' && + (n.left.type !== 'MemberExpression' && Number.isNaN(parseFloat(n.left?.value))) && + n.left?.type !== 'ThisExpression' && + n.right?.type !== 'ThisExpression' && + candidateFilter(n)) { + // Skip expressions containing ThisExpression for safe evaluation + if (doesDescendantMatchCondition(n, descendant => descendant.type === 'ThisExpression')) continue; + matches.push(n); + } + } + + return matches; +} + +/** + * Transforms unary and binary expressions by evaluating them to their simplified values. + * Uses sandbox evaluation to safely convert JSFuck-style obfuscated expressions. + * @param {Arborist} arb - Arborist instance + * @param {ASTNode[]} matches - Array of expression nodes to transform + * @return {Arborist} The modified Arborist instance + */ +export function resolveMinimalAlphabetTransform(arb, matches) { + if (!matches.length) return arb; + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + const replacementNode = evalInVm(n.src); + if (replacementNode !== evalInVm.BAD_VALUE) { + arb.markNode(n, replacementNode); } } + return arb; +} + +/** + * Resolve unary expressions on values which aren't numbers such as +true, +[], +[...], etc, + * as well as binary expressions around the + operator. These usually resolve to string values, + * which can be used to obfuscate code in schemes such as JSFuck. + * @param {Arborist} arb - Arborist instance + * @param {Function} [candidateFilter] - Optional filter function for additional candidate filtering + * @return {Arborist} The modified Arborist instance + */ +export default function resolveMinimalAlphabet(arb, candidateFilter = () => true) { + const matches = resolveMinimalAlphabetMatch(arb, candidateFilter); + return resolveMinimalAlphabetTransform(arb, matches); } \ No newline at end of file diff --git a/src/modules/utils/areReferencesModified.js b/src/modules/utils/areReferencesModified.js index de16b4d..9904308 100644 --- a/src/modules/utils/areReferencesModified.js +++ b/src/modules/utils/areReferencesModified.js @@ -1,47 +1,201 @@ -import {propertiesThatModifyContent} from '../config.js'; +import {PROPERTIES_THAT_MODIFY_CONTENT} from '../config.js'; + + + +// AST node types that indicate potential modification +const ASSIGNMENT_TYPES = ['AssignmentExpression', 'ForInStatement', 'ForOfStatement', 'ForAwaitStatement']; /** - * @param {ASTNode} r - * @param {ASTNode[]} assignmentExpressions - * @return {boolean} + * Checks if a member expression reference matches an assignment target. + * Handles cases like obj.prop = value where obj.prop is being assigned to. + * @param {ASTNode} memberExpr - The member expression reference to check + * @param {ASTNode[]} assignmentExpressions - Array of assignment expressions to check against + * @return {boolean} True if the member expression is being assigned to */ -function isMemberExpressionAssignedTo(r, assignmentExpressions) { +function isMemberExpressionAssignedTo(memberExpr, assignmentExpressions) { for (let i = 0; i < assignmentExpressions.length; i++) { - const n = assignmentExpressions[i]; - if (n.left.type === 'MemberExpression' && - (n.left.object.declNode && (r.object.declNode || r.object) === n.left.object.declNode) && - ((n.left.property?.name || n.left.property?.value) === (r.property?.name || r.property?.value))) return true; + const assignment = assignmentExpressions[i]; + if (assignment.left.type !== 'MemberExpression') continue; + + const leftObj = assignment.left.object; + const rightObj = memberExpr.object; + + // Compare object identities - both should refer to the same declared node + const leftDeclNode = leftObj.declNode || leftObj; + const rightDeclNode = rightObj.declNode || rightObj; + + if (leftDeclNode !== rightDeclNode) continue; + + // Compare property names/values + const leftProp = assignment.left.property?.name || assignment.left.property?.value; + const rightProp = memberExpr.property?.name || memberExpr.property?.value; + + if (leftProp === rightProp) return true; } return false; } /** - * @param {ASTNode[]} ast - * @param {ASTNode[]} refs - * @return {boolean} true if any of the references might modify the original value; false otherwise. + * Checks if a reference is used as the target of a delete operation. + * E.g. delete obj.prop, delete arr[index] + * @param {ASTNode} ref - The reference to check + * @return {boolean} True if the reference is being deleted */ -function areReferencesModified(ast, refs) { - // Verify no reference is on the left side of an assignment - for (let i = 0; i < refs.length; i++) { - const r = refs[i]; - if ((r.parentKey === 'left' && ['AssignmentExpression', 'ForInStatement', 'ForOfStatement'].includes(r.parentNode.type)) || - // Verify no reference is part of an update expression - r.parentNode.type === 'UpdateExpression' || - // Verify no variable with the same name is declared in a subscope - (r.parentNode.type === 'VariableDeclarator' && r.parentKey === 'id') || - // Verify no modifying calls are executed on any of the references - (r.parentNode.type === 'MemberExpression' && - (r.parentNode.parentNode.type === 'CallExpression' && - r.parentNode.parentNode.callee?.object === r && - propertiesThatModifyContent.includes(r.parentNode.property?.value || r.parentNode.property?.name)) || - // Verify the object's properties aren't being assigned to - (r.parentNode.parentNode.type === 'AssignmentExpression' && - r.parentNode.parentKey === 'left')) || - // Verify there are no member expressions among the references which are being assigned to - (r.type === 'MemberExpression' && - isMemberExpressionAssignedTo(r, ast[0].typeMap.AssignmentExpression || []))) return true; +function isReferenceDeleted(ref) { + // Direct deletion: delete ref + if (ref.parentNode.type === 'UnaryExpression' && + ref.parentNode.operator === 'delete' && + ref.parentNode.argument === ref) { + return true; + } + + // Member expression deletion: delete obj.prop, delete arr[index] + if (ref.parentNode.type === 'MemberExpression' && + ref.parentKey === 'object' && + ref.parentNode.parentNode.type === 'UnaryExpression' && + ref.parentNode.parentNode.operator === 'delete') { + return true; + } + + return false; +} + +/** + * Checks if a reference is part of a destructuring pattern that could modify the original. + * E.g. const {prop} = obj; prop = newValue; (modifies the destructured value, not obj) + * Note: This is a conservative check - actual modification depends on usage. + * @param {ASTNode} ref - The reference to check + * @return {boolean} True if the reference is in a destructuring context + */ +function isInDestructuringPattern(ref) { + let current = ref; + while (current.parentNode) { + if (['ObjectPattern', 'ArrayPattern'].includes(current.parentNode.type)) { + return true; + } + current = current.parentNode; } return false; } -export {areReferencesModified}; \ No newline at end of file +/** + * Checks if a reference is used in an increment/decrement operation. + * E.g. ++ref, ref++, --ref, ref--, ++obj.prop, obj.prop++ + * @param {ASTNode} ref - The reference to check + * @return {boolean} True if the reference is being incremented/decremented + */ +function isReferenceIncremented(ref) { + // Direct increment: ++ref, ref++, --ref, ref-- + if (ref.parentNode.type === 'UpdateExpression' && ref.parentNode.argument === ref) { + return true; + } + + // Member expression increment: ++obj.prop, obj.prop++ + if (ref.parentNode.type === 'MemberExpression' && + ref.parentKey === 'object' && + ref.parentNode.parentNode.type === 'UpdateExpression' && + ref.parentNode.parentNode.argument === ref.parentNode) { + return true; + } + + return false; +} + +/** + * Determines if any of the given references are potentially modified in ways that would + * make code transformations unsafe. This function performs comprehensive checks for various + * modification patterns including assignments, method calls, destructuring, and more. + * + * Critical for safe transformations: if this returns true, the variable/object should not + * be replaced or transformed as its value may change during execution. + * + * @param {ASTNode[]} ast - The AST array (expects ast[0] to contain typeMap) + * @param {ASTNode[]} refs - Array of reference nodes to analyze for modifications + * @return {boolean} True if any reference might be modified, false if all are safe to transform + * + * @example + * // Safe cases (returns false): + * const arr = [1, 2, 3]; const x = arr[0]; // No modification + * const obj = {a: 1}; console.log(obj.a); // Read-only access + * + * @example + * // Unsafe cases (returns true): + * const arr = [1, 2, 3]; arr[0] = 5; // Direct assignment + * const obj = {a: 1}; obj.a = 2; // Property assignment + * const arr = [1, 2, 3]; arr.push(4); // Mutating method call + * let x = 1; x++; // Increment operation + * const obj = {a: 1}; delete obj.a; // Delete operation + */ +export function areReferencesModified(ast, refs) { + if (!refs.length) return false; + + // Cache assignment expressions for performance + const assignmentExpressions = ast[0].typeMap.AssignmentExpression || []; + + for (let i = 0; i < refs.length; i++) { + const ref = refs[i]; + + // Check for direct assignment: ref = value, ref += value, etc. + if (ref.parentKey === 'left' && ASSIGNMENT_TYPES.includes(ref.parentNode.type)) { + return true; + } + + // Check for for-in/for-of/for-await with member expression: for (obj.prop in/of/await ...) + if (ref.parentNode.type === 'MemberExpression' && + ref.parentKey === 'object' && + ref.parentNode.parentKey === 'left' && + ['ForInStatement', 'ForOfStatement', 'ForAwaitStatement'].includes(ref.parentNode.parentNode.type)) { + return true; + } + + // Check for increment/decrement: ++ref, ref++, --ref, ref-- + if (isReferenceIncremented(ref)) { + return true; + } + + // Check for variable redeclaration in subscope: const ref = ... + if (ref.parentNode.type === 'VariableDeclarator' && ref.parentKey === 'id') { + return true; + } + + // Check for delete operations: delete ref, delete obj.prop + if (isReferenceDeleted(ref)) { + return true; + } + + // Check for destructuring patterns (conservative approach) + if (isInDestructuringPattern(ref)) { + return true; + } + + // Check for member expression modifications: obj.method(), obj.prop = value + if (ref.parentNode.type === 'MemberExpression') { + const memberExpr = ref.parentNode; + const grandParent = memberExpr.parentNode; + + // Check for mutating method calls: arr.push(), obj.sort() + if (grandParent.type === 'CallExpression' && + grandParent.callee === memberExpr && + memberExpr.object === ref) { + const methodName = memberExpr.property?.value || memberExpr.property?.name; + if (PROPERTIES_THAT_MODIFY_CONTENT.includes(methodName)) { + return true; + } + } + + // Check for property assignments: obj.prop = value + if (grandParent.type === 'AssignmentExpression' && + memberExpr.parentKey === 'left') { + return true; + } + } + + // Check for member expressions being assigned to: complex cases like nested.prop = value + if (ref.type === 'MemberExpression' && + isMemberExpressionAssignedTo(ref, assignmentExpressions)) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/src/modules/utils/canUnaryExpressionBeResolved.js b/src/modules/utils/canUnaryExpressionBeResolved.js deleted file mode 100644 index 08b960e..0000000 --- a/src/modules/utils/canUnaryExpressionBeResolved.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @param {ASTNode} argument - * @return {boolean} true if unary expression's argument can be resolved (i.e. independent of other identifier); false otherwise. - */ -function canUnaryExpressionBeResolved(argument) { - switch (argument.type) { // Examples for each type of argument which can be resolved: - case 'ArrayExpression': - return !argument.elements.length; // ![] - case 'ObjectExpression': - return !argument.properties.length; // !{} - case 'Identifier': - return argument.name === 'undefined'; // !undefined - case 'TemplateLiteral': - return !argument.expressions.length; // !`template literals with no expressions` - case 'UnaryExpression': - return canUnaryExpressionBeResolved(argument.argument); - } - return true; -} - -export {canUnaryExpressionBeResolved}; \ No newline at end of file diff --git a/src/modules/utils/createNewNode.js b/src/modules/utils/createNewNode.js index 7b4e432..88984d1 100644 --- a/src/modules/utils/createNewNode.js +++ b/src/modules/utils/createNewNode.js @@ -1,115 +1,175 @@ -import {badValue} from '../config.js'; +import {BAD_VALUE} from '../config.js'; import {getObjType} from './getObjType.js'; import {generateCode, parseCode, logger} from 'flast'; /** - * Create a node from a value by its type. - * @param {*} value The value to be parsed into an ASTNode. - * @returns {ASTNode|badValue} The newly created node if successful; badValue string otherwise. + * Creates an AST node from a JavaScript value by analyzing its type and structure. + * Handles primitive types, arrays, objects, and special cases like negative zero, + * unary expressions, and AST nodes. Returns BAD_VALUE for unsupported types. + * @param {*} value - The JavaScript value to convert into an AST node + * @return {ASTNode|BAD_VALUE} The newly created AST node if successful; BAD_VALUE otherwise */ -function createNewNode(value) { - let newNode = badValue; +export function createNewNode(value) { + let newNode = BAD_VALUE; try { - if (![undefined, null].includes(value) && value.__proto__.constructor.name === 'Node') value = generateCode(value); - switch (getObjType(value)) { - case 'String': - case 'Number': - case 'Boolean': - if (['-', '+', '!'].includes(String(value)[0]) && String(value).length > 1) { - const absVal = String(value).substring(1); - if (Number.isNaN(parseInt(absVal)) && !['Infinity', 'NaN'].includes(absVal)) { - newNode = { - type: 'Literal', - value, - raw: String(value), - }; - } else newNode = { - type: 'UnaryExpression', - operator: String(value)[0], - argument: createNewNode(absVal), - }; - } else if (['Infinity', 'NaN'].includes(String(value))) { - newNode = { - type: 'Identifier', - name: String(value), - }; - } else if (Object.is(value, -0)) { + const valueType = getObjType(value); + switch (valueType) { + case 'Node': + newNode = value; + break; + case 'String': + case 'Number': + case 'Boolean': { + const valueStr = String(value); + const firstChar = valueStr[0]; + + // Handle unary expressions like -3, +5, !true (from string representations) + if (['-', '+', '!'].includes(firstChar) && valueStr.length > 1) { + const absVal = valueStr.substring(1); + // Check if the remaining part is numeric (integers only to maintain original behavior) + if (isNaN(parseInt(absVal)) && !['Infinity', 'NaN'].includes(absVal)) { + // Non-numeric string like "!hello" - treat as literal newNode = { - type: 'UnaryExpression', - operator: '-', - argument: createNewNode(0), + type: 'Literal', + value, + raw: valueStr, }; } else { + // Create unary expression maintaining string representation for consistency newNode = { - type: 'Literal', - value: value, - raw: String(value), + type: 'UnaryExpression', + operator: firstChar, + argument: createNewNode(absVal), }; } - break; - case 'Array': { - const elements = []; - for (const el of Array.from(value)) { - elements.push(createNewNode(el)); - } + } else if (['Infinity', 'NaN'].includes(valueStr)) { + // Special numeric identifiers newNode = { - type: 'ArrayExpression', - elements, + type: 'Identifier', + name: valueStr, }; - break; - } - case 'Object': { - const properties = []; - for (const [k, v] of Object.entries(value)) { - const key = createNewNode(k); - const val = createNewNode(v); - if ([key, val].includes(badValue)) { - // noinspection ExceptionCaughtLocallyJS - throw Error(); - } - properties.push({ - type: 'Property', - key, - value: val, - }); - } + } else if (Object.is(value, -0)) { + // Special case: negative zero requires unary expression newNode = { - type: 'ObjectExpression', - properties, + type: 'UnaryExpression', + operator: '-', + argument: createNewNode(0), }; - break; - } - case 'Undefined': + } else { + // Regular literal values newNode = { - type: 'Identifier', - name: 'undefined', + type: 'Literal', + value: value, + raw: valueStr, }; - break; - case 'Null': + } + break; + } + case 'Array': { + const elements = []; + // Direct iteration over array (value is already an array) + for (let i = 0; i < value.length; i++) { + const elementNode = createNewNode(value[i]); + if (elementNode === BAD_VALUE) { + // If any element fails to convert, fail the entire array + throw new Error('Array contains unconvertible element'); + } + elements.push(elementNode); + } + newNode = { + type: 'ArrayExpression', + elements, + }; + break; + } + case 'Object': { + const properties = []; + const entries = Object.entries(value); + + for (let i = 0; i < entries.length; i++) { + const [k, v] = entries[i]; + const key = createNewNode(k); + const val = createNewNode(v); + + // If any property key or value fails to convert, fail the entire object + if (key === BAD_VALUE || val === BAD_VALUE) { + throw new Error('Object contains unconvertible property'); + } + + properties.push({ + type: 'Property', + key, + value: val, + }); + } + newNode = { + type: 'ObjectExpression', + properties, + }; + break; + } + case 'Undefined': + newNode = { + type: 'Identifier', + name: 'undefined', + }; + break; + case 'Null': + newNode = { + type: 'Literal', + raw: 'null', + }; + break; + case 'BigInt': + newNode = { + type: 'Literal', + value: value, + raw: value.toString() + 'n', + bigint: value.toString(), + }; + break; + case 'Symbol': + // Symbols cannot be represented as literals in AST + // They must be created via Symbol() calls + const symbolDesc = value.description; + if (symbolDesc) { newNode = { - type: 'Literal', - raw: 'null', + type: 'CallExpression', + callee: {type: 'Identifier', name: 'Symbol'}, + arguments: [createNewNode(symbolDesc)], }; - break; - case 'Function': // Covers functions and classes - try { - newNode = parseCode(value).body[0]; - } catch {} // Probably a native function - break; - case 'RegExp': + } else { newNode = { - type: 'Literal', - regex: { - pattern: value.source, - flags: value.flags, - }, + type: 'CallExpression', + callee: {type: 'Identifier', name: 'Symbol'}, + arguments: [], }; - break; + } + break; + case 'Function': // Covers functions and classes + try { + // Attempt to parse function source code into AST + const parsed = parseCode(value.toString()); + if (parsed?.body?.[0]) { + newNode = parsed.body[0]; + } + } catch { + // Native functions or unparseable functions return BAD_VALUE + // This is expected behavior for built-in functions like Math.max + } + break; + case 'RegExp': + newNode = { + type: 'Literal', + regex: { + pattern: value.source, + flags: value.flags, + }, + }; + break; } } catch (e) { logger.debug(`[-] Unable to create a new node: ${e}`); } return newNode; -} - -export {createNewNode}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/utils/createOrderedSrc.js b/src/modules/utils/createOrderedSrc.js index 3c5f703..85dec86 100644 --- a/src/modules/utils/createOrderedSrc.js +++ b/src/modules/utils/createOrderedSrc.js @@ -1,71 +1,125 @@ import {parseCode} from 'flast'; -const largeNumber = 999e8; +// Large number used to push IIFE nodes to the end when preserveOrder is false +const LARGE_NUMBER = 999e8; +const FUNC_START_REGEXP = /function[^(]*/; +const TYPES_REQUIRING_SEMICOLON = ['VariableDeclarator', 'AssignmentExpression']; + +/** + * Comparison function for sorting nodes by their nodeId. + * @param {ASTNode} a - First node to compare + * @param {ASTNode} b - Second node to compare + * @return {number} -1 if a comes before b, 1 if b comes before a, 0 if equal + */ const sortByNodeId = (a, b) => a.nodeId > b.nodeId ? 1 : b.nodeId > a.nodeId ? -1 : 0; -const funcStartRegexp = new RegExp('function[^(]*'); /** - * Add a name to a FunctionExpression. - * @param {ASTNode} n The target node - * @param {string} [name] The new name. Defaults to 'func + n.nodeId'. - * @return {ASTNode} The new node with the name set + * Adds a name to an anonymous FunctionExpression by parsing modified source code. + * This is necessary for creating standalone function declarations from anonymous functions. + * @param {ASTNode} n - The FunctionExpression node to add a name to + * @param {string} [name] - The new name. Defaults to 'func + n.nodeId' + * @return {ASTNode|null} The new named function node, or null if parsing fails */ function addNameToFE(n, name) { name = name || 'func' + n.nodeId; - const funcSrc = '(' + n.src.replace(funcStartRegexp, 'function ' + name) + ');'; - const newNode = parseCode(funcSrc); - if (newNode) { - newNode.nodeId = n.nodeId; - newNode.src = funcSrc; - return newNode; + const funcSrc = '(' + n.src.replace(FUNC_START_REGEXP, 'function ' + name) + ');'; + try { + const newNode = parseCode(funcSrc); + if (newNode) { + newNode.nodeId = n.nodeId; + newNode.src = funcSrc; + return newNode; + } + } catch (e) { + // Return null if parsing fails rather than undefined + return null; } + return null; } /** - * Return the source code of the ordered nodes. - * @param {ASTNode[]} nodes - * @param {boolean} preserveOrder (optional) When false, IIFEs are pushed to the end of the code. - * @return {string} Combined source code of the nodes. + * Creates ordered source code from AST nodes, handling special cases for IIFEs and function expressions. + * When preserveOrder is false, IIFEs are moved to the end to ensure proper execution order. + * This is critical for deobfuscation where dependencies must be resolved before usage. + * + * @param {ASTNode[]} nodes - Array of AST nodes to convert to source code + * @param {boolean} [preserveOrder=false] - When false, IIFEs are pushed to the end of the code + * @return {string} Combined source code of the nodes in proper execution order + * + * @example + * // Without preserveOrder: IIFEs moved to end + * const nodes = [iifeNode, regularCallNode]; + * createOrderedSrc(nodes); // β†’ "regularCall();\n(function(){})();\n" + * + * // With preserveOrder: original order preserved + * createOrderedSrc(nodes, true); // β†’ "(function(){})();\nregularCall();\n" */ -function createOrderedSrc(nodes, preserveOrder = false) { - const parsedNodes = []; - for (let i = 0; i < nodes.length; i++) { - let n = nodes[i]; - if (n.type === 'CallExpression') { - if (n.parentNode.type === 'ExpressionStatement') { - nodes[i] = n.parentNode; - if (!preserveOrder && n.callee.type === 'FunctionExpression') { - // Set nodeId to place IIFE just after its argument's declaration +export function createOrderedSrc(nodes, preserveOrder = false) { + const seenNodes = new Set(); + const processedNodes = []; + + for (let i = 0; i < nodes.length; i++) { + let currentNode = nodes[i]; + + // Handle CallExpression nodes + if (currentNode.type === 'CallExpression') { + if (currentNode.parentNode.type === 'ExpressionStatement') { + // Use the ExpressionStatement wrapper instead of the bare CallExpression + currentNode = currentNode.parentNode; + nodes[i] = currentNode; + + // IIFE reordering: place after argument dependencies when preserveOrder is false + if (!preserveOrder && nodes[i].expression.callee.type === 'FunctionExpression') { let maxArgNodeId = 0; - for (let j = 0; j < n.arguments.length; j++) { - const arg = n.arguments[j]; + for (let j = 0; j < nodes[i].expression.arguments.length; j++) { + const arg = nodes[i].expression.arguments[j]; if (arg?.declNode?.nodeId > maxArgNodeId) { maxArgNodeId = arg.declNode.nodeId; } } - nodes[i].nodeId = maxArgNodeId ? maxArgNodeId + 1 : nodes[i].nodeId + largeNumber; + // Place IIFE after latest argument dependency, or at end if no dependencies + currentNode.nodeId = maxArgNodeId ? maxArgNodeId + 1 : currentNode.nodeId + LARGE_NUMBER; } - } else if (n.callee.type === 'FunctionExpression') { + } else if (nodes[i].callee.type === 'FunctionExpression') { + // Standalone function expression calls (not in ExpressionStatement) if (!preserveOrder) { - const newNode = addNameToFE(n, n.parentNode?.id?.name); - newNode.nodeId = newNode.nodeId + largeNumber; - nodes[i] = newNode; - } else nodes[i] = n; + const namedFunc = addNameToFE(nodes[i], nodes[i].parentNode?.id?.name); + if (namedFunc) { + namedFunc.nodeId = namedFunc.nodeId + LARGE_NUMBER; + currentNode = namedFunc; + nodes[i] = currentNode; + } + } + // When preserveOrder is true, keep the original node unchanged + } + } else if (currentNode.type === 'FunctionExpression' && !currentNode.id) { + // Anonymous function expressions need names for standalone declarations + const namedFunc = addNameToFE(currentNode, currentNode.parentNode?.id?.name); + if (namedFunc) { + currentNode = namedFunc; + nodes[i] = currentNode; } - } else if (n.type === 'FunctionExpression' && !n.id) { - nodes[i] = addNameToFE(n, n.parentNode?.id?.name); } - n = nodes[i]; // In case the node was replaced - if (!parsedNodes.includes(n)) parsedNodes.push(n); + + // Add to processed list if not already seen + if (!seenNodes.has(currentNode)) { + seenNodes.add(currentNode); + processedNodes.push(currentNode); + } } - parsedNodes.sort(sortByNodeId); + + // Sort by nodeId to ensure proper execution order + processedNodes.sort(sortByNodeId); + + // Generate source code with proper formatting let output = ''; - for (let i = 0; i < parsedNodes.length; i++) { - const n = parsedNodes[i]; - const addSemicolon = ['VariableDeclarator', 'AssignmentExpression'].includes(n.type); - output += (n.type === 'VariableDeclarator' ? `${n.parentNode.kind} ` : '') + n.src + (addSemicolon ? ';' : '') + '\n'; + for (let i = 0; i < processedNodes.length; i++) { + const n = processedNodes[i]; + const needsSemicolon = TYPES_REQUIRING_SEMICOLON.includes(n.type); + const prefix = n.type === 'VariableDeclarator' ? `${n.parentNode.kind} ` : ''; + const suffix = needsSemicolon ? ';' : ''; + output += prefix + n.src + suffix + '\n'; } + return output; -} - -export {createOrderedSrc}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/utils/doesBinaryExpressionContainOnlyLiterals.js b/src/modules/utils/doesBinaryExpressionContainOnlyLiterals.js deleted file mode 100644 index 1960172..0000000 --- a/src/modules/utils/doesBinaryExpressionContainOnlyLiterals.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * - * @param {ASTNode} binaryExpression - * @return {boolean} true if ultimately the binary expression contains only literals; false otherwise - */ -function doesBinaryExpressionContainOnlyLiterals(binaryExpression) { - switch (binaryExpression.type) { - case 'BinaryExpression': - return doesBinaryExpressionContainOnlyLiterals(binaryExpression.left) && - doesBinaryExpressionContainOnlyLiterals(binaryExpression.right); - case 'UnaryExpression': - return doesBinaryExpressionContainOnlyLiterals(binaryExpression.argument); - case 'Literal': - return true; - } - return false; -} - -export {doesBinaryExpressionContainOnlyLiterals}; \ No newline at end of file diff --git a/src/modules/utils/doesDescendantMatchCondition.js b/src/modules/utils/doesDescendantMatchCondition.js index f54610e..fc5af20 100644 --- a/src/modules/utils/doesDescendantMatchCondition.js +++ b/src/modules/utils/doesDescendantMatchCondition.js @@ -1,18 +1,41 @@ /** + * Performs depth-first search through AST node descendants to find nodes matching a condition. + * Uses an iterative stack-based approach to avoid call stack overflow on deeply nested ASTs. + * This function is commonly used to check if transformations should be skipped due to + * specific node types being present in the subtree (e.g., ThisExpression, marked nodes). * - * @param {ASTNode} targetNode - * @param {function} condition - * @param {boolean} [returnNode] Return the node that matches the condition - * @return {boolean|ASTNode} + * @param {ASTNode} targetNode - The root AST node to start searching from + * @param {Function} condition - Predicate function that takes an ASTNode and returns boolean + * @param {boolean} [returnNode=false] - If true, returns the matching node; if false, returns boolean + * @return {boolean|ASTNode} True/false if returnNode is false, or the matching ASTNode if returnNode is true + * + * // Example usage: + * // Check if any descendant is marked: doesDescendantMatchCondition(node, n => n.isMarked) + * // Find ThisExpression: doesDescendantMatchCondition(node, n => n.type === 'ThisExpression', true) */ -function doesDescendantMatchCondition(targetNode, condition, returnNode = false) { +export function doesDescendantMatchCondition(targetNode, condition, returnNode = false) { + // Input validation - handle null/undefined gracefully + if (!targetNode || typeof condition !== 'function') { + return false; + } + + // Use stack-based DFS to avoid recursion depth limits const stack = [targetNode]; while (stack.length) { const currentNode = stack.pop(); - if (condition(currentNode)) return returnNode ? currentNode : true; - if (currentNode.childNodes?.length) stack.push(...currentNode.childNodes); + + // Test current node against condition + if (condition(currentNode)) { + return returnNode ? currentNode : true; + } + + // Add children to stack for continued traversal (use traditional loop for performance) + if (currentNode.childNodes?.length) { + for (let i = currentNode.childNodes.length - 1; i >= 0; i--) { + stack.push(currentNode.childNodes[i]); + } + } } + return false; -} - -export {doesDescendantMatchCondition}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/utils/evalInVm.js b/src/modules/utils/evalInVm.js index 0ec49e7..8dc4f84 100644 --- a/src/modules/utils/evalInVm.js +++ b/src/modules/utils/evalInVm.js @@ -1,25 +1,27 @@ import {Sandbox} from './sandbox.js'; -import {badValue} from '../config.js'; +import {BAD_VALUE} from '../config.js'; import {getObjType} from './getObjType.js'; import {generateHash} from './generateHash.js'; import {createNewNode} from './createNewNode.js'; -// Types of objects which can't be resolved in the deobfuscation context. -const badTypes = ['Promise']; +// Object types that cannot be safely resolved in the deobfuscation context +const BAD_TYPES = ['Promise']; -const matchingObjectKeys = { +// Pre-computed console object key signatures for builtin object detection +const MATCHING_OBJECT_KEYS = { [Object.keys(console).sort().join('')]: {type: 'Identifier', name: 'console'}, [Object.keys(console).sort().slice(1).join('')]: {type: 'Identifier', name: 'console'}, // Alternative console without the 'Console' object }; -const trapStrings = [ // Rules for diffusing code traps. +// Anti-debugging and infinite loop trap patterns with their neutralization replacements +const TRAP_STRINGS = [ { - trap: /while\s*\(\s*(true|1)\s*\)\s*\{\s*}/gi, + trap: /while\s*\(\s*(true|[1-9][0-9]*)\s*\)\s*\{\s*}/gi, replaceWith: 'while (0) {}', }, { trap: /debugger/gi, - replaceWith: 'debugge_', + replaceWith: '"debugge_"', }, { // TODO: Add as many permutations of this in an efficient manner trap: /["']debu["']\s*\+\s*["']gger["']/gi, @@ -27,39 +29,68 @@ const trapStrings = [ // Rules for diffusing code traps. }, ]; -let cache = {}; -const maxCacheSize = 100; +let CACHE = {}; +const MAX_CACHE_SIZE = 100; /** - * Eval a string in an ~isolated~ environment - * @param {string} stringToEval - * @param {Sandbox} [sb] (optional) an existing sandbox loaded with context. - * @return {ASTNode|string} A node based on the eval result if successful; badValue string otherwise. + * Safely evaluates JavaScript code in a somewhat isolated sandbox environment. + * Never trust the code you are evaluating, but if you do decide to execute it, this much is basic. + * Includes built-in caching, anti-debugging trap neutralization, and result transformation to AST nodes. + * + * Security features: + * - Runs code in an ~isolated~ sandbox + * - Neutralizes common debugging traps (infinite loops, debugger statements) + * - Limits memory usage and execution time through Sandbox configuration + * - Filters out dangerous object types that could cause security issues + * + * Performance optimizations: + * - Content-based caching prevents re-evaluation of identical code + * - Cache size limit prevents memory bloat + * - Reuses provided sandbox instances to avoid VM creation overhead + * + * @param {string} stringToEval - JavaScript code string to evaluate safely + * @param {Sandbox} [sb] - Optional existing sandbox with pre-loaded context for performance + * @return {ASTNode|string} AST node representation of the result, or BAD_VALUE if evaluation fails/unsafe + * + * @example + * // evalInVm('5 + 3') => {type: 'Literal', value: 8, raw: '8'} + * // evalInVm('Math.random()') => BAD_VALUE (unsafe/non-deterministic) + * // evalInVm('[1,2,3].length') => {type: 'Literal', value: 3, raw: '3'} */ -function evalInVm(stringToEval, sb) { +export function evalInVm(stringToEval, sb) { const cacheName = `eval-${generateHash(stringToEval)}`; - if (cache[cacheName] === undefined) { - if (Object.keys(cache).length >= maxCacheSize) cache = {}; - cache[cacheName] = badValue; + if (CACHE[cacheName] === undefined) { + // Simple cache eviction: clear all when hitting size limit + if (Object.keys(CACHE).length >= MAX_CACHE_SIZE) CACHE = {}; + CACHE[cacheName] = BAD_VALUE; try { - // Break known trap strings - for (let i = 0; i < trapStrings.length; i++) { - const ts = trapStrings[i]; + // Neutralize anti-debugging and infinite loop traps before evaluation + for (let i = 0; i < TRAP_STRINGS.length; i++) { + const ts = TRAP_STRINGS[i]; stringToEval = stringToEval.replace(ts.trap, ts.replaceWith); } let vm = sb || new Sandbox(); let res = vm.run(stringToEval); - if (vm.isReference(res) && !badTypes.includes(getObjType(res))) { + + // Only process valid, safe references that can be converted to AST nodes + if (vm.isReference(res) && !BAD_TYPES.includes(getObjType(res))) { // noinspection JSUnresolvedVariable - res = res.copySync(); - // If the result is a builtin object / function, return a matching identifier + res = res.copySync(); // Extract value from VM reference + + // Check if result matches a known builtin object (e.g., console) const objKeys = Object.keys(res).sort().join(''); - if (matchingObjectKeys[objKeys]) cache[cacheName] = matchingObjectKeys[objKeys]; - else cache[cacheName] = createNewNode(res); + if (MATCHING_OBJECT_KEYS[objKeys]) { + CACHE[cacheName] = MATCHING_OBJECT_KEYS[objKeys]; + } else { + CACHE[cacheName] = createNewNode(res); + } } - } catch {} + } catch { + // Evaluation failed - cache entry remains BAD_VALUE + } } - return cache[cacheName]; + return CACHE[cacheName]; } -export {evalInVm}; \ No newline at end of file +// Attach BAD_VALUE to evalInVm for convenient access by modules using evalInVm +evalInVm.BAD_VALUE = BAD_VALUE; \ No newline at end of file diff --git a/src/modules/utils/generateHash.js b/src/modules/utils/generateHash.js index 56834d4..fdf1bd5 100644 --- a/src/modules/utils/generateHash.js +++ b/src/modules/utils/generateHash.js @@ -1,7 +1,45 @@ import crypto from 'node:crypto'; -function generateHash(script) { - return crypto.createHash('md5').update(script).digest('hex'); -} - -export {generateHash}; \ No newline at end of file +/** + * Generates a fast MD5 hash of the input string for cache key generation and deduplication. + * MD5 is chosen over SHA algorithms for performance in non-security contexts like caching. + * Used across the codebase to create unique identifiers for parsed code strings and AST node source. + * + * @param {string|ASTNode} input - The string to hash, or AST node with .src property + * @return {string} A 32-character hexadecimal MD5 hash, or fallback hash for invalid inputs + * + * // Usage examples: + * // Cache key: `eval-${generateHash(codeString)}` + * // Deduplication: `context-${generateHash(node.src)}` + */ +export function generateHash(input) { + try { + // Input validation and normalization + let stringToHash; + + if (input === null || input === undefined) { + return 'null-undefined-hash'; + } + + // Handle AST nodes with .src property + if (typeof input === 'object' && input.src !== undefined) { + stringToHash = String(input.src); + } else { + // Convert to string (handles numbers, booleans, etc.) + stringToHash = String(input); + } + + // Generate MD5 hash for fast cache key generation + return crypto.createHash('md5').update(stringToHash).digest('hex'); + + } catch (error) { + // Fallback hash generation if crypto operations fail + // Simple string-based hash as last resort + const str = String(input?.src ?? input ?? 'error'); + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) & 0xffffffff; + } + return `fallback-${Math.abs(hash).toString(16)}`; + } +} \ No newline at end of file diff --git a/src/modules/utils/getCache.js b/src/modules/utils/getCache.js index 7b86732..34f6171 100644 --- a/src/modules/utils/getCache.js +++ b/src/modules/utils/getCache.js @@ -1,20 +1,42 @@ -let cache = {}; -let relevantScriptHash = null; +let CACHE = {}; +let RELEVANT_SCRIPT_HASH = null; /** - * @param {string} currentScriptHash - * @return {object} The relevant cache object. + * Gets a per-script cache object that automatically invalidates when the script hash changes. + * This ensures that cached results from one script don't contaminate processing of another script. + * The cache is shared across all modules processing the same script but isolated between scripts. + * + * Cache invalidation strategy: + * - When scriptHash changes: cache is cleared and new hash is stored + * - When same scriptHash: existing cache is returned + * - Manual flush: clears cache but preserves current scriptHash for next call + * + * @param {string} currentScriptHash - Hash identifying the current script being processed + * @return {Object} Shared cache object for the current script (empty object for new/changed scripts) + * + * // Usage patterns: + * // const cache = getCache(arb.ast[0].scriptHash); + * // cache[`eval-${generateHash(code)}`] = result; */ -function getCache(currentScriptHash) { - if (currentScriptHash !== relevantScriptHash) { - relevantScriptHash = currentScriptHash; - cache = {}; +export function getCache(currentScriptHash) { + // Input validation - handle null/undefined gracefully + const scriptHash = currentScriptHash ?? 'no-hash'; + + // Cache invalidation: clear when script changes + if (scriptHash !== RELEVANT_SCRIPT_HASH) { + RELEVANT_SCRIPT_HASH = scriptHash; + CACHE = {}; } - return cache; + + return CACHE; } +/** + * Manually flushes the current cache while preserving the script hash. + * Useful for clearing memory between processing phases or for testing. + */ getCache.flush = function() { - cache = {}; -}; - -export {getCache}; \ No newline at end of file + CACHE = {}; + // Note: RELEVANT_SCRIPT_HASH is intentionally preserved to avoid + // unnecessary cache misses on the next getCache call with same hash +}; \ No newline at end of file diff --git a/src/modules/utils/getCalleeName.js b/src/modules/utils/getCalleeName.js index 0eb1873..56b9027 100644 --- a/src/modules/utils/getCalleeName.js +++ b/src/modules/utils/getCalleeName.js @@ -1,10 +1,54 @@ /** - * @param {ASTNode} callExpression - * @return {string} The name of the identifier / value of the literal at the base of the call expression. + * Extracts the function name from a CallExpression's callee for frequency counting and sorting. + * Only returns names for direct function calls; returns empty string for method calls on literals + * and complex expressions to avoid counting collisions. + * + * Resolution strategy: + * - Direct function calls: returns function name (e.g., 'func' from func()) + * - Variable method calls: returns variable name (e.g., 'obj' from obj.method()) + * - Literal method calls: returns empty string (e.g., '' from 'str'.split()) + * - Complex expressions: returns empty string (e.g., '' from (a || b)()) + * + * This prevents counting collisions between function calls and literal method calls: + * - function t1() {}; t1(); => 't1' (counted) + * - 't1'.toString(); => '' (not counted, different category) + * + * @param {ASTNode} callExpression - CallExpression AST node to analyze + * @return {string} Function name for direct calls, variable name for method calls, empty string otherwise */ -function getCalleeName(callExpression) { - const callee = callExpression.callee?.object?.object || callExpression.callee?.object || callExpression.callee; - return callee.name || callee.value; -} - -export {getCalleeName}; \ No newline at end of file +export function getCalleeName(callExpression) { + // Input validation + if (!callExpression?.callee) { + return ''; + } + + const callee = callExpression.callee; + + // Direct function call: func() + if (callee.type === 'Identifier') { + return callee.name; + } + + // Method call: traverse to base object + if (callee.type === 'MemberExpression') { + let current = callee; + + // Find the base object: obj.nested.method() -> find 'obj' + while (current.object) { + current = current.object; + } + + // Only return name for variable-based method calls + if (current.type === 'Identifier') { + return current.name; // obj.method() => 'obj' + } + + // Literal method calls return empty string to avoid collision + // 'str'.method() => '' (not counted with function calls) + return ''; + } + + // All complex expressions return empty string + // (func || fallback)(), func()(), etc. + return ''; +} \ No newline at end of file diff --git a/src/modules/utils/getDeclarationWithContext.js b/src/modules/utils/getDeclarationWithContext.js index 073e1c5..f4cfe7d 100644 --- a/src/modules/utils/getDeclarationWithContext.js +++ b/src/modules/utils/getDeclarationWithContext.js @@ -1,63 +1,97 @@ import {getCache} from './getCache.js'; import {generateHash} from './generateHash.js'; import {isNodeInRanges} from './isNodeInRanges.js'; -import {propertiesThatModifyContent} from '../config.js'; +import {PROPERTIES_THAT_MODIFY_CONTENT} from '../config.js'; import {doesDescendantMatchCondition} from './doesDescendantMatchCondition.js'; -// Types that give no context by themselves -const irrelevantTypesToBeFilteredOut = [ +// Node types that provide no meaningful context and should be filtered from final results +const IRRELEVANT_FILTER_TYPES = [ 'Literal', - 'Identifier', + 'Identifier', 'MemberExpression', ]; -// Relevant types for giving context -const typesToCollect = [ +// Node types that provide meaningful context for code evaluation +const TYPES_TO_COLLECT = [ 'CallExpression', - 'ArrowFunctionExpression', + 'ArrowFunctionExpression', 'AssignmentExpression', 'FunctionDeclaration', 'FunctionExpression', 'VariableDeclarator', ]; -// Child nodes that can be skipped as they give no context -const irrelevantTypesToAvoidIteratingOver = [ +// Child node types that can be skipped during traversal as they provide no useful context +const SKIP_TRAVERSAL_TYPES = [ 'Literal', 'ThisExpression', ]; -// Direct child nodes of an if statement -const ifKeys = ['consequent', 'alternate']; +// IfStatement child keys for detecting conditional execution contexts +const IF_STATEMENT_KEYS = ['consequent', 'alternate']; -// Node types which are acceptable when wrapping an anonymous function -const standaloneNodeTypes = ['ExpressionStatement', 'AssignmentExpression', 'VariableDeclarator']; +// Node types that can properly wrap anonymous function expressions +const STANDALONE_WRAPPER_TYPES = ['ExpressionStatement', 'AssignmentExpression', 'VariableDeclarator']; /** - * @param {ASTNode} targetNode - * @return {boolean} True if the target node is directly under an if statement; false otherwise + * Determines if a node is positioned as the consequent or alternate branch of an IfStatement. + * This is used to identify nodes that are conditionally executed and may need special handling. + * + * @param {ASTNode} targetNode - The AST node to check + * @return {boolean} True if the node is in an if statement branch, false otherwise */ function isConsequentOrAlternate(targetNode) { + if (!targetNode?.parentNode) return false; + return targetNode.parentNode.type === 'IfStatement' || - ifKeys.includes(targetNode.parentKey) || - ifKeys.includes(targetNode.parentNode.parentKey) || - (targetNode.parentNode.parentNode.type === 'BlockStatement' && ifKeys.includes(targetNode.parentNode.parentNode.parentKey)); + IF_STATEMENT_KEYS.includes(targetNode.parentKey) || + IF_STATEMENT_KEYS.includes(targetNode.parentNode.parentKey) || + (targetNode.parentNode.parentNode?.type === 'BlockStatement' && + IF_STATEMENT_KEYS.includes(targetNode.parentNode.parentNode.parentKey)); } /** - * @param {ASTNode} n - * @return {boolean} True if the target node is the object of a member expression - * and its property is being assigned to; false otherwise. + * Determines if a node is the object of a member expression that is being assigned to or modified. + * This identifies cases where the node's content may be altered through property assignment + * or method calls that modify the object (e.g., array mutating methods). + * + * @param {ASTNode} n - The AST node to check + * @return {boolean} True if the node is subject to property assignment/modification, false otherwise + * + * Examples of detected patterns: + * - obj.prop = value (assignment to property) + * - obj.push(item) (mutating method call) + * - obj[key] = value (computed property assignment) */ function isNodeAnAssignmentToProperty(n) { - return n.parentNode.type === 'MemberExpression' && - !isConsequentOrAlternate(n.parentNode) && - ((n.parentNode.parentNode.type === 'AssignmentExpression' && // e.g. targetNode.prop = value - n.parentNode.parentKey === 'left') || - (n.parentKey === 'object' && - (n.parentNode.property.isMarked || // Marked references won't be collected - // propertiesThatModifyContent - e.g. targetNode.push(value) - changes the value of targetNode - propertiesThatModifyContent.includes(n.parentNode.property?.value || n.parentNode.property.name)))); + if (!n?.parentNode || n.parentNode.type !== 'MemberExpression') { + return false; + } + + if (isConsequentOrAlternate(n.parentNode)) { + return false; + } + + // Check for assignment to property: obj.prop = value + if (n.parentNode.parentNode?.type === 'AssignmentExpression' && + n.parentNode.parentKey === 'left') { + return true; + } + + // Check for mutating method calls: obj.push(value) + if (n.parentKey === 'object') { + const property = n.parentNode.property; + if (property?.isMarked) { + return true; // Marked references won't be collected + } + + const propertyName = property?.value || property?.name; + if (propertyName && PROPERTIES_THAT_MODIFY_CONTENT.includes(propertyName)) { + return true; + } + } + + return false; } /** @@ -79,27 +113,48 @@ function removeRedundantNodes(nodes) { } /** - * @param {ASTNode} originNode - * @param {boolean} [excludeOriginNode] (optional) Do not return the originNode. Defaults to false. - * @return {ASTNode[]} A flat array of all available declarations and call expressions relevant to - * the context of the origin node. + * Collects all declarations and call expressions that provide context for evaluating a given AST node. + * This function gathers relevant nodes needed for safe code evaluation, + * such as function declarations, variable assignments, and call expressions that may affect the behavior. + * + * The algorithm uses caching to avoid expensive re-computation for nodes with identical content, + * and includes logic to handle: + * - Variable references and their declarations + * - Function scope and closure variables + * - Anonymous function expressions and their contexts + * - Anti-debugging function overwrites (ignoring reassigned function declarations) + * - Marked nodes (scheduled for replacement/deletion) - aborts collection if found + * + * @param {ASTNode} originNode - The starting AST node to collect context for + * @param {boolean} [excludeOriginNode=false] - Whether to exclude the origin node from results + * @return {ASTNode[]} Array of context nodes (declarations, assignments, calls) relevant for evaluation */ export function getDeclarationWithContext(originNode, excludeOriginNode = false) { + // Input validation to prevent crashes + if (!originNode) { + return []; + } /** @type {ASTNode[]} */ const stack = [originNode]; // The working stack for nodes to be reviewed /** @type {ASTNode[]} */ const collected = []; // These will be our context - /** @type {ASTNode[]} */ - const seenNodes = []; // Collected to avoid re-iterating over the same nodes + /** @type {Set} */ + const visitedNodes = new Set(); // Track visited nodes to prevent infinite loops /** @type {number[][]} */ - const collectedRanges = []; // Collected to prevent collecting nodes from within collected nodes. + const collectedRanges = []; // Prevent collecting overlapping nodes + /** - * @param {ASTNode} node + * Adds a node to the traversal stack if it hasn't been visited and is worth traversing. + * @param {ASTNode} node - Node to potentially add to stack */ function addToStack(node) { - if (seenNodes.includes(node) || + if (!node || + visitedNodes.has(node) || stack.includes(node) || - irrelevantTypesToAvoidIteratingOver.includes(node.type)) {} else stack.push(node); + SKIP_TRAVERSAL_TYPES.includes(node.type)) { + return; + } + stack.push(node); } const cache = getCache(originNode.scriptHash); const srcHash = generateHash(originNode.src); @@ -109,14 +164,14 @@ export function getDeclarationWithContext(originNode, excludeOriginNode = false) if (!cached) { while (stack.length) { const node = stack.shift(); - if (seenNodes.includes(node)) continue; - seenNodes.push(node); + if (visitedNodes.has(node)) continue; + visitedNodes.add(node); // Do not collect any context if one of the relevant nodes is marked to be replaced or deleted if (node.isMarked || doesDescendantMatchCondition(node, n => n.isMarked)) { collected.length = 0; break; } - if (typesToCollect.includes(node.type) && !isNodeInRanges(node, collectedRanges)) { + if (TYPES_TO_COLLECT.includes(node.type) && !isNodeInRanges(node, collectedRanges)) { collected.push(node); collectedRanges.push(node.range); } @@ -153,19 +208,22 @@ export function getDeclarationWithContext(originNode, excludeOriginNode = false) if (node.property?.declNode) targetNodes.push(node.property.declNode); break; case 'FunctionExpression': - // Review the parent node of anonymous functions + // Review the parent node of anonymous functions to understand their context if (!node.id) { let targetParent = node; - while (targetParent.parentNode && !standaloneNodeTypes.includes(targetParent.type)) { + while (targetParent.parentNode && !STANDALONE_WRAPPER_TYPES.includes(targetParent.type)) { targetParent = targetParent.parentNode; } - if (standaloneNodeTypes.includes(targetParent.type)) targetNodes.push(targetParent); + if (STANDALONE_WRAPPER_TYPES.includes(targetParent.type)) { + targetNodes.push(targetParent); + } } + break; } for (let i = 0; i < targetNodes.length; i++) { const targetNode = targetNodes[i]; - if (!seenNodes.includes(targetNode)) stack.push(targetNode); + if (!visitedNodes.has(targetNode)) stack.push(targetNode); // noinspection JSUnresolvedVariable if (targetNode === targetNode.scope.block) { // Collect out-of-scope variables used inside the scope @@ -180,25 +238,42 @@ export function getDeclarationWithContext(originNode, excludeOriginNode = false) } } } - cached = new Set(); + // Filter and deduplicate collected nodes + /** @type {Set} */ + const filteredNodes = new Set(); + for (let i = 0; i < collected.length; i++) { const n = collected[i]; - if (!( - cached.has(n) || - irrelevantTypesToBeFilteredOut.includes(n.type)) && - !(excludeOriginNode && isNodeInRanges(n, [originNode.range]))) { - // A fix to ignore reassignments in cases where functions are overwritten as part of an anti-debugging mechanism - if (n.type === 'FunctionDeclaration' && n.id && n.id.references?.length) { - for (let j = 0; j < n.id.references.length; j++) { - const ref = n.id.references[j]; - if (!(ref.parentKey === 'left' && ref.parentNode.type === 'AssignmentExpression')) { - cached.add(n); - } + + // Skip if already added, irrelevant type, or should be excluded + if (filteredNodes.has(n) || + IRRELEVANT_FILTER_TYPES.includes(n.type) || + (excludeOriginNode && isNodeInRanges(n, [originNode.range]))) { + continue; + } + + // Handle anti-debugging function overwrites by ignoring reassigned functions + if (n.type === 'FunctionDeclaration' && n.id?.references?.length) { + let hasNonAssignmentReference = false; + const references = n.id.references; + + for (let j = 0; j < references.length; j++) { + const ref = references[j]; + if (!(ref.parentKey === 'left' && ref.parentNode?.type === 'AssignmentExpression')) { + hasNonAssignmentReference = true; + break; } - } else cached.add(n); + } + + if (hasNonAssignmentReference) { + filteredNodes.add(n); + } + } else { + filteredNodes.add(n); } } - cached = removeRedundantNodes([...cached]); + // Convert to array and remove redundant nodes + cached = removeRedundantNodes([...filteredNodes]); cache[cacheNameId] = cached; // Caching context for the same node cache[cacheNameSrc] = cached; // Caching context for a different node with similar content } diff --git a/src/modules/utils/getDescendants.js b/src/modules/utils/getDescendants.js index f566223..872133e 100644 --- a/src/modules/utils/getDescendants.js +++ b/src/modules/utils/getDescendants.js @@ -1,25 +1,52 @@ /** - * @param {ASTNode} targetNode - * @return {ASTNode[]} A flat array of all decendants of the target node + * Collects all descendant nodes from a given AST node. + * The function uses caching to avoid recomputation for nodes that have already been processed, + * storing results in a 'descendants' property on the target node. + * + * Algorithm: + * - Uses a stack-based traversal to avoid recursion depth limits + * - Uses Set for O(1) duplicate detection during traversal + * - Caches results as array on the node to prevent redundant computation + * - Returns a flat array containing all child nodes + * + * @param {ASTNode} targetNode - The AST node to collect descendants from + * @return {ASTNode[]} Flat array of all descendant nodes, or empty array if no descendants or invalid input + * + * @example + * // For a binary expression like "a + b" + * const descendants = getDescendants(binaryExprNode); + * // Returns [leftIdentifier, rightIdentifier] - all nested child nodes */ -function getDescendants(targetNode) { - if (targetNode?.['decendants']) return targetNode['decendants']; - /** @type {ASTNode[]} */ - const offsprings = []; +export function getDescendants(targetNode) { + // Input validation + if (!targetNode) { + return []; + } + + // Return cached result if available + if (targetNode.descendants) { + return targetNode.descendants; + } + + /** @type {Set} */ + const descendants = new Set(); /** @type {ASTNode[]} */ const stack = [targetNode]; + while (stack.length) { const currentNode = stack.pop(); - const childNodes = currentNode.childNodes || []; + const childNodes = currentNode?.childNodes || []; + for (let i = 0; i < childNodes.length; i++) { const childNode = childNodes[i]; - if (!offsprings.includes(childNode)) { - offsprings.push(childNode); + if (!descendants.has(childNode)) { + descendants.add(childNode); stack.push(childNode); } } } - return targetNode['decendants'] = offsprings; -} - -export {getDescendants}; \ No newline at end of file + + // Cache results as array on the target node for future calls + const descendantsArray = [...descendants]; + return targetNode.descendants = descendantsArray; +} \ No newline at end of file diff --git a/src/modules/utils/getMainDeclaredObjectOfMemberExpression.js b/src/modules/utils/getMainDeclaredObjectOfMemberExpression.js index 9c0fb72..b24e347 100644 --- a/src/modules/utils/getMainDeclaredObjectOfMemberExpression.js +++ b/src/modules/utils/getMainDeclaredObjectOfMemberExpression.js @@ -1,15 +1,41 @@ /** - * If this member expression is a part of another member expression - return the first parentNode - * which has a declaration in the code. - * E.g. a.b[c.d] --> if candidate is c.d, the c identifier will be returned. - * a.b.c.d --> if the candidate is c.d, the 'a' identifier will be returned. - * @param {ASTNode} memberExpression - * @return {ASTNode} The main object with an available declaration + * Traverses a member expression chain to find the root object that has a declaration. + * This function walks up nested member expressions (e.g., a.b.c.d) to locate the base + * identifier or object that contains a declNode, which indicates it was declared in the code. + * + * Algorithm: + * - Starts with the given member expression + * - Traverses up the object chain (.object property) until finding a node with declNode + * - Stops when reaching a non-MemberExpression or finding a declared object + * - Includes safety check to prevent infinite loops + * + * @param {ASTNode} memberExpression - MemberExpression AST node to analyze + * @return {ASTNode|null} The root object in the chain, or null if invalid input + * + * @example + * // a.b.c.d --> returns the 'a' identifier (if it has declNode) + * // obj.nested.prop --> returns 'obj' identifier (if it has declNode) + * // computed[key].value --> returns 'computed' identifier (if it has declNode) */ -function getMainDeclaredObjectOfMemberExpression(memberExpression) { +export function getMainDeclaredObjectOfMemberExpression(memberExpression) { + // Input validation: only reject null/undefined, allow any valid AST node + if (!memberExpression) { + return null; + } + let mainObject = memberExpression; - while (mainObject && !mainObject.declNode && mainObject.type === 'MemberExpression') mainObject = mainObject.object; - return mainObject; -} + let iterationCount = 0; + const MAX_ITERATIONS = 50; // Prevent infinite loops in malformed AST -export {getMainDeclaredObjectOfMemberExpression}; \ No newline at end of file + // Traverse up the member expression chain to find the root object with a declaration + while (mainObject && + !mainObject.declNode && + mainObject.type === 'MemberExpression' && + iterationCount < MAX_ITERATIONS) { + mainObject = mainObject.object; + iterationCount++; + } + + // Return the final object in the chain (original behavior preserved) + return mainObject; +} \ No newline at end of file diff --git a/src/modules/utils/getObjType.js b/src/modules/utils/getObjType.js index 3d7525f..c7748f6 100644 --- a/src/modules/utils/getObjType.js +++ b/src/modules/utils/getObjType.js @@ -1,9 +1,25 @@ /** - * @param {*} unknownObject - * @return {string} The type of whatever object is provided if possible; empty string otherwise. + * Determines the precise type of any JavaScript value using Object.prototype.toString. + * This function provides more accurate type detection than typeof, distinguishing between + * different object types like Array, Date, RegExp, etc. + * + * Uses the standard JavaScript pattern of calling Object.prototype.toString on the value + * and extracting the type name from the result string "[object TypeName]". + * + * @param {*} unknownObject - Any JavaScript value to analyze + * @return {string} The precise type name (e.g., 'Array', 'Date', 'RegExp', 'Null', 'Undefined') + * + * @example + * // getObjType([1, 2, 3]) => 'Array' + * // getObjType(new Date()) => 'Date' + * // getObjType(/regex/) => 'RegExp' + * // getObjType(null) => 'Null' + * // getObjType(undefined) => 'Undefined' + * // getObjType('string') => 'String' + * // getObjType(42) => 'Number' + * // getObjType({}) => 'Object' + * // getObjType(function() {}) => 'Function' */ -function getObjType(unknownObject) { +export function getObjType(unknownObject) { return ({}).toString.call(unknownObject).slice(8, -1); -} - -export {getObjType}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/utils/index.js b/src/modules/utils/index.js index 6437669..95a33b8 100644 --- a/src/modules/utils/index.js +++ b/src/modules/utils/index.js @@ -1,9 +1,7 @@ export default { areReferencesModified: (await import('./areReferencesModified.js')).areReferencesModified, - canUnaryExpressionBeResolved: (await import('./canUnaryExpressionBeResolved.js')).canUnaryExpressionBeResolved, createNewNode: (await import('./createNewNode.js')).createNewNode, createOrderedSrc: (await import('./createOrderedSrc.js')).createOrderedSrc, - doesBinaryExpressionContainOnlyLiterals: (await import('./doesBinaryExpressionContainOnlyLiterals.js')).doesBinaryExpressionContainOnlyLiterals, doesDescendantMatchCondition: (await import('./doesDescendantMatchCondition.js')).doesDescendantMatchCondition, evalInVm: (await import('./evalInVm.js')).evalInVm, generateHash: (await import('./generateHash.js')).generateHash, diff --git a/src/modules/utils/isNodeInRanges.js b/src/modules/utils/isNodeInRanges.js index c61eaec..af3792a 100644 --- a/src/modules/utils/isNodeInRanges.js +++ b/src/modules/utils/isNodeInRanges.js @@ -1,15 +1,42 @@ /** - * @param {ASTNode} targetNode - * @param {number[][]} ranges - * @return {boolean} true if the target node is contained in the provided array of ranges; false otherwise. + * Determines if an AST node's source range is completely contained within any of the provided ranges. + * A node is considered "in range" if its start position is greater than or equal to the range start + * AND its end position is less than or equal to the range end. + * + * This function is commonly used for: + * - Filtering nodes that overlap with already collected ranges + * - Excluding nodes from processing based on position constraints + * - Checking if modifications fall within specific code regions + * + * Range format: Each range is a two-element array [startIndex, endIndex] representing + * character positions in the source code, where startIndex is inclusive and endIndex is exclusive. + * + * @param {ASTNode} targetNode - AST node to check (must have a .range property) + * @param {number[][]} ranges - Array of range tuples [start, end] to check against + * @return {boolean} True if the target node is completely contained within any range; false otherwise. + * + * @example + * // Check if a node at positions 5-8 is within range 0-10 + * // const node = {range: [5, 8]}; + * // isNodeInRanges(node, [[0, 10]]) => true + * // isNodeInRanges(node, [[0, 7]]) => false (node extends beyond range) + * // isNodeInRanges(node, [[6, 10]]) => false (node starts before range) */ -function isNodeInRanges(targetNode, ranges) { +export function isNodeInRanges(targetNode, ranges) { + // Early return for empty ranges array - no ranges means node is not in any range + if (!ranges.length) { + return false; + } + const [nodeStart, nodeEnd] = targetNode.range; + + // Check if node range is completely contained within any provided range for (let i = 0; i < ranges.length; i++) { const [rangeStart, rangeEnd] = ranges[i]; - if (nodeStart >= rangeStart && nodeEnd <= rangeEnd) return true; + if (nodeStart >= rangeStart && nodeEnd <= rangeEnd) { + return true; + } } - return false; -} -export {isNodeInRanges}; \ No newline at end of file + return false; +} \ No newline at end of file diff --git a/src/modules/utils/normalizeScript.js b/src/modules/utils/normalizeScript.js index 53d914a..374ecb0 100644 --- a/src/modules/utils/normalizeScript.js +++ b/src/modules/utils/normalizeScript.js @@ -4,9 +4,25 @@ import * as normalizeEmptyStatements from '../safe/normalizeEmptyStatements.js'; import * as normalizeRedundantNotOperator from '../unsafe/normalizeRedundantNotOperator.js'; /** - * Make the script more readable without actually deobfuscating or affecting its functionality. - * @param {string} script - * @return {string} The normalized script. + * Normalizes JavaScript code to improve readability without affecting functionality. + * This function applies a series of safe transformations that make code more readable + * while preserving the original behavior. It's designed for preprocessing scripts + * before deobfuscation or analysis. + * + * Applied transformations (in order): + * 1. normalizeComputed - Converts bracket notation to dot notation where safe (obj['prop'] β†’ obj.prop) + * 2. normalizeRedundantNotOperator - Simplifies double negations and NOT operations on literals + * 3. normalizeEmptyStatements - Removes unnecessary empty statements and semicolons + * + * Uses flast's applyIteratively to ensure all transformations are applied until no more + * changes occur, handling cases where one transformation enables another. + * + * @param {string} script - JavaScript source code to normalize + * @return {string} The normalized script with improved readability + * + * @example + * // Input: obj['method'](); !!true; ;;; + * // Output: obj.method(); true; */ export function normalizeScript(script) { return applyIteratively(script, [ diff --git a/src/modules/utils/safe-atob.js b/src/modules/utils/safe-atob.js index 1944e68..da4baa9 100644 --- a/src/modules/utils/safe-atob.js +++ b/src/modules/utils/safe-atob.js @@ -1,9 +1,18 @@ /** - * @param {string} val - * @return {string} + * Safe implementation of atob (ASCII to Binary) for Node.js environments. + * Decodes a Base64-encoded string back to its original ASCII representation. + * This provides browser-compatible atob functionality using Node.js Buffer API. + * + * Used during deobfuscation to safely resolve Base64-encoded strings without + * relying on browser-specific global functions that may not be available in Node.js. + * + * @param {string} val - Base64-encoded string to decode + * @return {string} The decoded ASCII string + * + * @example + * // atob('SGVsbG8gV29ybGQ=') => 'Hello World' + * // atob('YWJjMTIz') => 'abc123' */ -function atob(val) { +export function atob(val) { return Buffer.from(val, 'base64').toString(); -} - -export {atob}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/utils/safe-btoa.js b/src/modules/utils/safe-btoa.js index b73b1a8..fcc46bc 100644 --- a/src/modules/utils/safe-btoa.js +++ b/src/modules/utils/safe-btoa.js @@ -1,9 +1,18 @@ /** - * @param {string} val - * @return {string} + * Safe implementation of btoa (Binary to ASCII) for Node.js environments. + * Encodes an ASCII string to its Base64 representation. + * This provides browser-compatible btoa functionality using Node.js Buffer API. + * + * Used during deobfuscation to safely resolve string-to-Base64 operations without + * relying on browser-specific global functions that may not be available in Node.js. + * + * @param {string} val - ASCII string to encode + * @return {string} The Base64-encoded string + * + * @example + * // btoa('Hello World') => 'SGVsbG8gV29ybGQ=' + * // btoa('abc123') => 'YWJjMTIz' */ -function btoa(val) { +export function btoa(val) { return Buffer.from(val).toString('base64'); -} - -export {btoa}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/utils/safeImplementations.js b/src/modules/utils/safeImplementations.js index cbedb10..885dcf3 100644 --- a/src/modules/utils/safeImplementations.js +++ b/src/modules/utils/safeImplementations.js @@ -1,5 +1,10 @@ /** - * Safe implementations of functions to be used during deobfuscation + * Safe implementations of browser-native functions for Node.js environments. + * These provide Node.js-compatible versions of functions that are available + * in browsers but not in Node.js, using Buffer API for encoding operations. + * + * Used by resolveBuiltinCalls to safely execute encoding/decoding operations + * during deobfuscation without relying on browser-specific globals. */ export const atob = (await import('./safe-atob.js')).atob; export const btoa = (await import('./safe-btoa.js')).btoa; \ No newline at end of file diff --git a/src/modules/utils/sandbox.js b/src/modules/utils/sandbox.js index 5ade9b5..a52d74e 100644 --- a/src/modules/utils/sandbox.js +++ b/src/modules/utils/sandbox.js @@ -1,25 +1,69 @@ import pkg from 'isolated-vm'; const {Isolate, Reference} = pkg; +// Security-critical APIs that must be blocked in the sandbox environment +const BLOCKED_APIS = { + debugger: undefined, + WebAssembly: undefined, + fetch: undefined, + XMLHttpRequest: undefined, + WebSocket: undefined, + globalThis: undefined, + navigator: undefined, + Navigator: undefined, +}; + +// Default memory limit for VM instances (in MB) +const DEFAULT_MEMORY_LIMIT = 128; + +// Default execution timeout (in milliseconds) +const DEFAULT_TIMEOUT = 1000; + +/** + * Isolated sandbox environment for executing untrusted JavaScript code during deobfuscation. + * + * SECURITY NOTE: This sandbox provides isolation and basic protections but is NOT truly secure. + * It's better than direct eval() but should not be relied upon for security-critical applications. + * The isolated-vm library provides process isolation but vulnerabilities may still exist. + * + * This class provides an isolated VM context using the isolated-vm library to evaluate + * potentially malicious JavaScript with reduced risk to the host environment. The sandbox includes: + * + * Isolation Features: + * - Separate V8 context isolated from host environment + * - Blocked access to dangerous APIs (WebAssembly, fetch, WebSocket, etc.) + * - Memory and execution time limits to prevent resource exhaustion + * - Deterministic evaluation (Math.random and Date are deleted for consistent results) + * + * Performance Optimizations: + * - Reusable instances to avoid VM creation overhead + * - Shared contexts for multiple evaluations + * - Pre-configured global environment setup + * + * Used extensively by unsafe transformation modules for: + * - Evaluating binary expressions with literal operands + * - Resolving member expressions on literal objects/arrays + * - Execution of prototype method calls + * - Local function call resolution with context + */ export class Sandbox { + /** + * Creates a new isolated sandbox environment with security restrictions. + * The sandbox is configured with memory limits, execution timeouts, and blocked APIs. + */ constructor() { - // Objects that shouldn't be available when running scripts in eval to avoid security issues or inconsistencies. - const replacedItems = { - debugger: undefined, - WebAssembly: undefined, - fetch: undefined, - XMLHttpRequest: undefined, - WebSocket: undefined, - }; - this.replacedItems = replacedItems; - this.replacedItemsNames = Object.keys(replacedItems); - this.timeout = 1.0 * 1000; - - this.vm = new Isolate({memoryLimit: 128}); + this.replacedItems = {...BLOCKED_APIS}; + this.replacedItemsNames = Object.keys(BLOCKED_APIS); + this.timeout = DEFAULT_TIMEOUT; + + // Create isolated V8 context with memory limits + this.vm = new Isolate({memoryLimit: DEFAULT_MEMORY_LIMIT}); this.context = this.vm.createContextSync(); + // Set up global reference for compatibility this.context.global.setSync('global', this.context.global.derefInto()); + // Block dangerous APIs by setting them to undefined in the sandbox for (let i = 0; i < this.replacedItemsNames.length; i++) { const itemName = this.replacedItemsNames[i]; this.context.global.setSync(itemName, this.replacedItems[itemName]); @@ -27,21 +71,37 @@ export class Sandbox { } /** - * Run code in an isolated VM - * @param code - * @return {Reference} + * Executes JavaScript code in the isolated sandbox environment. + * + * For deterministic results during deobfuscation, Math.random and Date are deleted + * before execution to ensure consistent output across runs. This is critical for + * reliable deobfuscation results. + * + * @param {string} code - JavaScript code to execute in the sandbox + * @return {Reference} A Reference object from isolated-vm containing the execution result + * + * @example + * // const sandbox = new Sandbox(); + * // const result = sandbox.run('2 + 3'); // Returns Reference containing 5 */ run(code) { - // Delete some properties that add randomness to the result + // Delete non-deterministic APIs to ensure consistent results across deobfuscation runs const script = this.vm.compileScriptSync('delete Math.random; delete Date;\n\n' + code); - // const script = this.vm.compileScriptSync(code); return script.runSync(this.context, { timeout: this.timeout, reference: true, }); } + /** + * Determines if an object is a VM Reference (from isolated-vm) rather than a native JavaScript value. + * This is used to distinguish between successfully evaluated results and objects that need + * further processing or conversion. + * + * @param {*} obj - Object to check + * @return {boolean} True if the object is a VM Reference, false otherwise + */ isReference(obj) { - return Object.getPrototypeOf(obj) === Reference.prototype; + return obj != null && Object.getPrototypeOf(obj) === Reference.prototype; } } \ No newline at end of file diff --git a/src/processors/README.md b/src/processors/README.md index 2994270..a4e4155 100644 --- a/src/processors/README.md +++ b/src/processors/README.md @@ -1,26 +1,507 @@ -# Processors -Processors are a collection of methods meant to prepare the script for obfuscation, removing anti-debugging traps -and performing any required modifications before (preprocessors) or after (postprocessors) the main deobfuscation process. +# REstringer Processors -The processors are created when necessary and are lazily loaded when a specific obfuscation type was detected -which requires these additional processes. +Processors are specialized modules that handle obfuscation-specific patterns and anti-debugging mechanisms. They run before (preprocessors) and after (postprocessors) the main deobfuscation process to prepare scripts and clean up results. -The mapping of obfuscation type to their processors can be found in the [index.js](index.js) file. +## Table of Contents + +- [Overview](#overview) + - [What are Processors?](#what-are-processors) + - [When are Processors Used?](#when-are-processors-used) + - [Processor Architecture](#processor-architecture) +- [Available Processors](#available-processors) + - [JavaScript Obfuscator](#javascript-obfuscator-obfuscatoriojs) + - [Augmented Array](#augmented-array-augmentedarrayjs) + - [Function to Array](#function-to-array-functiontoarrayjs) + - [Caesar Plus](#caesar-plus-caesarpjs) +- [Processor Mapping](#processor-mapping) +- [Usage Examples](#usage-examples) + - [Using Individual Processors](#using-individual-processors) + - [Custom Processor Integration](#custom-processor-integration) + - [Processor with Custom Filtering](#processor-with-custom-filtering) +- [Creating Custom Processors](#creating-custom-processors) + - [Basic Processor Template](#basic-processor-template) + - [Advanced Processor Features](#advanced-processor-features) +- [Performance Best Practices](#performance-best-practices) + - [Static Pattern Extraction](#static-pattern-extraction) + - [Efficient Node Traversal](#efficient-node-traversal) + - [Memory Management](#memory-management) +- [Testing Processors](#testing-processors) + - [Basic Test Structure](#basic-test-structure) + - [Test Categories](#test-categories) +- [Debugging Processors](#debugging-processors) + - [Enable Debug Logging](#enable-debug-logging) + - [Custom Debug Information](#custom-debug-information) +- [Contributing](#contributing) +- [Resources](#resources) + +--- + +## Overview + +### What are Processors? + +Processors are **obfuscation-specific handlers** that: +- **Remove anti-debugging traps** that prevent deobfuscation +- **Prepare scripts** for the main deobfuscation pipeline +- **Apply targeted transformations** for specific obfuscation tools +- **Clean up results** after core deobfuscation is complete + +### When are Processors Used? + +Processors are **lazily loaded** only when: +1. The [Obfuscation Detector](https://github.com/HumanSecurity/obfuscation-detector) identifies a specific obfuscation type +2. Manual processor selection is specified +3. Custom deobfuscation pipelines are created + +### Processor Architecture + +Processors export **preprocessors** and **postprocessors** arrays, not a default function: + +```javascript +// Main processor function - can be written as a single function +function myProcessorLogic(arb, candidateFilter = () => true) { + const candidates = arb.ast[0].typeMap.TargetNodeType + .concat(arb.ast[0].typeMap.AnotherTargetNodeType); + + for (let i = 0; i < candidates.length; i++) { + const node = candidates[i]; + if (matchesCriteria(node) && candidateFilter(node)) { + // Apply transformation directly + performTransformation(node); + } + } + return arb; +} + +// Processors export arrays of functions, not a default export +export const preprocessors = [myProcessorLogic]; +export const postprocessors = []; +``` + +**Code Style Note**: While **modules** (in `src/modules/`) often separate their logic into `match` and `transform` functions for better organization and testing, this is a **code style choice, not a requirement**. Processors can implement their logic as single functions or use any internal structure that makes sense for their specific use case. + +--- ## Available Processors -Processor specifics can always be found in comments in the code. -* [Caesar Plus](src/processors/caesarp.js)
- A description of the obfuscator and the deobfuscating process can be found [here](https://www.perimeterx.com/tech-blog/2020/deobfuscating-caesar/).
- - Preprocessor: - - Unwraps the outer layer. - - Postprocessor: - - Removes dead code. -* [Augmented Arrays](src/processors/augmentedArray.js)
- - Preprocessor: - - Augments the array once to avoid repeating the same action. -* [Obfuscator.io](src/processors/obfuscatorIo.js)
- - Preprocessor: - - Removes anti-debugging embedded in the code, and applies the augmented array processors. -* [Function to Array](src/processors/functionToArray.js)
- - Preprocessor: - - Generates the array from the function once to avoid repeating the same action. \ No newline at end of file + +### JavaScript Obfuscator (`obfuscator.io.js`) + +**Purpose**: Handles obfuscation patterns from [JavaScript Obfuscator](https://github.com/javascript-obfuscator/javascript-obfuscator) (also available online at [obfuscator.io](https://obfuscator.io/)), particularly anti-debugging mechanisms. + +**Anti-Debugging Protection**: JavaScript Obfuscator can inject code that: +- Tests function `toString()` output against regex patterns +- Triggers infinite loops when code modification is detected +- Prevents normal deobfuscation by freezing execution + +**How it Works**: +```javascript +// Detects and neutralizes patterns like: +// 'newState' -> triggers anti-debug check +// 'removeCookie' -> triggers protection mechanism + +// Before processing: +if (funcTest.toString().match(/function.*\{.*\}/)) { + while(true) {} // Infinite loop trap +} + +// After processing: +// Protection mechanisms replaced with bypass strings +``` + +**Configuration**: +- **Preprocessor**: Neutralizes anti-debugging, applies augmented array processing +- **Postprocessor**: None + +### Augmented Array (`augmentedArray.js`) + +**Purpose**: Resolves array shuffling patterns where arrays are dynamically reordered by IIFE functions. + +**Pattern Recognition**: Identifies IIFEs that: +- Take an array and a numeric shift count as arguments +- Perform array manipulation (shift, push operations) +- Are called immediately with literal values + +**Example Transformation**: +```javascript +// Before: +const arr = [1, 2, 3, 4, 5]; +(function(targetArray, shifts) { + for (let i = 0; i < shifts; i++) { + targetArray.push(targetArray.shift()); + } +})(arr, 2); + +// After: +const arr = [3, 4, 5, 1, 2]; // Pre-computed result +``` + +**Advanced Features**: +- Supports both function expressions and arrow functions +- Handles complex shifting logic through VM evaluation +- Prevents infinite loops with self-modifying function detection + +**Configuration**: +- **Preprocessor**: Resolves array augmentation patterns +- **Postprocessor**: None + +### Function to Array (`functionToArray.js`) + +**Purpose**: Wrapper processor that applies the `resolveFunctionToArray` module for function-based array generation patterns. + +**Pattern Example**: +```javascript +// Before: +function getArray() { return ['a', 'b', 'c']; } +const data = getArray(); +console.log(data[0]); // Complex array access + +// After: +function getArray() { return ['a', 'b', 'c']; } +const data = ['a', 'b', 'c']; // Direct array assignment +console.log('a'); // Resolved access +``` + +**Configuration**: +- **Preprocessor**: Applies function-to-array resolution +- **Postprocessor**: None + +### Caesar Plus (`caesarp.js`) + +**Purpose**: Handles Caesar cipher-based obfuscation with additional encoding layers. + +**Obfuscation Method**: +- Strings encoded using Caesar cipher variants +- Multiple encoding layers applied +- Decoder functions embedded in the code + +**Resources**: +- πŸ“– [Detailed Analysis](https://www.humansecurity.com/tech-engineering-blog/deobfuscating-caesar/) - Complete breakdown of Caesar Plus obfuscation + +**Configuration**: +- **Preprocessor**: Unwraps outer obfuscation layer +- **Postprocessor**: Removes dead code and cleanup + +--- + +## Processor Mapping + +The relationship between detected obfuscation types and their processors is defined in [`index.js`](index.js): + +```javascript +export const processors = { + 'obfuscator.io': await import('./obfuscator.io.js'), + 'augmented_array_replacements': await import('./augmentedArray.js'), + 'function_to_array_replacements': await import('./functionToArray.js'), + 'caesar_plus': await import('./caesarp.js'), + // ... other mappings +}; +``` + +--- + +## Usage Examples + +### Using Individual Processors + +```javascript +import {applyIteratively} from 'flast'; +import Arborist from 'arborist'; + +// Import specific processor +const targetProcessors = await import('./augmentedArray.js'); + +const code = ` +const arr = [1, 2, 3]; +(function(a, n) { + for(let i = 0; i < n; i++) a.push(a.shift()); +})(arr, 1); +`; + +// Processors export preprocessors and postprocessors arrays +let script = code; +script = applyIteratively(script, targetProcessors.preprocessors); +script = applyIteratively(script, targetProcessors.postprocessors); + +console.log(script); +// Output: const arr = [2, 3, 1]; (pre-computed) +``` + +### Custom Processor Integration + +```javascript +import {REstringer} from 'restringer'; +import {applyIteratively} from 'flast'; + +const restringer = new REstringer(code); + +// Apply specific processors only +restringer.detectObfuscationType = false; + +// Manually apply processors +const obfuscatorIoProcessor = await import('./processors/obfuscator.io.js'); + +// Apply preprocessors before main deobfuscation +restringer.script = applyIteratively(restringer.script, obfuscatorIoProcessor.preprocessors); + +// Run main deobfuscation +restringer.deobfuscate(); + +// Apply postprocessors after main deobfuscation +restringer.script = applyIteratively(restringer.script, obfuscatorIoProcessor.postprocessors); +``` + +### Processor with Custom Filtering + +```javascript +import {augmentedArrayMatch, augmentedArrayTransform} from './augmentedArray.js'; + +function customArrayProcessor(arb) { + // Only process arrays with more than 5 elements + const customFilter = (node) => { + const arrayArg = node.arguments[0]; + return arrayArg.declNode?.init?.elements?.length > 5; + }; + + const matches = augmentedArrayMatch(arb, customFilter); + + for (let i = 0; i < matches.length; i++) { + arb = augmentedArrayTransform(arb, matches[i]); + } + + return arb; +} +``` + +--- + +## Creating Custom Processors + +### Basic Processor Template + +```javascript +// Static patterns for performance +const DETECTION_PATTERNS = { + targetPattern: /your-regex-here/, + // ... other patterns +}; + +/** + * Identifies nodes that match your obfuscation pattern + */ +export function customProcessorMatch(arb, candidateFilter = () => true) { + const matches = []; + const candidates = arb.ast[0].typeMap.CallExpression; // or other node type + + for (let i = 0; i < candidates.length; i++) { + const node = candidates[i]; + if (DETECTION_PATTERNS.targetPattern.exec(node) && candidateFilter(node)) { + matches.push(node); + } + } + return matches; +} + +/** + * Transforms a matched node + */ +export function customProcessorTransform(arb, node) { + // Your transformation logic here + // Example: replace node with resolved value + if (canResolve(node)) { + const resolvedValue = resolveNode(node); + node.replace(createNewNode(resolvedValue)); + } + + return arb; +} + +/** + * Main processor function + */ +export default function customProcessor(arb, candidateFilter = () => true) { + const matches = customProcessorMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + arb = customProcessorTransform(arb, matches[i]); + } + return arb; +} +``` + +### Advanced Processor Features + +```javascript +import {evalInVm, createNewNode} from '../modules/utils/index.js'; + +export function advancedProcessorTransform(arb, node) { + // Use VM evaluation for complex expressions + const expression = extractExpression(node); + const result = evalInVm(expression); + + if (result !== evalInVm.BAD_VALUE) { + node.replace(result); + } + + // Handle multiple transformation types + switch (node.type) { + case 'CallExpression': + return handleCallExpression(arb, node); + case 'MemberExpression': + return handleMemberExpression(arb, node); + default: + return arb; + } +} + +function handleCallExpression(arb, node) { + // Specific handling for call expressions + const callee = node.callee; + if (callee.type === 'Identifier' && isDecodingFunction(callee.name)) { + const args = node.arguments.map(arg => arg.value); + const decoded = performDecoding(callee.name, args); + node.replace(createNewNode(decoded)); + } + return arb; +} +``` + +--- + +## Performance Best Practices + +### Static Pattern Extraction +```javascript +// βœ… Extract patterns outside functions +const STATIC_PATTERNS = { + methodCall: /^(decode|decrypt|transform)$/, + arrayPattern: /^\[.*\]$/ +}; + +// ❌ Don't create patterns in loops +function badExample(arb) { + for (let i = 0; i < nodes.length; i++) { + if (/pattern/.test(nodes[i].value)) { // Recreated each iteration + // ... + } + } +} +``` + +### Efficient Node Traversal +```javascript +// βœ… Use typeMap for direct access +const candidates = arb.ast[0].typeMap.CallExpression; + +// ❌ Don't traverse entire AST +function badTraversal(arb) { + traverse(arb.ast, { + CallExpression(node) { /* inefficient */ } + }); +} +``` + +### Memory Management +```javascript +// βœ… Traditional for loops for performance +for (let i = 0; i < candidates.length; i++) { + const node = candidates[i]; + // process node +} + +// βœ… Use direct array access patterns +const elements = array.slice(); // Copy array +const combined = array1.concat(array2); // Combine arrays +``` + +--- + +## Testing Processors + +### Basic Test Structure +```javascript +import assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {applyIteratively} from 'flast'; + +describe('Custom Processor Tests', () => { + const targetProcessors = await import('./customProcessor.js'); + + it('TP-1: Should transform basic pattern', () => { + const code = `/* obfuscated pattern */`; + const expected = `/* expected result */`; + + let script = applyIteratively(code, targetProcessors.preprocessors); + script = applyIteratively(script, targetProcessors.postprocessors); + + assert.strictEqual(script, expected); + }); + + it('TN-1: Should not transform invalid pattern', () => { + const code = `/* non-matching pattern */`; + const originalScript = code; + + let script = applyIteratively(code, targetProcessors.preprocessors); + script = applyIteratively(script, targetProcessors.postprocessors); + + assert.strictEqual(script, originalScript); + }); +}); +``` + +### Test Categories +- **TP (True Positive)**: Cases where transformation should occur +- **TN (True Negative)**: Cases where transformation should NOT occur +- **Edge Cases**: Boundary conditions and error scenarios + +--- + +## Debugging Processors + +### Enable Debug Logging +```javascript +import {logger} from 'flast'; + +// Enable detailed logging +logger.setLogLevelDebug(); + +// Your processor will now show detailed information about: +// - Nodes being processed +// - Transformations applied +// - Performance metrics +``` + +### Custom Debug Information +```javascript +export function debugProcessor(arb, candidateFilter = () => true) { + const matches = customProcessorMatch(arb, candidateFilter); + + console.log(`Found ${matches.length} candidates for processing`); + + for (let i = 0; i < matches.length; i++) { + console.log(`Processing node ${i + 1}:`, matches[i].type); + arb = customProcessorTransform(arb, matches[i]); + } + + return arb; +} +``` + +--- + +## Contributing + +For detailed guidelines on contributing to processors, see our [Contributing Guide](../../docs/CONTRIBUTING.md). It covers: + +- Processor development guidelines +- Code standards and performance requirements +- Testing requirements and best practices +- Submission process and review checklist + +--- + +## Resources + +- πŸ” [Obfuscation Detector](https://github.com/HumanSecurity/obfuscation-detector) - Pattern recognition system +- 🌳 [flAST Documentation](https://github.com/HumanSecurity/flast) - AST manipulation utilities +- πŸ“– [Main REstringer README](../../README.md) - Complete project documentation +- 🀝 [Contributing Guide](../../docs/CONTRIBUTING.md) - How to contribute to REstringer \ No newline at end of file diff --git a/src/processors/augmentedArray.js b/src/processors/augmentedArray.js index ab6271a..028a72f 100644 --- a/src/processors/augmentedArray.js +++ b/src/processors/augmentedArray.js @@ -1,67 +1,184 @@ /** * Augmented Array Replacements - * The obfuscated script uses a shuffled array, - * requiring an IIFE to re-order it before the values can be extracted correctly. - * E.g. + * + * Detects and resolves obfuscation patterns where arrays are shuffled by immediately-invoked + * function expressions (IIFEs). This processor identifies shuffled arrays that are re-ordered + * by IIFEs and replaces them with their final static state. + * + * Obfuscation Pattern: * const a = ['hello', 'log']; * (function(arr, times) { * for (let i = 0; i < times; i++) { * a.push(a.shift()); * } * })(a, 1); - * console[a[0]](a[1]); // If the array isn't un-shuffled, this will become `console['hello']('log');` which will throw an error. - * // Once un-shuffled, it will work correctly - `console['log']('hello');` - * This processor will un-shuffle the array by running the IIFE augmenting it, and replace the array with the un-shuffled version, - * while removing the augmenting IIFE. + * console[a[0]](a[1]); // Before: console['hello']('log') -> Error + * // After: console['log']('hello') -> Works + * + * Resolution Process: + * 1. Identify IIFE patterns that manipulate arrays with literal shift counts + * 2. Execute the IIFE in a secure VM to determine final array state + * 3. Replace the original array declaration with the final static array + * 4. Remove the augmenting IIFE as it's no longer needed */ -import {config, unsafe, utils} from '../modules/index.js'; +import {unsafe, utils} from '../modules/index.js'; const {resolveFunctionToArray} = unsafe; -const {badValue} = config; const {createOrderedSrc, evalInVm, getDeclarationWithContext} = utils.default; +// Function declaration type pattern for detecting array source context +const FUNCTION_DECLARATION_PATTERN = /function/i; + /** - * Extract the array and the immediately-invoking function expression. - * Run the IIFE and extract the new augmented array state. - * Remove the IIFE and replace the array with its new state. - * @param {Arborist} arb - * @return {Arborist} + * Identifies CallExpression nodes that represent IIFE patterns for array augmentation. + * These are function expressions or arrow functions called immediately with an array identifier + * and a literal number representing the shuffle operations to perform. + * + * Matching criteria: + * - CallExpression with FunctionExpression or ArrowFunctionExpression callee + * - At least 2 arguments: array identifier and literal numeric shift count + * - Valid numeric shift count (not NaN) + * - First argument must be either: + * - A variable (VariableDeclarator) containing an array, OR + * - A self-modifying function declaration (reassigns itself internally) + * + * @param {Arborist} arb - Arborist instance containing the AST + * @param {Function} [candidateFilter] - Optional filter function for additional criteria + * @return {ASTNode[]} Array of matching CallExpression nodes suitable for augmentation resolution + * + * @example + * // Matches: (function(arr, 3) { shuffle_logic })(myArrayVar, 3) + * // Matches: ((arr, n) => { shuffle_logic })(myArrayVar, 1) + * // Matches: (function(fn, n) { shuffle_logic })(selfModifyingFunc, 2) [if fn reassigns itself] + * // Ignores: (function() {})(), myFunc(arr), (function(fn) {})(staticFunction) */ -function replaceArrayWithStaticAugmentedVersion(arb) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.CallExpression || []), - ]; - for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (n.callee.type === 'FunctionExpression' && - n.arguments.length > 1 && n.arguments[0].type === 'Identifier' && - n.arguments[1].type === 'Literal' && !Number.isNaN(parseInt(n.arguments[1].value))) { - let targetNode = n; - while (targetNode && (targetNode.type !== 'ExpressionStatement' && targetNode.parentNode.type !== 'SequenceExpression')) { - targetNode = targetNode?.parentNode; - } - const relevantArrayIdentifier = n.arguments.find(n => n.type === 'Identifier'); - const declKind = /function/i.test(relevantArrayIdentifier.declNode.parentNode.type) ? '' : 'var '; - const ref = !declKind ? `${relevantArrayIdentifier.name}()` : relevantArrayIdentifier.name; - // The context for this eval is the relevant array and the IIFE augmenting it (the candidate). - const contextNodes = getDeclarationWithContext(n, true); - const context = `${contextNodes.length ? createOrderedSrc(contextNodes) : ''}`; - // By adding the name of the array after the context, the un-shuffled array is procured. - const src = `${context};\n${createOrderedSrc([targetNode])}\n${ref};`; - const replacementNode = evalInVm(src); // The new node will hold the un-shuffled array's assignment - if (replacementNode !== badValue) { - arb.markNode(targetNode || n); - if (relevantArrayIdentifier.declNode.parentNode.type === 'FunctionDeclaration') { - arb.markNode(relevantArrayIdentifier.declNode.parentNode.body, { - type: 'BlockStatement', - body: [{ - type: 'ReturnStatement', - argument: replacementNode, - }], - }); - } else arb.markNode(relevantArrayIdentifier.declNode.parentNode.init, replacementNode); +export function augmentedArrayMatch(arb, candidateFilter = () => true) { + const matches = []; + const candidates = arb.ast[0].typeMap.CallExpression; + + for (let i = 0; i < candidates.length; i++) { + const n = candidates[i]; + if ((n.callee.type === 'FunctionExpression' || n.callee.type === 'ArrowFunctionExpression') && + n.arguments.length > 1 && + n.arguments[0].type === 'Identifier' && + n.arguments[1].type === 'Literal' && + !Number.isNaN(parseInt(n.arguments[1].value)) && + candidateFilter(n)) { + // For function declarations, only match if they are self-modifying + if (n.arguments[0].declNode?.parentNode?.type === 'FunctionDeclaration') { + const functionBody = n.arguments[0].declNode.parentNode.body; + const functionName = n.arguments[0].name; + // Check if function reassigns itself (self-modifying pattern) + const isSelfModifying = functionBody?.body?.some(stmt => + stmt.type === 'ExpressionStatement' && + stmt.expression?.type === 'AssignmentExpression' && + stmt.expression.left?.type === 'Identifier' && + stmt.expression.left.name === functionName + ); + if (isSelfModifying) { + matches.push(n); + } + } else if (n.arguments[0].declNode?.parentNode?.type === 'VariableDeclarator') { + // Variables are always potential candidates + matches.push(n); } } } + return matches; +} + +/** + * Transforms a matched IIFE augmentation pattern by executing the IIFE to determine + * the final array state and replacing the original array with the computed result. + * + * The transformation process: + * 1. Locates the target ExpressionStatement containing the IIFE + * 2. Identifies the array being augmented from the IIFE arguments + * 3. Builds execution context including array declaration and IIFE code + * 4. Evaluates the context in a secure VM to get final array state + * 5. Replaces array declaration with computed static array + * 6. Marks IIFE for removal + * + * @param {Arborist} arb - Arborist instance to modify + * @param {ASTNode} n - CallExpression node representing the IIFE augmentation + * @return {Arborist} The modified Arborist instance + * + * @example + * // Input: const arr = [1, 2]; (function(a,n){a.push(a.shift())})(arr, 1); + * // Output: const arr = [2, 1]; + */ +export function augmentedArrayTransform(arb, n) { + // Find the target ExpressionStatement or SequenceExpression containing this IIFE + let targetNode = n; + while (targetNode && (targetNode.type !== 'ExpressionStatement' && targetNode.parentNode.type !== 'SequenceExpression')) { + targetNode = targetNode?.parentNode; + } + + // Extract the array identifier being augmented (first argument of the IIFE) + const relevantArrayIdentifier = n.arguments.find(node => node.type === 'Identifier'); + + // Determine if the array comes from a function declaration or variable declaration + const declKind = FUNCTION_DECLARATION_PATTERN.test(relevantArrayIdentifier.declNode.parentNode.type) ? '' : 'var '; + const ref = !declKind ? `${relevantArrayIdentifier.name}()` : relevantArrayIdentifier.name; + + // Build execution context: array declaration + IIFE + array reference for final state + const contextNodes = getDeclarationWithContext(n, true); + const context = `${contextNodes.length ? createOrderedSrc(contextNodes) : ''}`; + const src = `${context};\n${createOrderedSrc([targetNode])}\n${ref};`; + + // Execute the augmentation in VM to get the final array state + const replacementNode = evalInVm(src); + if (replacementNode !== evalInVm.BAD_VALUE) { + // Mark the IIFE for removal + arb.markNode(targetNode || n); + + // Replace the array with its final augmented state + if (relevantArrayIdentifier.declNode.parentNode.type === 'FunctionDeclaration') { + // For function declarations, replace the function body with a return statement + arb.markNode(relevantArrayIdentifier.declNode.parentNode.body, { + type: 'BlockStatement', + body: [{ + type: 'ReturnStatement', + argument: replacementNode, + }], + }); + } else { + // For variable declarations, replace the initializer with the computed array + arb.markNode(relevantArrayIdentifier.declNode.parentNode.init, replacementNode); + } + } + + return arb; +} + +/** + * Resolves obfuscated arrays that are augmented (shuffled/re-ordered) by immediately-invoked + * function expressions. This processor detects IIFE patterns that manipulate arrays through + * push/shift operations and replaces them with their final static state. + * + * The processor handles complex obfuscation where arrays are deliberately shuffled to hide + * their true content order, then un-shuffled by execution-time IIFEs. By pre-computing + * the final array state, we can eliminate the runtime shuffling logic entirely. + * + * Algorithm: + * 1. Identify IIFE patterns with array arguments and literal shift counts + * 2. For each match, execute the IIFE in a secure VM environment + * 3. Replace the original array declaration with the computed final state + * 4. Remove the augmenting IIFE as it's no longer needed + * + * @param {Arborist} arb - Arborist instance containing the AST to process + * @return {Arborist} The modified Arborist instance with augmented arrays resolved + * + * @example + * // Before: const a = [1,2]; (function(arr,n){for(let i=0;i true)] - Optional filter function for additional criteria + * @return {ASTNode[]} Array of matching Literal nodes suitable for debug protection bypass + * + * @example + * // Matches: 'newState' in function context, 'removeCookie' in property assignment + * // Ignores: Other literal values, literals in invalid contexts + */ +export function obfuscatorIoMatch(arb, candidateFilter = () => true) { + const matches = []; + const candidates = arb.ast[0].typeMap.Literal; + + for (let i = 0; i < candidates.length; i++) { + const n = candidates[i]; + if (DEBUG_PROTECTION_TRIGGERS.includes(n.value) && candidateFilter(n)) { + matches.push(n); + } + } + return matches; +} + +/** + * Transforms a debug protection trigger literal by replacing the associated function + * or value with a bypass string that satisfies obfuscator.io's validation tests. + * + * This function handles two specific protection patterns: + * 1. 'newState' - targets parent FunctionExpression nodes + * 2. 'removeCookie' - targets parent property values + * + * Algorithm: + * 1. Identify the protection trigger type ('newState' or 'removeCookie') + * 2. Navigate the AST structure to find the appropriate target node + * 3. Replace the target with a literal containing the bypass string + * 4. Mark the node for replacement in the Arborist instance + * + * @param {Arborist} arb - Arborist instance containing the AST + * @param {ASTNode} n - The Literal AST node containing the debug protection trigger + * @return {Arborist} The modified Arborist instance + */ +export function obfuscatorIoTransform(arb, n) { + let targetNode; + + // Determine target node based on protection trigger type + switch (n.value) { + case 'newState': + // Navigate up to find the containing FunctionExpression + if (n.parentNode?.parentNode?.parentNode?.type === 'FunctionExpression') { + targetNode = n.parentNode.parentNode.parentNode; + } + break; + case 'removeCookie': + // Target the parent value directly + targetNode = n.parentNode?.value; + break; + } + + // Apply the bypass replacement if a valid target was found + if (targetNode) { + arb.markNode(targetNode, { + type: 'Literal', + value: FREEZE_REPLACEMENT_STRING, + raw: `"${FREEZE_REPLACEMENT_STRING}"`, + }); + } + + return arb; +} + +/** + * Main function for obfuscator.io debug protection bypass. + * Orchestrates the matching and transformation of debug protection mechanisms + * to prevent infinite loops and allow deobfuscation to proceed. + * + * @param {Arborist} arb - Arborist instance containing the AST + * @param {Function} [candidateFilter=(() => true)] - Optional filter function for additional criteria + * @return {Arborist} The modified Arborist instance + */ +function freezeUnbeautifiedValues(arb, candidateFilter = () => true) { + const matches = obfuscatorIoMatch(arb, candidateFilter); + + for (let i = 0; i < matches.length; i++) { + const n = matches[i]; + arb = obfuscatorIoTransform(arb, n); + } + return arb; +} + +export const preprocessors = [freezeUnbeautifiedValues, ...augmentedArrayProcessors.preprocessors]; +export const postprocessors = [...augmentedArrayProcessors.postprocessors]; diff --git a/src/processors/obfuscatorIo.js b/src/processors/obfuscatorIo.js deleted file mode 100644 index e6a1b0c..0000000 --- a/src/processors/obfuscatorIo.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Obfuscator.io obfuscation - * The obfuscator optionally adds 'debug protection' methods that when triggered, result in an endless loop. - */ -import * as augmentedArrayProcessors from './augmentedArray.js'; - -const freezeReplacementString = 'function () {return "bypassed!"}'; - -/** - * The debug protection in this case revolves around detecting the script has been beautified by testing a function's - * toString against a regex. If the text fails the script creates an infinte loop which prevents the script from running. - * To circumvent this protection, the tested functions are replaced with a string that passes the test. - * @param {Arborist} arb - * @return {Arborist} - */ -function freezeUnbeautifiedValues(arb) { - const relevantNodes = [ - ...(arb.ast[0].typeMap.Literal || []), - ]; - for (let i = 0; i < relevantNodes.length; i++) { - const n = relevantNodes[i]; - if (['newState', 'removeCookie'].includes(n.value)) { - let targetNode; - switch (n.value) { - case 'newState': - if (n.parentNode?.parentNode?.parentNode?.type === 'FunctionExpression') { - targetNode = n.parentNode.parentNode.parentNode; - } - break; - case 'removeCookie': - targetNode = n.parentNode?.value; - break; - } - if (targetNode) { - arb.markNode(targetNode, { - type: 'Literal', - value: freezeReplacementString, - raw: `"${freezeReplacementString}"`, - }); - } - } - } - return arb; -} - -export const preprocessors = [freezeUnbeautifiedValues, ...augmentedArrayProcessors.preprocessors]; -export const postprocessors = [...augmentedArrayProcessors.postprocessors]; \ No newline at end of file diff --git a/src/restringer.js b/src/restringer.js index c6e85a7..a96986f 100755 --- a/src/restringer.js +++ b/src/restringer.js @@ -15,7 +15,7 @@ for (const funcName in unsafeMod) { unsafe[funcName] = unsafeMod[funcName].default || unsafeMod[funcName]; } -// Silence asyc errors +// Silence async errors // process.on('uncaughtException', () => {}); export class REstringer { @@ -24,7 +24,7 @@ export class REstringer { /** * @param {string} script The target script to be deobfuscated - * @param {boolean} normalize Run optional methods which will make the script more readable + * @param {boolean} [normalize] Run optional methods which will make the script more readable */ constructor(script, normalize = true) { this.script = script; @@ -34,7 +34,7 @@ export class REstringer { this._preprocessors = []; this._postprocessors = []; this.logger.setLogLevelLog(); - this.maxIterations = config.defaultMaxIterations; + this.maxIterations = config.DEFAULT_MAX_ITERATIONS; this.detectObfuscationType = true; // Deobfuscation methods that don't use eval this.safeMethods = [ @@ -98,23 +98,30 @@ export class REstringer { } /** - * Make all changes which don't involve eval first in order to avoid running eval on probelmatic values - * which can only be detected once part of the script is deobfuscated. Once all the safe changes are made, - * continue to the unsafe changes. - * Since the unsafe modification may be overreaching, run them only once and try the safe methods again. + * Iteratively applies safe and unsafe deobfuscation methods until no further changes occur. + * + * Algorithm per iteration: + * 1. Apply all safe methods repeatedly until they stop making changes (up to maxIterations) + * 2. Apply all unsafe methods exactly once (they may be overreaching, so limited to 1 iteration) + * 3. Repeat the entire process until no changes occur in either phase + * + * This approach maximizes safe deobfuscation before using potentially risky eval-based methods, + * while allowing unsafe methods to expose new opportunities for safe methods in subsequent iterations. */ _loopSafeAndUnsafeDeobfuscationMethods() { - let modified, script; + // Track whether any iteration made changes (vs this.modified which tracks current iteration only) + let wasEverModified, script; do { this.modified = false; - script = applyIteratively(this.script, this.safeMethods.concat(this.unsafeMethods), this.maxIterations); + script = applyIteratively(this.script, this.safeMethods, this.maxIterations); + script = applyIteratively(script, this.unsafeMethods, 1); if (this.script !== script) { this.modified = true; this.script = script; } - if (this.modified) modified = true; + if (this.modified) wasEverModified = true; } while (this.modified); // Run this loop until the deobfuscation methods stop being effective. - this.modified = modified; + this.modified = wasEverModified; } /** @@ -122,7 +129,7 @@ export class REstringer { * Determine obfuscation type and run the pre- and post- processors accordingly. * Run the deobfuscation methods in a loop until nothing more is changed. * Normalize script to make it more readable. - * @param {boolean} clean (optional) Remove dead nodes after deobfuscation. Defaults to false. + * @param {boolean} [clean] Remove dead nodes after deobfuscation. Defaults to false. * @return {boolean} true if the script was modified during deobfuscation; false otherwise. */ deobfuscate(clean = false) { @@ -131,7 +138,7 @@ export class REstringer { this._loopSafeAndUnsafeDeobfuscationMethods(); this._runProcessors(this._postprocessors); if (this.modified && this.normalize) this.script = normalizeScript(this.script); - if (clean) this.script = applyIteratively(this.script, [unsafe.removeDeadNodes], this.maxIterations); + if (clean) this.script = applyIteratively(this.script, [safe.removeDeadNodes], this.maxIterations); return this.modified; } diff --git a/src/utils/parseArgs.js b/src/utils/parseArgs.js index 046c896..dbe4a49 100644 --- a/src/utils/parseArgs.js +++ b/src/utils/parseArgs.js @@ -1,61 +1,166 @@ -export function printHelp() { - return ` -REstringer - a JavaScript deobfuscator - -Usage: restringer input_filename [-h] [-c] [-q | -v] [-m M] [-o [output_filename]] - -positional arguments: - input_filename The obfuscated JS file - -optional arguments: - -h, --help Show this help message and exit. - -c, --clean Remove dead nodes from script after deobfuscation is complete (unsafe). - -q, --quiet Suppress output to stdout. Output result only to stdout if the -o option is not set. - Does not go with the -v option. - -m, --max-iterations M Run at most M iterations - -v, --verbose Show more debug messages while deobfuscating. Does not go with the -q option. - -o, --output [output_filename] Write deobfuscated script to output_filename. - -deob.js is used if no filename is provided.`; +import {Command} from 'commander'; + +/** + * Pre-processes arguments to handle short option `=` syntax that Commander.js doesn't support. + * Commander.js supports `--long-option=value` but not `-o=value`, so we only need to handle short options. + * + * @param {string[]} args - Original command line arguments + * @return {string[]} Processed arguments compatible with Commander.js + */ +function preprocessShortOptionsWithEquals(args) { + const processed = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + // Handle short options with = syntax: -o=value, -m=value + if (arg.startsWith('-') && !arg.startsWith('--') && arg.includes('=')) { + const equalIndex = arg.indexOf('='); + const flag = arg.substring(0, equalIndex); + const value = arg.substring(equalIndex + 1); + processed.push(flag, value); + } + // All other arguments pass through unchanged (including --long=value which Commander.js handles) + else processed.push(arg); + } + + return processed; } +/** + * Parses command line arguments into a structured options object using Commander.js. + * + * @param {string[]} args - Array of command line arguments (typically process.argv.slice(2)) + * @return {Object} Parsed options object with the following structure: + * @return {string} return.inputFilename - Path to input JavaScript file + * @return {boolean} return.help - Whether help was requested + * @return {boolean} return.clean - Whether to remove dead nodes after deobfuscation + * @return {boolean} return.quiet - Whether to suppress output to stdout + * @return {boolean} return.verbose - Whether to show debug messages + * @return {boolean} return.outputToFile - Whether output should be written to file + * @return {number|boolean|null} return.maxIterations - Maximum iterations (number > 0), false if not set, or null if flag present with invalid value + * @return {string} return.outputFilename - Output filename (auto-generated or user-specified) + */ export function parseArgs(args) { - let opts; + // Input validation - handle edge cases gracefully + if (!args || !Array.isArray(args)) { + return createDefaultOptions(''); + } + try { - const inputFilename = args[0] && args[0][0] !== '-' ? args[0] : ''; - const argsStr = args.join(' '); - opts = { - inputFilename, - help: /(^|\s)(-h|--help)/.test(argsStr), - clean: /(^|\s)(-c|--clean)/.test(argsStr), - quiet: /(^|\s)(-q|--quiet)/.test(argsStr), - verbose: /(^|\s)(-v|--verbose)/.test(argsStr), - outputToFile: /(^|\s)(-o|--output)/.test(argsStr), - maxIterations: /(^|\s)(-m|--max-iterations)/.test(argsStr), - outputFilename: `${inputFilename}-deob.js`, - }; - for (let i = 1; i < args.length; i++) { - if (opts.outputToFile && /-o|--output/.exec(args[i])) { - if (args[i].includes('=')) opts.outputFilename = args[i].split('=')[1]; - else if (args[i + 1] && args[i + 1][0] !== '-') opts.outputFilename = args[i + 1]; - } else if (opts.maxIterations && /-m|--max-iterations/.exec(args[i])) { - if (args[i].includes('=')) opts.maxIterations = Number(args[i].split('=')[1]); - else if (args[i + 1] && args[i + 1][0] !== '-') opts.maxIterations = Number(args[i + 1]); + // Pre-process to handle short option `=` syntax (e.g., -o=file.js, -m=2) + const processedArgs = preprocessShortOptionsWithEquals(args); + + const program = new Command(); + + // Configure the command with options and validation + program + .name('restringer') + .version('2.0.8', '-V, --version', 'Show version number and exit') + .description('REstringer - a JavaScript deobfuscator') + .allowUnknownOption(false) + .exitOverride() // Prevent Commander from calling process.exit() + .argument('[input_filename]', 'The obfuscated JS file') + .option('-c, --clean', 'Remove dead nodes from script after deobfuscation is complete (unsafe)') + .option('-q, --quiet', 'Suppress output to stdout. Output result only to stdout if the -o option is not set') + .option('-v, --verbose', 'Show more debug messages while deobfuscating') + .option('-o, --output [filename]', 'Write deobfuscated script to output_filename. -deob.js is used if no filename is provided') + .option('-m, --max-iterations ', 'Run at most M iterations', (value) => { + const parsed = parseInt(value, 10); + if (isNaN(parsed) || parsed <= 0) { + throw new Error('max-iterations must be a positive number'); + } + return parsed; + }); + + // Add mutually exclusive validation using preAction hook + program.hook('preAction', (thisCommand) => { + const options = thisCommand.opts(); + if (options.verbose && options.quiet) { + throw new Error('Don\'t set both -q and -v at the same time *smh*'); + } + }); + + // Check if help is requested first, then parse without help to get all options + const hasHelp = processedArgs.includes('-h') || processedArgs.includes('--help'); + + // If help is requested, parse without the help flag to get all other options + let argsToProcess = processedArgs; + if (hasHelp) { + argsToProcess = processedArgs.filter(arg => arg !== '-h' && arg !== '--help'); + } + + // Parse arguments and handle potential errors + try { + program.parse(argsToProcess, { from: 'user' }); + } catch (error) { + // Handle parsing errors (like invalid max-iterations value) + if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') { + // Help or version was displayed, return with help flag set + return { ...createDefaultOptions(''), help: true }; + } + // For other errors (like invalid max-iterations), set maxIterations to null + const opts = createDefaultOptions(''); + if (error.message.includes('max-iterations')) { + opts.maxIterations = null; + } + return opts; + } + + const options = program.opts(); + const inputFilename = program.args[0] || ''; + + // Create the return object matching the original API + const opts = createDefaultOptions(inputFilename); + + // Map Commander.js options to our expected format + opts.help = hasHelp; + opts.clean = !!options.clean; + opts.quiet = !!options.quiet; + opts.verbose = !!options.verbose; + + // Handle output option + if (options.output !== undefined) { + opts.outputToFile = true; + if (typeof options.output === 'string' && options.output.length > 0) { + opts.outputFilename = options.output; } } - } catch {} - return opts; + + // Handle max-iterations option + if (options.maxIterations !== undefined) { + opts.maxIterations = options.maxIterations; + } + + // Validate required input filename (unless help is requested) + if (!hasHelp && (!opts.inputFilename || opts.inputFilename.length === 0)) { + throw new Error('missing required argument \'input_filename\''); + } + + return opts; + } catch (error) { + // Provide meaningful error context instead of silent failure + console.warn(`Warning: Error parsing arguments, using defaults. Error: ${error.message}`); + return createDefaultOptions(''); + } } /** - * If the arguments are invalid print the correct error message and return false. - * @param {object} args The parsed arguments - * @returns {boolean} true if all arguments are valid; false otherwise. + * Creates a default options object with safe fallback values. + * This helper ensures consistent default behavior and reduces code duplication. + * + * @param {string} inputFilename - The input filename to use for generating output filename + * @return {Object} Default options object with all required properties */ -export function argsAreValid(args) { - if (args.help) console.log(printHelp()); - else if (!args.inputFilename) console.log(`Error: Input filename must be provided`); - else if (args.verbose && args.quiet) console.log(`Error: Don't set both -q and -v at the same time *smh*`); - else if (args.maxIterations !== false && (Number.isNaN(parseInt(args.maxIterations)) || parseInt(args.maxIterations) <= 0)) console.log(`Error: --max-iterations requires a number larger than 0 (e.g. --max-iterations 12)`); - else return true; - return false; -} \ No newline at end of file +function createDefaultOptions(inputFilename) { + return { + inputFilename, + help: false, + clean: false, + quiet: false, + verbose: false, + outputToFile: false, + maxIterations: false, + outputFilename: inputFilename ? `${inputFilename}-deob.js` : '-deob.js', + }; +} diff --git a/tests/modules.safe.test.js b/tests/modules.safe.test.js index c08d869..deee7a7 100644 --- a/tests/modules.safe.test.js +++ b/tests/modules.safe.test.js @@ -1,8 +1,7 @@ /* eslint-disable no-unused-vars */ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {badValue} from '../src/modules/config.js'; -import {Arborist, generateFlatAST, applyIteratively} from 'flast'; +import {Arborist, applyIteratively} from 'flast'; /** * Apply a module to a given code snippet. @@ -53,22 +52,75 @@ describe('SAFE: removeRedundantBlockStatements', async () => { }); describe('SAFE: normalizeComputed', async () => { const targetModule = (await import('../src/modules/safe/normalizeComputed.js')).default; - it('TP-1: Only valid identifiers are normalized to non-computed properties', () => { + it('TP-1: Convert valid string identifiers to dot notation', () => { const code = `hello['world'][0]['%32']['valid']`; const expected = `hello.world[0]['%32'].valid;`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-2: Convert object properties with valid identifiers', () => { + const code = `const obj = {['validProp']: 1, ['invalid-prop']: 2, ['$valid']: 3};`; + const expected = `const obj = {\n validProp: 1,\n ['invalid-prop']: 2,\n $valid: 3\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Convert class method definitions with valid identifiers', () => { + const code = `class Test { ['method']() {} ['123invalid']() {} ['_valid']() {} }`; + const expected = `class Test {\n method() {\n }\n ['123invalid']() {\n }\n _valid() {\n }\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not convert invalid identifiers', () => { + const code = `obj['123']['-invalid']['spa ce']['@special'];`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, code); + }); + it('TN-2: Do not convert numeric indices but convert valid string', () => { + const code = `arr[0][42]['string'];`; + const expected = `arr[0][42].string;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: normalizeEmptyStatements', async () => { const targetModule = (await import('../src/modules/safe/normalizeEmptyStatements.js')).default; - it('TP-1: All relevant empty statement are removed', () => { + it('TP-1: Remove standalone empty statements', () => { const code = `;;var a = 3;;`; const expected = `var a = 3;`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TN-1: Empty statements are not removed from for-loops', () => { + it('TP-2: Remove empty statements in blocks', () => { + const code = `if (true) {;; var x = 1; ;;;};`; + const expected = `if (true) {\n var x = 1;\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Preserve empty statements in for-loops', () => { + const code = `;for (;;);;`; + const expected = `for (;;);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Preserve empty statements in while-loops', () => { + const code = `;while (true);;`; + const expected = `while (true);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Preserve empty statements in if-statements', () => { + const code = `;if (condition); else;;`; + const expected = `if (condition);\nelse ;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Preserve empty statements in do-while loops', () => { + const code = `;do; while(true);;`; + const expected = `do ;\nwhile (true);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Preserve empty statements in for-in loops', () => { const code = `;for (;;);;`; const expected = `for (;;);`; const result = applyModuleToCode(code, targetModule); @@ -77,12 +129,54 @@ describe('SAFE: normalizeEmptyStatements', async () => { }); describe('SAFE: parseTemplateLiteralsIntoStringLiterals', async () => { const targetModule = (await import('../src/modules/safe/parseTemplateLiteralsIntoStringLiterals.js')).default; - it('TP-1: Only valid identifiers are normalized to non-computed properties', () => { + it('TP-1: Convert template literal with string expression', () => { const code = '`hello ${"world"}!`;'; const expected = `'hello world!';`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-2: Convert template literal with multiple expressions', () => { + const code = '`start ${42} middle ${"end"} finish`;'; + const expected = `'start 42 middle end finish';`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Convert template literal with no expressions', () => { + const code = '`just plain text`;'; + const expected = `'just plain text';`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Convert template literal with boolean and number expressions', () => { + const code = '`flag: ${true}, count: ${123.456}`;'; + const expected = `'flag: true, count: 123.456';`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Convert empty template literal', () => { + const code = '``;'; + const expected = `'';`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not convert template literal with variable expression', () => { + const code = '`hello ${name}!`;'; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not convert template literal with function call expression', () => { + const code = '`result: ${getValue()}`;'; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not convert template literal with mixed literal and non-literal expressions', () => { + const code = '`hello ${"world"} and ${name}!`;'; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: rearrangeSequences', async () => { const targetModule = (await import('../src/modules/safe/rearrangeSequences.js')).default; @@ -110,10 +204,40 @@ describe('SAFE: rearrangeSequences', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-5: Split sequences with more than three expressions', () => { + const code = `function f() { return a(), b(), c(), d(), e(); }`; + const expected = `function f() {\n a();\n b();\n c();\n d();\n return e();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Split sequences in if condition with else clause', () => { + const code = `if (setup(), check(), validate()) action(); else fallback();`; + const expected = `{\n setup();\n check();\n if (validate())\n action();\n else\n fallback();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not transform single expression returns', () => { + const code = `function f() { return a(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not transform single expression if conditions', () => { + const code = `if (condition()) action();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not transform non-sequence expressions', () => { + const code = `function f() { return func(a, b, c); if (obj.prop) x(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: rearrangeSwitches', async () => { const targetModule = (await import('../src/modules/safe/rearrangeSwitches.js')).default; - it('TP-1', () => { + it('TP-1: Complex switch with multiple cases and return statement', () => { const code = `(() => {let a = 1;\twhile (true) {switch (a) {case 3: return console.log(3); case 2: console.log(2); a = 3; break; case 1: console.log(1); a = 2; break;}}})();`; const expected = `((() => { @@ -131,6 +255,62 @@ case 1: console.log(1); a = 2; break;}}})();`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-2: Simple switch with sequential cases', () => { + const code = `var state = 0; switch (state) { case 0: first(); state = 1; break; case 1: second(); break; }`; + const expected = `var state = 0; +{ + first(); + state = 1; + second(); +}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Switch with default case', () => { + const code = `var x = 1; switch (x) { case 1: action1(); x = 2; break; default: defaultAction(); break; case 2: action2(); break; }`; + const expected = `var x = 1; +{ + action1(); + x = 2; + defaultAction(); +}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Switch starting with non-initial case via default', () => { + const code = `var val = 99; switch (val) { case 1: step1(); val = 2; break; case 2: step2(); break; default: val = 1; break; }`; + const expected = `var val = 99; +{ + val = 1; + step1(); + val = 2; + step2(); +}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not transform switch without literal discriminant initialization', () => { + const code = `var a; switch (a) { case 1: doSomething(); break; }`; + const expected = `var a; switch (a) { case 1: doSomething(); break; }`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Transform switch but stop at multiple assignments to discriminant', () => { + const code = `var state = 0; switch (state) { case 0: state = 1; state = 2; break; case 1: action(); break; }`; + const expected = `var state = 0; +{ + state = 1; + state = 2; +}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not transform switch with non-literal case value', () => { + const code = `var x = 0; switch (x) { case variable: doSomething(); break; }`; + const expected = `var x = 0; switch (x) { case variable: doSomething(); break; }`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: removeDeadNodes', async () => { const targetModule = (await import('../src/modules/safe/removeDeadNodes.js')).default; @@ -155,6 +335,48 @@ describe('SAFE: replaceCallExpressionsWithUnwrappedIdentifier', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-3: Replace call expression with function expression assigned to variable', () => { + const code = `const a = function() {return btoa;}; a()('data');`; + const expected = `const a = function () {\n return btoa;\n};\nbtoa('data');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace call expression with arrow function using block statement', () => { + const code = `const a = () => {return btoa;}; a()('test');`; + const expected = `const a = () => {\n return btoa;\n};\nbtoa('test');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace call expression returning parameterless call', () => { + const code = `function a() {return someFunc();} a()('arg');`; + const expected = `function a() {\n return someFunc();\n}\nsomeFunc()('arg');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace function returning call expression with arguments', () => { + const code = `function a() {return someFunc('param');} a()('arg');`; + const expected = `function a() {return someFunc('param');} a()('arg');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace function with multiple statements', () => { + const code = `function a() {console.log('test'); return btoa;} a()('data');`; + const expected = `function a() {console.log('test'); return btoa;} a()('data');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace function with no return statement', () => { + const code = `function a() {console.log('test');} a()('data');`; + const expected = `function a() {console.log('test');} a()('data');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace non-function callee', () => { + const code = `const a = 'notAFunction'; a()('data');`; + const expected = `const a = 'notAFunction'; a()('data');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: replaceEvalCallsWithLiteralContent', async () => { const targetModule = (await import('../src/modules/safe/replaceEvalCallsWithLiteralContent.js')).default; @@ -194,6 +416,42 @@ describe('SAFE: replaceEvalCallsWithLiteralContent', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-7: Replace eval with single expression in conditional', () => { + const code = `if (eval('true')) console.log('test');`; + const expected = `if (true)\n console.log('test');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-8: Replace eval with function declaration', () => { + const code = `eval('function test() { return 42; }');`; + const expected = `(function test() {\n return 42;\n});`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace eval with non-literal argument', () => { + const code = `const x = 'alert(1)'; eval(x);`; + const expected = `const x = 'alert(1)'; eval(x);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace non-eval function calls', () => { + const code = `myEval('console.log("test")');`; + const expected = `myEval('console.log("test")');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace eval with invalid syntax', () => { + const code = `eval('invalid syntax {{{');`; + const expected = `eval('invalid syntax {{{');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace eval with no arguments', () => { + const code = `eval();`; + const expected = `eval();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: replaceFunctionShellsWithWrappedValue', async () => { const targetModule = (await import('../src/modules/safe/replaceFunctionShellsWithWrappedValue.js')).default; @@ -203,6 +461,30 @@ describe('SAFE: replaceFunctionShellsWithWrappedValue', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-2: Replace function returning literal number', () => { + const code = `function getValue() { return 42; }\nconsole.log(getValue());`; + const expected = `function getValue() {\n return 42;\n}\nconsole.log(42);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace function returning literal string', () => { + const code = `function getName() { return "test"; }\nalert(getName());`; + const expected = `function getName() {\n return 'test';\n}\nalert('test');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace function returning boolean literal', () => { + const code = `function isTrue() { return true; }\nif (isTrue()) console.log("yes");`; + const expected = `function isTrue() {\n return true;\n}\nif (true)\n console.log('yes');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace multiple calls to same function', () => { + const code = `function getX() { return x; }\ngetX() + getX();`; + const expected = `function getX() {\n return x;\n}\nx + x;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); it('TN-1: Should not replace literals 1', () => { const code = `function a() {\n return 0;\n}\nconst o = { key: a }`; const expected = code; @@ -215,6 +497,30 @@ describe('SAFE: replaceFunctionShellsWithWrappedValue', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TN-3: Do not replace function with multiple statements', () => { + const code = `function complex() { console.log("side effect"); return 42; }\ncomplex();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace function with no return statement', () => { + const code = `function noReturn() { console.log("void"); }\nnoReturn();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace function returning complex expression', () => { + const code = `function calc() { return a + b; }\ncalc();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not replace function used as callback', () => { + const code = `function getValue() { return 42; }\n[1,2,3].map(getValue);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: replaceFunctionShellsWithWrappedValueIIFE', async () => { const targetModule = (await import('../src/modules/safe/replaceFunctionShellsWithWrappedValueIIFE.js')).default; @@ -224,190 +530,1183 @@ describe('SAFE: replaceFunctionShellsWithWrappedValueIIFE', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-2: Replace IIFE returning literal number', () => { + const code = `(function() { return 42; })() + 1;`; + const expected = `42 + 1;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace IIFE returning literal string', () => { + const code = `console.log((function() { return "hello"; })());`; + const expected = `console.log('hello');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace IIFE returning boolean literal', () => { + const code = `if ((function() { return true; })()) console.log("yes");`; + const expected = `if (true)\n console.log('yes');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace IIFE returning identifier', () => { + const code = `var result = (function() { return someValue; })();`; + const expected = `var result = someValue;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Replace multiple IIFEs in expression', () => { + const code = `(function() { return 5; })() + (function() { return 3; })();`; + const expected = `5 + 3;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace IIFE with arguments', () => { + const code = `(function() { return 42; })(arg);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace IIFE with multiple statements', () => { + const code = `(function() { console.log("side effect"); return 42; })();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace IIFE with no return statement', () => { + const code = `(function() { console.log("void"); })();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace IIFE returning complex expression', () => { + const code = `(function() { return a + b; })();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace function expression not used as IIFE', () => { + const code = `var fn = function() { return 42; }; fn();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not replace function expression without a return value', () => { + const code = `var fn = function() { return; };`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: replaceIdentifierWithFixedAssignedValue', async () => { const targetModule = (await import('../src/modules/safe/replaceIdentifierWithFixedAssignedValue.js')).default; - it('TP-1', () => { + it('TP-1: Replace references with number literal', () => { const code = `const a = 3; const b = a * 2; console.log(b + a);`; const expected = `const a = 3;\nconst b = 3 * 2;\nconsole.log(b + 3);`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TN-1: Do no replace a value used in a for-in-loop', () => { - const code = `var a = 3; for (a in [1, 2]) console.log(a);`; - const expected = code; + it('TP-2: Replace references with string literal', () => { + const code = `const msg = "hello"; console.log(msg + " world");`; + const expected = `const msg = 'hello';\nconsole.log('hello' + ' world');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace references with boolean literal', () => { + const code = `const flag = true; if (flag) console.log("yes");`; + const expected = `const flag = true;\nif (true)\n console.log('yes');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace multiple different variables', () => { + const code = `const x = 5; const y = "test"; console.log(x, y);`; + const expected = `const x = 5;\nconst y = 'test';\nconsole.log(5, 'test');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace with null literal', () => { + const code = `const val = null; if (val === null) console.log("null");`; + const expected = `const val = null;\nif (null === null)\n console.log('null');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Replace with let declaration', () => { + const code = `let count = 0; console.log(count + 1);`; + const expected = `let count = 0;\nconsole.log(0 + 1);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Replace with var declaration', () => { + const code = `var total = 100; console.log(total / 2);`; + const expected = `var total = 100;\nconsole.log(100 / 2);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do no replace a value used in a for-in-loop', () => { + const code = `var a = 3; for (a in [1, 2]) console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do no replace a value used in a for-of-loop', () => { + const code = `var a = 3; for (a of [1, 2]) console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace variable with non-literal initializer', () => { + const code = `const result = getValue(); console.log(result);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace object property names', () => { + const code = `const key = "name"; const obj = { key: "value" };`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace modified variables', () => { + const code = `let counter = 0; counter++; console.log(counter);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not replace reassigned variables', () => { + const code = `let status = true; status = false; console.log(status);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-7: Do not replace variable without declaration', () => { + const code = `console.log(undeclaredVar);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: replaceIdentifierWithFixedValueNotAssignedAtDeclaration', async () => { + const targetModule = (await import('../src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js')).default; + it('TP-1: Replace identifier with number literal', () => { + const code = `let a; a = 3; const b = a * 2; console.log(b + a);`; + const expected = `let a;\na = 3;\nconst b = 3 * 2;\nconsole.log(b + 3);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Replace identifier with string literal', () => { + const code = `let name; name = 'test'; alert(name);`; + const expected = `let name;\nname = 'test';\nalert('test');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace identifier with boolean literal', () => { + const code = `let flag; flag = true; if (flag) console.log('yes');`; + const expected = `let flag;\nflag = true;\nif (true)\n console.log('yes');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace identifier with null literal', () => { + const code = `let value; value = null; console.log(value);`; + const expected = `let value;\nvalue = null;\nconsole.log(null);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace var declaration', () => { + const code = `var x; x = 42; console.log(x);`; + const expected = `var x;\nx = 42;\nconsole.log(42);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Replace with multiple references', () => { + const code = `let count; count = 5; alert(count); console.log(count);`; + const expected = `let count;\ncount = 5;\nalert(5);\nconsole.log(5);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace variable used in for-in loop', () => { + const code = `let a; a = 'prop'; for (a in obj) console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace variable used in for-of loop', () => { + const code = `let item; item = 1; for (item of arr) console.log(item);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace variable in conditional expression context', () => { + const code = `let a; b === c ? (a = 1) : (a = 2); console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace variable with multiple assignments', () => { + const code = `let a; a = 1; a = 2; console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace variable assigned non-literal value', () => { + const code = `let a; a = someFunction(); console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not replace function callee', () => { + const code = `let func; func = alert; func('hello');`; + const expected = `let func; func = alert; func('hello');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-7: Do not replace variable with initial value', () => { + const code = `let a = 1; a = 2; console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-8: Do not replace when references are modified', () => { + const code = `let a; a = 1; a++; console.log(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: replaceNewFuncCallsWithLiteralContent', async () => { + const targetModule = (await import('../src/modules/safe/replaceNewFuncCallsWithLiteralContent.js')).default; + it('TP-1: Replace Function constructor with IIFE', () => { + const code = `new Function("!function() {console.log('hello world')}()")();`; + const expected = `!(function () {\n console.log('hello world');\n}());`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Replace Function constructor with single expression', () => { + const code = `new Function("console.log('test')")();`; + const expected = `console.log('test');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace Function constructor with multiple statements', () => { + const code = `new Function("var x = 1; var y = 2; console.log(x + y);")();`; + const expected = `{\n var x = 1;\n var y = 2;\n console.log(x + y);\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace Function constructor with empty string', () => { + const code = `new Function("")();`; + const expected = `'';`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace Function constructor with variable declaration', () => { + const code = `new Function("let x = 'hello'; console.log(x);")();`; + const expected = `{\n let x = 'hello';\n console.log(x);\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace Function constructor with arguments', () => { + const code = `new Function("return a + b")(1, 2);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace Function constructor with multiple parameters', () => { + const code = `new Function("a", "b", "return a + b")();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace Function constructor with non-literal argument', () => { + const code = `new Function(someVariable)();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace non-Function constructor', () => { + const code = `new Array("1,2,3")();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace Function constructor not used as callee', () => { + const code = `var func = new Function("console.log('test')");`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not replace Function constructor with invalid syntax', () => { + const code = `new Function("invalid syntax {{{")();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: replaceBooleanExpressionsWithIf', async () => { + const targetModule = (await import('../src/modules/safe/replaceBooleanExpressionsWithIf.js')).default; + it('TP-1: Simple logical AND', () => { + const code = `x && y();`; + const expected = `if (x) {\n y();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Simple logical OR', () => { + const code = `x || y();`; + const expected = `if (!x) {\n y();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Chained logical AND', () => { + const code = `x && y && z();`; + const expected = `if (x && y) {\n z();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Chained logical OR', () => { + const code = `x || y || z();`; + const expected = `if (!(x || y)) {\n z();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Function call in condition', () => { + const code = `isValid() && doAction();`; + const expected = `if (isValid()) {\n doAction();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Member expression in condition', () => { + const code = `obj.prop && execute();`; + const expected = `if (obj.prop) {\n execute();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not transform non-logical expressions', () => { + const code = `x + y;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, code); + }); + it('TN-2: Do not transform logical expressions not in expression statements', () => { + const code = `var result = x && y;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, code); + }); + it('TN-3: Do not transform bitwise operators', () => { + const code = `x & y();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, code); + }); +}); +describe('SAFE: replaceSequencesWithExpressions', async () => { + const targetModule = (await import('../src/modules/safe/replaceSequencesWithExpressions.js')).default; + it('TP-1: Replace sequence with 2 expressions in if statement', () => { + const code = `if (a) (b(), c());`; + const expected = `if (a) {\n b();\n c();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Replace sequence with 3 expressions within existing block', () => { + const code = `if (a) { (b(), c()); d() }`; + const expected = `if (a) {\n b();\n c();\n d();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace sequence in while loop', () => { + const code = `while (x) (y++, z());`; + const expected = `while (x) {\n y++;\n z();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace sequence with 4 expressions', () => { + const code = `if (condition) (a(), b(), c(), d());`; + const expected = `if (condition) {\n a();\n b();\n c();\n d();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace sequence in for loop body', () => { + const code = `for (let i = 0; i < 10; i++) (foo(i), bar(i));`; + const expected = `for (let i = 0; i < 10; i++) {\n foo(i);\n bar(i);\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Replace sequence with mixed expression types', () => { + const code = `if (test) (x = 5, func(), obj.method());`; + const expected = `if (test) {\n x = 5;\n func();\n obj.method();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Replace sequence in else clause', () => { + const code = `if (a) doSomething(); else (first(), second());`; + const expected = `if (a)\n doSomething();\nelse {\n first();\n second();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace single expression (not a sequence)', () => { + const code = `if (a) b();`; + const expected = `if (a) b();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace sequence with only one expression', () => { + const code = `if (a) b;`; + const expected = `if (a) b;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace sequence in non-ExpressionStatement context', () => { + const code = `const result = (a(), b());`; + const expected = `const result = (a(), b());`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace sequence in return statement', () => { + const code = `function test() { return (x(), y()); }`; + const expected = `function test() { return (x(), y()); }`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace sequence in assignment', () => { + const code = `let value = (init(), compute());`; + const expected = `let value = (init(), compute());`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveDeterministicIfStatements', async () => { + const targetModule = (await import('../src/modules/safe/resolveDeterministicIfStatements.js')).default; + it('TP-1: Resolve true and false literals', () => { + const code = `if (true) do_a(); else do_b(); if (false) do_c(); else do_d();`; + const expected = `do_a();\ndo_d();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Resolve truthy number literal', () => { + const code = `if (1) console.log('truthy'); else console.log('falsy');`; + const expected = `console.log('truthy');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Resolve falsy number literal (0)', () => { + const code = `if (0) console.log('truthy'); else console.log('falsy');`; + const expected = `console.log('falsy');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Resolve truthy string literal', () => { + const code = `if ('hello') console.log('truthy'); else console.log('falsy');`; + const expected = `console.log('truthy');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Resolve falsy string literal (empty)', () => { + const code = `if ('') console.log('truthy'); else console.log('falsy');`; + const expected = `console.log('falsy');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Resolve null literal', () => { + const code = `if (null) console.log('truthy'); else console.log('falsy');`; + const expected = `console.log('falsy');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Resolve if statement with no else clause (truthy)', () => { + const code = `if (true) console.log('executed');`; + const expected = `console.log('executed');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-8: Remove if statement with no else clause (falsy)', () => { + const code = `before(); if (false) console.log('never'); after();`; + const expected = `before();\nafter();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-9: Resolve negative number literal', () => { + const code = `if (-1) console.log('truthy'); else console.log('falsy');`; + const expected = `console.log('truthy');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-10: Resolve nested if statements', () => { + const code = `if (true) { if (false) inner(); else other(); }`; + const expected = `{\n other();\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not resolve if with variable condition', () => { + const code = `if (someVar) console.log('maybe');`; + const expected = `if (someVar) console.log('maybe');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not resolve if with function call condition', () => { + const code = `if (getValue()) console.log('maybe');`; + const expected = `if (getValue()) console.log('maybe');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not resolve if with expression condition', () => { + const code = `if (x + y) console.log('maybe');`; + const expected = `if (x + y) console.log('maybe');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not resolve if with member expression condition', () => { + const code = `if (obj.prop) console.log('maybe');`; + const expected = `if (obj.prop) console.log('maybe');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveFunctionConstructorCalls', async () => { + const targetModule = (await import('../src/modules/safe/resolveFunctionConstructorCalls.js')).default; + it('TP-1: Replace Function.constructor with no parameters', () => { + const code = `const func = Function.constructor('', "console.log('hello world!');");`; + const expected = `const func = function () {\n console.log('hello world!');\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Part of a member expression', () => { + const code = `a = Function.constructor('return /" + this + "/')().constructor('^([^ ]+( +[^ ]+)+)+[^ ]}');`; + const expected = `a = function () {\n return /" + this + "/;\n}().constructor('^([^ ]+( +[^ ]+)+)+[^ ]}');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace Function.constructor with single parameter', () => { + const code = `const func = Function.constructor('x', 'return x * 2;');`; + const expected = `const func = function (x) {\n return x * 2;\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace Function.constructor with multiple parameters', () => { + const code = `const func = Function.constructor('a', 'b', 'return a + b;');`; + const expected = `const func = function (a, b) {\n return a + b;\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace Function.constructor with complex body', () => { + const code = `const func = Function.constructor('if (true) { return 42; } else { return 0; }');`; + const expected = `const func = function () {\n if (true) {\n return 42;\n } else {\n return 0;\n }\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Replace Function.constructor with empty body', () => { + const code = `const func = Function.constructor('');`; + const expected = `const func = function () {\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Replace Function.constructor in variable assignment', () => { + const code = `var myFunc = Function.constructor('n', 'return n > 0;');`; + const expected = `var myFunc = function (n) {\n return n > 0;\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-8: Replace Function.constructor in call expression', () => { + const code = `console.log(Function.constructor('return "test"')());`; + const expected = `console.log((function () {\n return 'test';\n}()));`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace Function.constructor with non-literal arguments', () => { + const code = `const func = Function.constructor(param, 'return value;');`; + const expected = `const func = Function.constructor(param, 'return value;');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace Function.constructor with no arguments', () => { + const code = `const func = Function.constructor();`; + const expected = `const func = Function.constructor();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace non-constructor calls', () => { + const code = `const func = Function.prototype('test');`; + const expected = `const func = Function.prototype('test');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace Function.constructor with invalid syntax body', () => { + const code = `const func = Function.constructor('invalid syntax {{{');`; + const expected = `const func = Function.constructor('invalid syntax {{{');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-9: Replace any constructor call with literal arguments', () => { + const code = `const result = obj.constructor('test');`; + const expected = `const result = function () {\n test;\n};`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveMemberExpressionReferencesToArrayIndex', async () => { + const targetModule = (await import('../src/modules/safe/resolveMemberExpressionReferencesToArrayIndex.js')).default; + it('TP-1', () => { + const code = `const a = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; b = a[0]; c = a[20];`; + const expected = `const a = [\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1, + 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 3\n];\nb = 1;\nc = 3;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Replace multiple array accesses on same array', () => { + const code = `const arr = [5,5,5,5,5,5,5,5,5,5,6,6,6,6,6,6,6,6,6,6,7]; const x = arr[0], y = arr[10], z = arr[20];`; + const expected = `const arr = [\n 5,\n 5,\n 5,\n 5,\n 5,\n 5,\n 5,\n 5,\n 5,\n 5,\n 6,\n 6,\n 6,\n 6,\n 6,\n 6,\n 6,\n 6,\n 6,\n 6,\n 7\n];\nconst x = 5, y = 6, z = 7;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace array access with string literal elements', () => { + const code = `const words = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u']; const first = words[0]; const last = words[20];`; + const expected = `const words = [\n 'a',\n 'b',\n 'c',\n 'd',\n 'e',\n 'f',\n 'g',\n 'h',\n 'i',\n 'j',\n 'k',\n 'l',\n 'm',\n 'n',\n 'o',\n 'p',\n 'q',\n 'r',\n 's',\n 't',\n 'u'\n];\nconst first = 'a';\nconst last = 'u';`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace array access in function call arguments', () => { + const code = `const nums = [9,9,9,9,9,9,9,9,9,9,8,8,8,8,8,8,8,8,8,8,7]; console.log(nums[0], nums[10], nums[20]);`; + const expected = `const nums = [\n 9,\n 9,\n 9,\n 9,\n 9,\n 9,\n 9,\n 9,\n 9,\n 9,\n 8,\n 8,\n 8,\n 8,\n 8,\n 8,\n 8,\n 8,\n 8,\n 8,\n 7\n];\nconsole.log(9, 8, 7);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it(`TN-1: Don't resolve references to array methods`, () => { + const code = `const a = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; b = a['indexOf']; c = a['length'];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not resolve arrays smaller than minimum length', () => { + const code = `const small = [1,2,3,4,5]; const x = small[0]; const y = small[2];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not resolve assignment to array elements', () => { + const code = `const items = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; items[0] = 99; items[10] = 88;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not resolve computed property access with variables', () => { + const code = `const data = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; const i = 5; const val = data[i];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not resolve out-of-bounds array access', () => { + const code = `const bounds = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; const invalid = bounds[100];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not resolve negative array indices', () => { + const code = `const negTest = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; const neg = negTest[-1];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-7: Do not resolve floating point indices', () => { + const code = `const floatTest = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; const flt = floatTest[1.5];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveMemberExpressionsWithDirectAssignment', async () => { + const targetModule = (await import('../src/modules/safe/resolveMemberExpressionsWithDirectAssignment.js')).default; + it('TP-1: Replace direct property assignments with literal values', () => { + const code = `function a() {} a.b = 3; a.c = '5'; console.log(a.b + a.c);`; + const expected = `function a() {\n}\na.b = 3;\na.c = '5';\nconsole.log(3 + '5');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Replace object property assignments', () => { + const code = `const obj = {}; obj.name = 'test'; obj.value = 42; const result = obj.name + obj.value;`; + const expected = `const obj = {};\nobj.name = 'test';\nobj.value = 42;\nconst result = 'test' + 42;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace computed property assignments', () => { + const code = `const data = {}; data['key'] = 'value'; console.log(data['key']);`; + const expected = `const data = {};\ndata['key'] = 'value';\nconsole.log('value');`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace boolean and null assignments', () => { + const code = `const state = {}; state.flag = true; state.data = null; if (state.flag) console.log(state.data);`; + const expected = `const state = {};\nstate.flag = true;\nstate.data = null;\nif (true)\n console.log(null);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace multiple references to same property', () => { + const code = `let config = {}; config.timeout = 5000; const a = config.timeout; const b = config.timeout + 1000;`; + const expected = `let config = {};\nconfig.timeout = 5000;\nconst a = 5000;\nconst b = 5000 + 1000;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it(`TN-1: Don't resolve with multiple assignments`, () => { + const code = `const a = {}; a.b = ''; a.b = 3;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it(`TN-2: Don't resolve with update expressions`, () => { + const code = `const a = {}; a.b = 0; ++a.b + 2;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not resolve when assigned non-literal value', () => { + const code = `const obj = {}; obj.prop = getValue(); console.log(obj.prop);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not resolve when object has no declaration', () => { + const code = `unknown.prop = 'value'; console.log(unknown.prop);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not resolve when property is reassigned', () => { + const code = `const obj = {}; obj.data = 'first'; obj.data = 'second'; console.log(obj.data);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not resolve when used in assignment expression', () => { + const code = `const obj = {}; obj.counter = 0; obj.counter += 5; console.log(obj.counter);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-7: Do not resolve when property is computed with variable', () => { + const code = `const obj = {}; const key = 'prop'; obj[key] = 'value'; console.log(obj[key]);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-8: Do not resolve when no references exist', () => { + const code = `const obj = {}; obj.unused = 'value';`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveProxyCalls', async () => { + const targetModule = (await import('../src/modules/safe/resolveProxyCalls.js')).default; + it('TP-1: Replace chained proxy calls with direct function calls', () => { + const code = `function call1(a, b) {return a + b;} function call2(c, d) {return call1(c, d);} function call3(e, f) {return call2(e, f);}`; + const expected = `function call1(a, b) {\n return a + b;\n}\nfunction call2(c, d) {\n return call1(c, d);\n}\nfunction call3(e, f) {\n return call1(e, f);\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Replace proxy with no parameters', () => { + const code = `function target() { return 42; } function proxy() { return target(); } const result = proxy();`; + const expected = `function target() {\n return 42;\n}\nfunction proxy() {\n return target();\n}\nconst result = target();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace proxy with multiple parameters', () => { + const code = `function add(a, b, c) { return a + b + c; } function addProxy(x, y, z) { return add(x, y, z); } const sum = addProxy(1, 2, 3);`; + const expected = `function add(a, b, c) {\n return a + b + c;\n}\nfunction addProxy(x, y, z) {\n return add(x, y, z);\n}\nconst sum = add(1, 2, 3);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace proxy that calls another proxy (single-step resolution)', () => { + const code = `function base() { return 'test'; } function proxy1() { return base(); } function proxy2() { return proxy1(); } console.log(proxy2());`; + const expected = `function base() {\n return 'test';\n}\nfunction proxy1() {\n return base();\n}\nfunction proxy2() {\n return base();\n}\nconsole.log(proxy1());`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace function with multiple statements', () => { + const code = `function target() { return 42; } function notProxy() { console.log('side effect'); return target(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace function with no return statement', () => { + const code = `function target() { return 42; } function notProxy() { target(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace function that returns non-call expression', () => { + const code = `function notProxy() { return 42; }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace function that calls member expression', () => { + const code = `const obj = { method: () => 42 }; function notProxy() { return obj.method(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace function with parameter count mismatch', () => { + const code = `function target(a, b) { return a + b; } function notProxy(x) { return target(x, 0); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not replace function with reordered parameters', () => { + const code = `function target(a, b) { return a - b; } function notProxy(x, y) { return target(y, x); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-7: Do not replace function with modified parameters', () => { + const code = `function target(a) { return a * 2; } function notProxy(x) { return target(x + 1); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-8: Do not replace function with no references', () => { + const code = `function target() { return 42; } function unreferencedProxy() { return target(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveProxyReferences', async () => { + const targetModule = (await import('../src/modules/safe/resolveProxyReferences.js')).default; + it('TP-1: Replace proxy reference with direct reference', () => { + const code = `const a = ['']; const b = a; const c = b[0];`; + const expected = `const a = [''];\nconst b = a;\nconst c = a[0];`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Replace multiple proxy references to same target', () => { + const code = `const arr = [1, 2, 3]; const proxy = arr; const x = proxy[0]; const y = proxy[1];`; + const expected = `const arr = [\n 1,\n 2,\n 3\n];\nconst proxy = arr;\nconst x = arr[0];\nconst y = arr[1];`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace member expression proxy references', () => { + const code = `const obj = {prop: 42}; const alias = obj.prop; const result = alias;`; + const expected = `const obj = { prop: 42 };\nconst alias = obj.prop;\nconst result = obj.prop;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Replace chained proxy references', () => { + const code = `const original = 'test'; const proxy1 = original; const proxy2 = proxy1; const final = proxy2;`; + const expected = `const original = 'test';\nconst proxy1 = original;\nconst proxy2 = original;\nconst final = proxy1;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace variable with let declaration', () => { + const code = `let source = 'value'; let reference = source; console.log(reference);`; + const expected = `let source = 'value';\nlet reference = source;\nconsole.log(source);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Replace variable with var declaration', () => { + const code = `var base = [1, 2]; var link = base; var item = link[0];`; + const expected = `var base = [\n 1,\n 2\n];\nvar link = base;\nvar item = base[0];`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace proxy in for-in statement', () => { + const code = `const obj = {a: 1}; for (const key in obj) { const proxy = key; }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace proxy in for-of statement', () => { + const code = `const arr = [1, 2]; for (const item of arr) { const proxy = item; }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace proxy in for statement', () => { + const code = `const arr = [1, 2]; for (let i = 0; i < arr.length; i++) { const proxy = arr[i]; }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace circular references', () => { + const code = `let a; let b; a = b; b = a;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace self-referencing variables', () => { + const code = `const a = someFunction(a);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-6: Do not replace when proxy is modified', () => { + const code = `const original = [1]; const proxy = original; proxy.push(2);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-7: Do not replace when target is modified', () => { + const code = `let original = [1]; const proxy = original; original = [2];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-8: Do not replace when proxy has no references', () => { + const code = `const original = 'test'; const unused = original;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-9: Do not replace non-identifier/non-member expression variables', () => { + const code = `const a = func(); const b = a;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Replace when target comes from function call (still safe)', () => { + const code = `const a = getValue(); const b = a; console.log(b);`; + const expected = `const a = getValue();\nconst b = a;\nconsole.log(a);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveProxyVariables', async () => { + const targetModule = (await import('../src/modules/safe/resolveProxyVariables.js')).default; + it('TP-1: Replace proxy variable references with target identifier', () => { + const code = `const a2b = atob; console.log(a2b('NDI='));`; + const expected = `console.log(atob('NDI='));`; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TP-2: Remove unused proxy variable declaration', () => { + const code = `const a2b = atob, a = 3; console.log(a2b('NDI='));`; + const expected = `const a = 3;\nconsole.log(atob('NDI='));`; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TP-3: Replace multiple references to same proxy', () => { + const code = `const alias = original; console.log(alias); console.log(alias);`; + const expected = `console.log(original);\nconsole.log(original);`; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TP-4: Remove proxy variable with no references', () => { + const code = `const unused = target; console.log('other');`; + const expected = `console.log('other');`; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TP-5: Replace with let declaration', () => { + const code = `let proxy = original; console.log(proxy);`; + const expected = `console.log(original);`; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TP-6: Replace with var declaration', () => { + const code = `var proxy = original; console.log(proxy);`; + const expected = `console.log(original);`; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not replace when proxy is assigned non-identifier', () => { + const code = `const proxy = getValue(); console.log(proxy);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not replace when proxy is modified', () => { + const code = `const proxy = original; proxy = 'modified'; console.log(proxy);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not replace when proxy is updated', () => { + const code = `const proxy = original; proxy++; console.log(proxy);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not replace when reference is used in assignment', () => { + const code = `const proxy = original; const x = proxy = 'new';`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not replace non-identifier initialization', () => { + const code = `const proxy = obj.prop; console.log(proxy);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); +}); +describe('SAFE: resolveRedundantLogicalExpressions', async () => { + const targetModule = (await import('../src/modules/safe/resolveRedundantLogicalExpressions.js')).default; + it('TP-1: Simplify basic true and false literals with && and ||', () => { + const code = `if (false && true) {} if (false || true) {} if (true && false) {} if (true || false) {}`; + const expected = `if (false) {\n}\nif (true) {\n}\nif (false) {\n}\nif (true) {\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-2: Simplify AND expressions with truthy left operand', () => { + const code = `if (true && someVar) {} if (1 && someFunc()) {} if ("str" && obj.prop) {}`; + const expected = `if (someVar) {\n}\nif (someFunc()) {\n}\nif (obj.prop) {\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-3: Simplify AND expressions with falsy left operand', () => { + const code = `if (false && someVar) {} if (0 && someFunc()) {} if ("" && obj.prop) {} if (null && x) {}`; + const expected = `if (false) {\n}\nif (0) {\n}\nif ('') {\n}\nif (null) {\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Simplify AND expressions with truthy right operand', () => { + const code = `if (someVar && true) {} if (someFunc() && 1) {} if (obj.prop && "str") {}`; + const expected = `if (someVar) {\n}\nif (someFunc()) {\n}\nif (obj.prop) {\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Simplify AND expressions with falsy right operand', () => { + const code = `if (someVar && false) {} if (someFunc() && 0) {} if (obj.prop && "") {} if (x && null) {}`; + const expected = `if (false) {\n}\nif (0) {\n}\nif ('') {\n}\nif (null) {\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Simplify OR expressions with truthy left operand', () => { + const code = `if (true || someVar) {} if (1 || someFunc()) {} if ("str" || obj.prop) {}`; + const expected = `if (true) {\n}\nif (1) {\n}\nif ('str') {\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Simplify OR expressions with falsy left operand', () => { + const code = `if (false || someVar) {} if (0 || someFunc()) {} if ("" || obj.prop) {} if (null || x) {}`; + const expected = `if (someVar) {\n}\nif (someFunc()) {\n}\nif (obj.prop) {\n}\nif (x) {\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TN-2: Do no replace a value used in a for-of-loop', () => { - const code = `var a = 3; for (a of [1, 2]) console.log(a);`; - const expected = code; + it('TP-8: Simplify OR expressions with truthy right operand', () => { + const code = `if (someVar || true) {} if (someFunc() || 1) {} if (obj.prop || "str") {}`; + const expected = `if (true) {\n}\nif (1) {\n}\nif ('str') {\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: replaceIdentifierWithFixedValueNotAssignedAtDeclaration', async () => { - const targetModule = (await import('../src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js')).default; - it('TP-1', () => { - const code = `let a; a = 3; const b = a * 2; console.log(b + a);`; - const expected = `let a;\na = 3;\nconst b = 3 * 2;\nconsole.log(b + 3);`; + it('TP-9: Simplify OR expressions with falsy right operand', () => { + const code = `if (someVar || false) {} if (someFunc() || 0) {} if (obj.prop || "") {} if (x || null) {}`; + const expected = `if (someVar) {\n}\nif (someFunc()) {\n}\nif (obj.prop) {\n}\nif (x) {\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: replaceNewFuncCallsWithLiteralContent', async () => { - const targetModule = (await import('../src/modules/safe/replaceNewFuncCallsWithLiteralContent.js')).default; - it('TP-1', () => { - const code = `new Function("!function() {console.log('hello world')}()")();`; - const expected = `!(function () {\n console.log('hello world');\n}());`; + it('TP-10: Handle complex expressions with nested logical operators', () => { + const code = `if (true && (someVar && false)) {} if (false || (x || true)) {}`; + const expected = `if (someVar && false) {\n}\nif (x || true) {\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: replaceBooleanExpressionsWithIf', async () => { - const targetModule = (await import('../src/modules/safe/replaceBooleanExpressionsWithIf.js')).default; - it('TP-1: Logical AND', () => { - const code = `x && y && z();`; - const expected = `if (x && y) {\n z();\n}`; + it('TN-1: Do not simplify when both operands are non-literals', () => { + const code = `if (someVar && otherVar) {} if (func1() || func2()) {} if (obj.a && obj.b) {}`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TP-2: Logical OR', () => { - const code = `x || y || z();`; - const expected = `if (!(x || y)) {\n z();\n}`; + it('TN-2: Do not simplify non-logical expressions', () => { + const code = `if (a + b) {} if (a === b) {} if (a > b) {} if (!a) {}`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: replaceSequencesWithExpressions', async () => { - const targetModule = (await import('../src/modules/safe/replaceSequencesWithExpressions.js')).default; - it('TP-1: 2 expressions', () => { - const code = `if (a) (b(), c());`; - const expected = `if (a) {\n b();\n c();\n}`; + it('TN-3: Do not simplify logical expressions outside if statements', () => { + const code = `if (someVar) { const x = true && someVar; const y = false || someFunc(); }`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TP-2: 3 expressions', () => { - const code = `if (a) { (b(), c()); d() }`; - const expected = `if (a) {\n b();\n c();\n d();\n}`; + it('TN-4: Do not simplify unsupported logical operators (if any)', () => { + const code = `if (a & b) {} if (a | b) {} if (a ^ b) {}`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); }); -describe('SAFE: resolveDeterministicIfStatements', async () => { - const targetModule = (await import('../src/modules/safe/resolveDeterministicIfStatements.js')).default; - it('TP-1', () => { - const code = `if (true) do_a(); else do_b(); if (false) do_c(); else do_d();`; - const expected = `do_a();\ndo_d();`; +describe('SAFE: unwrapFunctionShells', async () => { + const targetModule = (await import('../src/modules/safe/unwrapFunctionShells.js')).default; + it('TP-1: Unwrap and rename', () => { + const code = `function a(x) {return function b() {return x + 3}.apply(this, arguments);}`; + const expected = `function b(x) {\n return x + 3;\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: resolveFunctionConstructorCalls', async () => { - const targetModule = (await import('../src/modules/safe/resolveFunctionConstructorCalls.js')).default; - it('TP-1', () => { - const code = `const func = Function.constructor('', "console.log('hello world!');");`; - const expected = `const func = function () {\n console.log('hello world!');\n};`; + it('TP-2: Unwrap anonymous without renaming', () => { + const code = `function a(x) {return function() {return x + 3}.apply(this, arguments);}`; + const expected = `function a(x) {\n return x + 3;\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TP-2: Part of a member expression', () => { - const code = `a = Function.constructor('return /" + this + "/')().constructor('^([^ ]+( +[^ ]+)+)+[^ ]}');`; - const expected = `a = function () {\n return /" + this + "/;\n}().constructor('^([^ ]+( +[^ ]+)+)+[^ ]}');`; + it('TP-3: Unwrap function expression assigned to variable', () => { + const code = `const outer = function(param) { return function inner() { return param * 2; }.apply(this, arguments); };`; + const expected = `const outer = function inner(param) {\n return param * 2;\n};`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: resolveMemberExpressionReferencesToArrayIndex', async () => { - const targetModule = (await import('../src/modules/safe/resolveMemberExpressionReferencesToArrayIndex.js')).default; - it('TP-1', () => { - const code = `const a = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; b = a[0]; c = a[20];`; - const expected = `const a = [\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1,\n 1, - 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 3\n];\nb = 1;\nc = 3;`; + it('TP-4: Inner function already has parameters', () => { + const code = `function wrapper() { return function inner(existing) { return existing + 1; }.apply(this, arguments); }`; + const expected = `function inner(existing) {\n return existing + 1;\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it(`TN-1: Don't resolve references to array methods`, () => { - const code = `const a = [1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,3]; b = a['indexOf']; c = a['length'];`; - const expected = code; + it('TP-5: Outer function has multiple parameters', () => { + const code = `function multi(a, b, c) { return function() { return a + b + c; }.apply(this, arguments); }`; + const expected = `function multi(a, b, c) {\n return a + b + c;\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: resolveMemberExpressionsWithDirectAssignment', async () => { - const targetModule = (await import('../src/modules/safe/resolveMemberExpressionsWithDirectAssignment.js')).default; - it('TP-1', () => { - const code = `function a() {} a.b = 3; a.c = '5'; console.log(a.b + a.c);`; - const expected = `function a() {\n}\na.b = 3;\na.c = '5';\nconsole.log(3 + '5');`; + it('TP-6: Complex inner function body', () => { + const code = `function complex(x) { return function process() { const temp = x * 2; return temp + 1; }.apply(this, arguments); }`; + const expected = `function process(x) {\n const temp = x * 2;\n return temp + 1;\n}`; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it(`TN-1: Don't resolve with multiple assignments`, () => { - const code = `const a = {}; a.b = ''; a.b = 3;`; + it('TN-1: Do not unwrap function with multiple statements', () => { + const code = `function multi() { console.log('test'); return function() { return 42; }.apply(this, arguments); }`; const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it(`TN-2: Don't resolve with update expressions`, () => { - const code = `const a = {}; a.b = 0; ++a.b + 2;`; + it('TN-2: Do not unwrap function with no return statement', () => { + const code = `function noReturn() { console.log('no return'); }`; const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: resolveProxyCalls', async () => { - const targetModule = (await import('../src/modules/safe/resolveProxyCalls.js')).default; - it('TP-1', () => { - const code = `function call1(a, b) {return a + b;} function call2(c, d) {return call1(c, d);} function call3(e, f) {return call2(e, f);}`; - const expected = `function call1(a, b) {\n return a + b;\n}\nfunction call2(c, d) {\n return call1(c, d);\n}\nfunction call3(e, f) {\n return call1(e, f);\n}`; + it('TN-3: Do not unwrap function returning .call instead of .apply', () => { + const code = `function useCall(x) { return function() { return x + 1; }.call(this, x); }`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: resolveProxyReferences', async () => { - const targetModule = (await import('../src/modules/safe/resolveProxyReferences.js')).default; - it('TP-1', () => { - const code = `const a = ['']; const b = a; const c = b[0];`; - const expected = `const a = [''];\nconst b = a;\nconst c = a[0];`; + it('TN-4: Do not unwrap .apply with wrong argument count', () => { + const code = `function wrongArgs(x) { return function() { return x; }.apply(this); }`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: resolveProxyVariables', async () => { - const targetModule = (await import('../src/modules/safe/resolveProxyVariables.js')).default; - it('TP-1', () => { - const code = `const a2b = atob; console.log(a2b('NDI='));`; - const expected = `console.log(atob('NDI='));`; - const result = applyModuleToCode(code, targetModule, true); + it('TN-5: Do not unwrap when callee object is not FunctionExpression', () => { + const code = `function notFunc(x) { return someFunc.apply(this, arguments); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TP-2', () => { - const code = `const a2b = atob, a = 3; console.log(a2b('NDI='));`; - const expected = `const a = 3;\nconsole.log(atob('NDI='));`; - const result = applyModuleToCode(code, targetModule, true); + it('TN-6: Do not unwrap function returning non-call expression', () => { + const code = `function nonCall(x) { return x + 1; }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: resolveRedundantLogicalExpressions', async () => { - const targetModule = (await import('../src/modules/safe/resolveRedundantLogicalExpressions.js')).default; - it('TP-1', () => { - const code = `if (false && true) {} if (false || true) {} if (true && false) {} if (true || false) {}`; - const expected = `if (false) {\n}\nif (true) {\n}\nif (false) {\n}\nif (true) {\n}`; + it('TN-7: Do not unwrap function with empty body', () => { + const code = `function empty() {}`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); -}); -describe('SAFE: unwrapFunctionShells', async () => { - const targetModule = (await import('../src/modules/safe/unwrapFunctionShells.js')).default; - it('TP-1: Unwrap and rename', () => { - const code = `function a(x) {return function b() {return x + 3}.apply(this, arguments);}`; - const expected = `function b(x) {\n return x + 3;\n}`; + it('TN-8: Do not unwrap function with BlockStatement but no statements', () => { + const code = `function emptyBlock() { }`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); - it('TP-2: Unwrap anonymous without renaming', () => { - const code = `function a(x) {return function() {return x + 3}.apply(this, arguments);}`; - const expected = `function a(x) {\n return x + 3;\n}`; + it('TN-9: Do not unwrap arrow function as outer function', () => { + const code = `const arrow = (x) => { return function inner() { return x * 3; }.apply(this, arguments); };`; + const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); @@ -459,6 +1758,40 @@ describe('SAFE: unwrapIIFEs', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-4: IIFE with multiple statements unwrapped', () => { + const code = `!function() { + var x = 1; + var y = 2; + console.log(x + y); +}();`; + const expected = `var x = 1;\nvar y = 2;\nconsole.log(x + y);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not unwrap IIFE with arguments', () => { + const code = `var result = (function(x) { return x * 2; })(5);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not unwrap named function IIFE', () => { + const code = `var result = (function named() { return 42; })();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not unwrap IIFE in assignment context', () => { + const code = `obj.prop = (function() { return getValue(); })();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Arrow function IIFE with expression body', () => { + const code = `var result = (() => someValue)();`; + const expected = `var result = someValue;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: unwrapSimpleOperations', async () => { const targetModule = (await import('../src/modules/safe/unwrapSimpleOperations.js')).default; @@ -646,6 +1979,46 @@ typeof 1; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TN-1: Do not unwrap function with multiple statements', () => { + const code = `function complexAdd(a, b) { + console.log('adding'); + return a + b; + } + complexAdd(1, 2);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not unwrap function with wrong parameter count', () => { + const code = `function singleParam(a) { return a + 1; } + singleParam(5);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not unwrap operation not using parameters', () => { + const code = `function fixedAdd(a, b) { return 5 + 10; } + fixedAdd(1, 2);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not unwrap function with no return statement', () => { + const code = `function noReturn(a, b) { + var result = a + b; + } + noReturn(1, 2);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not unwrap unsupported operator', () => { + const code = `function assignmentOp(a, b) { return a = b; } + assignmentOp(x, 5);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: separateChainedDeclarators', async () => { const targetModule = (await import('../src/modules/safe/separateChainedDeclarators.js')).default; @@ -673,12 +2046,66 @@ describe('SAFE: separateChainedDeclarators', async () => { const result = applyModuleToCode(code, targetModule, true); assert.strictEqual(result, expected); }); - it('TN-1L Variable declarators are not chained', () => { + it('TP-5: Mixed initialization patterns', () => { + const code = `var a, b = 2, c;`; + const expected = `var a;\nvar b = 2;\nvar c;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Mixed declaration types with complex expressions', () => { + const code = `const x = func(), y = [1, 2, 3], z = {prop: 'value'};`; + const expected = `const x = func();\nconst y = [\n 1,\n 2,\n 3\n];\nconst z = { prop: 'value' };`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Three or more declarations', () => { + const code = `let a = 1, b = 2, c = 3, d = 4, e = 5;`; + const expected = `let a = 1;\nlet b = 2;\nlet c = 3;\nlet d = 4;\nlet e = 5;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-8: Declarations in function scope', () => { + const code = `function test() { const x = 1, y = 2; return x + y; }`; + const expected = `function test() {\n const x = 1;\n const y = 2;\n return x + y;\n}`; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TN-1: Variable declarators are not chained in for statement', () => { const code = `for (let i, b = 2, c = 3;;);`; const expected = code; const result = applyModuleToCode(code, targetModule, true); assert.strictEqual(result, expected); }); + it('TN-2: Variable declarators are not chained in for-in statement', () => { + const code = `for (let a, b in obj);`; + const expected = code; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TN-3: Variable declarators are not chained in for-of statement', () => { + const code = `for (let a, b of arr);`; + const expected = code; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TN-4: Single declarator should not be transformed', () => { + const code = `const singleVar = 42;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: ForAwaitStatement declarations should be preserved', () => { + const code = `for await (let a, b of asyncIterable);`; + const expected = code; + const result = applyModuleToCode(code, targetModule, true); + assert.strictEqual(result, expected); + }); + it('TN-6: Destructuring patterns should not be separated', () => { + const code = `const {a, b} = obj, c = 3;`; + const expected = `const {a, b} = obj;\nconst c = 3;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); describe('SAFE: simplifyCalls', async () => { const targetModule = (await import('../src/modules/safe/simplifyCalls.js')).default; @@ -694,8 +2121,62 @@ describe('SAFE: simplifyCalls', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-3: Mixed calls with complex arguments', () => { + const code = `func.call(this, a + b, getValue()); obj.method.apply(this, [x, y, z]);`; + const expected = `func(a + b, getValue());\nobj.method(x, y, z);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-4: Calls on member expressions', () => { + const code = `obj.method.call(this, arg1); nested.obj.func.apply(this, [arg2]);`; + const expected = `obj.method(arg1);\nnested.obj.func(arg2);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-5: Apply with empty array', () => { + const code = `func.apply(this, []);`; + const expected = `func();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-6: Call and apply in same expression', () => { + const code = `func1.call(this, arg) + func2.apply(this, [arg]);`; + const expected = `func1(arg) + func2(arg);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-7: Call and apply with null for context', () => { + const code = `func1.call(null, arg); func2.apply(null, [arg]);`; + const expected = `func1(arg);\nfunc2(arg);`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); it('TN-1: Ignore calls without ThisExpression', () => { - const code = `func1.apply({}); func2.call(null);`; + const code = `func1.apply({}); func2.call(undefined); func3.apply(obj);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not transform Function constructor calls', () => { + const code = `Function.call(this, 'return 42'); Function.apply(this, ['return 42']);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not transform calls on function expressions', () => { + const code = `(function() {}).call(this); (function() {}).apply(this, []);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not transform other method names', () => { + const code = `func.bind(this, arg); func.toString(this); func.valueOf(this);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-5: Do not transform calls with this in wrong position', () => { + const code = `func.call(arg, this); func.apply(arg1, this, arg2);`; const expected = code; const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); @@ -745,4 +2226,52 @@ describe('SAFE: simplifyIfStatements', async () => { const result = applyModuleToCode(code, targetModule); assert.strictEqual(result, expected); }); + it('TP-8: Populated consequent with empty alternate block', () => { + const code = `if (test) doSomething(); else {}`; + const expected = `if (test)\n doSomething();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-9: Populated consequent with empty alternate statement', () => { + const code = `if (condition) action(); else;`; + const expected = `if (condition)\n action();`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-10: Complex expression in test with empty branches', () => { + const code = `if (a && b || c) {} else {}`; + const expected = `a && b || c;`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TP-11: Nested empty if statements', () => { + const code = `if (outer) { if (inner) {} else {} }`; + const expected = `if (outer) {\n inner;\n}`; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-1: Do not transform if with populated consequent and alternate', () => { + const code = `if (test) doThis(); else doThat();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-2: Do not transform if with populated block statements', () => { + const code = `if (condition) { action1(); action2(); } else { action3(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-3: Do not transform if with only populated consequent block', () => { + const code = `if (test) { performAction(); }`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + it('TN-4: Do not transform complex if-else chains', () => { + const code = `if (a) first(); else if (b) second(); else third();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); }); \ No newline at end of file diff --git a/tests/modules.unsafe.test.js b/tests/modules.unsafe.test.js index 99b348e..42715ab 100644 --- a/tests/modules.unsafe.test.js +++ b/tests/modules.unsafe.test.js @@ -1,8 +1,7 @@ /* eslint-disable no-unused-vars */ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {badValue} from '../src/modules/config.js'; -import {Arborist, generateFlatAST, applyIteratively} from 'flast'; +import {Arborist, applyIteratively, generateFlatAST} from 'flast'; /** * Apply a module to a given code snippet. @@ -26,18 +25,156 @@ function applyModuleToCode(code, func, looped = false) { describe('UNSAFE: normalizeRedundantNotOperator', async () => { const targetModule = (await import('../src/modules/unsafe/normalizeRedundantNotOperator.js')).default; - it('TP-1', () => { + it('TP-1: Mixed literals and expressions', () => { const code = `!true || !false || !0 || !1 || !a || !'a' || ![] || !{} || !-1 || !!true || !!!true`; const expected = `false || true || true || false || !a || false || false || false || false || true || false;`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TP-2: String literals', () => { + const code = `!'' || !'hello' || !'0' || !' '`; + const expected = `true || false || false || false;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: Number literals', () => { + const code = `!42 || !-42 || !0.5 || !-0.5`; + const expected = `false || false || false || false;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Null literal', () => { + const code = `!null`; + const expected = `true;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Empty array and object literals', () => { + const code = `!{} || ![]`; + const expected = `false || false;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Simple nested NOT operations', () => { + const code = `!!false || !!true`; + const expected = `false || true;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-7: Normalize complex literals that can be safely evaluated', () => { + const code = `!undefined || ![1,2,3] || !{a:1}`; + const expected = `true || false || false;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Do not normalize NOT on variables', () => { + const code = `!variable || !obj.prop || !func()`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-2: Do not normalize NOT on complex expressions', () => { + const code = `!(a + b) || !(x > y) || !(z && w)`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Do not normalize NOT on function calls', () => { + const code = `!getValue() || !Math.random() || !Array.isArray(arr)`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Do not normalize NOT on computed properties', () => { + const code = `!obj[key] || !arr[0] || !matrix[i][j]`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Do not normalize literals with unpredictable values', () => { + const code = `!Infinity || !-Infinity`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveAugmentedFunctionWrappedArrayReplacements', async () => { - // Load the module even though there are no tests for it - to include it in the coverage report - // noinspection JSUnusedLocalSymbols const targetModule = (await import('../src/modules/unsafe/resolveAugmentedFunctionWrappedArrayReplacements.js')).default; - it.todo('TODO: Write tests for function', () => {}); + + it.todo('Add Missing True Positive Test Cases'); + + it('TN-1: Do not transform functions without augmentation', () => { + const code = `function simpleFunc() { return 'test'; } + simpleFunc();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + + it('TN-2: Do not transform functions without array operations', () => { + const code = `function myFunc() { myFunc = 'modified'; return 'value'; } + myFunc();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + + it('TN-3: Do not transform when no matching expression statements', () => { + const code = `var arr = ['a', 'b']; + function decrypt(i) { return arr[i]; } + decrypt.modified = true; + decrypt(0);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + + it('TN-4: Do not transform anonymous functions', () => { + const code = `var func = function() { func = 'modified'; return arr[0]; }; + func();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + + it('TN-5: Do not transform when array candidate has no declNode', () => { + const code = `function decrypt() { + decrypt = 'modified'; + return undeclaredArr[0]; + } + decrypt();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + + it('TN-6: Do not transform when expression statement pattern is wrong', () => { + const code = `var arr = ['a', 'b']; + function decrypt(i) { + decrypt = 'modified'; + return arr[i]; + } + (function() { return arr; })(); // Wrong pattern - not matching + decrypt(0);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + + it('TN-7: Do not transform when no replacement candidates found', () => { + const code = `var arr = ['a', 'b']; + function decrypt(i) { + decrypt = 'modified'; + return arr[i]; + } + (function(arr) { return arr; })(arr); + // No calls to decrypt function to replace + console.log('test');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.strictEqual(result, expected); + }); + }); describe('UNSAFE: resolveBuiltinCalls', async () => { const targetModule = (await import('../src/modules/unsafe/resolveBuiltinCalls.js')).default; @@ -59,6 +196,24 @@ describe('UNSAFE: resolveBuiltinCalls', async () => { const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TP-4: Member expression with literal arguments', () => { + const code = `String.fromCharCode(72, 101, 108, 108, 111);`; + const expected = `'Hello';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Multiple builtin calls', () => { + const code = `btoa('test') + atob('dGVzdA==');`; + const expected = `'dGVzdA==' + 'test';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: String method with multiple arguments', () => { + const code = `'hello world'.replace('world', 'universe');`; + const expected = `'hello universe';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); it('TN-1: querySelector', () => { const code = `document.querySelector('div');`; const expected = code; @@ -77,78 +232,612 @@ describe('UNSAFE: resolveBuiltinCalls', async () => { const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TN-4: Skip builtin function call', () => { + const code = `Array(5);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Skip member expression with restricted property', () => { + const code = `'test'.length;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Function call with this expression', () => { + const code = `this.btoa('test');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-7: Constructor property access', () => { + const code = `String.constructor('return 1');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-8: Member expression with computed property using variable', () => { + const code = `String[methodName]('test');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveDefiniteBinaryExpressions', async () => { const targetModule = (await import('../src/modules/unsafe/resolveDefiniteBinaryExpressions.js')).default; - it('TP-1', () => { + it('TP-1: Mixed arithmetic and string operations', () => { const code = `5 * 3; '2' + 2; '10' - 1; 'o' + 'k'; 'o' - 'k'; 3 - -1;`; const expected = `15;\n'22';\n9;\n'ok';\nNaN;\n4;`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TP-2: Division and modulo operations', () => { + const code = `10 / 2; 7 % 3; 15 / 3;`; + const expected = `5;\n1;\n5;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: Bitwise operations', () => { + const code = `5 & 3; 5 | 3; 5 ^ 3;`; + const expected = `1;\n7;\n6;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Comparison operations', () => { + const code = `5 > 3; 2 < 1; 5 === 5; 'a' !== 'b';`; + const expected = `true;\nfalse;\ntrue;\ntrue;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Negative number edge case handling', () => { + const code = `10 - 15; 3 - 8;`; + const expected = `-5;\n-5;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Null operations and string concatenation', () => { + const code = `null + 5; 'test' + 'ing';`; + const expected = `5;\n'testing';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Do not resolve expressions with variables', () => { + const code = `x + 5; a * b;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-2: Do not resolve expressions with function calls', () => { + const code = `foo() + 5; Math.max(1, 2) * 3;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Do not resolve member expressions', () => { + const code = `obj.prop + 5; arr[0] * 2;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Do not resolve complex nested expressions', () => { + const code = `(x + y) * z; foo(a) + bar(b);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Do not resolve logical expressions (not BinaryExpressions)', () => { + const code = `true && false; true || false; !true;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Do not resolve expressions with undefined identifier', () => { + const code = `undefined + 3; x + undefined;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + + // Test the inlined helper function + const {doesBinaryExpressionContainOnlyLiterals} = await import('../src/modules/unsafe/resolveDefiniteBinaryExpressions.js'); + + it('Helper TP-1: Literal node', () => { + const ast = generateFlatAST(`'a'`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'Literal')); + assert.ok(result); + }); + it('Helper TP-2: Binary expression with literals', () => { + const ast = generateFlatAST(`1 + 2`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'BinaryExpression')); + assert.ok(result); + }); + it('Helper TP-3: Unary expression with literal', () => { + const ast = generateFlatAST(`-'a'`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'UnaryExpression')); + assert.ok(result); + }); + it('Helper TP-4: Complex nested binary expressions', () => { + const ast = generateFlatAST(`1 + 2 + 3 + 4`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'BinaryExpression')); + assert.ok(result); + }); + it('Helper TP-5: Logical expression with literals', () => { + const ast = generateFlatAST(`true && false`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'LogicalExpression')); + assert.ok(result); + }); + it('Helper TP-6: Conditional expression with literals', () => { + const ast = generateFlatAST(`true ? 1 : 2`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'ConditionalExpression')); + assert.ok(result); + }); + it('Helper TP-7: Sequence expression with literals', () => { + const ast = generateFlatAST(`(1, 2, 3)`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'SequenceExpression')); + assert.ok(result); + }); + it('Helper TN-7: Update expression with identifier', () => { + const ast = generateFlatAST(`let x = 5; ++x;`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'UpdateExpression')); + assert.strictEqual(result, false); // ++x contains an identifier, not a literal + }); + it('Helper TN-1: Identifier is rejected', () => { + const ast = generateFlatAST(`a`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'Identifier')); + assert.strictEqual(result, false); + }); + it('Helper TN-2: Unary expression with identifier', () => { + const ast = generateFlatAST(`!a`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'UnaryExpression')); + assert.strictEqual(result, false); + }); + it('Helper TN-3: Binary expression with identifier', () => { + const ast = generateFlatAST(`1 + b`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'BinaryExpression')); + assert.strictEqual(result, false); + }); + it('Helper TN-4: Complex non-literal expressions are rejected', () => { + const ast = generateFlatAST(`true && x`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'LogicalExpression')); + assert.strictEqual(result, false); + }); + it('Helper TN-5: Function calls and member expressions', () => { + const ast = generateFlatAST(`func()`); + const result = doesBinaryExpressionContainOnlyLiterals(ast.find(n => n.type === 'CallExpression')); + assert.strictEqual(result, false); + + const ast2 = generateFlatAST(`obj.prop`); + const result2 = doesBinaryExpressionContainOnlyLiterals(ast2.find(n => n.type === 'MemberExpression')); + assert.strictEqual(result2, false); + }); + it('Helper TN-6: Null and undefined handling', () => { + assert.strictEqual(doesBinaryExpressionContainOnlyLiterals(null), false); + assert.strictEqual(doesBinaryExpressionContainOnlyLiterals(undefined), false); + assert.strictEqual(doesBinaryExpressionContainOnlyLiterals({}), false); + }); }); describe('UNSAFE: resolveDefiniteMemberExpressions', async () => { const targetModule = (await import('../src/modules/unsafe/resolveDefiniteMemberExpressions.js')).default; - it('TP-1', () => { + it('TP-1: String and array indexing with properties', () => { const code = `'123'[0]; 'hello'.length;`; const expected = `'1';\n5;`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); - it('TN-1', () => { + it('TP-2: Array literal indexing', () => { + const code = `[1, 2, 3][0]; [4, 5, 6][2];`; + const expected = `1;\n6;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: String indexing with different positions', () => { + const code = `'test'[1]; 'world'[4];`; + const expected = `'e';\n'd';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Array length property', () => { + const code = `[1, 2, 3, 4].length; ['a', 'b'].length;`; + const expected = `4;\n2;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Mixed literal types in arrays', () => { + const code = `['hello', 42, true][0]; [null, undefined, 'test'][2];`; + const expected = `'hello';\n'test';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Non-computed property access with identifier', () => { + const code = `'testing'.length; [1, 2, 3].length;`; + const expected = `7;\n3;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Do not transform update expressions', () => { const code = `++[[]][0];`; const expected = code; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TN-2: Do not transform method calls (callee position)', () => { + const code = `'test'.split(''); [1, 2, 3].join(',');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Do not transform computed properties with variables', () => { + const code = `'hello'[index]; arr[i];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Do not transform non-literal objects', () => { + const code = `obj.property; variable[0];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Do not transform empty literals', () => { + const code = `''[0]; [].length;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Do not transform complex property expressions', () => { + const code = `'test'[getValue()]; obj[prop + 'name'];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-7: Do not transform out-of-bounds access (handled by sandbox)', () => { + const code = `'abc'[10]; [1, 2][5];`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveDeterministicConditionalExpressions', async () => { const targetModule = (await import('../src/modules/unsafe/resolveDeterministicConditionalExpressions.js')).default; - it('TP-1', () => { + it('TP-1: Boolean literals (true/false)', () => { const code = `(true ? 1 : 2); (false ? 3 : 4);`; const expected = `1;\n4;`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); - it('TN-1', () => { + it('TP-2: Truthy string literals', () => { + const code = `('hello' ? 'yes' : 'no'); ('a' ? 42 : 0);`; + const expected = `'yes';\n42;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: Falsy string literal (empty string)', () => { + const code = `('' ? 'yes' : 'no'); ('' ? 42 : 0);`; + const expected = `'no';\n0;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Truthy number literals', () => { + const code = `(1 ? 'one' : 'zero'); (42 ? 'yes' : 'no'); (123 ? 'positive' : 'zero');`; + const expected = `'one';\n'yes';\n'positive';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Falsy number literal (zero)', () => { + const code = `(0 ? 'yes' : 'no'); (0 ? 42 : 'zero');`; + const expected = `'no';\n'zero';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Null literal', () => { + const code = `(null ? 'yes' : 'no'); (null ? 'defined' : 'null');`; + const expected = `'no';\n'null';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-7: Nested conditional expressions (single pass)', () => { + const code = `(true ? (false ? 'inner1' : 'inner2') : 'outer');`; + const expected = `false ? 'inner1' : 'inner2';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-8: Complex expressions as branches', () => { + const code = `(1 ? console.log('truthy') : console.log('falsy'));`; + const expected = `console.log('truthy');`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Non-literal test expressions', () => { const code = `({} ? 1 : 2); ([].length ? 3 : 4);`; const expected = code; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TN-2: Variable test expressions', () => { + const code = `(x ? 'yes' : 'no'); (condition ? true : false);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Function call test expressions', () => { + const code = `(getValue() ? 'yes' : 'no'); (check() ? 1 : 0);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Binary expression test expressions', () => { + const code = `(a + b ? 'yes' : 'no'); (x > 5 ? 'big' : 'small');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Member expression test expressions', () => { + const code = `(obj.prop ? 'yes' : 'no'); (arr[0] ? 'first' : 'empty');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Unary expressions (not literals)', () => { + const code = `(-1 ? 'negative' : 'zero'); (!true ? 'no' : 'yes');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-7: Undefined identifier (not literal)', () => { + const code = `(undefined ? 'defined' : 'undefined');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveEvalCallsOnNonLiterals', async () => { const targetModule = (await import('../src/modules/unsafe/resolveEvalCallsOnNonLiterals.js')).default; - it('TP-1', () => { + it('TP-1: Function call that returns string', () => { const code = `eval(function(a) {return a}('atob'));`; const expected = `atob;`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); - it('TP-2', () => { + it('TP-2: Array access returning empty string', () => { const code = `eval([''][0]);`; const expected = `''`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TP-3: Variable reference resolution', () => { + const code = `var x = 'console.log("test")'; eval(x);`; + const expected = `var x = 'console.log("test")';\nconsole.log('test');`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Function expression IIFE', () => { + const code = `eval((function() { return 'var a = 5;'; })());`; + const expected = `var a = 5;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Member expression property access', () => { + const code = `var obj = {code: 'var y = 10;'}; eval(obj.code);`; + const expected = `var obj = { code: 'var y = 10;' };\nvar y = 10;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Array index with complex expression', () => { + const code = `var arr = ['if (true) { x = 1; }']; eval(arr[0]);`; + const expected = `var arr = ['if (true) { x = 1; }'];\nif (true) {\n x = 1;\n}`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Eval with literal string (already handled by another module)', () => { + const code = `eval('console.log("literal")');`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-2: Non-eval function calls', () => { + const code = `execute(function() { return 'code'; }());`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Eval with multiple arguments', () => { + const code = `eval('code', extra);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Eval with no arguments', () => { + const code = `eval();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Computed member expression for eval', () => { + const code = `obj['eval'](dynamicCode);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Eval with non-evaluable expression', () => { + const code = `eval(undefined);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveFunctionToArray', async () => { const targetModule = (await import('../src/modules/unsafe/resolveFunctionToArray.js')).default; - it('TP-1', () => { + it('TP-1: Simple function returning array', () => { const code = `function a() {return [1];}\nconst b = a();`; const expected = `function a() {\n return [1];\n}\nconst b = [1];`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TP-2: Function with multiple elements', () => { + const code = `function getArr() { return ['one', 'two', 'three']; }\nlet arr = getArr();`; + const expected = `function getArr() {\n return [\n 'one',\n 'two',\n 'three'\n ];\n}\nlet arr = [\n 'one',\n 'two',\n 'three'\n];`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: Arrow function returning array', () => { + const code = `const makeArray = () => [1, 2, 3];\nconst data = makeArray();`; + const expected = `const makeArray = () => [\n 1,\n 2,\n 3\n];\nconst data = [\n 1,\n 2,\n 3\n];`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Function with parameters (ignored)', () => { + const code = `function createArray(x) { return [x, x + 1]; }\nconst nums = createArray();`; + const expected = `function createArray(x) {\n return [\n x,\n x + 1\n ];\n}\nconst nums = [\n undefined,\n NaN\n];`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Multiple variables with array access only', () => { + const code = `function getColors() { return ['red', 'blue']; }\nconst colors = getColors();\nconst first = colors[0];`; + const expected = `function getColors() {\n return [\n 'red',\n 'blue'\n ];\n}\nconst colors = [\n 'red',\n 'blue'\n];\nconst first = colors[0];`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Function call with non-array-access usage', () => { + const code = `function getValue() { return 'test'; }\nconst val = getValue();\nconsole.log(val);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Variable with empty references array (should transform)', () => { + const code = `function getArray() { return [1, 2]; }\nconst unused = getArray();`; + const expected = `function getArray() {\n return [\n 1,\n 2\n ];\n}\nconst unused = [\n 1,\n 2\n];`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Variable not assigned function call', () => { + const code = `const arr = [1, 2, 3];\nconsole.log(arr[0]);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Mixed usage (array access and other)', () => { + const code = `function getData() { return [1, 2]; }\nconst data = getData();\nconsole.log(data[0], data);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-7: Function with property access (length is MemberExpression)', () => { + const code = `function getArray() { return [1, 2, 3]; }\nconst arr = getArray();\nconst len = arr.length;`; + const expected = `function getArray() {\n return [\n 1,\n 2,\n 3\n ];\n}\nconst arr = [\n 1,\n 2,\n 3\n];\nconst len = arr.length;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Function with method calls (not just property access)', () => { + const code = `function getArray() { return [1, 2, 3]; }\nconst arr = getArray();\narr.push(4);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Non-literal init expression', () => { + const code = `const arr = someFunction();\nconsole.log(arr[0]);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveInjectedPrototypeMethodCalls', async () => { const targetModule = (await import('../src/modules/unsafe/resolveInjectedPrototypeMethodCalls.js')).default; - it('TP-1', () => { + it('TP-1: String prototype method injection', () => { const code = `String.prototype.secret = function () {return 'secret ' + this;}; 'hello'.secret();`; const expected = `String.prototype.secret = function () {\n return 'secret ' + this;\n};\n'secret hello';`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TP-2: Number prototype method injection', () => { + const code = `Number.prototype.double = function () {return this * 2;}; (5).double();`; + const expected = `Number.prototype.double = function () {\n return this * 2;\n};\n10;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: Array prototype method injection', () => { + const code = `Array.prototype.first = function () {return this[0];}; [1, 2, 3].first();`; + const expected = `Array.prototype.first = function () {\n return this[0];\n};\n1;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Method with parameters', () => { + const code = `String.prototype.multiply = function (n) {return this + this;}; 'hi'.multiply(2);`; + const expected = `String.prototype.multiply = function (n) {\n return this + this;\n};\n'hihi';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Multiple calls to same injected method', () => { + const code = `String.prototype.shout = function () {return this.toUpperCase() + '!';}; 'hello'.shout(); 'world'.shout();`; + const expected = `String.prototype.shout = function () {\n return this.toUpperCase() + '!';\n};\n'HELLO!';\n'WORLD!';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Identifier assignment to prototype method', () => { + const code = `function helper() {return 'helped';} String.prototype.help = helper; 'test'.help();`; + const expected = `function helper() {\n return 'helped';\n}\nString.prototype.help = helper;\n'helped';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-7: Method call with missing arguments resolves to expected result', () => { + const code = `String.prototype.test = function (a, b) {return a + b;}; 'hello'.test();`; + const expected = `String.prototype.test = function (a, b) {\n return a + b;\n};\nNaN;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-8: Arrow function prototype method injection', () => { + const code = `String.prototype.reverse = () => 'reversed'; 'hello'.reverse();`; + const expected = `String.prototype.reverse = () => 'reversed';\n'reversed';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-9: Arrow function with parameters', () => { + const code = `String.prototype.repeat = (n) => 'repeated'; 'test'.repeat(3);`; + const expected = `String.prototype.repeat = n => 'repeated';\n'repeated';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-10: Arrow function using closure variable', () => { + const code = `const value = 'closure'; String.prototype.getClosure = () => value; 'hello'.getClosure();`; + const expected = `const value = 'closure';\nString.prototype.getClosure = () => value;\n'closure';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Non-prototype property assignment', () => { + const code = `String.custom = function () {return 'custom';}; String.custom();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-2: Non-function assignment to prototype', () => { + const code = `String.prototype.value = 'static'; 'test'.value;`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Call to non-injected method', () => { + const code = `String.prototype.custom = function () {return 'custom';}; 'test'.other();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Assignment with non-assignment operator', () => { + const code = `String.prototype.test += function () {return 'test';}; 'hello'.test();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Complex expression assignment to prototype', () => { + const code = `String.prototype.complex = getValue() + 'suffix'; 'test'.complex();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Arrow function returning this (may not evaluate safely)', () => { + const code = `String.prototype.getThis = () => this; 'hello'.getThis();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveLocalCalls', async () => { const targetModule = (await import('../src/modules/unsafe/resolveLocalCalls.js')).default; @@ -170,6 +859,24 @@ describe('UNSAFE: resolveLocalCalls', async () => { const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TP-4: Function expression', () => { + const code = `const multiply = function(a, b) {return a * b;}; multiply(3, 4);`; + const expected = `const multiply = function (a, b) {\n return a * b;\n};\n12;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-5: Multiple calls to same function', () => { + const code = `function double(x) {return x * 2;} double(5); double(10);`; + const expected = `function double(x) {\n return x * 2;\n}\n10;\n20;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Function returning string', () => { + const code = `function greet(name) {return 'Hello ' + name;} greet('World');`; + const expected = `function greet(name) {\n return 'Hello ' + name;\n}\n'Hello World';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); it('TN-1: Missing declaration', () => { const code = `add(1, 2);`; const expected = code; @@ -182,31 +889,162 @@ describe('UNSAFE: resolveLocalCalls', async () => { const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); - it('TN-2: No replacement with undefined', () => { + it('TN-3: No replacement with undefined', () => { const code = `function a() {} a();`; const expected = code; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); + it('TN-4: Complex member expression property access', () => { + const code = `const obj = {value: 'test'}; const fn = (o) => o.value; fn(obj);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-7: Function call argument with FunctionExpression', () => { + const code = `function test(fn) {return fn();} test(function(){return 'call';});`; + const expected = `function test(fn) {\n return fn();\n}\n'call';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Function toString (anti-debugging protection)', () => { + const code = `function test() {return 'test';} test.toString();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Simple wrapper function (handled by safe modules)', () => { + const code = `function wrapper() {return 'literal';} wrapper();`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-7: Call with ThisExpression argument', () => { + const code = `function test(ctx) {return ctx;} test(this);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-8: Member expression call on empty array', () => { + const code = `const arr = []; const fn = a => a.length; fn(arr);`; + const expected = code; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); }); describe('UNSAFE: resolveMinimalAlphabet', async () => { const targetModule = (await import('../src/modules/unsafe/resolveMinimalAlphabet.js')).default; - it('TP-1', () => { + it('TP-1: Unary expressions on literals and arrays', () => { const code = `+true; -true; +false; -false; +[]; ~true; ~false; ~[]; +[3]; +['']; -[4]; ![]; +[[]];`; const expected = `1;\n-'1';\n0;\n-0;\n0;\n-'2';\n-'1';\n-'1';\n3;\n0;\n-'4';\nfalse;\n0;`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); - it('TP-2', () => { + it('TP-2: Binary expressions with arrays (JSFuck patterns)', () => { const code = `[] + []; [+[]]; (![]+[]); +[!+[]+!+[]];`; const expected = `'';\n[0];\n'false';\n2;`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); - it('TN-1', () => { + it('TP-3: Unary expressions on null literal', () => { + const code = `+null; -null; !null;`; + const expected = `0;\n-0;\ntrue;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Binary expressions with string concatenation', () => { + const code = `true + []; false + ''; null + 'test';`; + const expected = `'true';\n'false';\n'nulltest';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Expressions containing ThisExpression should be skipped', () => { const code = `-false; -[]; +{}; -{}; -'a'; ~{}; -['']; +[1, 2]; +this; +[this];`; const expected = `-0;\n-0;\n+{};\n-{};\nNaN;\n~{};\n-0;\nNaN;\n+this;\n+[this];`; const result = applyModuleToCode(code, targetModule); assert.deepStrictEqual(result, expected); }); -}); \ No newline at end of file + it('TN-2: Binary expressions with non-plus operators', () => { + const code = `true - false; true * false; true / false;`; + const expected = `true - false; true * false; true / false;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Unary expressions on numeric literals', () => { + const code = `+42; -42; ~42; !42;`; + const expected = `+42; -42; ~42; !42;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Unary expressions on undefined identifier', () => { + const code = `+undefined; -undefined;`; + const expected = `+undefined; -undefined;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); +}); + +describe('resolveMemberExpressionsLocalReferences (resolveMemberExpressionsLocalReferences.js)', async () => { + const targetModule = (await import('../src/modules/unsafe/resolveMemberExpressionsLocalReferences.js')).default; + it('TP-1: Array index access with literal', () => { + const code = `const a = [1, 2, 3]; const b = a[1];`; + const expected = `const a = [\n 1,\n 2,\n 3\n];\nconst b = 2;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-2: Object property access with dot notation', () => { + const code = `const obj = {hello: 'world'}; const val = obj.hello;`; + const expected = `const obj = { hello: 'world' };\nconst val = 'world';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: Object property access with string literal', () => { + const code = `const obj = {hello: 'world'}; const val = obj['hello'];`; + const expected = `const obj = { hello: 'world' };\nconst val = 'world';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Constructor property access', () => { + const code = `const obj = {constructor: 'test'}; const val = obj.constructor;`; + const expected = `const obj = { constructor: 'test' };\nconst val = 'test';`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Object computed property with identifier variable', () => { + const code = `const obj = {key: 'value'}; const prop = 'key'; const val = obj[prop];`; + const expected = `const obj = {key: 'value'}; const prop = 'key'; const val = obj[prop];`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-2: Array index with identifier variable', () => { + const code = `const a = [10, 20, 30]; const idx = 0; const b = a[idx];`; + const expected = `const a = [10, 20, 30]; const idx = 0; const b = a[idx];`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Function parameter reference', () => { + const code = `function test(param) { const arr = [1, 2, 3]; return arr[param]; }`; + const expected = `function test(param) { const arr = [1, 2, 3]; return arr[param]; }`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Member expression on left side of assignment', () => { + const code = `const obj = {prop: 1}; obj.prop = 2;`; + const expected = `const obj = {prop: 1}; obj.prop = 2;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Member expression used as call expression callee', () => { + const code = `const obj = {fn: function() { return 42; }}; obj.fn();`; + const expected = `const obj = {fn: function() { return 42; }}; obj.fn();`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Property with skipped name (length)', () => { + const code = `const arr = [1, 2, 3]; const val = arr.length;`; + const expected = `const arr = [1, 2, 3]; const val = arr.length;`; + const result = applyModuleToCode(code, targetModule); + assert.deepStrictEqual(result, expected); + }); +}); + diff --git a/tests/modules.utils.test.js b/tests/modules.utils.test.js index aa65673..4f2e50f 100644 --- a/tests/modules.utils.test.js +++ b/tests/modules.utils.test.js @@ -1,26 +1,136 @@ /* eslint-disable no-unused-vars */ import assert from 'node:assert'; -import {describe, it, beforeEach} from 'node:test'; -import {badValue} from '../src/modules/config.js'; import {generateFlatAST} from 'flast'; +import {describe, it, beforeEach} from 'node:test'; +import {BAD_VALUE} from '../src/modules/config.js'; describe('UTILS: evalInVm', async () => { const targetModule = (await import('../src/modules/utils/evalInVm.js')).evalInVm; - it('TP-1', () => { + it('TP-1: String concatenation', () => { const code = `'hello ' + 'there';`; const expected = {type: 'Literal', value: 'hello there', raw: 'hello there'}; const result = targetModule(code); assert.deepStrictEqual(result, expected); }); - it('TN-1', () => { + it('TP-2: Arithmetic operations', () => { + const code = `5 + 3 * 2`; + const expected = {type: 'Literal', value: 11, raw: '11'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-3: Array literal evaluation', () => { + const code = `[1, 2, 3]`; + const result = targetModule(code); + assert.strictEqual(result.type, 'ArrayExpression'); + assert.strictEqual(result.elements.length, 3); + }); + it('TP-4: Object literal evaluation', () => { + const code = `({a: 1, b: 2})`; + const result = targetModule(code); + assert.strictEqual(result.type, 'ObjectExpression'); + assert.strictEqual(result.properties.length, 2); + }); + it('TP-5: Boolean operations', () => { + const code = `true && false`; + const expected = {type: 'Literal', value: false, raw: 'false'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-6: Array length property', () => { + const code = `[1, 2, 3].length`; + const expected = {type: 'Literal', value: 3, raw: '3'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-7: String method calls', () => { + const code = `'test'.toUpperCase()`; + const expected = {type: 'Literal', value: 'TEST', raw: 'TEST'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-8: Caching behavior - identical code returns same result', () => { + const code = `2 + 2`; + const result1 = targetModule(code); + const result2 = targetModule(code); + assert.deepStrictEqual(result1, result2); + }); + it('TP-9: Sandbox reuse', async () => { + const {Sandbox} = await import('../src/modules/utils/sandbox.js'); + const sandbox = new Sandbox(); + const code = `5 * 5`; + const expected = {type: 'Literal', value: 25, raw: '25'}; + const result = targetModule(code, sandbox); + assert.deepStrictEqual(result, expected); + }); + it('TP-10: Multi-statement code with valid operations', () => { + const code = `var x = 5; x * 2`; + const expected = {type: 'Literal', value: 10, raw: '10'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-11: Trap neutralization - infinite while loop', () => { + const code = `while(true) {}; 'safe'`; + const expected = {type: 'Literal', value: 'safe', raw: 'safe'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-12: Complex expression evaluation', () => { + const code = `Math.pow(2, 3) + 2`; + const expected = {type: 'Literal', value: 10, raw: '10'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-14: Debugger statement (neutralized and evaluates successfully)', () => { + const code = `debugger; 42`; + const expected = {type: 'Literal', value: 42, raw: '42'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TP-13: Split debugger string neutralization works', () => { + const code = `'debu' + 'gger'; 123`; + const expected = {type: 'Literal', value: 123, raw: '123'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Non-deterministic function calls', () => { const code = `Math.random();`; - const expected = badValue; + const expected = BAD_VALUE; const result = targetModule(code); assert.deepStrictEqual(result, expected); }); - it('TN-2', () => { + it('TN-2: Console object evaluation', () => { const code = `function a() {return console;} a();`; - const expected = badValue; + const expected = BAD_VALUE; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Promise objects (bad type)', () => { + const code = `Promise.resolve(42)`; + const expected = BAD_VALUE; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Invalid syntax', () => { + const code = `invalid syntax {{{`; + const expected = BAD_VALUE; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Function calls with side effects', () => { + const code = `alert('test')`; + const expected = BAD_VALUE; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Variable references (undefined)', () => { + const code = `unknownVariable`; + const expected = BAD_VALUE; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('TN-7: Complex expressions with timing dependencies', () => { + const code = `Date.now()`; + const expected = BAD_VALUE; const result = targetModule(code); assert.deepStrictEqual(result, expected); }); @@ -51,6 +161,60 @@ describe('UTILS: areReferencesModified', async () => { const result = targetModule(ast, [ast.find(n => n.src === `a.c = a.b`)?.right]); assert.ok(result); }); + it('TP-5: Delete operation on object property', () => { + const code = `const a = {b: 1, c: 2}; delete a.b;`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = {b: 1, c: 2}').id.references); + assert.ok(result); + }); + it('TP-6: Delete operation on array element', () => { + const code = `const a = [1, 2, 3]; delete a[1];`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = [1, 2, 3]').id.references); + assert.ok(result); + }); + it('TP-7: For-in loop variable modification', () => { + const code = `const a = {x: 1}; for (a.prop in {y: 2}) {}`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = {x: 1}').id.references); + assert.ok(result); + }); + it('TP-8: For-of loop variable modification', () => { + const code = `let a = []; for (a.item of [1, 2, 3]) {}`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = []').id.references); + assert.ok(result); + }); + it('TP-9: Array mutating method call', () => { + const code = `const a = [1, 2]; a.push(3);`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = [1, 2]').id.references); + assert.ok(result); + }); + it('TP-10: Array sort method call', () => { + const code = `const a = [3, 1, 2]; a.sort();`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = [3, 1, 2]').id.references); + assert.ok(result); + }); + it('TP-11: Object destructuring assignment', () => { + const code = `let a = {x: 1}; ({x: a.y} = {x: 2});`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = {x: 1}').id.references); + assert.ok(result); + }); + it('TP-12: Array destructuring assignment', () => { + const code = `let a = [1]; [a.item] = [2];`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = [1]').id.references); + assert.ok(result); + }); + it('TP-13: Update expression on member expression', () => { + const code = `const a = {count: 0}; a.count++;`; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = {count: 0}').id.references); + assert.ok(result); + }); it('TN-1: No assignment', () => { const code = `const a = 1; let b = 2 + a, c = a + 3;`; const expected = false; @@ -58,6 +222,41 @@ describe('UTILS: areReferencesModified', async () => { const result = targetModule(ast, ast.find(n => n.src === 'a = 1').id.references); assert.deepStrictEqual(result, expected); }); + it('TN-2: Read-only property access', () => { + const code = `const a = {b: 1}; const c = a.b;`; + const expected = false; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = {b: 1}').id.references); + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Read-only array access', () => { + const code = `const a = [1, 2, 3]; const b = a[1];`; + const expected = false; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = [1, 2, 3]').id.references); + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Non-mutating method calls', () => { + const code = `const a = [1, 2, 3]; const b = a.slice(1);`; + const expected = false; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = [1, 2, 3]').id.references); + assert.deepStrictEqual(result, expected); + }); + it('TN-5: For-in loop with different variable', () => { + const code = `const a = {x: 1}; for (let key in a) {}`; + const expected = false; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = {x: 1}').id.references); + assert.deepStrictEqual(result, expected); + }); + it('TN-6: Safe destructuring (different variable)', () => { + const code = `const a = {x: 1}; const {x} = a;`; + const expected = false; + const ast = generateFlatAST(code); + const result = targetModule(ast, ast.find(n => n.src === 'a = {x: 1}').id.references); + assert.deepStrictEqual(result, expected); + }); }); describe('UTILS: createNewNode', async () => { const targetModule = (await import('../src/modules/utils/createNewNode.js')).createNewNode; @@ -150,9 +349,9 @@ describe('UTILS: createNewNode', async () => { const result = targetModule(code); assert.deepEqual(result, expected); }); - it('Object: populated with BadValue', () => { + it('Object: populated with BAD_VALUE', () => { const code = {a() {}}; - const expected = badValue; + const expected = BAD_VALUE; const result = targetModule(code); assert.deepEqual(result, expected); }); @@ -176,8 +375,179 @@ describe('UTILS: createNewNode', async () => { const result = targetModule(code); assert.deepStrictEqual(result, expected); }); + it('BigInt', () => { + const code = 123n; + const expected = {type: 'Literal', value: 123n, raw: '123n', bigint: '123'}; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('Symbol with description', () => { + const code = Symbol('test'); + const expected = { + type: 'CallExpression', + callee: {type: 'Identifier', name: 'Symbol'}, + arguments: [{type: 'Literal', value: 'test', raw: 'test'}] + }; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); + it('Symbol without description', () => { + const code = Symbol(); + const expected = { + type: 'CallExpression', + callee: {type: 'Identifier', name: 'Symbol'}, + arguments: [] + }; + const result = targetModule(code); + assert.deepStrictEqual(result, expected); + }); }); +describe('UTILS: doesDescendantMatchCondition', async () => { + const targetModule = (await import('../src/modules/utils/doesDescendantMatchCondition.js')).doesDescendantMatchCondition; + + it('TP-1: Find descendant by type (boolean return)', () => { + const code = `function test() { return this.prop; }`; + const ast = generateFlatAST(code); + const functionNode = ast.find(n => n.type === 'FunctionDeclaration'); + const result = targetModule(functionNode, n => n.type === 'ThisExpression'); + assert.ok(result); + }); + it('TP-2: Find descendant by type (node return)', () => { + const code = `function test() { return this.prop; }`; + const ast = generateFlatAST(code); + const functionNode = ast.find(n => n.type === 'FunctionDeclaration'); + const result = targetModule(functionNode, n => n.type === 'ThisExpression', true); + assert.strictEqual(result.type, 'ThisExpression'); + }); + it('TP-3: Find marked descendant (simulating isMarked property)', () => { + const code = `const a = 1 + 2;`; + const ast = generateFlatAST(code); + const varDecl = ast.find(n => n.type === 'VariableDeclaration'); + // Simulate marking a descendant node + const binaryExpr = ast.find(n => n.type === 'BinaryExpression'); + binaryExpr.isMarked = true; + const result = targetModule(varDecl, n => n.isMarked); + assert.ok(result); + }); + it('TP-4: Multiple nested descendants', () => { + const code = `function outer() { function inner() { return this.value; } }`; + const ast = generateFlatAST(code); + const outerFunc = ast.find(n => n.type === 'FunctionDeclaration' && n.id.name === 'outer'); + const result = targetModule(outerFunc, n => n.type === 'ThisExpression'); + assert.ok(result); + }); + it('TP-5: Find specific assignment pattern', () => { + const code = `const obj = {prop: value}; obj.prop = newValue;`; + const ast = generateFlatAST(code); + const program = ast[0]; + const result = targetModule(program, n => + n.type === 'AssignmentExpression' && + n.left?.property?.name === 'prop' + ); + assert.ok(result); + }); + it('TN-1: No matching descendants', () => { + const code = `const a = 1 + 2;`; + const ast = generateFlatAST(code); + const varDecl = ast.find(n => n.type === 'VariableDeclaration'); + const result = targetModule(varDecl, n => n.type === 'ThisExpression'); + assert.strictEqual(result, false); + }); + it('TN-2: Node itself matches condition', () => { + const code = `const a = 1;`; + const ast = generateFlatAST(code); + const literal = ast.find(n => n.type === 'Literal'); + const result = targetModule(literal, n => n.type === 'Literal'); + assert.ok(result); // Should find the node itself + }); + it('TN-3: Null/undefined input handling', () => { + const result1 = targetModule(null, n => n.type === 'Literal'); + const result2 = targetModule(undefined, n => n.type === 'Literal'); + const result3 = targetModule({}, null); + const result4 = targetModule({}, undefined); + assert.strictEqual(result1, false); + assert.strictEqual(result2, false); + assert.strictEqual(result3, false); + assert.strictEqual(result4, false); + }); + it('TN-4: Node with no children', () => { + const code = `const name = 'test';`; + const ast = generateFlatAST(code); + const literal = ast.find(n => n.type === 'Literal'); + const result = targetModule(literal, n => n.type === 'ThisExpression'); + assert.strictEqual(result, false); + }); + it('TN-5: Empty childNodes array', () => { + const mockNode = { type: 'MockNode', childNodes: [] }; + const result = targetModule(mockNode, n => n.type === 'ThisExpression'); + assert.strictEqual(result, false); + }); +}); + +describe('UTILS: generateHash', async () => { + const targetModule = (await import('../src/modules/utils/generateHash.js')).generateHash; + + it('TP-1: Generate hash for normal string', () => { + const input = 'const a = 1;'; + const result = targetModule(input); + assert.strictEqual(typeof result, 'string'); + assert.strictEqual(result.length, 32); // MD5 produces 32-char hex + assert.match(result, /^[a-f0-9]{32}$/); // Valid hex string + }); + it('TP-2: Generate hash for AST node with .src property', () => { + const mockNode = { src: 'const b = 2;', type: 'VariableDeclaration' }; + const result = targetModule(mockNode); + assert.strictEqual(typeof result, 'string'); + assert.strictEqual(result.length, 32); + assert.match(result, /^[a-f0-9]{32}$/); + }); + it('TP-3: Generate hash for number input', () => { + const result = targetModule(42); + assert.strictEqual(typeof result, 'string'); + assert.strictEqual(result.length, 32); + assert.match(result, /^[a-f0-9]{32}$/); + }); + it('TP-4: Generate hash for boolean input', () => { + const result = targetModule(true); + assert.strictEqual(typeof result, 'string'); + assert.strictEqual(result.length, 32); + assert.match(result, /^[a-f0-9]{32}$/); + }); + it('TP-5: Generate hash for empty string', () => { + const result = targetModule(''); + assert.strictEqual(typeof result, 'string'); + assert.strictEqual(result.length, 32); + assert.match(result, /^[a-f0-9]{32}$/); + }); + it('TP-6: Consistent hashes for identical inputs', () => { + const input = 'function test() {}'; + const hash1 = targetModule(input); + const hash2 = targetModule(input); + assert.strictEqual(hash1, hash2); + }); + it('TP-7: Different hashes for different inputs', () => { + const hash1 = targetModule('const a = 1;'); + const hash2 = targetModule('const a = 2;'); + assert.notStrictEqual(hash1, hash2); + }); + it('TN-1: Handle null input gracefully', () => { + const result = targetModule(null); + assert.strictEqual(result, 'null-undefined-hash'); + }); + it('TN-2: Handle undefined input gracefully', () => { + const result = targetModule(undefined); + assert.strictEqual(result, 'null-undefined-hash'); + }); + it('TN-3: Handle object without .src property', () => { + const mockObj = { type: 'SomeNode', value: 42 }; + const result = targetModule(mockObj); + assert.strictEqual(typeof result, 'string'); + // Should convert object to string representation + assert.match(result, /^[a-f0-9]{32}$/); + }); +}); + describe('UTILS: createOrderedSrc', async () => { const targetModule = (await import('../src/modules/utils/createOrderedSrc.js')).createOrderedSrc; it('TP-1: Re-order nodes', () => { @@ -257,125 +627,318 @@ describe('UTILS: createOrderedSrc', async () => { const result = targetModule(targetNodes.map(n => ast[n]), true); assert.deepStrictEqual(result, expected); }); -}); -describe('UTILS: doesBinaryExpressionContainOnlyLiterals', async () => { - const targetModule = (await import('../src/modules/utils/doesBinaryExpressionContainOnlyLiterals.js')).doesBinaryExpressionContainOnlyLiterals; - it('TP-1: Literal', () => { - const code = `'a';`; + it('TP-8: Variable declarations with semicolons', () => { + const code = 'const a = 1; let b = 2;'; + const expected = `const a = 1;\nlet b = 2;\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'Literal')); - assert.ok(result); + const targetNodes = [ + 2, // a = 1 + 5, // b = 2 + ]; + const result = targetModule(targetNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); - it('TP-2: Unary literal', () => { - const code = `-'a';`; + it('TP-9: Assignment expressions with semicolons', () => { + const code = 'let a; a = 1; a = 2;'; + const expected = `a = 1;\na = 2;\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'UnaryExpression')); - assert.ok(result); + const targetNodes = [ + 8, // a = 2 (ExpressionStatement) + 4, // a = 1 (ExpressionStatement) + ]; + const result = targetModule(targetNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); - it('TP-3: Binary expression', () => { - const code = `1 + 2`; + it('TP-10: Duplicate node elimination', () => { + const code = 'a(); b();'; + const expected = `a();\nb();\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'BinaryExpression')); - assert.ok(result); + const duplicatedNodes = [ + 2, // a() + 5, // b() + 2, // a() again (duplicate) + ]; + const result = targetModule(duplicatedNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); - it('TP-4: Nesting binary expressions', () => { - const code = `1 + 2 + 3 + 4`; + it('TP-11: IIFE dependency ordering with arguments', () => { + const code = 'const x = 1; (function(a){return a;})(x);'; + const expected = `const x = 1;\n(function(a){return a;})(x);\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'BinaryExpression')); - assert.ok(result); + const targetNodes = [ + 5, // (function(a){return a;})(x) + 2, // x = 1 + ]; + const result = targetModule(targetNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); - it('TN-1: Identifier', () => { - const code = `a`; - const expected = false; + it('TN-1: Empty node array', () => { + const expected = ''; + const result = targetModule([]); + assert.deepStrictEqual(result, expected); + }); + it('TN-2: Single node without reordering', () => { + const code = 'a();'; + const expected = `a();\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'Identifier')); - assert.strictEqual(result, expected); + const targetNodes = [2]; // a() + const result = targetModule(targetNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); - it('TN-2: Unary Identifier', () => { - const code = `!a`; - const expected = false; + it('TN-3: Non-CallExpression and non-FunctionExpression nodes', () => { + const code = 'const a = 1; const b = "hello";'; + const expected = `const a = 1;\nconst b = "hello";\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'UnaryExpression')); - assert.strictEqual(result, expected); + const targetNodes = [ + 5, // b = "hello" + 2, // a = 1 + ]; + const result = targetModule(targetNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); - it('TN-3: Binary expression', () => { - const code = `1 + b`; - const expected = false; + it('TN-4: CallExpression without ExpressionStatement parent', () => { + const code = 'const result = a();'; + const expected = `const result = a();\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'BinaryExpression')); - assert.strictEqual(result, expected); + const targetNodes = [2]; // result = a() + const result = targetModule(targetNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); - it('TN-3: Nesting binary expression', () => { - const code = `1 + b + 3 + 4`; - const expected = false; + it('TN-5: Named function expressions (no renaming needed)', () => { + const code = 'const f = function named() {};'; + const expected = `const f = function named() {};\n`; const ast = generateFlatAST(code); - const result = targetModule(ast.find(n => n.type === 'BinaryExpression')); - assert.strictEqual(result, expected); + const targetNodes = [2]; // f = function named() {} + const result = targetModule(targetNodes.map(n => ast[n])); + assert.deepStrictEqual(result, expected); }); }); + describe('UTILS: getCache', async () => { const getCache = (await import('../src/modules/utils/getCache.js')).getCache; - it('TP-1: Retain values', () => { - const key1 = 'hash1'; - const key2 = 'hash2'; - const cache = getCache(key1); - assert.deepStrictEqual(cache, {}); - cache['key1'] = 'value1'; - const expectedC1 = {key1: 'value1'}; - assert.deepStrictEqual(cache, expectedC1); - const cache2 = getCache(key1); - assert.deepStrictEqual(cache2, expectedC1); - const cache3 = getCache(key2); - assert.deepStrictEqual(cache3, {}); - }); - it('TP-2: Flush cache', () => { - const key = 'flush1'; - let cache = getCache(key); + + // Reset cache before each test to ensure isolation + beforeEach(() => { + getCache.flush(); + }); + + it('TP-1: Retain values for same script hash', () => { + const hash1 = 'script-hash-1'; + const cache = getCache(hash1); assert.deepStrictEqual(cache, {}); - cache['k'] = 'v'; - const expectedC1 = {k: 'v'}; - assert.deepStrictEqual(cache, expectedC1); + + cache['eval-result'] = 'cached-value'; + const cache2 = getCache(hash1); // Same hash should return same cache + assert.deepStrictEqual(cache2, {['eval-result']: 'cached-value'}); + assert.strictEqual(cache, cache2); // Should be same object reference + }); + it('TP-2: Cache invalidation on script hash change', () => { + const hash1 = 'script-hash-1'; + const hash2 = 'script-hash-2'; + + const cache1 = getCache(hash1); + cache1['data'] = 'first-script'; + + // Different hash should get fresh cache + const cache2 = getCache(hash2); + assert.deepStrictEqual(cache2, {}); + assert.notStrictEqual(cache1, cache2); // Different object references + + // Original cache data should be lost + const cache1Again = getCache(hash1); + assert.deepStrictEqual(cache1Again, {}); // Fresh cache for hash1 + }); + it('TP-3: Manual flush preserves script hash', () => { + const hash = 'preserve-hash'; + const cache = getCache(hash); + cache['before-flush'] = 'data'; + getCache.flush(); - cache = getCache(key); + + // Should get empty cache but same hash should not trigger invalidation + const cacheAfterFlush = getCache(hash); + assert.deepStrictEqual(cacheAfterFlush, {}); + }); + it('TP-4: Multiple script hash switches', () => { + const hashes = ['hash-a', 'hash-b', 'hash-c']; + + // Fill cache for each hash + for (let i = 0; i < hashes.length; i++) { + const cache = getCache(hashes[i]); + cache[`data-${i}`] = `value-${i}`; + } + + // Only the last hash should have preserved cache + const finalCache = getCache('hash-c'); + assert.deepStrictEqual(finalCache, {'data-2': 'value-2'}); + + // Previous hashes should get fresh caches + for (const hash of ['hash-a', 'hash-b']) { + const cache = getCache(hash); + assert.deepStrictEqual(cache, {}); + } + }); + it('TP-5: Cache object mutation persistence', () => { + const hash = 'mutation-test'; + const cache1 = getCache(hash); + const cache2 = getCache(hash); + + // Both should reference the same object + cache1['shared'] = 'value'; + assert.strictEqual(cache2['shared'], 'value'); + + cache2['another'] = 'different'; + assert.strictEqual(cache1['another'], 'different'); + }); + it('TN-1: Handle null script hash gracefully', () => { + const cache = getCache(null); assert.deepStrictEqual(cache, {}); + cache['null-test'] = 'handled'; + + // Should maintain cache for 'no-hash' key + const cache2 = getCache(null); + assert.deepStrictEqual(cache2, {'null-test': 'handled'}); + }); + it('TN-2: Handle undefined script hash gracefully', () => { + const cache = getCache(undefined); + assert.deepStrictEqual(cache, {}); + cache['undefined-test'] = 'handled'; + + // Should maintain cache for 'no-hash' key + const cache2 = getCache(undefined); + assert.deepStrictEqual(cache2, {'undefined-test': 'handled'}); + }); + it('TN-3: Null and undefined should share same fallback cache', () => { + const cache1 = getCache(null); + const cache2 = getCache(undefined); + + cache1['shared-fallback'] = 'test'; + assert.strictEqual(cache2['shared-fallback'], 'test'); + assert.strictEqual(cache1, cache2); // Same object reference + }); + it('TN-4: Empty string script hash', () => { + const cache = getCache(''); + assert.deepStrictEqual(cache, {}); + cache['empty-string'] = 'value'; + + const cache2 = getCache(''); + assert.deepStrictEqual(cache2, {'empty-string': 'value'}); + }); + it('TN-5: Flush after multiple hash changes', () => { + const hash1 = 'multi-1'; + const hash2 = 'multi-2'; + + getCache(hash1)['data1'] = 'value1'; + getCache(hash2)['data2'] = 'value2'; // This invalidates hash1's cache + + getCache.flush(); // Should clear current (hash2) cache + + // Both should now be empty + assert.deepStrictEqual(getCache(hash1), {}); + assert.deepStrictEqual(getCache(hash2), {}); }); }); describe('UTILS: getCalleeName', async () => { const targetModule = (await import('../src/modules/utils/getCalleeName.js')).getCalleeName; - it('TP-1: Simple call expression', () => { - const code = `a();`; - const expected = 'a'; + it('TP-1: Simple identifier callee', () => { + const code = `func();`; const ast = generateFlatAST(code); const result = targetModule(ast.find(n => n.type === 'CallExpression')); - assert.deepStrictEqual(result, expected); + assert.strictEqual(result, 'func'); }); - it('TP-2: Member expression callee', () => { - const code = `a.b();`; - const expected = 'a'; + it('TP-2: Member expression callee (single level)', () => { + const code = `obj.method();`; const ast = generateFlatAST(code); const result = targetModule(ast.find(n => n.type === 'CallExpression')); - assert.deepStrictEqual(result, expected); + assert.strictEqual(result, 'obj'); }); it('TP-3: Nested member expression callee', () => { - const code = `a.b.c();`; - const expected = 'a'; + const code = `obj.nested.method();`; const ast = generateFlatAST(code); const result = targetModule(ast.find(n => n.type === 'CallExpression')); - assert.deepStrictEqual(result, expected); + assert.strictEqual(result, 'obj'); }); - it('TP-4: Literal callee (string)', () => { - const code = `'a'.split('');`; - const expected = 'a'; + it('TP-4: Deeply nested member expression', () => { + const code = `obj.a.b.c.d();`; const ast = generateFlatAST(code); const result = targetModule(ast.find(n => n.type === 'CallExpression')); - assert.deepStrictEqual(result, expected); + assert.strictEqual(result, 'obj'); }); - it('TP-5: Literal callee (number)', () => { + it('TP-5: Avoid counting collision between function and literal calls', () => { + // This test demonstrates the collision avoidance + const code = `function t1() { return 1; } t1(); 't1'.toString();`; + const ast = generateFlatAST(code); + const calls = ast.filter(n => n.type === 'CallExpression'); + + const functionCall = calls[0]; // t1() + const literalMethodCall = calls[1]; // 't1'.toString() + + assert.strictEqual(targetModule(functionCall), 't1'); // Function call counted + assert.strictEqual(targetModule(literalMethodCall), ''); // Literal method not counted + }); + it('TN-1: Literal string method calls return empty', () => { + const code = `'test'.split('');`; + const ast = generateFlatAST(code); + const result = targetModule(ast.find(n => n.type === 'CallExpression')); + assert.strictEqual(result, ''); // Don't count literal methods + }); + it('TN-2: Literal number method calls return empty', () => { const code = `1..toString();`; - const expected = 1; const ast = generateFlatAST(code); const result = targetModule(ast.find(n => n.type === 'CallExpression')); - assert.deepStrictEqual(result, expected); + assert.strictEqual(result, ''); // Don't count literal methods + }); + it('TN-3: ThisExpression method calls return empty', () => { + const code = `this.method();`; + const ast = generateFlatAST(code); + const result = targetModule(ast.find(n => n.type === 'CallExpression')); + assert.strictEqual(result, ''); // Don't count 'this' methods + }); + it('TN-4: Boolean literal method calls return empty', () => { + const code = `true.valueOf();`; + const ast = generateFlatAST(code); + const result = targetModule(ast.find(n => n.type === 'CallExpression')); + assert.strictEqual(result, ''); // Don't count literal methods + }); + it('TN-5: Logical expression callee returns empty', () => { + const code = `(func || fallback)();`; + const ast = generateFlatAST(code); + const result = targetModule(ast.find(n => n.type === 'CallExpression')); + assert.strictEqual(result, ''); // Don't count complex expressions + }); + it('TN-6: CallExpression as base object returns empty', () => { + const code = `func()[0]();`; + const ast = generateFlatAST(code); + const outerCall = ast.filter(n => n.type === 'CallExpression')[0]; // First = outer call func()[0]() + const result = targetModule(outerCall); + assert.strictEqual(result, ''); // Don't count chained calls + }); + it('TN-7: Null/undefined input handling', () => { + const result1 = targetModule(null); + const result2 = targetModule(undefined); + const result3 = targetModule({}); + const result4 = targetModule({callee: null}); + assert.strictEqual(result1, ''); + assert.strictEqual(result2, ''); + assert.strictEqual(result3, ''); + assert.strictEqual(result4, ''); + }); + it('TN-8: Computed member expression with identifier', () => { + const code = `obj[key]();`; + const ast = generateFlatAST(code); + const result = targetModule(ast.find(n => n.type === 'CallExpression')); + assert.strictEqual(result, 'obj'); // Variable method call, return base variable + }); + it('TN-9: Complex callee without name returns empty', () => { + // Create mock node with no name/value + const mockCall = { + callee: { + type: 'SomeComplexExpression', + // No name, value, or object properties + } + }; + const result = targetModule(mockCall); + assert.strictEqual(result, ''); // Complex expressions return empty }); }); describe('UTILS: getDeclarationWithContext', async () => { @@ -426,6 +989,24 @@ describe('UTILS: getDeclarationWithContext', async () => { const expected = [ast[2]]; assert.deepStrictEqual(result, expected); }); + it(`TP-7: Node without scriptHash should still work` , () => { + const code = `function test() { return 42; } test();`; + const ast = generateFlatAST(code); + const callNode = ast.find(n => n.type === 'CallExpression'); + delete callNode.scriptHash; // Remove scriptHash property + const result = targetModule(callNode); + const expected = [ast.find(n => n.type === 'CallExpression'), ast.find(n => n.type === 'FunctionDeclaration')]; + assert.deepStrictEqual(result, expected); + }); + it(`TP-8: Node without nodeId should still work` , () => { + const code = `const x = 1; console.log(x);`; + const ast = generateFlatAST(code); + const callNode = ast.find(n => n.type === 'CallExpression'); + delete callNode.nodeId; // Remove nodeId property + const result = targetModule(callNode); + assert.ok(Array.isArray(result)); + assert.ok(result.length > 0); + }); it(`TN-1: Prevent collection before changes are applied` , () => { const code = `function a() {}\na = {};\na.b = 2;\na = a.b;\na(a.b);`; const ast = generateFlatAST(code); @@ -434,6 +1015,16 @@ describe('UTILS: getDeclarationWithContext', async () => { const expected = []; assert.deepStrictEqual(result, expected); }); + it(`TN-2: Handle null input gracefully` , () => { + const result = targetModule(null); + const expected = []; + assert.deepStrictEqual(result, expected); + }); + it(`TN-3: Handle undefined input gracefully` , () => { + const result = targetModule(undefined); + const expected = []; + assert.deepStrictEqual(result, expected); + }); }); describe('UTILS: getDescendants', async () => { const targetModule = (await import('../src/modules/utils/getDescendants.js')).getDescendants; @@ -453,7 +1044,52 @@ describe('UTILS: getDescendants', async () => { const result = targetModule(targetNode); assert.deepStrictEqual(result, expected); }); - it('TN-1: No descendants', () => { + it('TP-3: Nested function with complex descendants', () => { + const code = `function test(a) { return a + (b * c); }`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'FunctionDeclaration'); + const result = targetModule(targetNode); + // Should include all nested nodes: parameters, body, expressions, identifiers + assert.ok(Array.isArray(result)); + assert.ok(result.length > 8); // Should have many nested descendants + assert.ok(result.some(n => n.type === 'Identifier' && n.name === 'a')); + assert.ok(result.some(n => n.type === 'BinaryExpression')); + }); + it('TP-4: Object expression with properties', () => { + const code = `const obj = { prop1: value1, prop2: value2 };`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'ObjectExpression'); + const result = targetModule(targetNode); + assert.ok(Array.isArray(result)); + assert.ok(result.length > 4); // Properties and their values + assert.ok(result.some(n => n.type === 'Property')); + assert.ok(result.some(n => n.type === 'Identifier' && n.name === 'value1')); + }); + it('TP-5: Array expression with elements', () => { + const code = `const arr = [a, b + c, func()];`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'ArrayExpression'); + const result = targetModule(targetNode); + assert.ok(Array.isArray(result)); + assert.ok(result.length > 5); // Elements and their nested parts + assert.ok(result.some(n => n.type === 'Identifier' && n.name === 'a')); + assert.ok(result.some(n => n.type === 'BinaryExpression')); + assert.ok(result.some(n => n.type === 'CallExpression')); + }); + it('TP-6: Caching behavior - same node returns cached result', () => { + const code = `a + b;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'BinaryExpression'); + + const result1 = targetModule(targetNode); + const result2 = targetModule(targetNode); + + // Should return same cached array reference + assert.strictEqual(result1, result2); + assert.ok(targetNode.descendants); // Cache property should exist + assert.strictEqual(targetNode.descendants, result1); + }); + it('TN-1: No descendants for leaf nodes', () => { const code = `a; b; c;`; const ast = generateFlatAST(code); const targetNode = ast.find(n => n.type === 'Identifier'); @@ -461,10 +1097,32 @@ describe('UTILS: getDescendants', async () => { const result = targetModule(targetNode); assert.deepStrictEqual(result, expected); }); + it('TN-2: Null input returns empty array', () => { + const result = targetModule(null); + const expected = []; + assert.deepStrictEqual(result, expected); + }); + it('TN-3: Undefined input returns empty array', () => { + const result = targetModule(undefined); + const expected = []; + assert.deepStrictEqual(result, expected); + }); + it('TN-4: Node with no childNodes property', () => { + const mockNode = { type: 'MockNode' }; // No childNodes + const result = targetModule(mockNode); + const expected = []; + assert.deepStrictEqual(result, expected); + }); + it('TN-5: Node with empty childNodes array', () => { + const mockNode = { type: 'MockNode', childNodes: [] }; + const result = targetModule(mockNode); + const expected = []; + assert.deepStrictEqual(result, expected); + }); }); describe('UTILS: getMainDeclaredObjectOfMemberExpression', async () => { const targetModule = (await import('../src/modules/utils/getMainDeclaredObjectOfMemberExpression.js')).getMainDeclaredObjectOfMemberExpression; - it('TP-1', () => { + it('TP-1: Simple member expression with declared object', () => { const code = `a.b;`; const ast = generateFlatAST(code); const targetNode = ast.find(n => n.type === 'MemberExpression'); @@ -472,7 +1130,7 @@ describe('UTILS: getMainDeclaredObjectOfMemberExpression', async () => { const result = targetModule(targetNode); assert.deepStrictEqual(result, expected); }); - it('TP-2: Nested member expression', () => { + it('TP-2: Nested member expression finds root identifier', () => { const code = `a.b.c.d;`; const ast = generateFlatAST(code); const targetNode = ast.find(n => n.type === 'MemberExpression'); @@ -480,22 +1138,260 @@ describe('UTILS: getMainDeclaredObjectOfMemberExpression', async () => { const result = targetModule(targetNode); assert.deepStrictEqual(result, expected); }); + it('TP-3: Computed member expression with declared base', () => { + const code = `obj[key].prop;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'MemberExpression' && n.property?.name === 'prop'); + const expected = ast.find(n => n.type === 'Identifier' && n.name === 'obj'); + const result = targetModule(targetNode); + assert.deepStrictEqual(result, expected); + }); + it('TP-4: Deep nesting finds correct root', () => { + const code = `root.level1.level2.level3.level4;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'MemberExpression' && n.property?.name === 'level4'); + const expected = ast.find(n => n.type === 'Identifier' && n.name === 'root'); + const result = targetModule(targetNode); + assert.deepStrictEqual(result, expected); + }); + it('TN-1: Non-MemberExpression input returns the input unchanged', () => { + const code = `const x = 42;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'Identifier' && n.name === 'x'); + const result = targetModule(targetNode); + assert.deepStrictEqual(result, targetNode); // Original behavior: return input unchanged + }); + it('TN-2: Null input returns null', () => { + const result = targetModule(null); + assert.strictEqual(result, null); + }); + it('TN-3: Undefined input returns null', () => { + const result = targetModule(undefined); + assert.strictEqual(result, null); + }); + it('TN-4: Member expression with no declNode still returns the object', () => { + const code = `undeclared.prop;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'MemberExpression'); + // Remove declNode from the identifier to simulate undeclared variable + const identifier = targetNode.object; + delete identifier.declNode; + const result = targetModule(targetNode); + assert.deepStrictEqual(result, identifier); // Should return the identifier even without declNode + }); + it('TN-5: Non-MemberExpression with declNode returns itself', () => { + const code = `const x = 42; x;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'Identifier' && n.name === 'x' && n.declNode); + const result = targetModule(targetNode); + assert.deepStrictEqual(result, targetNode); + }); +}); +describe('UTILS: getObjType', async () => { + const targetModule = (await import('../src/modules/utils/getObjType.js')).getObjType; + it('TP-1: Detect Array type', () => { + const result = targetModule([1, 2, 3]); + assert.strictEqual(result, 'Array'); + }); + it('TP-2: Detect Object type', () => { + const result = targetModule({key: 'value'}); + assert.strictEqual(result, 'Object'); + }); + it('TP-3: Detect String type', () => { + const result = targetModule('hello'); + assert.strictEqual(result, 'String'); + }); + it('TP-4: Detect Number type', () => { + const result = targetModule(42); + assert.strictEqual(result, 'Number'); + }); + it('TP-5: Detect Boolean type', () => { + const result = targetModule(true); + assert.strictEqual(result, 'Boolean'); + }); + it('TP-6: Detect Null type', () => { + const result = targetModule(null); + assert.strictEqual(result, 'Null'); + }); + it('TP-7: Detect Undefined type', () => { + const result = targetModule(undefined); + assert.strictEqual(result, 'Undefined'); + }); + it('TP-8: Detect Date type', () => { + const result = targetModule(new Date()); + assert.strictEqual(result, 'Date'); + }); + it('TP-9: Detect RegExp type', () => { + const result = targetModule(/pattern/); + assert.strictEqual(result, 'RegExp'); + }); + it('TP-10: Detect Function type', () => { + const result = targetModule(function() {}); + assert.strictEqual(result, 'Function'); + }); + it('TP-11: Detect Arrow Function type', () => { + const result = targetModule(() => {}); + assert.strictEqual(result, 'Function'); + }); + it('TP-12: Detect Error type', () => { + const result = targetModule(new Error('test')); + assert.strictEqual(result, 'Error'); + }); + it('TP-13: Detect empty array', () => { + const result = targetModule([]); + assert.strictEqual(result, 'Array'); + }); + it('TP-14: Detect empty object', () => { + const result = targetModule({}); + assert.strictEqual(result, 'Object'); + }); + it('TP-15: Detect Symbol type', () => { + const result = targetModule(Symbol('test')); + assert.strictEqual(result, 'Symbol'); + }); + it('TP-16: Detect BigInt type', () => { + const result = targetModule(BigInt(123)); + assert.strictEqual(result, 'BigInt'); + }); }); describe('UTILS: isNodeInRanges', async () => { const targetModule = (await import('../src/modules/utils/isNodeInRanges.js')).isNodeInRanges; - it('TP-1: In range', () => { + it('TP-1: Node completely within single range', () => { const code = `a.b;`; const ast = generateFlatAST(code); const targetNode = ast.find(n => n.src === 'b'); const result = targetModule(targetNode, [[2, 3]]); assert.ok(result); }); - it('TN-1: Not in range', () => { + it('TP-2: Node within multiple ranges (first match)', () => { + const code = `a.b;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.src === 'b'); + const result = targetModule(targetNode, [[0, 5], [10, 15]]); + assert.ok(result); + }); + it('TP-3: Node within multiple ranges (second match)', () => { + const code = `a.b;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.src === 'b'); + const result = targetModule(targetNode, [[0, 1], [2, 4]]); + assert.ok(result); + }); + it('TP-4: Node exactly matching range boundaries', () => { + const code = `a.b;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.src === 'b'); + const result = targetModule(targetNode, [[2, 3]]); + assert.ok(result); + }); + it('TP-5: Large range containing small node', () => { + const code = `function test() { return x; }`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.src === 'x'); + const result = targetModule(targetNode, [[0, 100]]); + assert.ok(result); + }); + it('TN-1: Node extends beyond range end', () => { const code = `a.b;`; const ast = generateFlatAST(code); const targetNode = ast.find(n => n.src === 'b'); const result = targetModule(targetNode, [[1, 2]]); - const expected = false; - assert.strictEqual(result, expected); + assert.strictEqual(result, false); + }); + it('TN-2: Node starts before range start', () => { + const code = `a.b;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.src === 'a'); + const result = targetModule(targetNode, [[1, 5]]); + assert.strictEqual(result, false); + }); + it('TN-3: Empty ranges array', () => { + const code = `a.b;`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.src === 'b'); + const result = targetModule(targetNode, []); + assert.strictEqual(result, false); + }); + it('TN-4: Node range partially overlapping but not contained', () => { + const code = `function test() {}`; + const ast = generateFlatAST(code); + const targetNode = ast.find(n => n.type === 'FunctionDeclaration'); + const result = targetModule(targetNode, [[5, 10]]); + assert.strictEqual(result, false); + }); +}); +describe('UTILS: Sandbox', async () => { + const {Sandbox} = await import('../src/modules/utils/sandbox.js'); + it('TP-1: Basic code execution', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('2 + 3'); + assert.ok(sandbox.isReference(result)); + assert.strictEqual(result.copySync(), 5); + }); + it('TP-2: String operations', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('"hello" + " world"'); + assert.ok(sandbox.isReference(result)); + assert.strictEqual(result.copySync(), 'hello world'); + }); + it('TP-3: Array operations', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('[1, 2, 3].length'); + assert.ok(sandbox.isReference(result)); + assert.strictEqual(result.copySync(), 3); + }); + it('TP-4: Object operations', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('({a: 1, b: 2}).a'); + assert.ok(sandbox.isReference(result)); + assert.strictEqual(result.copySync(), 1); + }); + it('TP-5: Multiple executions on same sandbox', () => { + const sandbox = new Sandbox(); + const result1 = sandbox.run('var x = 10; x'); + const result2 = sandbox.run('x * 2'); + assert.strictEqual(result1.copySync(), 10); + assert.strictEqual(result2.copySync(), 20); + }); + it('TP-6: Deterministic behavior - Math.random is deleted', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('typeof Math.random'); + assert.strictEqual(result.copySync(), 'undefined'); + }); + it('TP-7: Deterministic behavior - Date is deleted', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('typeof Date'); + assert.strictEqual(result.copySync(), 'undefined'); + }); + it('TP-8: Blocked API - WebAssembly is undefined', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('typeof WebAssembly'); + assert.strictEqual(result.copySync(), 'undefined'); + }); + it('TP-9: Blocked API - fetch is undefined', () => { + const sandbox = new Sandbox(); + const result = sandbox.run('typeof fetch'); + assert.strictEqual(result.copySync(), 'undefined'); + }); + it('TP-10: isReference method correctly identifies VM References', () => { + const sandbox = new Sandbox(); + const vmRef = sandbox.run('42'); + const nativeValue = 42; + assert.ok(sandbox.isReference(vmRef)); + assert.ok(!sandbox.isReference(nativeValue)); + }); + it('TN-1: isReference returns false for null', () => { + const sandbox = new Sandbox(); + assert.ok(!sandbox.isReference(null)); + }); + it('TN-2: isReference returns false for undefined', () => { + const sandbox = new Sandbox(); + assert.ok(!sandbox.isReference(undefined)); + }); + it('TN-3: isReference returns false for regular objects', () => { + const sandbox = new Sandbox(); + assert.ok(!sandbox.isReference({})); + assert.ok(!sandbox.isReference([])); + assert.ok(!sandbox.isReference('string')); }); }); \ No newline at end of file diff --git a/tests/processors.test.js b/tests/processors.test.js index 69cedb4..705a103 100644 --- a/tests/processors.test.js +++ b/tests/processors.test.js @@ -27,7 +27,7 @@ function applyProcessors(arb, processors) { describe('Processors tests: Augmented Array', async () => { const targetProcessors = (await import('../src/processors/augmentedArray.js')); - it('TP-1', () => { + it('TP-1: Complex IIFE with mixed array elements', () => { const code = `const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 'a', 'b', 'c']; (function (targetArray, numberOfShifts) { var augmentArray = function (counter) { @@ -42,6 +42,182 @@ describe('Processors tests: Augmented Array', async () => { arb = applyProcessors(arb, targetProcessors); assert.strictEqual(arb.script, expected); }); + it('TP-2: Simple array with single shift', () => { + const code = `const data = ['first', 'second', 'third']; +(function(arr, shifts) { + for (let i = 0; i < shifts; i++) { + arr.push(arr.shift()); + } +})(data, 1);`; + const expected = `const data = [\n 'second',\n 'third',\n 'first'\n];`; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TP-3: Array with zero shifts (no change)', () => { + const code = `const unchanged = [1, 2, 3]; +(function(arr, n) { + for (let i = 0; i < n; i++) { + arr.push(arr.shift()); + } +})(unchanged, 0);`; + const expected = `const unchanged = [\n 1,\n 2,\n 3\n];`; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TP-4: Array with larger shift count', () => { + const code = `const numbers = [10, 20, 30, 40, 50]; +(function(arr, count) { + for (let i = 0; i < count; i++) { + arr.push(arr.shift()); + } +})(numbers, 3);`; + const expected = `const numbers = [\n 40,\n 50,\n 10,\n 20,\n 30\n];`; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TN-1: IIFE with non-literal shift count', () => { + const code = `const arr = [1, 2, 3]; +let shifts = 2; +(function(array, n) { + for (let i = 0; i < n; i++) { + array.push(array.shift()); + } +})(arr, shifts);`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TN-2: IIFE with insufficient arguments', () => { + const code = `const arr = [1, 2, 3]; +(function(array) { + array.push(array.shift()); +})(arr);`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TN-3: IIFE with non-identifier array argument', () => { + const code = `(function(array, shifts) { + for (let i = 0; i < shifts; i++) { + array.push(array.shift()); + } +})([1, 2, 3], 1);`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TN-4: Non-IIFE function call', () => { + const code = `const arr = [1, 2, 3]; +function shuffle(array, shifts) { + for (let i = 0; i < shifts; i++) { + array.push(array.shift()); + } +} +shuffle(arr, 2);`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TN-5: Invalid shift count (NaN)', () => { + const code = `const arr = [1, 2, 3]; +(function(array, shifts) { + for (let i = 0; i < shifts; i++) { + array.push(array.shift()); + } +})(arr, "invalid");`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TN-9: Function passed to IIFE (function not self-modifying)', () => { + const code = `function getArray() { + return ['a', 'b', 'c']; +} +(function(fn, shifts) { + const arr = fn(); + for (let i = 0; i < shifts; i++) { + arr.push(arr.shift()); + } +})(getArray, 2);`; + // The IIFE modifies a local copy, but the function itself is not self-modifying + // so no transformation should occur + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TP-5: Arrow function IIFE', () => { + const code = `const items = ['x', 'y', 'z']; +((arr, n) => { + for (let i = 0; i < n; i++) { + arr.push(arr.shift()); + } +})(items, 1);`; + const expected = `const items = [ + 'y', + 'z', + 'x' +];`; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TP-6: Shift count larger than array length', () => { + const code = `const small = ['a', 'b']; +(function(arr, shifts) { + for (let i = 0; i < shifts; i++) { + arr.push(arr.shift()); + } +})(small, 5);`; + // 5 shifts on 2-element array: a,b -> b,a -> a,b -> b,a -> a,b -> b,a + const expected = `const small = [ + 'b', + 'a' +];`; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TN-10: Arrow function without parentheses around parameters', () => { + const code = `const arr = [1, 2, 3]; +(arr => { + arr.push(arr.shift()); +})(arr);`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TN-11: Negative shift count', () => { + const code = `const arr = [1, 2, 3]; +(function(array, shifts) { + for (let i = 0; i < shifts; i++) { + array.push(array.shift()); + } +})(arr, -1);`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); + it('TN-12: IIFE with complex array manipulation that cannot be resolved', () => { + const code = `const arr = [1, 2, 3]; +(function(array, shifts) { + Math.random() > 0.5 ? array.push(array.shift()) : array.unshift(array.pop()); +})(arr, 1);`; + let arb = new Arborist(code); + const originalScript = arb.script; + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, originalScript); + }); }); describe('Processors tests: Caesar Plus', async () => { const targetProcessors = (await import('../src/processors/caesarp.js')); @@ -80,16 +256,44 @@ describe('Processors tests: Function to Array', async () => { arb = applyProcessors(arb, targetProcessors); assert.strictEqual(arb.script, expected); }); - it('TN-1', () => { + it('TP-3: Arrow function returning array', () => { + const code = `const getItems = () => ['x', 'y', 'z']; const items = getItems(); console.log(items[0]);`; + const expected = `const getItems = () => [\n 'x',\n 'y',\n 'z'\n];\nconst items = [\n 'x',\n 'y',\n 'z'\n];\nconsole.log(items[0]);`; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TP-4: Multiple variables with array access only', () => { + const code = `function getData() {return [1, 2, 3]} const x = getData(); const y = getData(); console.log(x[0], y[1]);`; + const expected = `function getData() {\n return [\n 1,\n 2,\n 3\n ];\n}\nconst x = [\n 1,\n 2,\n 3\n];\nconst y = [\n 1,\n 2,\n 3\n];\nconsole.log(x[0], y[1]);`; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TN-1: Function called multiple times without assignment', () => { const code = `function getArr() {return ['One', 'Two', 'Three']} console.log(getArr()[0] + ' + ' + getArr()[1] + ' = ' + getArr()[2]);`; const expected = code; let arb = new Arborist(code); arb = applyProcessors(arb, targetProcessors); assert.strictEqual(arb.script, expected); }); + it('TN-2: Mixed usage (array access and other)', () => { + const code = `function getArr() {return ['a', 'b', 'c']} const data = getArr(); console.log(data[0], data.length, data.slice(1));`; + const expected = code; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); + it('TN-3: Variable not assigned function call', () => { + const code = `const arr = ['static', 'array']; console.log(arr[0], arr[1]);`; + const expected = code; + let arb = new Arborist(code); + arb = applyProcessors(arb, targetProcessors); + assert.strictEqual(arb.script, expected); + }); }); describe('Processors tests: Obfuscator.io', async () => { - const targetProcessors = (await import('../src/processors/obfuscatorIo.js')); + const targetProcessors = (await import('../src/processors/obfuscator.io.js')); it('TP-1', () => { const code = `var a = { 'removeCookie': function () { diff --git a/tests/resources/evalOxd.js-deob.js b/tests/resources/evalOxd.js-deob.js index 73d187a..c505162 100644 --- a/tests/resources/evalOxd.js-deob.js +++ b/tests/resources/evalOxd.js-deob.js @@ -209,6 +209,9 @@ var lo; return j._.join('').split('%').join('').split('#1').join('%').split('#0').join('#').split(''); } function b() { + if (e()) { + return; + } if (!('navigator' in this)) { this.navigator = {}; } @@ -498,8 +501,8 @@ var lo; l(); m(); lo = setInterval(() => { - const c = window.outerWidth - window.innerWidth > 160; - const b = window.outerHeight - window.innerHeight > 160; + const c = window.outerWidth - window.innerWidth > th; + const b = window.outerHeight - window.innerHeight > th; if (!(b && c) && (window.Firebug && window.Firebug.chrome && window.Firebug.chrome.isInitialized || c || b)) { bH(); clearInterval(lo); diff --git a/tests/resources/newFunc.js-deob.js b/tests/resources/newFunc.js-deob.js index ae14cf8..1415f7e 100644 --- a/tests/resources/newFunc.js-deob.js +++ b/tests/resources/newFunc.js-deob.js @@ -44,91 +44,13 @@ function t() { })(); } function e(n, a) { - var r = [ - 'appendChild', - 'length', - '100%', - '491ObZCcR', - '40024ItvVfk', - '177822QQLRDD', - 'style', - '364LQAOhD', - 'iframe', - 'data-fiikfu', - 'searchParams', - '999999', - '8FpuLea', - '10cZXSHP', - '3029155zGDxjW', - '12qNvHsa', - 'ddrido', - '8964021vmeNuO', - 'substring', - 'fixed', - '567228cqlBcB', - 'bottom', - '572509wwXbzV', - 'margin', - 'random', - 'height', - 'right', - 'hash', - 'abcdefghijklmnopqrstuvwxyz', - '378NHloDJ', - '478KOasfu', - 'overflow', - 'location', - 'createElement', - 'border', - 'position', - 'floor', - 'left' - ]; + var r = t(); return (e = function (t, e) { return r[t -= 494]; })(n, a); } (function (t, n) { - for (var r = [ - 'appendChild', - 'length', - '100%', - '491ObZCcR', - '40024ItvVfk', - '177822QQLRDD', - 'style', - '364LQAOhD', - 'iframe', - 'data-fiikfu', - 'searchParams', - '999999', - '8FpuLea', - '10cZXSHP', - '3029155zGDxjW', - '12qNvHsa', - 'ddrido', - '8964021vmeNuO', - 'substring', - 'fixed', - '567228cqlBcB', - 'bottom', - '572509wwXbzV', - 'margin', - 'random', - 'height', - 'right', - 'hash', - 'abcdefghijklmnopqrstuvwxyz', - '378NHloDJ', - '478KOasfu', - 'overflow', - 'location', - 'createElement', - 'border', - 'position', - 'floor', - 'left' - ];;) + for (var r = t();;) try { break; r.push(r.shift()); diff --git a/tests/resources/obfuscatorIo.js b/tests/resources/obfuscator.io.js similarity index 100% rename from tests/resources/obfuscatorIo.js rename to tests/resources/obfuscator.io.js diff --git a/tests/resources/obfuscatorIo.js-deob.js b/tests/resources/obfuscator.io.js-deob.js similarity index 99% rename from tests/resources/obfuscatorIo.js-deob.js rename to tests/resources/obfuscator.io.js-deob.js index e73bd06..034000b 100644 --- a/tests/resources/obfuscatorIo.js-deob.js +++ b/tests/resources/obfuscator.io.js-deob.js @@ -208,11 +208,11 @@ function _yk(a) { } else { if (('' + c / c).length !== 1 || c % 20 === 0) { (function () { - undefined; + debugge_; }.call('action')); } else { (function () { - undefined; + debugge_; }.apply('stateObject')); } } diff --git a/tests/resources/prototypeCalls.js-deob.js b/tests/resources/prototypeCalls.js-deob.js index 9fe5b41..53bf98a 100644 Binary files a/tests/resources/prototypeCalls.js-deob.js and b/tests/resources/prototypeCalls.js-deob.js differ diff --git a/tests/samples.test.js b/tests/samples.test.js index f14343a..8056d3d 100644 --- a/tests/samples.test.js +++ b/tests/samples.test.js @@ -80,7 +80,7 @@ describe('Samples tests', () => { assert.strictEqual(result, expected); }); it('Deobfuscate sample: Obfuscator.io', () => { - const sampleFilename = join(cwd, resourcePath, 'obfuscatorIo.js'); + const sampleFilename = join(cwd, resourcePath, 'obfuscator.io.js'); const expectedSolutionFilename = sampleFilename + '-deob.js'; const code = readFileSync(sampleFilename, 'utf-8'); const expected = readFileSync(expectedSolutionFilename, 'utf-8'); diff --git a/tests/utils.test.js b/tests/utils.test.js index 2d98584..0890de9 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -1,19 +1,19 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {argsAreValid, parseArgs} from '../src/utils/parseArgs.js'; +import {parseArgs} from '../src/utils/parseArgs.js'; const consolelog = console.log; describe('parseArgs tests', () => { it('TP-1: Defaults', () => { - assert.deepEqual(parseArgs([]), { - inputFilename: '', + assert.deepEqual(parseArgs(['input.js']), { + inputFilename: 'input.js', help: false, clean: false, quiet: false, verbose: false, outputToFile: false, maxIterations: false, - outputFilename: '-deob.js' + outputFilename: 'input.js-deob.js' }); }); it('TP-2: All on - short', () => { @@ -124,48 +124,4 @@ describe('parseArgs tests', () => { outputFilename: 'input.js-deob.js' }); }); -}); -describe('argsAreValid tests', () => { - it('TP-1: Input filename only', () => { - console.log = () => {}; // Mute log - const result = argsAreValid(parseArgs(['input.js'])); - console.log = consolelog; - assert.ok(result); - }); - it('TP-2: All on, no quiet, no help', () => { - console.log = () => {}; // Mute log - const result = argsAreValid(parseArgs(['input.js', '-m=2', '-o', 'outputfile.js', '--verbose', '-c'])); - console.log = consolelog; - assert.ok(result); - }); - it('TP-3: Invalidate when printing help', () => { - console.log = () => {}; // Mute log - const result = argsAreValid(parseArgs(['input.js', '-m=2', '-o', 'outputfile.js', '--verbose', '-c', '-h'])); - console.log = consolelog; - assert.strictEqual(result, false); - }); - it('TN-1: Missing input filename', () => { - console.log = () => {}; // Mute log - const result = argsAreValid(parseArgs([])); - console.log = consolelog; - assert.strictEqual(result, false); - }); - it('TN-2: Mutually exclusive verbose and quiet', () => { - console.log = () => {}; // Mute log - const result = argsAreValid(parseArgs(['input.js', '-v', '-q'])); - console.log = consolelog; - assert.strictEqual(result, false); - }); - it('TN-3: Max iterations missing value', () => { - console.log = () => {}; // Mute log - const result = argsAreValid(parseArgs(['input.js', '-m'])); - console.log = consolelog; - assert.strictEqual(result, false); - }); - it('TN-4: Max iterations invalid value NaN', () => { - console.log = () => {}; // Mute log - const result = argsAreValid(parseArgs(['input.js', '-m', 'a'])); - console.log = consolelog; - assert.strictEqual(result, false); - }); }); \ No newline at end of file