pyjsclear 0.1.4__tar.gz → 0.1.6__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 (97) hide show
  1. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/LICENSE +1 -1
  2. {pyjsclear-0.1.4/pyjsclear.egg-info → pyjsclear-0.1.6}/PKG-INFO +7 -6
  3. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/README.md +5 -4
  4. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/__init__.py +17 -8
  5. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/__main__.py +1 -0
  6. pyjsclear-0.1.6/pyjsclear/deobfuscator.py +391 -0
  7. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/generator.py +57 -34
  8. pyjsclear-0.1.6/pyjsclear/parser.py +87 -0
  9. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/scope.py +236 -184
  10. pyjsclear-0.1.6/pyjsclear/transforms/aa_decode.py +148 -0
  11. pyjsclear-0.1.6/pyjsclear/transforms/anti_tamper.py +121 -0
  12. pyjsclear-0.1.6/pyjsclear/transforms/base.py +73 -0
  13. pyjsclear-0.1.6/pyjsclear/transforms/class_static_resolver.py +230 -0
  14. pyjsclear-0.1.6/pyjsclear/transforms/class_string_decoder.py +387 -0
  15. pyjsclear-0.1.6/pyjsclear/transforms/cleanup.py +342 -0
  16. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/constant_prop.py +54 -33
  17. pyjsclear-0.1.6/pyjsclear/transforms/control_flow.py +374 -0
  18. pyjsclear-0.1.6/pyjsclear/transforms/dead_branch.py +150 -0
  19. pyjsclear-0.1.6/pyjsclear/transforms/dead_class_props.py +344 -0
  20. pyjsclear-0.1.6/pyjsclear/transforms/dead_expressions.py +43 -0
  21. pyjsclear-0.1.6/pyjsclear/transforms/dead_object_props.py +239 -0
  22. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/else_if_flatten.py +20 -7
  23. pyjsclear-0.1.6/pyjsclear/transforms/enum_resolver.py +267 -0
  24. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/eval_unpack.py +21 -21
  25. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/expression_simplifier.py +91 -96
  26. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/global_alias.py +57 -31
  27. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/hex_escapes.py +31 -23
  28. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/hex_numerics.py +11 -8
  29. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/jj_decode.py +180 -170
  30. pyjsclear-0.1.6/pyjsclear/transforms/jsfuck_decode.py +633 -0
  31. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/logical_to_if.py +27 -14
  32. pyjsclear-0.1.6/pyjsclear/transforms/member_chain_resolver.py +171 -0
  33. pyjsclear-0.1.6/pyjsclear/transforms/noop_calls.py +118 -0
  34. pyjsclear-0.1.6/pyjsclear/transforms/nullish_coalescing.py +120 -0
  35. pyjsclear-0.1.6/pyjsclear/transforms/object_packer.py +156 -0
  36. pyjsclear-0.1.6/pyjsclear/transforms/object_simplifier.py +224 -0
  37. pyjsclear-0.1.6/pyjsclear/transforms/optional_chaining.py +217 -0
  38. pyjsclear-0.1.6/pyjsclear/transforms/property_simplifier.py +111 -0
  39. pyjsclear-0.1.6/pyjsclear/transforms/proxy_functions.py +281 -0
  40. pyjsclear-0.1.6/pyjsclear/transforms/reassignment.py +230 -0
  41. pyjsclear-0.1.6/pyjsclear/transforms/require_inliner.py +103 -0
  42. pyjsclear-0.1.6/pyjsclear/transforms/sequence_splitter.py +261 -0
  43. pyjsclear-0.1.6/pyjsclear/transforms/single_use_vars.py +221 -0
  44. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/string_revealer.py +298 -262
  45. pyjsclear-0.1.6/pyjsclear/transforms/unreachable_code.py +68 -0
  46. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/unused_vars.py +56 -20
  47. pyjsclear-0.1.6/pyjsclear/transforms/variable_renamer.py +544 -0
  48. pyjsclear-0.1.6/pyjsclear/transforms/xor_string_decode.py +295 -0
  49. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/traverser.py +75 -84
  50. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/utils/ast_helpers.py +119 -86
  51. pyjsclear-0.1.6/pyjsclear/utils/string_decoders.py +162 -0
  52. {pyjsclear-0.1.4 → pyjsclear-0.1.6/pyjsclear.egg-info}/PKG-INFO +7 -6
  53. pyjsclear-0.1.6/pyjsclear.egg-info/requires.txt +1 -0
  54. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyproject.toml +3 -2
  55. pyjsclear-0.1.4/pyjsclear/deobfuscator.py +0 -317
  56. pyjsclear-0.1.4/pyjsclear/parser.py +0 -46
  57. pyjsclear-0.1.4/pyjsclear/transforms/aa_decode.py +0 -150
  58. pyjsclear-0.1.4/pyjsclear/transforms/anti_tamper.py +0 -103
  59. pyjsclear-0.1.4/pyjsclear/transforms/base.py +0 -58
  60. pyjsclear-0.1.4/pyjsclear/transforms/class_static_resolver.py +0 -212
  61. pyjsclear-0.1.4/pyjsclear/transforms/class_string_decoder.py +0 -333
  62. pyjsclear-0.1.4/pyjsclear/transforms/cleanup.py +0 -283
  63. pyjsclear-0.1.4/pyjsclear/transforms/control_flow.py +0 -327
  64. pyjsclear-0.1.4/pyjsclear/transforms/dead_branch.py +0 -89
  65. pyjsclear-0.1.4/pyjsclear/transforms/dead_class_props.py +0 -254
  66. pyjsclear-0.1.4/pyjsclear/transforms/dead_expressions.py +0 -29
  67. pyjsclear-0.1.4/pyjsclear/transforms/dead_object_props.py +0 -150
  68. pyjsclear-0.1.4/pyjsclear/transforms/enum_resolver.py +0 -175
  69. pyjsclear-0.1.4/pyjsclear/transforms/jsfuck_decode.py +0 -610
  70. pyjsclear-0.1.4/pyjsclear/transforms/member_chain_resolver.py +0 -149
  71. pyjsclear-0.1.4/pyjsclear/transforms/noop_calls.py +0 -79
  72. pyjsclear-0.1.4/pyjsclear/transforms/nullish_coalescing.py +0 -109
  73. pyjsclear-0.1.4/pyjsclear/transforms/object_packer.py +0 -131
  74. pyjsclear-0.1.4/pyjsclear/transforms/object_simplifier.py +0 -197
  75. pyjsclear-0.1.4/pyjsclear/transforms/optional_chaining.py +0 -153
  76. pyjsclear-0.1.4/pyjsclear/transforms/property_simplifier.py +0 -68
  77. pyjsclear-0.1.4/pyjsclear/transforms/proxy_functions.py +0 -238
  78. pyjsclear-0.1.4/pyjsclear/transforms/reassignment.py +0 -197
  79. pyjsclear-0.1.4/pyjsclear/transforms/require_inliner.py +0 -77
  80. pyjsclear-0.1.4/pyjsclear/transforms/sequence_splitter.py +0 -213
  81. pyjsclear-0.1.4/pyjsclear/transforms/single_use_vars.py +0 -164
  82. pyjsclear-0.1.4/pyjsclear/transforms/unreachable_code.py +0 -38
  83. pyjsclear-0.1.4/pyjsclear/transforms/variable_renamer.py +0 -511
  84. pyjsclear-0.1.4/pyjsclear/transforms/xor_string_decode.py +0 -228
  85. pyjsclear-0.1.4/pyjsclear/utils/string_decoders.py +0 -140
  86. pyjsclear-0.1.4/pyjsclear.egg-info/requires.txt +0 -1
  87. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/MANIFEST.in +0 -0
  88. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/NOTICE +0 -0
  89. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/THIRD_PARTY_LICENSES.md +0 -0
  90. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/__init__.py +0 -0
  91. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/utils/__init__.py +0 -0
  92. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/SOURCES.txt +0 -0
  93. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/dependency_links.txt +0 -0
  94. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/entry_points.txt +0 -0
  95. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/top_level.txt +0 -0
  96. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/setup.cfg +0 -0
  97. {pyjsclear-0.1.4 → pyjsclear-0.1.6}/tests/test_regression.py +0 -0
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright [yyyy] [name of copyright owner]
189
+ Copyright 2026 Intezer Labs Ltd.
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyjsclear
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Pure Python JavaScript deobfuscator
5
5
  Author-email: Intezer Labs <info@intezer.com>
