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.
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/LICENSE +1 -1
- {pyjsclear-0.1.4/pyjsclear.egg-info → pyjsclear-0.1.6}/PKG-INFO +7 -6
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/README.md +5 -4
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/__init__.py +17 -8
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/__main__.py +1 -0
- pyjsclear-0.1.6/pyjsclear/deobfuscator.py +391 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/generator.py +57 -34
- pyjsclear-0.1.6/pyjsclear/parser.py +87 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/scope.py +236 -184
- pyjsclear-0.1.6/pyjsclear/transforms/aa_decode.py +148 -0
- pyjsclear-0.1.6/pyjsclear/transforms/anti_tamper.py +121 -0
- pyjsclear-0.1.6/pyjsclear/transforms/base.py +73 -0
- pyjsclear-0.1.6/pyjsclear/transforms/class_static_resolver.py +230 -0
- pyjsclear-0.1.6/pyjsclear/transforms/class_string_decoder.py +387 -0
- pyjsclear-0.1.6/pyjsclear/transforms/cleanup.py +342 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/constant_prop.py +54 -33
- pyjsclear-0.1.6/pyjsclear/transforms/control_flow.py +374 -0
- pyjsclear-0.1.6/pyjsclear/transforms/dead_branch.py +150 -0
- pyjsclear-0.1.6/pyjsclear/transforms/dead_class_props.py +344 -0
- pyjsclear-0.1.6/pyjsclear/transforms/dead_expressions.py +43 -0
- pyjsclear-0.1.6/pyjsclear/transforms/dead_object_props.py +239 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/else_if_flatten.py +20 -7
- pyjsclear-0.1.6/pyjsclear/transforms/enum_resolver.py +267 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/eval_unpack.py +21 -21
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/expression_simplifier.py +91 -96
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/global_alias.py +57 -31
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/hex_escapes.py +31 -23
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/hex_numerics.py +11 -8
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/jj_decode.py +180 -170
- pyjsclear-0.1.6/pyjsclear/transforms/jsfuck_decode.py +633 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/logical_to_if.py +27 -14
- pyjsclear-0.1.6/pyjsclear/transforms/member_chain_resolver.py +171 -0
- pyjsclear-0.1.6/pyjsclear/transforms/noop_calls.py +118 -0
- pyjsclear-0.1.6/pyjsclear/transforms/nullish_coalescing.py +120 -0
- pyjsclear-0.1.6/pyjsclear/transforms/object_packer.py +156 -0
- pyjsclear-0.1.6/pyjsclear/transforms/object_simplifier.py +224 -0
- pyjsclear-0.1.6/pyjsclear/transforms/optional_chaining.py +217 -0
- pyjsclear-0.1.6/pyjsclear/transforms/property_simplifier.py +111 -0
- pyjsclear-0.1.6/pyjsclear/transforms/proxy_functions.py +281 -0
- pyjsclear-0.1.6/pyjsclear/transforms/reassignment.py +230 -0
- pyjsclear-0.1.6/pyjsclear/transforms/require_inliner.py +103 -0
- pyjsclear-0.1.6/pyjsclear/transforms/sequence_splitter.py +261 -0
- pyjsclear-0.1.6/pyjsclear/transforms/single_use_vars.py +221 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/string_revealer.py +298 -262
- pyjsclear-0.1.6/pyjsclear/transforms/unreachable_code.py +68 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/unused_vars.py +56 -20
- pyjsclear-0.1.6/pyjsclear/transforms/variable_renamer.py +544 -0
- pyjsclear-0.1.6/pyjsclear/transforms/xor_string_decode.py +295 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/traverser.py +75 -84
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/utils/ast_helpers.py +119 -86
- pyjsclear-0.1.6/pyjsclear/utils/string_decoders.py +162 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6/pyjsclear.egg-info}/PKG-INFO +7 -6
- pyjsclear-0.1.6/pyjsclear.egg-info/requires.txt +1 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyproject.toml +3 -2
- pyjsclear-0.1.4/pyjsclear/deobfuscator.py +0 -317
- pyjsclear-0.1.4/pyjsclear/parser.py +0 -46
- pyjsclear-0.1.4/pyjsclear/transforms/aa_decode.py +0 -150
- pyjsclear-0.1.4/pyjsclear/transforms/anti_tamper.py +0 -103
- pyjsclear-0.1.4/pyjsclear/transforms/base.py +0 -58
- pyjsclear-0.1.4/pyjsclear/transforms/class_static_resolver.py +0 -212
- pyjsclear-0.1.4/pyjsclear/transforms/class_string_decoder.py +0 -333
- pyjsclear-0.1.4/pyjsclear/transforms/cleanup.py +0 -283
- pyjsclear-0.1.4/pyjsclear/transforms/control_flow.py +0 -327
- pyjsclear-0.1.4/pyjsclear/transforms/dead_branch.py +0 -89
- pyjsclear-0.1.4/pyjsclear/transforms/dead_class_props.py +0 -254
- pyjsclear-0.1.4/pyjsclear/transforms/dead_expressions.py +0 -29
- pyjsclear-0.1.4/pyjsclear/transforms/dead_object_props.py +0 -150
- pyjsclear-0.1.4/pyjsclear/transforms/enum_resolver.py +0 -175
- pyjsclear-0.1.4/pyjsclear/transforms/jsfuck_decode.py +0 -610
- pyjsclear-0.1.4/pyjsclear/transforms/member_chain_resolver.py +0 -149
- pyjsclear-0.1.4/pyjsclear/transforms/noop_calls.py +0 -79
- pyjsclear-0.1.4/pyjsclear/transforms/nullish_coalescing.py +0 -109
- pyjsclear-0.1.4/pyjsclear/transforms/object_packer.py +0 -131
- pyjsclear-0.1.4/pyjsclear/transforms/object_simplifier.py +0 -197
- pyjsclear-0.1.4/pyjsclear/transforms/optional_chaining.py +0 -153
- pyjsclear-0.1.4/pyjsclear/transforms/property_simplifier.py +0 -68
- pyjsclear-0.1.4/pyjsclear/transforms/proxy_functions.py +0 -238
- pyjsclear-0.1.4/pyjsclear/transforms/reassignment.py +0 -197
- pyjsclear-0.1.4/pyjsclear/transforms/require_inliner.py +0 -77
- pyjsclear-0.1.4/pyjsclear/transforms/sequence_splitter.py +0 -213
- pyjsclear-0.1.4/pyjsclear/transforms/single_use_vars.py +0 -164
- pyjsclear-0.1.4/pyjsclear/transforms/unreachable_code.py +0 -38
- pyjsclear-0.1.4/pyjsclear/transforms/variable_renamer.py +0 -511
- pyjsclear-0.1.4/pyjsclear/transforms/xor_string_decode.py +0 -228
- pyjsclear-0.1.4/pyjsclear/utils/string_decoders.py +0 -140
- pyjsclear-0.1.4/pyjsclear.egg-info/requires.txt +0 -1
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/MANIFEST.in +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/NOTICE +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/THIRD_PARTY_LICENSES.md +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/transforms/__init__.py +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear/utils/__init__.py +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/SOURCES.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/dependency_links.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/entry_points.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/pyjsclear.egg-info/top_level.txt +0 -0
- {pyjsclear-0.1.4 → pyjsclear-0.1.6}/setup.cfg +0 -0
- {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
|
|
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.
|
|
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
|
|
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 / >
|
|
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
|
-
- **
|
|
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 / >
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|