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: 1 addition & 1 deletion pyjsclear/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

__all__ = ['Deobfuscator', 'deobfuscate', 'deobfuscate_file']

__version__ = '0.1.5'
__version__ = '0.1.6'


def deobfuscate(code: str, max_iterations: int = 50) -> str:
Expand Down
28 changes: 25 additions & 3 deletions pyjsclear/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,32 @@ def _gen_expr_stmt(node: dict, indent: int) -> str:
return generate(node['expression'], indent)


def _mixes_nullish_and_logical(operator: str, operand: dict | None) -> bool:
# ES forbids ?? adjacent to || or && without parens.
if not isinstance(operand, dict) or operand.get('type') != 'LogicalExpression':
return False
operand_operator = operand.get('operator', '')
if operator == '??':
return operand_operator in ('||', '&&')
if operator in ('||', '&&'):
return operand_operator == '??'
return False


def _gen_binary(node: dict, indent: int) -> str:
operator = node.get('operator', '')
left = generate(node['left'], indent)
right = generate(node['right'], indent)
my_prec = _PRECEDENCE.get(operator, 1)
left_prec = _expr_precedence(node['left'])
right_prec = _expr_precedence(node['right'])
if left_prec < my_prec:
if left_prec < my_prec or _mixes_nullish_and_logical(operator, node['left']):
left = f'({left})'
if right_prec < my_prec or (right_prec == my_prec and operator not in ('+', '*', '|', '&', '^')):
if (
right_prec < my_prec
or (right_prec == my_prec and operator not in ('+', '*', '|', '&', '^'))
or _mixes_nullish_and_logical(operator, node['right'])
):
right = f'({right})'
return f'{left} {operator} {right}'

Expand Down Expand Up @@ -385,6 +401,11 @@ def _gen_call(node: dict, indent: int) -> str:
return f'{callee}({argument_string})'


def _gen_chain(node: dict, indent: int) -> str:
# ChainExpression wraps an optional chain; the ?. is on the inner node.
return generate(node.get('expression'), indent)


def _gen_new(node: dict, indent: int) -> str:
callee = generate(node['callee'], indent)
arguments = node.get('arguments', [])
Expand Down Expand Up @@ -700,7 +721,7 @@ def _expr_precedence(node: dict) -> int:
| 'TemplateLiteral'
):
return 20
case 'MemberExpression' | 'CallExpression' | 'NewExpression' | 'TaggedTemplateExpression':
case 'MemberExpression' | 'CallExpression' | 'NewExpression' | 'TaggedTemplateExpression' | 'ChainExpression':
return 19
case 'UpdateExpression':
return 17 if node.get('prefix') else 18
Expand Down Expand Up @@ -749,6 +770,7 @@ def _expr_precedence(node: dict) -> int:
'AssignmentExpression': _gen_assignment,
'MemberExpression': _gen_member,
'CallExpression': _gen_call,
'ChainExpression': _gen_chain,
'NewExpression': _gen_new,
'ConditionalExpression': _gen_conditional,
'SequenceExpression': _gen_sequence,
Expand Down
14 changes: 13 additions & 1 deletion pyjsclear/transforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,21 @@ def get_parent_map(self) -> dict[int, tuple[dict, str, int | None]]:
return self._parent_map

def invalidate_parent_map(self) -> None:
"""Invalidate the cached parent map after AST modifications."""
"""Drop the cached parent map so the next lookup rebuilds it.

Prefer record_replacement() for in-place swaps — O(1) vs this O(N) rebuild (quadratic over many).
"""
self._parent_map = None

def record_replacement(self, replacement: dict, parent: dict, key: str, index: int | None) -> None:
"""Patch the cached parent map after an in-place node swap.

Only for index-preserving swaps; use invalidate_parent_map() for insert/remove. The old subtree's
entries and the replacement's descendants stay unregistered (find_parent returns None until a rebuild).
"""
if self._parent_map is not None:
self._parent_map[id(replacement)] = (parent, key, index)