6
6
  License-Expression: Apache-2.0
@@ -21,7 +21,7 @@ Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  License-File: NOTICE
24
- Requires-Dist: esprima2>=5.0.1
24
+ Requires-Dist: esprima2==6.0.0
25
25
  Dynamic: license-file
26
26
 
27
27
  <p align="center">
@@ -30,7 +30,7 @@ Dynamic: license-file
30
30
 
31
31
  # PyJSClear
32
32
 
33
- Pure Python JavaScript deobfuscator.
33
+ Pure Python JavaScript deobfuscator for malware analysis and security automation.
34
34
 
35
35
  ## Installation
36
36
 
@@ -97,14 +97,14 @@ variables and converts var/let to const.
97
97
  - Anti-tamper / anti-debug removal
98
98
  - Variable renaming (_0x… → readable names)
99
99
 
100
- Large files (>500 KB / >50 K AST nodes) automatically use a lite mode
100
+ Large files (>500 KB / >50K AST nodes) automatically use a lite mode
101
101
  that skips expensive transforms.
102
102
 
103
103
  ## Limitations
104
104
 
105
105
  - **Best results on obfuscator.io output.** JSFuck, JJEncode, AAEncode, and eval-packed code are fully decoded; other obfuscation tools may only partially deobfuscate.
106
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.
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.
107
+ - **Very deep AST nesting** traversal automatically falls back from recursion to an iterative strategy at depth thresholds, but pathologically deep inputs may still produce partial results.
108
108
 
