Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: Fuzz Testing
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
fuzz:
Expand Down
21 changes: 0 additions & 21 deletions .gitignore

This file was deleted.

14 changes: 7 additions & 7 deletions pyjsclear/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
from .deobfuscator import Deobfuscator


__version__ = '0.1.3'
__version__ = '0.1.4'


def deobfuscate(code, max_iterations=50):
def deobfuscate(code: str, max_iterations: int = 50) -> str:
"""Deobfuscate JavaScript code. Returns cleaned source.

Args:
Expand All @@ -24,7 +24,7 @@ def deobfuscate(code, max_iterations=50):
return Deobfuscator(code, max_iterations=max_iterations).execute()


def deobfuscate_file(input_path, output_path=None, max_iterations=50):
def deobfuscate_file(input_path: str, output_path: str | None = None, max_iterations: int = 50) -> str | bool:
"""Deobfuscate a JavaScript file.

Args:
Expand All @@ -35,13 +35,13 @@ def deobfuscate_file(input_path, output_path=None, max_iterations=50):
Returns:
True if content changed (when output_path given), or the deobfuscated string.
"""
with open(input_path, 'r', errors='replace') as f:
code = f.read()
with open(input_path, 'r', errors='replace') as input_file:
code = input_file.read()

result = deobfuscate(code, max_iterations=max_iterations)

if output_path:
with open(output_path, 'w') as f:
f.write(result)
with open(output_path, 'w') as output_file:
output_file.write(result)
return result != code
return result
7 changes: 4 additions & 3 deletions pyjsclear/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from . import deobfuscate


def main():
def main() -> None:
parser = argparse.ArgumentParser(description='Deobfuscate JavaScript files.')
parser.add_argument('input', help='Input JS file (use - for stdin)')
parser.add_argument('-o', '--output', help='Output file (default: stdout)')
Expand All @@ -29,8 +29,9 @@ def main():
if args.output:
with open(args.output, 'w') as output_file:
output_file.write(result)
else:
sys.stdout.write(result)
return

sys.stdout.write(result)


if __name__ == '__main__':
Expand Down
63 changes: 47 additions & 16 deletions pyjsclear/deobfuscator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from .generator import generate
from .parser import parse
from .scope import build_scope_tree
from .transforms.aa_decode import aa_decode
from .transforms.aa_decode import is_aa_encoded
from .transforms.anti_tamper import AntiTamperRemover
from .transforms.class_static_resolver import ClassStaticResolver
from .transforms.class_string_decoder import ClassStringDecoder
Expand All @@ -26,8 +29,6 @@
from .transforms.hex_escapes import HexEscapes
from .transforms.hex_escapes import decode_hex_escapes_source
from .transforms.hex_numerics import HexNumerics
from .transforms.aa_decode import aa_decode
from .transforms.aa_decode import is_aa_encoded
from .transforms.jj_decode import is_jj_encoded
from .transforms.jj_decode import jj_decode
from .transforms.jsfuck_decode import is_jsfuck
Expand All @@ -53,6 +54,22 @@
from .traverser import simple_traverse


# Transforms that use build_scope_tree and benefit from cached scope
_SCOPE_TRANSFORMS = frozenset(
{
ConstantProp,
SingleUseVarInliner,
ReassignmentRemover,
ProxyFunctionInliner,
UnusedVariableRemover,
ObjectSimplifier,
StringRevealer,
VariableRenamer,
VarToConst,
LetToConst,
}
)

# StringRevealer runs first to handle string arrays before other transforms
# modify the wrapper function structure.
# Remaining transforms follow obfuscator-io-deobfuscator order.
Expand Down Expand Up @@ -106,26 +123,26 @@
_NODE_COUNT_LIMIT = 50_000 # Skip ControlFlowRecoverer above this


def _count_nodes(ast):
def _count_nodes(ast: dict) -> int:
"""Count total AST nodes."""
count = 0

def cb(node, parent):
def increment_count(node: dict, parent: dict | None) -> None:
nonlocal count
count += 1

simple_traverse(ast, cb)
simple_traverse(ast, increment_count)
return count


class Deobfuscator:
"""Multi-pass JavaScript deobfuscator."""

def __init__(self, code, max_iterations=50):
def __init__(self, code: str, max_iterations: int = 50) -> None:
self.original_code = code
self.max_iterations = max_iterations

def _run_pre_passes(self, code):
def _run_pre_passes(self, code: str) -> str | None:
"""Run encoding detection and eval unpacking pre-passes.

Returns decoded code if an encoding/packing was detected and decoded,
Expand Down Expand Up @@ -160,7 +177,7 @@ def _run_pre_passes(self, code):
# Maximum number of outer re-parse cycles (generate → re-parse → re-transform)
_MAX_OUTER_CYCLES = 5

def execute(self):
def execute(self) -> str:
"""Run all transforms and return cleaned source."""
code = self.original_code

Expand Down Expand Up @@ -238,7 +255,7 @@ def execute(self):
# but also recursive. Return best result so far.
return previous_code

def _run_ast_transforms(self, ast, code_size=0):
def _run_ast_transforms(self, ast: dict, code_size: int = 0) -> bool:
"""Run all AST transform passes. Returns True if any transform changed the AST."""
node_count = _count_nodes(ast) if code_size > _LARGE_FILE_SIZE else 0

Expand All @@ -259,26 +276,40 @@ def _run_ast_transforms(self, ast, code_size=0):
# Track which transforms are no longer productive
skip_transforms = set()

# Cache scope tree across transforms — only rebuild when a transform
# that modifies bindings returns changed=True
scope_tree = None
node_scope = None
scope_dirty = True # Start dirty to build on first use

# Multi-pass transform loop
any_transform_changed = False
for i in range(max_iterations):
for iteration in range(max_iterations):
modified = False
for transform_class in transform_classes:
if transform_class in skip_transforms:
continue
try:
transform = transform_class(ast)
# Build scope tree lazily when needed by a scope-using transform
if transform_class in _SCOPE_TRANSFORMS and scope_dirty:
scope_tree, node_scope = build_scope_tree(ast)
scope_dirty = False

if transform_class in _SCOPE_TRANSFORMS:
transform = transform_class(ast, scope_tree=scope_tree, node_scope=node_scope)
else:
transform = transform_class(ast)
result = transform.execute()
except Exception:
continue
if result:
modified = True
any_transform_changed = True
else:
# If a transform didn't change anything after the first pass,
# skip it in subsequent iterations
if i > 0:
skip_transforms.add(transform_class)
# Any AST change invalidates the cached scope tree
scope_dirty = True
elif iteration > 0:
# Skip transforms that haven't changed anything after the first pass
skip_transforms.add(transform_class)

if not modified:
break
Expand Down
Loading
Loading