def find_parent(self, target_node: dict) -> tuple[dict, str, int | None] | None:
"""Find the parent of a node using the parent map."""
parent_map = self.get_parent_map()
Expand Down
1 change: 1 addition & 0 deletions pyjsclear/transforms/class_static_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,4 @@ def _replace_in_parent(
parent[key][index] = replacement
else:
parent[key] = replacement
self.record_replacement(replacement, parent, key, index)
2 changes: 1 addition & 1 deletion pyjsclear/transforms/object_simplifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def _replace_node(self, target: dict, replacement: dict) -> bool:
parent[key][index] = replacement
else:
parent[key] = replacement
self.invalidate_parent_map()
self.record_replacement(replacement, parent, key, index)
return True

def _inline_function(self, function_node: dict, arguments: list[dict]) -> dict | None:
Expand Down
2 changes: 1 addition & 1 deletion pyjsclear/transforms/string_revealer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1297,7 +1297,7 @@ def _replace_node_in_ast(self, target: dict, replacement: dict) -> None:
parent[key][index] = replacement
else:
parent[key] = replacement
self.invalidate_parent_map()
self.record_replacement(replacement, parent, key, index)

# ================================================================
# Strategy 3: Simple static array unpacking
Expand Down
1 change: 1 addition & 0 deletions pyjsclear/utils/ast_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def is_valid_identifier(name: Any) -> bool:
'AssignmentExpression': ('left', 'right'),
'MemberExpression': ('object', 'property'),
'CallExpression': ('callee', 'arguments'),
'ChainExpression': ('expression',),
'NewExpression': ('callee', 'arguments'),
'ConditionalExpression': ('test', 'consequent', 'alternate'),
'SequenceExpression': ('expressions',),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description = "Pure Python JavaScript deobfuscator"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.11"
dependencies = ["esprima2>=5.0.1"]
dependencies = ["esprima2==6.0.0"]
keywords = ["javascript", "deobfuscator", "deobfuscation", "security", "malware-analysis", "ast"]
authors = [
{name = "Intezer Labs", email = "info@intezer.com"},
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
esprima2>=5.0.0
esprima2==6.0.0
4 changes: 2 additions & 2 deletions tests/resources/sample.deobfuscated.js
Original file line number Diff line number Diff line change
Expand Up @@ -2822,7 +2822,7 @@
try {
const data9 = JSON.parse(fs11.readFileSync(vg, "utf8"));
const vh = await this.g4EE56L("wv-key");
if (data9[nr.E506IW4.w668BQY] ?? (true || (data9[nr.E506IW4.q4D91PM]?.[nr.E506IW4.P5D7IHK] ?? true) || (data9[nr.E506IW4.r6BA6EQ] ?? true) || (data9[nr.E506IW4.g65BAO8] ?? true))) {
if ((data9[nr.E506IW4.w668BQY] ?? true) || (data9[nr.E506IW4.q4D91PM]?.[nr.E506IW4.P5D7IHK] ?? true) || (data9[nr.E506IW4.r6BA6EQ] ?? true) || (data9[nr.E506IW4.g65BAO8] ?? true)) {
if (0 == vh || ve) {
await this.D45AYQ3(nr.E506IW4.D472X8L);
data9[nr.E506IW4.w668BQY] = false;
Expand Down Expand Up @@ -2915,7 +2915,7 @@
let flag8 = true;
if ("shift" in data11 && "browser" in data11.shift) {
const vt = data11.shift.browser;
flag8 = vt.launch_on_login_enabled ?? (true || (vt.launch_on_wake_enabled ?? true) || (vt.run_in_background_enabled ?? true));
flag8 = (vt.launch_on_login_enabled ?? true) || (vt.launch_on_wake_enabled ?? true) || (vt.run_in_background_enabled ?? true);
}
const vs = await this.g4EE56L("sf-key");
if (flag8) {
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/generator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,30 @@ def test_nullish_coalescing(self):
assert generate(node) == 'a ?? b'


class TestNullishMixingParentheses:
"""ES forbids ?? adjacent to || or && without parens."""

@staticmethod
def _logical(operator, left, right):
return {'type': 'LogicalExpression', 'operator': operator, 'left': left, 'right': right}

def test_nullish_left_of_or_is_parenthesized(self):
node = self._logical('||', self._logical('??', _id('a'), _id('b')), _id('c'))
assert generate(node) == '(a ?? b) || c'

def test_or_left_of_nullish_is_parenthesized(self):
node = self._logical('??', self._logical('||', _id('a'), _id('b')), _id('c'))
assert generate(node) == '(a || b) ?? c'

def test_nullish_with_higher_precedence_and_is_parenthesized(self):
node = self._logical('??', _id('a'), self._logical('&&', _id('b'), _id('c')))
assert generate(node) == 'a ?? (b && c)'

def test_nullish_chained_with_nullish_is_not_parenthesized(self):
node = self._logical('??', self._logical('??', _id('a'), _id('b')), _id('c'))
assert generate(node) == 'a ?? b ?? c'


class TestUnaryExpressions:
def test_typeof(self):
node = {
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/incremental_parent_map_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Tests for incremental parent-map maintenance across transform call sites (TKT-16478).

Transforms replace nodes in place while iterating; each replacement calls
record_replacement() to patch the cached parent map in O(1) instead of
invalidate_parent_map(), which would force a full O(N) rebuild on the next
find_parent lookup -- quadratic overall when many nodes are replaced in one
pass. These tests assert the observable effect: build_parent_map() is called
at most once per transform execution, no matter how many nodes it replaces.
"""

from unittest.mock import patch

from pyjsclear.parser import parse
from pyjsclear.transforms.class_static_resolver import ClassStaticResolver
from pyjsclear.transforms.object_simplifier import ObjectSimplifier
from pyjsclear.transforms.string_revealer import StringRevealer
from pyjsclear.traverser import build_parent_map


_REFERENCE_COUNT = 200


class TestObjectSimplifierIncrementalParentMap:
def test_build_parent_map_called_once_when_many_properties_are_inlined(self):
# Arrange
properties = ', '.join(f'k{i}: {i}' for i in range(_REFERENCE_COUNT))
accesses = '; '.join(f'console.log(o.k{i})' for i in range(_REFERENCE_COUNT))
ast = parse(f'const o = {{{properties}}}; {accesses};')
transform = ObjectSimplifier(ast)

# Act
with patch('pyjsclear.transforms.base.build_parent_map', wraps=build_parent_map) as mock_build_parent_map:
changed = transform.execute()

# Assert
assert changed is True
assert mock_build_parent_map.call_count == 1


class TestStringRevealerIncrementalParentMap:
def test_build_parent_map_called_once_when_many_array_accesses_are_replaced(self):
# Arrange
elements = ', '.join(f'"s{i}"' for i in range(_REFERENCE_COUNT))
accesses = '; '.join(f'f(arr[{i}])' for i in range(_REFERENCE_COUNT))
ast = parse(f'var arr = [{elements}]; {accesses};')
transform = StringRevealer(ast)

# Act
with patch('pyjsclear.transforms.base.build_parent_map', wraps=build_parent_map) as mock_build_parent_map:
changed = transform.execute()

# Assert
assert changed is True
assert mock_build_parent_map.call_count <= 1


class TestClassStaticResolverIncrementalParentMap:
def test_build_parent_map_called_once_when_many_static_properties_are_inlined(self):
# Arrange
accesses = '; '.join(f'console.log(C.X + {i})' for i in range(_REFERENCE_COUNT))
ast = parse(f'var C = class {{}}; C.X = 100; {accesses};')
transform = ClassStaticResolver(ast)

# Act
with patch('pyjsclear.transforms.base.build_parent_map', wraps=build_parent_map) as mock_build_parent_map:
changed = transform.execute()

# Assert
assert changed is True
assert mock_build_parent_map.call_count == 1
# The transform invalidates the cache once the traversal completes,
# so the next lookup rebuilds fresh rather than reusing stale entries.
assert transform._parent_map is None
56 changes: 56 additions & 0 deletions tests/unit/transforms/base_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pytest

from pyjsclear.parser import parse
from pyjsclear.transforms.base import Transform
from pyjsclear.traverser import build_parent_map


class TestTransformInit:
Expand Down Expand Up @@ -52,6 +54,60 @@ def test_set_changed_is_idempotent(self):
assert transform.has_changed() is True


class TestRecordReplacement:
def test_find_parent_returns_recorded_entry_without_rebuild(self):
ast = parse('f(1);')
transform = Transform(ast)
cached_map = transform.get_parent_map()

call = ast['body'][0]['expression']
replacement = {'type': 'Literal', 'value': 2, 'raw': '2'}
call['arguments'][0] = replacement
transform.record_replacement(replacement, call, 'arguments', 0)

parent, key, index = transform.find_parent(replacement)
assert parent is call
assert key == 'arguments'
assert index == 0
# The lookup was served by the patched cache, not a full rebuild.
assert transform._parent_map is cached_map

def test_noop_when_map_not_built(self):
ast = parse('f(1);')
transform = Transform(ast)

call = ast['body'][0]['expression']
replacement = {'type': 'Literal', 'value': 2, 'raw': '2'}
call['arguments'][0] = replacement
transform.record_replacement(replacement, call, 'arguments', 0)

assert transform._parent_map is None

def test_recorded_entry_matches_rebuild_for_list_child(self):
ast = parse('f(1);')
transform = Transform(ast)
transform.get_parent_map()

call = ast['body'][0]['expression']
replacement = {'type': 'Literal', 'value': 2, 'raw': '2'}
call['arguments'][0] = replacement
transform.record_replacement(replacement, call, 'arguments', 0)

assert transform.find_parent(replacement) == build_parent_map(ast)[id(replacement)]

def test_recorded_entry_matches_rebuild_for_dict_child(self):
ast = parse('x + 1;')
transform = Transform(ast)
transform.get_parent_map()

statement = ast['body'][0]
replacement = {'type': 'Literal', 'value': 2, 'raw': '2'}
statement['expression'] = replacement
transform.record_replacement(replacement, statement, 'expression', None)

assert transform.find_parent(replacement) == build_parent_map(ast)[id(replacement)]


class TestTransformRebuildScope:
def test_class_default_is_false(self):
assert Transform.rebuild_scope is False
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/traverser_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,12 @@ def test_collect_deeply_nested(self):
assert True in values
assert 42 in values

def test_recurses_into_optional_chain(self):
# obj?.[k] is wrapped in a ChainExpression; traversal must still reach the identifiers inside.
ast = parse('var y = obj?.[key];')
names = {n['name'] for n in collect_nodes(ast, 'Identifier')}
assert {'obj', 'key'} <= names


# ===========================================================================
# 8. find_parent
Expand Down
Loading