From d060f98854ab84dc42b079690bea64c8c52f6e52 Mon Sep 17 00:00:00 2001 From: Itamar Gafni Date: Sun, 12 Jul 2026 22:23:14 +0300 Subject: [PATCH 1/7] perf: maintain parent map incrementally to avoid quadratic re-scan [TKT-16478] Co-Authored-By: Claude Fable 5 --- pyjsclear/transforms/base.py | 23 +++++++- pyjsclear/transforms/class_static_resolver.py | 1 + pyjsclear/transforms/object_simplifier.py | 2 +- pyjsclear/transforms/string_revealer.py | 2 +- tests/unit/incremental_parent_map_test.py | 51 +++++++++++++++++ tests/unit/transforms/base_test.py | 56 +++++++++++++++++++ 6 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/unit/incremental_parent_map_test.py diff --git a/pyjsclear/transforms/base.py b/pyjsclear/transforms/base.py index 9dbd46c..db6d02d 100644 --- a/pyjsclear/transforms/base.py +++ b/pyjsclear/transforms/base.py @@ -52,9 +52,30 @@ 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() after an in-place node swap: it keeps the + cached map valid in O(1) instead of forcing an O(N) full rebuild on the + next find_parent (which is quadratic when many nodes are replaced). + """ 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 valid for in-place swaps (parent[key][index] = replacement or + parent[key] = replacement) that leave list indices unchanged; for + insertions/removals that shift indices, call invalidate_parent_map() + instead. The detached original subtree's entries go stale — callers + must not look them up afterwards — and descendants of the replacement + are not registered, so find_parent on them returns None until a full + 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/tests/unit/incremental_parent_map_test.py b/tests/unit/incremental_parent_map_test.py new file mode 100644 index 0000000..c5c03dd --- /dev/null +++ b/tests/unit/incremental_parent_map_test.py @@ -0,0 +1,51 @@ +"""Performance regression test for incremental parent-map maintenance (TKT-16478). + +Transforms replace nodes in place; before the fix, every replacement dropped the +cached parent map, forcing a full O(N) rebuild on the next lookup — quadratic +overall when thousands of proxy-object references are inlined. +""" + +import signal +import time + +import pyjsclear + + +_PROXY_OBJECT_OBFUSCATION = ( + "var _0xmap = {" + + ",".join(f"'k{i}': {i}" for i in range(4000)) + + "};\n" + + ";".join(f"console.log(_0xmap.k{i})" for i in range(4000)) + + ";\n" +) + + +class _TestTimeout(BaseException): + """Raised by the alarm handler. + + Derives from BaseException so it sails past the deobfuscator pipeline's + broad `except Exception` (deobfuscator.py) and actually kills the test at + the deadline instead of being swallowed and stalling CI for the full + quadratic runtime. + """ + + +class TestIncrementalParentMap: + def test_object_simplifier_is_not_quadratic_on_many_references(self): + def _timeout(signum, frame): + raise _TestTimeout() + + old_handler = signal.signal(signal.SIGALRM, _timeout) + signal.setitimer(signal.ITIMER_REAL, 20) + try: + start = time.monotonic() + result = pyjsclear.deobfuscate(_PROXY_OBJECT_OBFUSCATION) + elapsed = time.monotonic() - start + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old_handler) + + # Inlining happened (proxy map references replaced by their literal values). + assert '_0xmap.k0' not in result + # And it did not take quadratic time (pre-fix this is minutes). + assert elapsed < 15 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 From f1de886a020835b95a5fccd01db0384ff1181290 Mon Sep 17 00:00:00 2001 From: Itamar Gafni Date: Mon, 13 Jul 2026 00:21:02 +0300 Subject: [PATCH 2/7] feat: optional wall-clock budget for deobfuscate() [TKT-16478] Co-Authored-By: Claude Fable 5 --- pyjsclear/__init__.py | 31 +++++++-- pyjsclear/deobfuscator.py | 31 ++++++++- tests/unit/deobfuscate_budget_test.py | 96 +++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/unit/deobfuscate_budget_test.py diff --git a/pyjsclear/__init__.py b/pyjsclear/__init__.py index 52fa0af..c17f9f8 100644 --- a/pyjsclear/__init__.py +++ b/pyjsclear/__init__.py @@ -12,26 +12,42 @@ __all__ = ['Deobfuscator', 'deobfuscate', 'deobfuscate_file'] -__version__ = '0.1.5' +__version__ = '0.1.6' -def deobfuscate(code: str, max_iterations: int = 50) -> str: +def deobfuscate( + code: str, + max_iterations: int = 50, + time_budget_seconds: float | None = None, +) -> str: """Deobfuscate JavaScript code and return cleaned source. Args: code: JavaScript source code string. max_iterations: Maximum transform passes (default 50). + time_budget_seconds: Optional coarse wall-clock budget. It is checked + between transform cycles only — a single stuck transform is NOT + interrupted (callers needing a hard bound must enforce it + externally). On expiry, the best result so far is returned. + The budget restarts at each nested decode layer (JSFuck/ + eval-packed recursion); it is not a global deadline for the + whole call. ``None`` (default) means no budget. Returns: Deobfuscated JavaScript source code. """ - return Deobfuscator(code, max_iterations=max_iterations).execute() + return Deobfuscator( + code, + max_iterations=max_iterations, + time_budget_seconds=time_budget_seconds, + ).execute() def deobfuscate_file( input_path: str | Path, output_path: str | Path | None = None, max_iterations: int = 50, + time_budget_seconds: float | None = None, ) -> str | bool: """Deobfuscate a JavaScript file. @@ -39,6 +55,13 @@ def deobfuscate_file( input_path: Path to input JS file. output_path: Path to write output (if None, returns string). max_iterations: Maximum transform passes. + time_budget_seconds: Optional coarse wall-clock budget. It is checked + between transform cycles only — a single stuck transform is NOT + interrupted (callers needing a hard bound must enforce it + externally). On expiry, the best result so far is returned. + The budget restarts at each nested decode layer (JSFuck/ + eval-packed recursion); it is not a global deadline for the + whole call. ``None`` (default) means no budget. Returns: True if content changed (when output_path given), or the deobfuscated string. @@ -46,7 +69,7 @@ def deobfuscate_file( with open(input_path, 'r', errors='replace') as input_file: code = input_file.read() - result = deobfuscate(code, max_iterations=max_iterations) + result = deobfuscate(code, max_iterations=max_iterations, time_budget_seconds=time_budget_seconds) if not output_path: return result diff --git a/pyjsclear/deobfuscator.py b/pyjsclear/deobfuscator.py index fb59b50..34a53cf 100644 --- a/pyjsclear/deobfuscator.py +++ b/pyjsclear/deobfuscator.py @@ -2,6 +2,8 @@ from __future__ import annotations +import time + from .generator import generate from .parser import parse from .scope import build_scope_tree @@ -171,9 +173,15 @@ class Deobfuscator: _MAX_OUTER_CYCLES: int = 5 - def __init__(self, code: str, max_iterations: int = 50) -> None: + def __init__( + self, + code: str, + max_iterations: int = 50, + time_budget_seconds: float | None = None, + ) -> None: self.original_code: str = code self.max_iterations: int = max_iterations + self.time_budget_seconds: float | None = time_budget_seconds def _run_pre_passes(self, code: str) -> str | None: """Detect whole-file encodings (JSFuck, AAEncode, etc.) and decode them. @@ -209,7 +217,11 @@ def execute(self) -> str: decoded = self._run_pre_passes(code) if decoded: - recursive_deobfuscator = Deobfuscator(decoded, max_iterations=self.max_iterations) + recursive_deobfuscator = Deobfuscator( + decoded, + max_iterations=self.max_iterations, + time_budget_seconds=self.time_budget_seconds, + ) return recursive_deobfuscator.execute() syntax_tree = self._try_parse_or_fallback(code) @@ -235,13 +247,28 @@ def _try_parse_or_fallback(self, code: str) -> dict | str: def _transform_loop(self, syntax_tree: dict, code: str) -> str: """Run the outer generate-reparse convergence loop and post-passes. + When ``time_budget_seconds`` is set, the elapsed wall-clock time is + checked inline at the top of each outer cycle (never via exceptions, + which the pipeline's broad ``except Exception`` handlers would + swallow); once exceeded, the loop stops and the best result so far + flows into the normal return path. The budget restarts at each nested + decode layer (JSFuck/eval-packed recursion); it is not a global + deadline for the whole call. + Returns the best deobfuscated source produced. """ previous_code = code last_changed_tree: dict | None = None + start_time = time.monotonic() try: for _cycle in range(self._MAX_OUTER_CYCLES): + if ( + self.time_budget_seconds is not None + and time.monotonic() - start_time >= self.time_budget_seconds + ): + break + changed = self._run_ast_transforms( syntax_tree, code_size=len(previous_code), diff --git a/tests/unit/deobfuscate_budget_test.py b/tests/unit/deobfuscate_budget_test.py new file mode 100644 index 0000000..3a11ad9 --- /dev/null +++ b/tests/unit/deobfuscate_budget_test.py @@ -0,0 +1,96 @@ +"""Tests for the optional wall-clock budget on deobfuscation (TKT-16478). + +The budget is deliberately coarse: it is checked inline between outer transform +cycles (never via exceptions, which the pipeline's broad ``except Exception`` +handlers would swallow). On expiry the best result so far is returned. The +budget restarts at each nested decode layer (JSFuck/eval-packed recursion). +""" + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pyjsclear +from pyjsclear.deobfuscator import Deobfuscator + + +class TestDeobfuscateBudget: + def test_deobfuscate_accepts_a_time_budget_and_returns_a_string(self): + # Budget is honored between outer cycles; a tiny budget still returns valid source. + out = pyjsclear.deobfuscate('var a=1;var b=2;console.log(a+b);', time_budget_seconds=0.001) + assert isinstance(out, str) + assert out + + def test_deobfuscate_file_accepts_a_time_budget(self, tmp_path): + input_file = tmp_path / 'input.js' + input_file.write_text('var x = 1;') + + result = pyjsclear.deobfuscate_file(str(input_file), time_budget_seconds=0.001) + assert isinstance(result, str) + + def test_without_budget_behavior_is_unchanged(self): + # Mirrors an existing proven deobfuscation (hex escape decoding) to show + # the default (no kwarg) path still fully deobfuscates. + code = 'var x = "\\x48\\x65\\x6c\\x6c\\x6f";' + result = pyjsclear.deobfuscate(code) + assert '\\x48' not in result + assert 'Hello' in result + + +class TestDeobfuscatorBudgetInternals: + def test_budget_defaults_to_none_and_is_stored_when_given(self): + assert Deobfuscator('var x = 1;').time_budget_seconds is None + assert Deobfuscator('var x = 1;', time_budget_seconds=2.5).time_budget_seconds == 2.5 + + def test_budget_expiry_mid_loop_stops_after_one_cycle_and_returns_source(self): + # Deterministic mid-loop expiry: fake the clock so the budget check + # passes at cycle 0 and trips at cycle 1. Expected monotonic() calls on + # this path (verified against deobfuscator.py, the only module using + # time): start capture -> 0.0, cycle-0 check -> 0.0, cycle-1 check -> + # 10.0. The `time` attribute is patched in the deobfuscator module + # namespace only, so nothing else consumes the side_effect values. + fake_time = MagicMock() + fake_time.monotonic.side_effect = [0.0, 0.0, 10.0] + deobfuscator = Deobfuscator('var a = 1; var b = a; console.log(b);', time_budget_seconds=5.0) + + with ( + patch.object(Deobfuscator, '_run_ast_transforms', return_value=True) as run_transforms_mock, + patch('pyjsclear.deobfuscator.time', fake_time), + ): + result = deobfuscator.execute() + + # Exactly one transform cycle ran before the budget expired. + assert run_transforms_mock.call_count == 1 + # All three clock reads happened: the cycle-1 check executed and + # tripped (i.e. the loop did not end early for another reason). + assert fake_time.monotonic.call_count == 3 + assert isinstance(result, str) + assert result + + def test_exhausted_budget_skips_transform_cycles_entirely(self): + # The check sits at the top of each outer cycle: a zero budget is + # already expired at cycle 0, so no transform cycle should run at all, + # yet execute() must still return valid source (best-so-far path). + deobfuscator = Deobfuscator('var a = 1; var b = a; console.log(b);', time_budget_seconds=0.0) + + with patch.object(Deobfuscator, '_run_ast_transforms', return_value=False) as run_transforms_mock: + result = deobfuscator.execute() + + run_transforms_mock.assert_not_called() + assert isinstance(result, str) + assert result + + def test_recursive_pre_pass_deobfuscator_inherits_the_budget(self): + # When a pre-pass decodes a nested layer (JSFuck/eval-packed), the + # recursively constructed Deobfuscator must receive the same budget. + outer = Deobfuscator('outer code', time_budget_seconds=1.5) + nested = MagicMock() + nested.execute.return_value = 'const x = 1;' + + with ( + patch.object(Deobfuscator, '_run_pre_passes', return_value='var x = 1;'), + patch('pyjsclear.deobfuscator.Deobfuscator', return_value=nested) as constructor_mock, + ): + result = outer.execute() + + constructor_mock.assert_called_once_with('var x = 1;', max_iterations=50, time_budget_seconds=1.5) + assert result == 'const x = 1;' From 8865795f823234b9e3365ec2af9bc0a7bd41e60b Mon Sep 17 00:00:00 2001 From: Itamar Gafni Date: Mon, 13 Jul 2026 12:35:33 +0300 Subject: [PATCH 3/7] docs: tighten the time_budget_seconds docstring [TKT-16478] prepare-pr-next polish: condense the repeated coarse-budget clauses in the deobfuscate/deobfuscate_file docstrings. No behavior change. Co-Authored-By: Claude Fable 5 --- pyjsclear/__init__.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/pyjsclear/__init__.py b/pyjsclear/__init__.py index c17f9f8..a5efaa5 100644 --- a/pyjsclear/__init__.py +++ b/pyjsclear/__init__.py @@ -25,13 +25,11 @@ def deobfuscate( Args: code: JavaScript source code string. max_iterations: Maximum transform passes (default 50). - time_budget_seconds: Optional coarse wall-clock budget. It is checked - between transform cycles only — a single stuck transform is NOT - interrupted (callers needing a hard bound must enforce it - externally). On expiry, the best result so far is returned. - The budget restarts at each nested decode layer (JSFuck/ - eval-packed recursion); it is not a global deadline for the - whole call. ``None`` (default) means no budget. + time_budget_seconds: Optional coarse wall-clock budget, checked between + transform cycles (a stuck transform is not interrupted — enforce a + hard bound externally). Restarts at each nested decode layer, so it + is not a global deadline; on expiry the best result so far is + returned. ``None`` (default) disables it. Returns: Deobfuscated JavaScript source code. @@ -55,13 +53,11 @@ def deobfuscate_file( input_path: Path to input JS file. output_path: Path to write output (if None, returns string). max_iterations: Maximum transform passes. - time_budget_seconds: Optional coarse wall-clock budget. It is checked - between transform cycles only — a single stuck transform is NOT - interrupted (callers needing a hard bound must enforce it - externally). On expiry, the best result so far is returned. - The budget restarts at each nested decode layer (JSFuck/ - eval-packed recursion); it is not a global deadline for the - whole call. ``None`` (default) means no budget. + time_budget_seconds: Optional coarse wall-clock budget, checked between + transform cycles (a stuck transform is not interrupted — enforce a + hard bound externally). Restarts at each nested decode layer, so it + is not a global deadline; on expiry the best result so far is + returned. ``None`` (default) disables it. Returns: True if content changed (when output_path given), or the deobfuscated string. From abbbec4c860ed7c11aa7df17d0a808f90d59e8c8 Mon Sep 17 00:00:00 2001 From: Itamar Gafni Date: Thu, 23 Jul 2026 10:08:17 +0300 Subject: [PATCH 4/7] fix: pin esprima2 below 5.0.2 to keep the build green esprima2 5.0.2 changed optional-chaining (a?.b) parsing to the ESTree ChainExpression wrapper node. The code generator has no ChainExpression handler (emits "/* unknown: ChainExpression */") and the deobfuscation transforms don't see through the wrapper, so on a fresh install (which floated the unpinned dependency up to 5.0.2/6.0.0) the suite fails on optional-chaining round-trips, invalid ?? / || output, and leftover _0x identifiers. The last green build (March) resolved to 5.0.1. Cap the dependency at the tested range to restore a green build; full esprima2 >=5.0.2 support (generator + transforms + snapshot) is a separate follow-up. Ref: TKT-16478 Co-Authored-By: Claude Fable 5 --- pyproject.toml | 4 +++- requirements.txt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c25ca8..7eb8c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,9 @@ description = "Pure Python JavaScript deobfuscator" readme = "README.md" license = "Apache-2.0" requires-python = ">=3.11" -dependencies = ["esprima2>=5.0.1"] +# esprima2 capped below 5.0.2: it switched optional-chaining to the ChainExpression AST wrapper +# the generator/transforms don't yet support (tracked as a follow-up). +dependencies = ["esprima2>=5.0.1,<5.0.2"] 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..a8d1c1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -esprima2>=5.0.0 +# Cap below 5.0.2: it changed optional-chaining (a?.b) parsing to the ESTree ChainExpression +# wrapper, which the generator and transforms don't yet support (tracked as a follow-up). +esprima2>=5.0.0,<5.0.2 From 405b8956580dc5832c0823aa9c01c8ebd4cbd534 Mon Sep 17 00:00:00 2001 From: Itamar Gafni Date: Thu, 23 Jul 2026 10:21:33 +0300 Subject: [PATCH 5/7] docs: condense docstrings and strengthen parent-map tests Tighten the docstrings added for the incremental parent-map maintenance and the optional deobfuscate() time budget, removing redundancy while keeping the behavioural caveats. Replace the wall-clock parent-map regression test with deterministic build_parent_map call-count assertions (immune to machine variance) and drop a budget test that duplicated existing hex-escape coverage. Co-Authored-By: Claude Fable 5 --- pyjsclear/__init__.py | 16 ++-- pyjsclear/deobfuscator.py | 16 ++-- pyjsclear/transforms/base.py | 20 ++--- tests/unit/deobfuscate_budget_test.py | 8 -- tests/unit/incremental_parent_map_test.py | 98 ++++++++++++++--------- 5 files changed, 78 insertions(+), 80 deletions(-) diff --git a/pyjsclear/__init__.py b/pyjsclear/__init__.py index a5efaa5..e03cbb3 100644 --- a/pyjsclear/__init__.py +++ b/pyjsclear/__init__.py @@ -25,11 +25,9 @@ def deobfuscate( Args: code: JavaScript source code string. max_iterations: Maximum transform passes (default 50). - time_budget_seconds: Optional coarse wall-clock budget, checked between - transform cycles (a stuck transform is not interrupted — enforce a - hard bound externally). Restarts at each nested decode layer, so it - is not a global deadline; on expiry the best result so far is - returned. ``None`` (default) disables it. + time_budget_seconds: Optional coarse wall-clock budget checked between transform + cycles (a stuck transform is not interrupted — enforce a hard bound externally); + restarts per nested decode layer, returning the best result so far on expiry. None disables it. Returns: Deobfuscated JavaScript source code. @@ -53,11 +51,9 @@ def deobfuscate_file( input_path: Path to input JS file. output_path: Path to write output (if None, returns string). max_iterations: Maximum transform passes. - time_budget_seconds: Optional coarse wall-clock budget, checked between - transform cycles (a stuck transform is not interrupted — enforce a - hard bound externally). Restarts at each nested decode layer, so it - is not a global deadline; on expiry the best result so far is - returned. ``None`` (default) disables it. + time_budget_seconds: Optional coarse wall-clock budget checked between transform + cycles (a stuck transform is not interrupted — enforce a hard bound externally); + restarts per nested decode layer, returning the best result so far on expiry. None disables it. Returns: True if content changed (when output_path given), or the deobfuscated string. diff --git a/pyjsclear/deobfuscator.py b/pyjsclear/deobfuscator.py index 34a53cf..74041b1 100644 --- a/pyjsclear/deobfuscator.py +++ b/pyjsclear/deobfuscator.py @@ -247,13 +247,10 @@ def _try_parse_or_fallback(self, code: str) -> dict | str: def _transform_loop(self, syntax_tree: dict, code: str) -> str: """Run the outer generate-reparse convergence loop and post-passes. - When ``time_budget_seconds`` is set, the elapsed wall-clock time is - checked inline at the top of each outer cycle (never via exceptions, - which the pipeline's broad ``except Exception`` handlers would - swallow); once exceeded, the loop stops and the best result so far - flows into the normal return path. The budget restarts at each nested - decode layer (JSFuck/eval-packed recursion); it is not a global - deadline for the whole call. + When ``time_budget_seconds`` is set, elapsed wall-clock is polled at the top of each + outer cycle (not raised as an exception, which the pipeline's broad ``except Exception`` + handlers would swallow); on expiry the loop stops and the best result so far is returned. + The budget restarts at each nested decode layer, so it is not a global deadline. Returns the best deobfuscated source produced. """ @@ -263,10 +260,7 @@ def _transform_loop(self, syntax_tree: dict, code: str) -> str: try: for _cycle in range(self._MAX_OUTER_CYCLES): - if ( - self.time_budget_seconds is not None - and time.monotonic() - start_time >= self.time_budget_seconds - ): + if self.time_budget_seconds is not None and time.monotonic() - start_time >= self.time_budget_seconds: break changed = self._run_ast_transforms( diff --git a/pyjsclear/transforms/base.py b/pyjsclear/transforms/base.py index db6d02d..739375c 100644 --- a/pyjsclear/transforms/base.py +++ b/pyjsclear/transforms/base.py @@ -54,24 +54,18 @@ def get_parent_map(self) -> dict[int, tuple[dict, str, int | None]]: def invalidate_parent_map(self) -> None: """Drop the cached parent map so the next lookup rebuilds it. - Prefer record_replacement() after an in-place node swap: it keeps the - cached map valid in O(1) instead of forcing an O(N) full rebuild on the - next find_parent (which is quadratic when many nodes are replaced). + Prefer record_replacement() after an in-place swap: it keeps the map valid in O(1) + instead of forcing an O(N) rebuild on the next find_parent (quadratic over many swaps). """ self._parent_map = None - def record_replacement( - self, replacement: dict, parent: dict, key: str, index: int | None - ) -> 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 valid for in-place swaps (parent[key][index] = replacement or - parent[key] = replacement) that leave list indices unchanged; for - insertions/removals that shift indices, call invalidate_parent_map() - instead. The detached original subtree's entries go stale — callers - must not look them up afterwards — and descendants of the replacement - are not registered, so find_parent on them returns None until a full - rebuild. + Valid only for swaps that leave list indices unchanged; for insertions/removals that + shift indices, call invalidate_parent_map() instead. The detached original subtree's + entries go stale (don't look them up), and descendants of the replacement aren't + registered, so find_parent returns None on them until a full rebuild. """ if self._parent_map is not None: self._parent_map[id(replacement)] = (parent, key, index) diff --git a/tests/unit/deobfuscate_budget_test.py b/tests/unit/deobfuscate_budget_test.py index 3a11ad9..a542f0c 100644 --- a/tests/unit/deobfuscate_budget_test.py +++ b/tests/unit/deobfuscate_budget_test.py @@ -27,14 +27,6 @@ def test_deobfuscate_file_accepts_a_time_budget(self, tmp_path): result = pyjsclear.deobfuscate_file(str(input_file), time_budget_seconds=0.001) assert isinstance(result, str) - def test_without_budget_behavior_is_unchanged(self): - # Mirrors an existing proven deobfuscation (hex escape decoding) to show - # the default (no kwarg) path still fully deobfuscates. - code = 'var x = "\\x48\\x65\\x6c\\x6c\\x6f";' - result = pyjsclear.deobfuscate(code) - assert '\\x48' not in result - assert 'Hello' in result - class TestDeobfuscatorBudgetInternals: def test_budget_defaults_to_none_and_is_stored_when_given(self): diff --git a/tests/unit/incremental_parent_map_test.py b/tests/unit/incremental_parent_map_test.py index c5c03dd..179426c 100644 --- a/tests/unit/incremental_parent_map_test.py +++ b/tests/unit/incremental_parent_map_test.py @@ -1,51 +1,73 @@ -"""Performance regression test for incremental parent-map maintenance (TKT-16478). +"""Tests for incremental parent-map maintenance across transform call sites (TKT-16478). -Transforms replace nodes in place; before the fix, every replacement dropped the -cached parent map, forcing a full O(N) rebuild on the next lookup — quadratic -overall when thousands of proxy-object references are inlined. +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. """ -import signal -import time +from unittest.mock import patch -import pyjsclear +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 -_PROXY_OBJECT_OBFUSCATION = ( - "var _0xmap = {" - + ",".join(f"'k{i}': {i}" for i in range(4000)) - + "};\n" - + ";".join(f"console.log(_0xmap.k{i})" for i in range(4000)) - + ";\n" -) +_REFERENCE_COUNT = 200 -class _TestTimeout(BaseException): - """Raised by the alarm handler. +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) - Derives from BaseException so it sails past the deobfuscator pipeline's - broad `except Exception` (deobfuscator.py) and actually kills the test at - the deadline instead of being swallowed and stalling CI for the full - quadratic runtime. - """ + # 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 TestIncrementalParentMap: - def test_object_simplifier_is_not_quadratic_on_many_references(self): - def _timeout(signum, frame): - raise _TestTimeout() - old_handler = signal.signal(signal.SIGALRM, _timeout) - signal.setitimer(signal.ITIMER_REAL, 20) - try: - start = time.monotonic() - result = pyjsclear.deobfuscate(_PROXY_OBJECT_OBFUSCATION) - elapsed = time.monotonic() - start - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, old_handler) +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) - # Inlining happened (proxy map references replaced by their literal values). - assert '_0xmap.k0' not in result - # And it did not take quadratic time (pre-fix this is minutes). - assert elapsed < 15 + # 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 From f5e3f5d7c18006ec9a0262373a5df7ef20506ac5 Mon Sep 17 00:00:00 2001 From: Itamar Gafni Date: Thu, 23 Jul 2026 10:37:50 +0300 Subject: [PATCH 6/7] Remove the optional deobfuscate() time budget Drop the time_budget_seconds parameter from deobfuscate(), deobfuscate_file(), and Deobfuscator, along with its test. Time limiting is enforced externally by the caller, so an in-library budget is redundant. The incremental parent-map maintenance (the main performance fix) is unaffected. Co-Authored-By: Claude Fable 5 --- pyjsclear/__init__.py | 21 +------ pyjsclear/deobfuscator.py | 25 +------- tests/unit/deobfuscate_budget_test.py | 88 --------------------------- 3 files changed, 5 insertions(+), 129 deletions(-) delete mode 100644 tests/unit/deobfuscate_budget_test.py diff --git a/pyjsclear/__init__.py b/pyjsclear/__init__.py index e03cbb3..ee5c57b 100644 --- a/pyjsclear/__init__.py +++ b/pyjsclear/__init__.py @@ -15,35 +15,23 @@ __version__ = '0.1.6' -def deobfuscate( - code: str, - max_iterations: int = 50, - time_budget_seconds: float | None = None, -) -> str: +def deobfuscate(code: str, max_iterations: int = 50) -> str: """Deobfuscate JavaScript code and return cleaned source. Args: code: JavaScript source code string. max_iterations: Maximum transform passes (default 50). - time_budget_seconds: Optional coarse wall-clock budget checked between transform - cycles (a stuck transform is not interrupted — enforce a hard bound externally); - restarts per nested decode layer, returning the best result so far on expiry. None disables it. Returns: Deobfuscated JavaScript source code. """ - return Deobfuscator( - code, - max_iterations=max_iterations, - time_budget_seconds=time_budget_seconds, - ).execute() + return Deobfuscator(code, max_iterations=max_iterations).execute() def deobfuscate_file( input_path: str | Path, output_path: str | Path | None = None, max_iterations: int = 50, - time_budget_seconds: float | None = None, ) -> str | bool: """Deobfuscate a JavaScript file. @@ -51,9 +39,6 @@ def deobfuscate_file( input_path: Path to input JS file. output_path: Path to write output (if None, returns string). max_iterations: Maximum transform passes. - time_budget_seconds: Optional coarse wall-clock budget checked between transform - cycles (a stuck transform is not interrupted — enforce a hard bound externally); - restarts per nested decode layer, returning the best result so far on expiry. None disables it. Returns: True if content changed (when output_path given), or the deobfuscated string. @@ -61,7 +46,7 @@ def deobfuscate_file( with open(input_path, 'r', errors='replace') as input_file: code = input_file.read() - result = deobfuscate(code, max_iterations=max_iterations, time_budget_seconds=time_budget_seconds) + result = deobfuscate(code, max_iterations=max_iterations) if not output_path: return result diff --git a/pyjsclear/deobfuscator.py b/pyjsclear/deobfuscator.py index 74041b1..fb59b50 100644 --- a/pyjsclear/deobfuscator.py +++ b/pyjsclear/deobfuscator.py @@ -2,8 +2,6 @@ from __future__ import annotations -import time - from .generator import generate from .parser import parse from .scope import build_scope_tree @@ -173,15 +171,9 @@ class Deobfuscator: _MAX_OUTER_CYCLES: int = 5 - def __init__( - self, - code: str, - max_iterations: int = 50, - time_budget_seconds: float | None = None, - ) -> None: + def __init__(self, code: str, max_iterations: int = 50) -> None: self.original_code: str = code self.max_iterations: int = max_iterations - self.time_budget_seconds: float | None = time_budget_seconds def _run_pre_passes(self, code: str) -> str | None: """Detect whole-file encodings (JSFuck, AAEncode, etc.) and decode them. @@ -217,11 +209,7 @@ def execute(self) -> str: decoded = self._run_pre_passes(code) if decoded: - recursive_deobfuscator = Deobfuscator( - decoded, - max_iterations=self.max_iterations, - time_budget_seconds=self.time_budget_seconds, - ) + recursive_deobfuscator = Deobfuscator(decoded, max_iterations=self.max_iterations) return recursive_deobfuscator.execute() syntax_tree = self._try_parse_or_fallback(code) @@ -247,22 +235,13 @@ def _try_parse_or_fallback(self, code: str) -> dict | str: def _transform_loop(self, syntax_tree: dict, code: str) -> str: """Run the outer generate-reparse convergence loop and post-passes. - When ``time_budget_seconds`` is set, elapsed wall-clock is polled at the top of each - outer cycle (not raised as an exception, which the pipeline's broad ``except Exception`` - handlers would swallow); on expiry the loop stops and the best result so far is returned. - The budget restarts at each nested decode layer, so it is not a global deadline. - Returns the best deobfuscated source produced. """ previous_code = code last_changed_tree: dict | None = None - start_time = time.monotonic() try: for _cycle in range(self._MAX_OUTER_CYCLES): - if self.time_budget_seconds is not None and time.monotonic() - start_time >= self.time_budget_seconds: - break - changed = self._run_ast_transforms( syntax_tree, code_size=len(previous_code), diff --git a/tests/unit/deobfuscate_budget_test.py b/tests/unit/deobfuscate_budget_test.py deleted file mode 100644 index a542f0c..0000000 --- a/tests/unit/deobfuscate_budget_test.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Tests for the optional wall-clock budget on deobfuscation (TKT-16478). - -The budget is deliberately coarse: it is checked inline between outer transform -cycles (never via exceptions, which the pipeline's broad ``except Exception`` -handlers would swallow). On expiry the best result so far is returned. The -budget restarts at each nested decode layer (JSFuck/eval-packed recursion). -""" - -from unittest.mock import MagicMock -from unittest.mock import patch - -import pyjsclear -from pyjsclear.deobfuscator import Deobfuscator - - -class TestDeobfuscateBudget: - def test_deobfuscate_accepts_a_time_budget_and_returns_a_string(self): - # Budget is honored between outer cycles; a tiny budget still returns valid source. - out = pyjsclear.deobfuscate('var a=1;var b=2;console.log(a+b);', time_budget_seconds=0.001) - assert isinstance(out, str) - assert out - - def test_deobfuscate_file_accepts_a_time_budget(self, tmp_path): - input_file = tmp_path / 'input.js' - input_file.write_text('var x = 1;') - - result = pyjsclear.deobfuscate_file(str(input_file), time_budget_seconds=0.001) - assert isinstance(result, str) - - -class TestDeobfuscatorBudgetInternals: - def test_budget_defaults_to_none_and_is_stored_when_given(self): - assert Deobfuscator('var x = 1;').time_budget_seconds is None - assert Deobfuscator('var x = 1;', time_budget_seconds=2.5).time_budget_seconds == 2.5 - - def test_budget_expiry_mid_loop_stops_after_one_cycle_and_returns_source(self): - # Deterministic mid-loop expiry: fake the clock so the budget check - # passes at cycle 0 and trips at cycle 1. Expected monotonic() calls on - # this path (verified against deobfuscator.py, the only module using - # time): start capture -> 0.0, cycle-0 check -> 0.0, cycle-1 check -> - # 10.0. The `time` attribute is patched in the deobfuscator module - # namespace only, so nothing else consumes the side_effect values. - fake_time = MagicMock() - fake_time.monotonic.side_effect = [0.0, 0.0, 10.0] - deobfuscator = Deobfuscator('var a = 1; var b = a; console.log(b);', time_budget_seconds=5.0) - - with ( - patch.object(Deobfuscator, '_run_ast_transforms', return_value=True) as run_transforms_mock, - patch('pyjsclear.deobfuscator.time', fake_time), - ): - result = deobfuscator.execute() - - # Exactly one transform cycle ran before the budget expired. - assert run_transforms_mock.call_count == 1 - # All three clock reads happened: the cycle-1 check executed and - # tripped (i.e. the loop did not end early for another reason). - assert fake_time.monotonic.call_count == 3 - assert isinstance(result, str) - assert result - - def test_exhausted_budget_skips_transform_cycles_entirely(self): - # The check sits at the top of each outer cycle: a zero budget is - # already expired at cycle 0, so no transform cycle should run at all, - # yet execute() must still return valid source (best-so-far path). - deobfuscator = Deobfuscator('var a = 1; var b = a; console.log(b);', time_budget_seconds=0.0) - - with patch.object(Deobfuscator, '_run_ast_transforms', return_value=False) as run_transforms_mock: - result = deobfuscator.execute() - - run_transforms_mock.assert_not_called() - assert isinstance(result, str) - assert result - - def test_recursive_pre_pass_deobfuscator_inherits_the_budget(self): - # When a pre-pass decodes a nested layer (JSFuck/eval-packed), the - # recursively constructed Deobfuscator must receive the same budget. - outer = Deobfuscator('outer code', time_budget_seconds=1.5) - nested = MagicMock() - nested.execute.return_value = 'const x = 1;' - - with ( - patch.object(Deobfuscator, '_run_pre_passes', return_value='var x = 1;'), - patch('pyjsclear.deobfuscator.Deobfuscator', return_value=nested) as constructor_mock, - ): - result = outer.execute() - - constructor_mock.assert_called_once_with('var x = 1;', max_iterations=50, time_budget_seconds=1.5) - assert result == 'const x = 1;' From 046b284b2c898393532b5fd0eddc11e3117fbba1 Mon Sep 17 00:00:00 2001 From: Itamar Gafni Date: Thu, 23 Jul 2026 11:07:24 +0300 Subject: [PATCH 7/7] feat: support esprima2 6.0.0 optional chains esprima2 5.0.2+ wraps optional chains (a?.b) in an ESTree ChainExpression node and rejects ?? mixed with ||/&& without parentheses. Add a generator handler and traverser child-key entry so chains round-trip and identifiers inside them still resolve, and parenthesize ?? next to ||/&&. Pin esprima2==6.0.0 and bump version to 0.1.6. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyjsclear/generator.py | 28 +++++++++++++++++++++++--- pyjsclear/transforms/base.py | 9 +++------ pyjsclear/utils/ast_helpers.py | 1 + pyproject.toml | 4 +--- requirements.txt | 4 +--- tests/resources/sample.deobfuscated.js | 4 ++-- tests/unit/generator_test.py | 24 ++++++++++++++++++++++ tests/unit/traverser_test.py | 6 ++++++ 8 files changed, 63 insertions(+), 17 deletions(-) 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 739375c..02fc1e8 100644 --- a/pyjsclear/transforms/base.py +++ b/pyjsclear/transforms/base.py @@ -54,18 +54,15 @@ def get_parent_map(self) -> dict[int, tuple[dict, str, int | None]]: def invalidate_parent_map(self) -> None: """Drop the cached parent map so the next lookup rebuilds it. - Prefer record_replacement() after an in-place swap: it keeps the map valid in O(1) - instead of forcing an O(N) rebuild on the next find_parent (quadratic over many swaps). + 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. - Valid only for swaps that leave list indices unchanged; for insertions/removals that - shift indices, call invalidate_parent_map() instead. The detached original subtree's - entries go stale (don't look them up), and descendants of the replacement aren't - registered, so find_parent returns None on them until a full rebuild. + 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) 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 7eb8c91..68966fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,9 +9,7 @@ description = "Pure Python JavaScript deobfuscator" readme = "README.md" license = "Apache-2.0" requires-python = ">=3.11" -# esprima2 capped below 5.0.2: it switched optional-chaining to the ChainExpression AST wrapper -# the generator/transforms don't yet support (tracked as a follow-up). -dependencies = ["esprima2>=5.0.1,<5.0.2"] +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 a8d1c1b..aa62669 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1 @@ -# Cap below 5.0.2: it changed optional-chaining (a?.b) parsing to the ESTree ChainExpression -# wrapper, which the generator and transforms don't yet support (tracked as a follow-up). -esprima2>=5.0.0,<5.0.2 +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/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