diff --git a/pyjsclear/__init__.py b/pyjsclear/__init__.py index 52fa0af..ee5c57b 100644 --- a/pyjsclear/__init__.py +++ b/pyjsclear/__init__.py @@ -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: diff --git a/pyjsclear/generator.py b/pyjsclear/generator.py index 8712c6f..d3cfcfb 100644 --- a/pyjsclear/generator.py +++ b/pyjsclear/generator.py @@ -298,6 +298,18 @@ 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) @@ -305,9 +317,13 @@ def _gen_binary(node: dict, indent: int) -> str: 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}' @@ -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', []) @@ -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 @@ -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, diff --git a/pyjsclear/transforms/base.py b/pyjsclear/transforms/base.py index 9dbd46c..02fc1e8 100644 --- a/pyjsclear/transforms/base.py +++ b/pyjsclear/transforms/base.py @@ -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() diff --git a/pyjsclear/transforms/class_static_resolver.py b/pyjsclear/transforms/class_static_resolver.py index d90c4ef..8f505f6 100644 --- a/pyjsclear/transforms/class_static_resolver.py +++ b/pyjsclear/transforms/class_static_resolver.py @@ -227,3 +227,4 @@ def _replace_in_parent( parent[key][index] = replacement else: parent[key] = replacement + self.record_replacement(replacement, parent, key, index) diff --git a/pyjsclear/transforms/object_simplifier.py b/pyjsclear/transforms/object_simplifier.py index 1049ff6..bd4561c 100644 --- a/pyjsclear/transforms/object_simplifier.py +++ b/pyjsclear/transforms/object_simplifier.py @@ -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: diff --git a/pyjsclear/transforms/string_revealer.py b/pyjsclear/transforms/string_revealer.py index 5433f4c..c354fa7 100644 --- a/pyjsclear/transforms/string_revealer.py +++ b/pyjsclear/transforms/string_revealer.py @@ -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 diff --git a/pyjsclear/utils/ast_helpers.py b/pyjsclear/utils/ast_helpers.py index 1b29009..23e01de 100644 --- a/pyjsclear/utils/ast_helpers.py +++ b/pyjsclear/utils/ast_helpers.py @@ -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',), diff --git a/pyproject.toml b/pyproject.toml index 6c25ca8..68966fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}, diff --git a/requirements.txt b/requirements.txt index 843a8fa..aa62669 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -esprima2>=5.0.0 +esprima2==6.0.0 diff --git a/tests/resources/sample.deobfuscated.js b/tests/resources/sample.deobfuscated.js index 8a900d0..95064ea 100644 --- a/tests/resources/sample.deobfuscated.js +++ b/tests/resources/sample.deobfuscated.js @@ -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; @@ -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) { diff --git a/tests/unit/generator_test.py b/tests/unit/generator_test.py index 17f35a4..dbf1792 100644 --- a/tests/unit/generator_test.py +++ b/tests/unit/generator_test.py @@ -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 = { diff --git a/tests/unit/incremental_parent_map_test.py b/tests/unit/incremental_parent_map_test.py new file mode 100644 index 0000000..179426c --- /dev/null +++ b/tests/unit/incremental_parent_map_test.py @@ -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 diff --git a/tests/unit/transforms/base_test.py b/tests/unit/transforms/base_test.py index 5752f3b..78d5f6d 100644 --- a/tests/unit/transforms/base_test.py +++ b/tests/unit/transforms/base_test.py @@ -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: @@ -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 diff --git a/tests/unit/traverser_test.py b/tests/unit/traverser_test.py index 00b0102..2c61b1e 100644 --- a/tests/unit/traverser_test.py +++ b/tests/unit/traverser_test.py @@ -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