109
109
  ## License
110
110
 
@@ -120,5 +120,6 @@ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
120
120
  [NOTICE](NOTICE) for full attribution.
121
121
 
122
122
  Test samples include obfuscated JavaScript from the
123
- [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0),
123
+ [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
124
+ and the [Obfuscated JavaScript Dataset](https://www.kaggle.com/datasets/fanbyprinciple/obfuscated-javascript-dataset),
124
125
  used solely for evaluation purposes.
@@ -4,7 +4,7 @@
4
4
 
5
5
  # PyJSClear
6
6
 
7
- Pure Python JavaScript deobfuscator.
7
+ Pure Python JavaScript deobfuscator for malware analysis and security automation.
8
8
 
9
9
  ## Installation
10
10
 
@@ -71,14 +71,14 @@ variables and converts var/let to const.
71
71
  - Anti-tamper / anti-debug removal
72
72
  - Variable renaming (_0x… → readable names)
73
73
 
74
- Large files (>500 KB / >50 K AST nodes) automatically use a lite mode
74
+ Large files (>500 KB / >50K AST nodes) automatically use a lite mode
75
75
  that skips expensive transforms.
76
76
 
77
77
  ## Limitations
78
78
 
79
79
  - **Best results on obfuscator.io output.** JSFuck, JJEncode, AAEncode, and eval-packed code are fully decoded; other obfuscation tools may only partially deobfuscate.
80
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.
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.
81
+ - **Very deep AST nesting** traversal automatically falls back from recursion to an iterative strategy at depth thresholds, but pathologically deep inputs may still produce partial results.
82
82
 
83
83
  ## License
84
84
 
@@ -94,5 +94,6 @@ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
94
94
  [NOTICE](NOTICE) for full attribution.
95
95
 
96
96
  Test samples include obfuscated JavaScript from the
97
- [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0),
97
+ [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
98
+ and the [Obfuscated JavaScript Dataset](https://www.kaggle.com/datasets/fanbyprinciple/obfuscated-javascript-dataset),
98
99
  used solely for evaluation purposes.
@@ -5,14 +5,18 @@ and javascript-deobfuscator (3 surface-cleanup modules) into a single
5
5
  Python package.
6
6
  """
7
7
 
8
+ from pathlib import Path
9
+
8
10
  from .deobfuscator import Deobfuscator
9
11
 
10
12
 
11
- __version__ = '0.1.4'
13
+ __all__ = ['Deobfuscator', 'deobfuscate', 'deobfuscate_file']
14
+
15
+ __version__ = '0.1.6'
12
16
 
13
17
 
14
18
  def deobfuscate(code: str, max_iterations: int = 50) -> str:
15
- """Deobfuscate JavaScript code. Returns cleaned source.
19
+ """Deobfuscate JavaScript code and return cleaned source.
16
20
 
17
21
  Args:
18
22
  code: JavaScript source code string.
@@ -24,7 +28,11 @@ def deobfuscate(code: str, max_iterations: int = 50) -> str:
24
28
  return Deobfuscator(code, max_iterations=max_iterations).execute()
25
29
 
26
30
 
27
- def deobfuscate_file(input_path: str, output_path: str | None = None, max_iterations: int = 50) -> str | bool:
31
+ def deobfuscate_file(
32
+ input_path: str | Path,
33
+ output_path: str | Path | None = None,
34
+ max_iterations: int = 50,
35
+ ) -> str | bool:
28
36
  """Deobfuscate a JavaScript file.
29
37
 
30
38
  Args:
@@ -40,8 +48,9 @@ def deobfuscate_file(input_path: str, output_path: str | None = None, max_iterat
40
48
 
41
49
  result = deobfuscate(code, max_iterations=max_iterations)
42
50
 
43
- if output_path:
44
- with open(output_path, 'w') as output_file:
45
- output_file.write(result)
46
- return result != code
47
- return result
51
+ if not output_path:
52
+ return result
53
+
54
+ with open(output_path, 'w') as output_file:
55
+ output_file.write(result)
56
+ return result != code
@@ -7,6 +7,7 @@ from . import deobfuscate
7
7
 
8
8
 
9
9
  def main() -> None:
10
+ """Parse CLI arguments and run the deobfuscator."""
10
11
  parser = argparse.ArgumentParser(description='Deobfuscate JavaScript files.')
11
12
  parser.add_argument('input', help='Input JS file (use - for stdin)')
12
13
  parser.add_argument('-o', '--output', help='Output file (default: stdout)')
@@ -0,0 +1,391 @@
1
+ """Multi-pass deobfuscation orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .generator import generate
6
+ from .parser import parse
7
+ from .scope import build_scope_tree
8
+ from .transforms.aa_decode import aa_decode
9
+ from .transforms.aa_decode import is_aa_encoded
10
+ from .transforms.anti_tamper import AntiTamperRemover
11
+ from .transforms.class_static_resolver import ClassStaticResolver
12
+ from .transforms.class_string_decoder import ClassStringDecoder
13
+ from .transforms.cleanup import EmptyIfRemover
14
+ from .transforms.cleanup import LetToConst
15
+ from .transforms.cleanup import OptionalCatchBinding
16
+ from .transforms.cleanup import ReturnUndefinedCleanup
17
+ from .transforms.cleanup import TrailingReturnRemover
18
+ from .transforms.cleanup import VarToConst
19
+ from .transforms.constant_prop import ConstantProp
20
+ from .transforms.control_flow import ControlFlowRecoverer
21
+ from .transforms.dead_branch import DeadBranchRemover
22
+ from .transforms.dead_class_props import DeadClassPropRemover
23
+ from .transforms.dead_expressions import DeadExpressionRemover
24
+ from .transforms.dead_object_props import DeadObjectPropRemover
25
+ from .transforms.else_if_flatten import ElseIfFlattener
26
+ from .transforms.enum_resolver import EnumResolver
27
+ from .transforms.eval_unpack import eval_unpack
28
+ from .transforms.eval_unpack import is_eval_packed
29
+ from .transforms.expression_simplifier import ExpressionSimplifier
30
+ from .transforms.global_alias import GlobalAliasInliner
31
+ from .transforms.hex_escapes import HexEscapes
32
+ from .transforms.hex_escapes import decode_hex_escapes_source
33
+ from .transforms.hex_numerics import HexNumerics
34
+ from .transforms.jj_decode import is_jj_encoded
35
+ from .transforms.jj_decode import jj_decode
36
+ from .transforms.jsfuck_decode import is_jsfuck
37
+ from .transforms.jsfuck_decode import jsfuck_decode
38
+ from .transforms.logical_to_if import LogicalToIf
39
+ from .transforms.member_chain_resolver import MemberChainResolver
40
+ from .transforms.noop_calls import NoopCallRemover
41
+ from .transforms.nullish_coalescing import NullishCoalescing
42
+ from .transforms.object_packer import ObjectPacker
43
+ from .transforms.object_simplifier import ObjectSimplifier
44
+ from .transforms.optional_chaining import OptionalChaining
45
+ from .transforms.property_simplifier import PropertySimplifier
46
+ from .transforms.proxy_functions import ProxyFunctionInliner
47
+ from .transforms.reassignment import ReassignmentRemover
48
+ from .transforms.require_inliner import RequireInliner
49
+ from .transforms.sequence_splitter import SequenceSplitter
50
+ from .transforms.single_use_vars import SingleUseVarInliner
51
+ from .transforms.string_revealer import StringRevealer
52
+ from .transforms.unreachable_code import UnreachableCodeRemover
53
+ from .transforms.unused_vars import UnusedVariableRemover
54
+ from .transforms.variable_renamer import VariableRenamer
55
+ from .transforms.xor_string_decode import XorStringDecoder
56
+ from .utils.ast_helpers import _CHILD_KEYS
57
+ from .utils.ast_helpers import get_child_keys
58
+
59
+
60
+ _SCOPE_TRANSFORMS: frozenset[type] = frozenset(
61
+ {
62
+ ConstantProp,
63
+ SingleUseVarInliner,
64
+ ReassignmentRemover,
65
+ ProxyFunctionInliner,
66
+ UnusedVariableRemover,
67
+ ObjectSimplifier,
68
+ StringRevealer,
69
+ VariableRenamer,
70
+ VarToConst,
71
+ LetToConst,
72
+ }
73
+ )
74
+
75
+ # StringRevealer runs first to decode string arrays before other transforms
76
+ # modify wrapper function structure. Remaining order follows obfuscator-io-deobfuscator.
77
+ TRANSFORM_CLASSES: list[type] = [
78
+ StringRevealer,
79
+ HexEscapes,
80
+ HexNumerics,
81
+ ClassStringDecoder,
82
+ XorStringDecoder,
83
+ MemberChainResolver,
84
+ DeadClassPropRemover,
85
+ ClassStaticResolver,
86
+ EnumResolver,
87
+ RequireInliner,
88
+ GlobalAliasInliner,
89
+ UnusedVariableRemover,
90
+ ConstantProp,
91
+ ReassignmentRemover,
92
+ SingleUseVarInliner,
93
+ DeadBranchRemover,
94
+ UnreachableCodeRemover,
95
+ NoopCallRemover,
96
+ EmptyIfRemover,
97
+ DeadObjectPropRemover,
98
+ ObjectPacker,
99
+ ProxyFunctionInliner,
100
+ SequenceSplitter,
101
+ DeadExpressionRemover,
102
+ ExpressionSimplifier,
103
+ NullishCoalescing,
104
+ OptionalChaining,
105
+ LogicalToIf,
106
+ ElseIfFlattener,
107
+ OptionalCatchBinding,
108
+ ReturnUndefinedCleanup,
109
+ TrailingReturnRemover,
110
+ ControlFlowRecoverer,
111
+ PropertySimplifier,
112
+ AntiTamperRemover,
113
+ ObjectSimplifier,
114
+ StringRevealer,
115
+ ]
116
+
117
+ _EXPENSIVE_TRANSFORMS: frozenset[type] = frozenset({ControlFlowRecoverer, ProxyFunctionInliner, ObjectPacker})
118
+
119
+ _POST_PASS_TRANSFORMS: list[type] = [VariableRenamer, VarToConst, LetToConst]
120
+
121
+ # File-size thresholds
122
+ _LARGE_FILE_SIZE: int = 500_000 # 500 KB — reduce iterations
123
+ _MAX_CODE_SIZE: int = 2_000_000 # 2 MB — use lite mode
124
+ _LITE_MAX_ITERATIONS: int = 10
125
+ _NODE_COUNT_LIMIT: int = 50_000 # skip ControlFlowRecoverer above this
126
+ _VERY_LARGE_NODE_COUNT: int = 100_000 # cap iterations to 3
127
+
128
+
129
+ def _count_nodes(syntax_tree: dict) -> int:
130
+ """Return the total number of nodes in *syntax_tree*.
131
+
132
+ Uses a direct iterative loop instead of simple_traverse to avoid
133
+ per-node callback overhead.
134
+ """
135
+ child_keys_map = _CHILD_KEYS
136
+ _get_child_keys = get_child_keys
137
+ _dict = dict
138
+ _list = list
139
+ _type = type
140
+
141
+ count = 0
142
+ stack = [syntax_tree]
143
+ while stack:
144
+ node = stack.pop()
145
+ node_type = node.get('type')
146
+ if node_type is None:
147
+ continue
148
+ count += 1
149
+ child_keys = child_keys_map.get(node_type)
150
+ if child_keys is None:
151
+ child_keys = _get_child_keys(node)
152
+ for key in child_keys:
153
+ child = node.get(key)
154
+ if child is None:
155
+ continue
156
+ if _type(child) is _list:
157
+ for item in child:
158
+ if _type(item) is _dict and 'type' in item:
159
+ stack.append(item)
160
+ elif _type(child) is _dict and 'type' in child:
161
+ stack.append(child)
162
+ return count
163
+
164
+
165
+ class Deobfuscator:
166
+ """Multi-pass JavaScript deobfuscator.
167
+
168
+ Applies a configurable sequence of AST transforms in a loop until the code
169
+ stabilises or *max_iterations* is reached, then runs cosmetic post-passes.
170
+ """
171
+
172
+ _MAX_OUTER_CYCLES: int = 5
173
+
174
+ def __init__(self, code: str, max_iterations: int = 50) -> None:
175
+ self.original_code: str = code
176
+ self.max_iterations: int = max_iterations
177
+
178
+ def _run_pre_passes(self, code: str) -> str | None:
179
+ """Detect whole-file encodings (JSFuck, AAEncode, etc.) and decode them.
180
+
181
+ Returns the decoded source when a known encoding is found, or ``None``
182
+ to continue with the normal AST pipeline.
183
+ """
184
+ if is_jsfuck(code):
185
+ decoded = jsfuck_decode(code)
186
+ if decoded:
187
+ return decoded
188
+
189
+ if is_aa_encoded(code):
190
+ decoded = aa_decode(code)
191
+ if decoded:
192
+ return decoded
193
+
194
+ if is_jj_encoded(code):
195
+ decoded = jj_decode(code)
196
+ if decoded:
197
+ return decoded
198
+
199
+ if is_eval_packed(code):
200
+ decoded = eval_unpack(code)
201
+ if decoded:
202
+ return decoded
203
+
204
+ return None
205
+
206
+ def execute(self) -> str:
207
+ """Run all deobfuscation passes and return cleaned JavaScript source."""
208
+ code = self.original_code
209
+
210
+ decoded = self._run_pre_passes(code)
211
+ if decoded:
212
+ recursive_deobfuscator = Deobfuscator(decoded, max_iterations=self.max_iterations)
213
+ return recursive_deobfuscator.execute()
214
+
215
+ syntax_tree = self._try_parse_or_fallback(code)
216
+ if isinstance(syntax_tree, str):
217
+ return syntax_tree
218
+
219
+ return self._transform_loop(syntax_tree, code)
220
+
221
+ def _try_parse_or_fallback(self, code: str) -> dict | str:
222
+ """Parse *code* into an AST, falling back to hex-decode on failure.
223
+
224
+ Returns the parsed AST dict on success, or a decoded/original source
225
+ string when parsing fails.
226
+ """
227
+ try:
228
+ return parse(code)
229
+ except SyntaxError:
230
+ decoded = decode_hex_escapes_source(code)
231
+ if decoded != code:
232
+ return decoded
233
+ return self.original_code
234
+
235
+ def _transform_loop(self, syntax_tree: dict, code: str) -> str:
236
+ """Run the outer generate-reparse convergence loop and post-passes.
237
+
238
+ Returns the best deobfuscated source produced.
239
+ """
240
+ previous_code = code
241
+ last_changed_tree: dict | None = None
242
+
243
+ try:
244
+ for _cycle in range(self._MAX_OUTER_CYCLES):
245
+ changed = self._run_ast_transforms(
246
+ syntax_tree,
247
+ code_size=len(previous_code),
248
+ )
249
+
250
+ if not changed:
251
+ break
252
+
253
+ last_changed_tree = syntax_tree
254
+ generated = self._try_generate(syntax_tree)
255
+ if generated is None or generated == previous_code:
256
+ break
257
+
258
+ previous_code = generated
259
+ try:
260
+ syntax_tree = parse(generated)
261
+ except SyntaxError:
262
+ break
263
+
264
+ any_post_changed = self._run_post_passes(syntax_tree)
265
+
266
+ if last_changed_tree is None and not any_post_changed:
267
+ return self.original_code
268
+
269
+ return self._try_generate(syntax_tree) or previous_code
270
+ except RecursionError:
271
+ # Deeply nested JS can exceed Python's recursion limit during
272
+ # parsing or AST walking. Return best result so far.
273
+ return previous_code
274
+
275
+ @staticmethod
276
+ def _try_generate(syntax_tree: dict) -> str | None:
277
+ """Generate source from *syntax_tree*, returning ``None`` on failure."""
278
+ try:
279
+ return generate(syntax_tree)
280
+ except Exception:
281
+ return None
282
+
283
+ @staticmethod
284
+ def _run_post_passes(syntax_tree: dict) -> bool:
285
+ """Run cosmetic post-passes (renaming, var-to-const).
286
+
287
+ Returns ``True`` if any post-pass modified the AST.
288
+ """
289
+ any_changed = False
290
+ for post_transform_class in _POST_PASS_TRANSFORMS:
291
+ try:
292
+ if post_transform_class(syntax_tree).execute():
293
+ any_changed = True
294
+ except Exception:
295
+ pass
296
+ return any_changed
297
+
298
+ def _run_ast_transforms(self, syntax_tree: dict, code_size: int = 0) -> bool:
299
+ """Run all AST transform passes.
300
+
301
+ Returns ``True`` if any transform modified the AST.
302
+ """
303
+ node_count = _count_nodes(syntax_tree) if code_size > _LARGE_FILE_SIZE else 0
304
+ iteration_limit = self._compute_iteration_limit(code_size, node_count)
305
+ active_transforms = self._select_transforms(code_size, node_count)
306
+
307
+ skipped_transforms: set[type] = set()
308
+
309
+ scope_tree: dict | None = None
310
+ node_scope: dict | None = None
311
+ scope_dirty: bool = True
312
+
313
+ any_transform_changed = False
314
+ for iteration in range(iteration_limit):
315
+ modified = False
316
+ for transform_class in active_transforms:
317
+ if transform_class in skipped_transforms:
318
+ continue
319
+
320
+ result, scope_tree, node_scope, scope_dirty = self._execute_single_transform(
321
+ syntax_tree,
322
+ transform_class,
323
+ scope_tree,
324
+ node_scope,
325
+ scope_dirty,
326
+ )
327
+
328
+ if result is None:
329
+ continue
330
+ if result:
331
+ modified = True
332
+ any_transform_changed = True
333
+ elif iteration > 0:
334
+ skipped_transforms.add(transform_class)
335
+
336
+ if not modified:
337
+ break
338
+
339
+ return any_transform_changed
340
+
341
+ def _compute_iteration_limit(self, code_size: int, node_count: int) -> int:
342
+ """Determine the maximum iteration count based on file/AST size."""
343
+ limit = self.max_iterations
344
+ if code_size > _LARGE_FILE_SIZE:
345
+ limit = min(limit, _LITE_MAX_ITERATIONS)
346
+ if node_count > _VERY_LARGE_NODE_COUNT:
347
+ limit = min(limit, 3)
348
+ return limit
349
+
350
+ @staticmethod
351
+ def _select_transforms(code_size: int, node_count: int) -> list[type]:
352
+ """Return the transform list, excluding expensive ones for large inputs."""
353
+ if code_size > _MAX_CODE_SIZE or node_count > _NODE_COUNT_LIMIT:
354
+ return [transform for transform in TRANSFORM_CLASSES if transform not in _EXPENSIVE_TRANSFORMS]
355
+ return TRANSFORM_CLASSES
356
+
357
+ @staticmethod
358
+ def _execute_single_transform(
359
+ syntax_tree: dict,
360
+ transform_class: type,
361
+ scope_tree: dict | None,
362
+ node_scope: dict | None,
363
+ scope_dirty: bool,
364
+ ) -> tuple[bool | None, dict | None, dict | None, bool]:
365
+ """Run a single transform, rebuilding scope lazily as needed.
366
+
367
+ Returns ``(result, scope_tree, node_scope, scope_dirty)`` where
368
+ *result* is ``True``/``False`` for success/no-change, or ``None``
369
+ if the transform raised an exception.
370
+ """
371
+ try:
372
+ if transform_class in _SCOPE_TRANSFORMS and scope_dirty:
373
+ scope_tree, node_scope = build_scope_tree(syntax_tree)
374
+ scope_dirty = False
375
+
376
+ if transform_class in _SCOPE_TRANSFORMS:
377
+ transform = transform_class(
378
+ syntax_tree,
379
+ scope_tree=scope_tree,
380
+ node_scope=node_scope,
381
+ )
382
+ else:
383
+ transform = transform_class(syntax_tree)
384
+
385
+ result = transform.execute()
386
+ except Exception:
387
+ return None, scope_tree, node_scope, scope_dirty
388
+
389
+ if result:
390
+ scope_dirty = True
391
+ return result, scope_tree, node_scope, scope_dirty