pyjsclear 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. pyjsclear-0.1.4/NOTICE +28 -0
  2. {pyjsclear-0.1.2/pyjsclear.egg-info → pyjsclear-0.1.4}/PKG-INFO +14 -16
  3. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/README.md +13 -15
  4. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/THIRD_PARTY_LICENSES.md +27 -0
  5. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/__init__.py +7 -7
  6. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/__main__.py +4 -3
  7. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/deobfuscator.py +126 -36
  8. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/generator.py +111 -110
  9. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/parser.py +2 -2
  10. pyjsclear-0.1.4/pyjsclear/scope.py +505 -0
  11. pyjsclear-0.1.4/pyjsclear/transforms/aa_decode.py +150 -0
  12. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/anti_tamper.py +12 -15
  13. pyjsclear-0.1.4/pyjsclear/transforms/base.py +58 -0
  14. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/class_static_resolver.py +27 -21
  15. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/class_string_decoder.py +64 -63
  16. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/cleanup.py +54 -43
  17. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/constant_prop.py +41 -32
  18. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/control_flow.py +94 -97
  19. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/dead_branch.py +9 -9
  20. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/dead_class_props.py +55 -52
  21. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/dead_expressions.py +5 -5
  22. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/dead_object_props.py +29 -29
  23. pyjsclear-0.1.4/pyjsclear/transforms/else_if_flatten.py +32 -0
  24. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/enum_resolver.py +15 -15
  25. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/eval_unpack.py +28 -27
  26. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/expression_simplifier.py +96 -78
  27. pyjsclear-0.1.4/pyjsclear/transforms/global_alias.py +128 -0
  28. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/hex_escapes.py +10 -13
  29. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/hex_numerics.py +2 -2
  30. pyjsclear-0.1.4/pyjsclear/transforms/jj_decode.py +720 -0
  31. pyjsclear-0.1.4/pyjsclear/transforms/jsfuck_decode.py +610 -0
  32. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/logical_to_if.py +57 -57
  33. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/member_chain_resolver.py +51 -73
  34. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/noop_calls.py +22 -20
  35. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/nullish_coalescing.py +39 -28
  36. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/object_packer.py +11 -13
  37. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/object_simplifier.py +44 -41
  38. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/optional_chaining.py +44 -43
  39. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/property_simplifier.py +4 -4
  40. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/proxy_functions.py +38 -35
  41. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/reassignment.py +26 -17
  42. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/require_inliner.py +13 -17
  43. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/sequence_splitter.py +59 -61
  44. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/single_use_vars.py +30 -21
  45. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/string_revealer.py +99 -88
  46. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/unreachable_code.py +14 -13
  47. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/unused_vars.py +22 -19
  48. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/variable_renamer.py +108 -108
  49. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/xor_string_decode.py +39 -39
  50. pyjsclear-0.1.4/pyjsclear/traverser.py +434 -0
  51. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/utils/ast_helpers.py +36 -36
  52. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/utils/string_decoders.py +18 -19
  53. {pyjsclear-0.1.2 → pyjsclear-0.1.4/pyjsclear.egg-info}/PKG-INFO +14 -16
  54. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear.egg-info/SOURCES.txt +2 -0
  55. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/tests/test_regression.py +26 -29
  56. pyjsclear-0.1.2/NOTICE +0 -19
  57. pyjsclear-0.1.2/pyjsclear/scope.py +0 -309
  58. pyjsclear-0.1.2/pyjsclear/transforms/aa_decode.py +0 -83
  59. pyjsclear-0.1.2/pyjsclear/transforms/base.py +0 -24
  60. pyjsclear-0.1.2/pyjsclear/transforms/else_if_flatten.py +0 -32
  61. pyjsclear-0.1.2/pyjsclear/transforms/global_alias.py +0 -117
  62. pyjsclear-0.1.2/pyjsclear/traverser.py +0 -195
  63. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/LICENSE +0 -0
  64. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/MANIFEST.in +0 -0
  65. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/transforms/__init__.py +0 -0
  66. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear/utils/__init__.py +0 -0
  67. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear.egg-info/dependency_links.txt +0 -0
  68. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear.egg-info/entry_points.txt +0 -0
  69. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear.egg-info/requires.txt +0 -0
  70. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyjsclear.egg-info/top_level.txt +0 -0
  71. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/pyproject.toml +0 -0
  72. {pyjsclear-0.1.2 → pyjsclear-0.1.4}/setup.cfg +0 -0
pyjsclear-0.1.4/NOTICE ADDED
@@ -0,0 +1,28 @@
1
+ PyJSClear
2
+ Copyright 2026 Intezer Labs
3
+
4
+ This product is a derivative work based on the following projects:
5
+
6
+ 1. obfuscator-io-deobfuscator (v1.0.6)
7
+ Copyright Ben (ben-sb)
8
+ Licensed under the Apache License, Version 2.0
9
+ https://github.com/ben-sb/obfuscator-io-deobfuscator
10
+
11
+ 2. javascript-deobfuscator
12
+ Copyright Ben (ben-sb)
13
+ Licensed under the Apache License, Version 2.0
14
+ https://github.com/ben-sb/javascript-deobfuscator
15
+
16
+ 3. webcrack (v2.14.1)
17
+ Copyright (c) 2023 j4k0xb
18
+ Licensed under the MIT License
19
+ https://github.com/j4k0xb/webcrack
20
+
21
+ This Python library re-implements the deobfuscation algorithms and transform
22
+ logic from the above projects in pure Python. No source code was directly
23
+ copied; the implementations were written from scratch following the same
24
+ algorithmic approaches.
25
+
26
+ Test dataset: obfuscated JavaScript samples from the JSIMPLIFIER dataset
27
+ (https://zenodo.org/records/17531662) are included in tests/resources/ for
28
+ evaluation purposes only. They are not part of the distributed package.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyjsclear
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Pure Python JavaScript deobfuscator
5
5
  Author-email: Intezer Labs <info@intezer.com>
6
6
  License-Expression: Apache-2.0
@@ -82,11 +82,12 @@ pyjsclear input.js --max-iterations 20
82
82
 
83
83
  ## What it does
84
84
 
85
- PyJSClear applies ~40 transforms in a multi-pass loop until the code
86
- stabilises (default limit: 50 iterations). A final one-shot pass renames
85
+ PyJSClear applies transforms in a multi-pass loop until the code
86
+ stabilizes (default limit: 50 iterations). A final one-shot pass renames
87
87
  variables and converts var/let to const.
88
88
 
89
89
  **Capabilities:**
90
+ - Whole-file encoding detection: JSFuck, JJEncode, AAEncode, eval-packing
90
91
  - String array decoding (obfuscator.io basic/base64/RC4, XOR, class-based)
91
92
  - Constant propagation & reassignment elimination
92
93
  - Dead code / dead branch / unreachable code removal
@@ -99,20 +100,11 @@ variables and converts var/let to const.
99
100
  Large files (>500 KB / >50 K AST nodes) automatically use a lite mode
100
101
  that skips expensive transforms.
101
102
 
102
- ## Testing
103
-
104
- ```bash
105
- pytest tests/ # all tests
106
- pytest tests/test_regression.py # regression suite (62 tests across 25 samples)
107
- pytest tests/ -n auto # parallel execution (requires pytest-xdist)
108
- ```
109
-
110
103
  ## Limitations
111
104
 
112
- - **Optimised for obfuscator.io output.** Other obfuscation tools may only partially deobfuscate.
105
+ - **Best results on obfuscator.io output.** JSFuck, JJEncode, AAEncode, and eval-packed code are fully decoded; other obfuscation tools may only partially deobfuscate.
113
106
  - **Large files get reduced treatment.** Files >500 KB or ASTs >50 K nodes skip expensive transforms; files >2 MB use a minimal lite mode.
114
- - **No minification reversal.** Minified-but-not-obfuscated code won't be reformatted or beautified.
115
- - **Recursive AST traversal** may hit Python's default recursion limit (~1 000 frames) on extremely deep nesting.
107
+ - **Recursive AST traversal** may hit Python's default recursion limit (~1 000 frames) on extremely deep nesting; the deobfuscator catches this and returns the best partial result.
116
108
 
117
109
  ## License
118
110
 
@@ -120,7 +112,13 @@ Apache License 2.0 — see [LICENSE](LICENSE).
120
112
 
121
113
  This project is a derivative work based on
122
114
  [obfuscator-io-deobfuscator](https://github.com/ben-sb/obfuscator-io-deobfuscator)
123
- (Apache 2.0) and
115
+ (Apache 2.0),
124
116
  [javascript-deobfuscator](https://github.com/ben-sb/javascript-deobfuscator)
125
- (Apache 2.0). See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
117
+ (Apache 2.0), and
118
+ [webcrack](https://github.com/j4k0xb/webcrack) (MIT).
119
+ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
126
120
  [NOTICE](NOTICE) for full attribution.
121
+
122
+ Test samples include obfuscated JavaScript from the
123
+ [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0),
124
+ used solely for evaluation purposes.
@@ -56,11 +56,12 @@ pyjsclear input.js --max-iterations 20
56
56
 
57
57
  ## What it does
58
58
 
59
- PyJSClear applies ~40 transforms in a multi-pass loop until the code
60
- stabilises (default limit: 50 iterations). A final one-shot pass renames
59
+ PyJSClear applies transforms in a multi-pass loop until the code
60
+ stabilizes (default limit: 50 iterations). A final one-shot pass renames
61
61
  variables and converts var/let to const.
62
62
 
63
63
  **Capabilities:**
64
+ - Whole-file encoding detection: JSFuck, JJEncode, AAEncode, eval-packing
64
65
  - String array decoding (obfuscator.io basic/base64/RC4, XOR, class-based)
65
66
  - Constant propagation & reassignment elimination
66
67
  - Dead code / dead branch / unreachable code removal
@@ -73,20 +74,11 @@ variables and converts var/let to const.
73
74
  Large files (>500 KB / >50 K AST nodes) automatically use a lite mode
74
75
  that skips expensive transforms.
75
76
 
76
- ## Testing
77
-
78
- ```bash
79
- pytest tests/ # all tests
80
- pytest tests/test_regression.py # regression suite (62 tests across 25 samples)
81
- pytest tests/ -n auto # parallel execution (requires pytest-xdist)
82
- ```
83
-
84
77
  ## Limitations
85
78
 
86
- - **Optimised for obfuscator.io output.** Other obfuscation tools may only partially deobfuscate.
79
+ - **Best results on obfuscator.io output.** JSFuck, JJEncode, AAEncode, and eval-packed code are fully decoded; other obfuscation tools may only partially deobfuscate.
87
80
  - **Large files get reduced treatment.** Files >500 KB or ASTs >50 K nodes skip expensive transforms; files >2 MB use a minimal lite mode.
88
- - **No minification reversal.** Minified-but-not-obfuscated code won't be reformatted or beautified.
89
- - **Recursive AST traversal** may hit Python's default recursion limit (~1 000 frames) on extremely deep nesting.
81
+ - **Recursive AST traversal** may hit Python's default recursion limit (~1 000 frames) on extremely deep nesting; the deobfuscator catches this and returns the best partial result.
90
82
 
91
83
  ## License
92
84
 
@@ -94,7 +86,13 @@ Apache License 2.0 — see [LICENSE](LICENSE).
94
86
 
95
87
  This project is a derivative work based on
96
88
  [obfuscator-io-deobfuscator](https://github.com/ben-sb/obfuscator-io-deobfuscator)
97
- (Apache 2.0) and
89
+ (Apache 2.0),
98
90
  [javascript-deobfuscator](https://github.com/ben-sb/javascript-deobfuscator)
99
- (Apache 2.0). See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
91
+ (Apache 2.0), and
92
+ [webcrack](https://github.com/j4k0xb/webcrack) (MIT).
93
+ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
100
94
  [NOTICE](NOTICE) for full attribution.
95
+
96
+ Test samples include obfuscated JavaScript from the
97
+ [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0),
98
+ used solely for evaluation purposes.
@@ -237,3 +237,30 @@ https://github.com/ben-sb/javascript-deobfuscator/blob/master/LICENSE).
237
237
  **Features derived from this project:** hex escape decoding (`--he`),
238
238
  static array unpacking (`--su`), property access transformation (`--tp`).
239
239
 
240
+ ---
241
+
242
+ ## webcrack
243
+
244
+ - **Version:** 2.14.1
245
+ - **Author:** j4k0xb
246
+ - **Repository:** https://github.com/j4k0xb/webcrack
247
+ - **License:** MIT
248
+
249
+ See [licenses/LICENSE-webcrack](licenses/LICENSE-webcrack) for the full license text.
250
+
251
+ **Features derived from this project:** general deobfuscation transform
252
+ patterns and architecture reference.
253
+
254
+ ---
255
+
256
+ ## Test dataset
257
+
258
+ - **JSIMPLIFIER dataset**
259
+ - **Author:** Dongchao Zhou
260
+ - **Source:** https://zenodo.org/records/17531662
261
+ - **License:** GPL-3.0
262
+
263
+ Obfuscated JavaScript samples from this dataset are included in
264
+ `tests/resources/` for evaluation purposes only. They are not part of the
265
+ distributed package and no code from JSIMPLIFIER is incorporated into
266
+ PyJSClear.
@@ -8,10 +8,10 @@ Python package.
8
8
  from .deobfuscator import Deobfuscator
9
9
 
10
10
 
11
- __version__ = '0.1.2'
11
+ __version__ = '0.1.4'
12
12
 
13
13
 
14
- def deobfuscate(code, max_iterations=50):
14
+ def deobfuscate(code: str, max_iterations: int = 50) -> str:
15
15
  """Deobfuscate JavaScript code. Returns cleaned source.
16
16
 
17
17
  Args:
@@ -24,7 +24,7 @@ def deobfuscate(code, max_iterations=50):
24
24
  return Deobfuscator(code, max_iterations=max_iterations).execute()
25
25
 
26
26
 
27
- def deobfuscate_file(input_path, output_path=None, max_iterations=50):
27
+ def deobfuscate_file(input_path: str, output_path: str | None = None, max_iterations: int = 50) -> str | bool:
28
28
  """Deobfuscate a JavaScript file.
29
29
 
30
30
  Args:
@@ -35,13 +35,13 @@ def deobfuscate_file(input_path, output_path=None, max_iterations=50):
35
35
  Returns:
36
36
  True if content changed (when output_path given), or the deobfuscated string.
37
37
  """
38
- with open(input_path, 'r', errors='replace') as f:
39
- code = f.read()
38
+ with open(input_path, 'r', errors='replace') as input_file:
39
+ code = input_file.read()
40
40
 
41
41
  result = deobfuscate(code, max_iterations=max_iterations)
42
42
 
43
43
  if output_path:
44
- with open(output_path, 'w') as f:
45
- f.write(result)
44
+ with open(output_path, 'w') as output_file:
45
+ output_file.write(result)
46
46
  return result != code
47
47
  return result
@@ -6,7 +6,7 @@ import sys
6
6
  from . import deobfuscate
7
7
 
8
8
 
9
- def main():
9
+ def main() -> None:
10
10
  parser = argparse.ArgumentParser(description='Deobfuscate JavaScript files.')
11
11
  parser.add_argument('input', help='Input JS file (use - for stdin)')
12
12
  parser.add_argument('-o', '--output', help='Output file (default: stdout)')
@@ -29,8 +29,9 @@ def main():
29
29
  if args.output:
30
30
  with open(args.output, 'w') as output_file:
31
31
  output_file.write(result)
32
- else:
33
- sys.stdout.write(result)
32
+ return
33
+
34
+ sys.stdout.write(result)
34
35
 
35
36
 
36
37
  if __name__ == '__main__':
@@ -2,6 +2,7 @@
2
2
 
3
3
  from .generator import generate
4
4
  from .parser import parse
5
+ from .scope import build_scope_tree
5
6
  from .transforms.aa_decode import aa_decode
6
7
  from .transforms.aa_decode import is_aa_encoded
7
8
  from .transforms.anti_tamper import AntiTamperRemover
@@ -28,6 +29,10 @@ from .transforms.global_alias import GlobalAliasInliner
28
29
  from .transforms.hex_escapes import HexEscapes
29
30
  from .transforms.hex_escapes import decode_hex_escapes_source
30
31
  from .transforms.hex_numerics import HexNumerics
32
+ from .transforms.jj_decode import is_jj_encoded
33
+ from .transforms.jj_decode import jj_decode
34
+ from .transforms.jsfuck_decode import is_jsfuck
35
+ from .transforms.jsfuck_decode import jsfuck_decode
31
36
  from .transforms.logical_to_if import LogicalToIf
32
37
  from .transforms.member_chain_resolver import MemberChainResolver
33
38
  from .transforms.noop_calls import NoopCallRemover
@@ -49,6 +54,22 @@ from .transforms.xor_string_decode import XorStringDecoder
49
54
  from .traverser import simple_traverse
50
55
 
51
56
 
57
+ # Transforms that use build_scope_tree and benefit from cached scope
58
+ _SCOPE_TRANSFORMS = frozenset(
59
+ {
60
+ ConstantProp,
61
+ SingleUseVarInliner,
62
+ ReassignmentRemover,
63
+ ProxyFunctionInliner,
64
+ UnusedVariableRemover,
65
+ ObjectSimplifier,
66
+ StringRevealer,
67
+ VariableRenamer,
68
+ VarToConst,
69
+ LetToConst,
70
+ }
71
+ )
72
+
52
73
  # StringRevealer runs first to handle string arrays before other transforms
53
74
  # modify the wrapper function structure.
54
75
  # Remaining transforms follow obfuscator-io-deobfuscator order.
@@ -102,37 +123,49 @@ _LITE_MAX_ITERATIONS = 10
102
123
  _NODE_COUNT_LIMIT = 50_000 # Skip ControlFlowRecoverer above this
103
124
 
104
125
 
105
- def _count_nodes(ast):
126
+ def _count_nodes(ast: dict) -> int:
106
127
  """Count total AST nodes."""
107
128
  count = 0
108
129
 
109
- def cb(node, parent):
130
+ def increment_count(node: dict, parent: dict | None) -> None:
110
131
  nonlocal count
111
132
  count += 1
112
133
 
113
- simple_traverse(ast, cb)
134
+ simple_traverse(ast, increment_count)
114
135
  return count
115
136
 
116
137
 
117
138
  class Deobfuscator:
118
139
  """Multi-pass JavaScript deobfuscator."""
119
140
 
120
- def __init__(self, code, max_iterations=50):
141
+ def __init__(self, code: str, max_iterations: int = 50) -> None:
121
142
  self.original_code = code
122
143
  self.max_iterations = max_iterations
123
144
 
124
- def _run_pre_passes(self, code):
145
+ def _run_pre_passes(self, code: str) -> str | None:
125
146
  """Run encoding detection and eval unpacking pre-passes.
126
147
 
127
148
  Returns decoded code if an encoding/packing was detected and decoded,
128
149
  or None to continue with the normal AST pipeline.
129
150
  """
151
+ # JSFuck check (must be first — these are whole-file encodings)
152
+ if is_jsfuck(code):
153
+ decoded = jsfuck_decode(code)
154
+ if decoded:
155
+ return decoded
156
+
130
157
  # AAEncode check
131
158
  if is_aa_encoded(code):
132
159
  decoded = aa_decode(code)
133
160
  if decoded:
134
161
  return decoded
135
162
 
163
+ # JJEncode check
164
+ if is_jj_encoded(code):
165
+ decoded = jj_decode(code)
166
+ if decoded:
167
+ return decoded
168
+
136
169
  # Eval packer check
137
170
  if is_eval_packed(code):
138
171
  decoded = eval_unpack(code)
@@ -141,7 +174,10 @@ class Deobfuscator:
141
174
 
142
175
  return None
143
176
 
144
- def execute(self):
177
+ # Maximum number of outer re-parse cycles (generate → re-parse → re-transform)
178
+ _MAX_OUTER_CYCLES = 5
179
+
180
+ def execute(self) -> str:
145
181
  """Run all transforms and return cleaned source."""
146
182
  code = self.original_code
147
183
 
@@ -162,66 +198,120 @@ class Deobfuscator:
162
198
  return decoded
163
199
  return self.original_code
164
200
 
165
- # Determine optimization mode based on code size
166
- code_size = len(code)
201
+ # Outer loop: run AST transforms until generate→re-parse converges.
202
+ # Post-passes (VariableRenamer, VarToConst, LetToConst) only run on
203
+ # the final cycle to avoid interfering with subsequent transform rounds.
204
+ previous_code = code
205
+ last_changed_ast = None
206
+ try:
207
+ for _cycle in range(self._MAX_OUTER_CYCLES):
208
+ changed = self._run_ast_transforms(
209
+ ast,
210
+ code_size=len(previous_code),
211
+ )
212
+
213
+ if not changed:
214
+ break
215
+
216
+ last_changed_ast = ast
217
+
218
+ try:
219
+ generated = generate(ast)
220
+ except Exception:
221
+ break
222
+
223
+ if generated == previous_code:
224
+ break
225
+
226
+ previous_code = generated
227
+
228
+ # Re-parse for the next cycle
229
+ try:
230
+ ast = parse(generated)
231
+ except SyntaxError:
232
+ break
233
+
234
+ # Run post-passes on the final AST (always — they're cheap and handle
235
+ # cosmetic transforms like var→const even when no main transforms fired)
236
+ any_post_changed = False
237
+ for post_transform in [VariableRenamer, VarToConst, LetToConst]:
238
+ try:
239
+ if post_transform(ast).execute():
240
+ any_post_changed = True
241
+ except Exception:
242
+ pass
243
+
244
+ if last_changed_ast is None and not any_post_changed:
245
+ return self.original_code
246
+
247
+ try:
248
+ return generate(ast)
249
+ except Exception:
250
+ return previous_code
251
+ except RecursionError:
252
+ # Safety net: esprima's parser is purely recursive with no depth
253
+ # limit, so deeply nested JS hits Python's recursion limit during
254
+ # parsing or re-parsing. Our AST walkers are cheaper per level
255
+ # but also recursive. Return best result so far.
256
+ return previous_code
257
+
258
+ def _run_ast_transforms(self, ast: dict, code_size: int = 0) -> bool:
259
+ """Run all AST transform passes. Returns True if any transform changed the AST."""
260
+ node_count = _count_nodes(ast) if code_size > _LARGE_FILE_SIZE else 0
261
+
167
262
  lite_mode = code_size > _MAX_CODE_SIZE
168
263
  max_iterations = self.max_iterations
169
264
  if code_size > _LARGE_FILE_SIZE:
170
265
  max_iterations = min(max_iterations, _LITE_MAX_ITERATIONS)
171
266
 
172
- # Check node count for expensive transform gating
173
- node_count = _count_nodes(ast) if code_size > _LARGE_FILE_SIZE else 0
174
-
175
267
  # For very large ASTs, further reduce iterations
176
268
  if node_count > 100_000:
177
269
  max_iterations = min(max_iterations, 3)
178
270
 
179
271
  # Build transform list based on mode
180
272
  transform_classes = TRANSFORM_CLASSES
181
- if lite_mode:
182
- transform_classes = [t for t in TRANSFORM_CLASSES if t not in _EXPENSIVE_TRANSFORMS]
183
- elif node_count > _NODE_COUNT_LIMIT:
273
+ if lite_mode or node_count > _NODE_COUNT_LIMIT:
184
274
  transform_classes = [t for t in TRANSFORM_CLASSES if t not in _EXPENSIVE_TRANSFORMS]
185
275
 
186
276
  # Track which transforms are no longer productive
187
277
  skip_transforms = set()
188
278
 
279
+ # Cache scope tree across transforms — only rebuild when a transform
280
+ # that modifies bindings returns changed=True
281
+ scope_tree = None
282
+ node_scope = None
283
+ scope_dirty = True # Start dirty to build on first use
284
+
189
285
  # Multi-pass transform loop
190
286
  any_transform_changed = False
191
- for i in range(max_iterations):
287
+ for iteration in range(max_iterations):
192
288
  modified = False
193
289
  for transform_class in transform_classes:
194
290
  if transform_class in skip_transforms:
195
291
  continue
196
292
  try:
197
- transform = transform_class(ast)
293
+ # Build scope tree lazily when needed by a scope-using transform
294
+ if transform_class in _SCOPE_TRANSFORMS and scope_dirty:
295
+ scope_tree, node_scope = build_scope_tree(ast)
296
+ scope_dirty = False
297
+
298
+ if transform_class in _SCOPE_TRANSFORMS:
299
+ transform = transform_class(ast, scope_tree=scope_tree, node_scope=node_scope)
300
+ else:
301
+ transform = transform_class(ast)
198
302
  result = transform.execute()
199
303
  except Exception:
200
304
  continue
201
305
  if result:
202
306
  modified = True
203
307
  any_transform_changed = True
204
- else:
205
- # If a transform didn't change anything after the first pass,
206
- # skip it in subsequent iterations
207
- if i > 0:
208
- skip_transforms.add(transform_class)
308
+ # Any AST change invalidates the cached scope tree
309
+ scope_dirty = True
310
+ elif iteration > 0:
311
+ # Skip transforms that haven't changed anything after the first pass
312
+ skip_transforms.add(transform_class)
209
313
 
210
314
  if not modified:
211
315
  break
212
316
 
213
- # Post-passes: cosmetic transforms that run once after convergence
214
- for post_transform in [VariableRenamer, VarToConst, LetToConst]:
215
- try:
216
- if post_transform(ast).execute():
217
- any_transform_changed = True
218
- except Exception:
219
- pass
220
-
221
- if not any_transform_changed:
222
- return self.original_code
223
-
224
- try:
225
- return generate(ast)
226
- except Exception:
227
- return self.original_code
317
+ return any_transform_changed