pyjsclear 0.1.0__py3-none-any.whl

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 (38) hide show
  1. pyjsclear/__init__.py +47 -0
  2. pyjsclear/__main__.py +37 -0
  3. pyjsclear/deobfuscator.py +191 -0
  4. pyjsclear/generator.py +772 -0
  5. pyjsclear/parser.py +46 -0
  6. pyjsclear/scope.py +283 -0
  7. pyjsclear/transforms/__init__.py +0 -0
  8. pyjsclear/transforms/aa_decode.py +83 -0
  9. pyjsclear/transforms/anti_tamper.py +106 -0
  10. pyjsclear/transforms/base.py +24 -0
  11. pyjsclear/transforms/constant_prop.py +103 -0
  12. pyjsclear/transforms/control_flow.py +330 -0
  13. pyjsclear/transforms/dead_branch.py +71 -0
  14. pyjsclear/transforms/eval_unpack.py +133 -0
  15. pyjsclear/transforms/expression_simplifier.py +403 -0
  16. pyjsclear/transforms/hex_escapes.py +68 -0
  17. pyjsclear/transforms/jj_decode.py +91 -0
  18. pyjsclear/transforms/jsfuck_decode.py +93 -0
  19. pyjsclear/transforms/logical_to_if.py +173 -0
  20. pyjsclear/transforms/object_packer.py +133 -0
  21. pyjsclear/transforms/object_simplifier.py +192 -0
  22. pyjsclear/transforms/property_simplifier.py +43 -0
  23. pyjsclear/transforms/proxy_functions.py +193 -0
  24. pyjsclear/transforms/reassignment.py +183 -0
  25. pyjsclear/transforms/sequence_splitter.py +215 -0
  26. pyjsclear/transforms/string_revealer.py +1259 -0
  27. pyjsclear/transforms/unused_vars.py +111 -0
  28. pyjsclear/traverser.py +195 -0
  29. pyjsclear/utils/__init__.py +0 -0
  30. pyjsclear/utils/ast_helpers.py +257 -0
  31. pyjsclear/utils/string_decoders.py +141 -0
  32. pyjsclear-0.1.0.dist-info/METADATA +168 -0
  33. pyjsclear-0.1.0.dist-info/RECORD +38 -0
  34. pyjsclear-0.1.0.dist-info/WHEEL +5 -0
  35. pyjsclear-0.1.0.dist-info/entry_points.txt +2 -0
  36. pyjsclear-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. pyjsclear-0.1.0.dist-info/licenses/NOTICE +19 -0
  38. pyjsclear-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,403 @@
1
+ """Evaluate static unary/binary expressions to literals."""
2
+
3
+ import math
4
+
5
+ from ..traverser import traverse
6
+ from ..utils.ast_helpers import is_literal
7
+ from ..utils.ast_helpers import make_literal
8
+ from .base import Transform
9
+
10
+
11
+ # Sentinel to distinguish JS null (Literal value=None) from JS undefined
12
+ # (Identifier name='undefined'). Python None represents undefined.
13
+ _JS_NULL = object()
14
+
15
+ _RESOLVABLE_UNARY = {'-', '+', '!', '~', 'typeof', 'void'}
16
+ _RESOLVABLE_BINARY = {
17
+ '==',
18
+ '!=',
19
+ '===',
20
+ '!==',
21
+ '<',
22
+ '<=',
23
+ '>',
24
+ '>=',
25
+ '<<',
26
+ '>>',
27
+ '>>>',
28
+ '+',
29
+ '-',
30
+ '*',
31
+ '/',
32
+ '%',
33
+ '**',
34
+ '|',
35
+ '^',
36
+ '&',
37
+ }
38
+
39
+
40
+ class ExpressionSimplifier(Transform):
41
+ """Simplify constant unary/binary expressions to literals."""
42
+
43
+ def execute(self):
44
+ self._simplify_unary_binary()
45
+ self._simplify_conditionals()
46
+ self._simplify_awaits()
47
+ return self.has_changed()
48
+
49
+ def _simplify_unary_binary(self):
50
+ """Fold constant unary and binary expressions."""
51
+
52
+ def enter(node, parent, key, index):
53
+ match node.get('type', ''):
54
+ case 'UnaryExpression':
55
+ result = self._simplify_unary(node)
56
+ case 'BinaryExpression':
57
+ result = self._simplify_binary(node)
58
+ case _:
59
+ return
60
+ if result is not None:
61
+ self.set_changed()
62
+ return result
63
+
64
+ traverse(self.ast, {'enter': enter})
65
+
66
+ def _simplify_conditionals(self):
67
+ """Convert test ? false : true → !test."""
68
+
69
+ def enter(node, parent, key, index):
70
+ if node.get('type') != 'ConditionalExpression':
71
+ return
72
+ cons = node.get('consequent')
73
+ alt = node.get('alternate')
74
+ if is_literal(cons) and cons.get('value') is False and is_literal(alt) and alt.get('value') is True:
75
+ self.set_changed()
76
+ return {
77
+ 'type': 'UnaryExpression',
78
+ 'operator': '!',
79
+ 'prefix': True,
80
+ 'argument': node['test'],
81
+ }
82
+
83
+ traverse(self.ast, {'enter': enter})
84
+
85
+ def _simplify_awaits(self):
86
+ """Simplify await (0x0, expr) → await expr."""
87
+
88
+ def enter(node, parent, key, index):
89
+ if node.get('type') != 'AwaitExpression':
90
+ return
91
+ arg = node.get('argument')
92
+ if not isinstance(arg, dict) or arg.get('type') != 'SequenceExpression':
93
+ return
94
+ exprs = arg.get('expressions', [])
95
+ if len(exprs) <= 1:
96
+ return
97
+ node['argument'] = exprs[-1]
98
+ self.set_changed()
99
+
100
+ traverse(self.ast, {'enter': enter})
101
+
102
+ def _simplify_unary(self, node):
103
+ operator = node.get('operator', '')
104
+ if operator not in _RESOLVABLE_UNARY:
105
+ return None
106
+ # Skip negative numeric literals (already in normal form)
107
+ if (
108
+ operator == '-'
109
+ and is_literal(node.get('argument'))
110
+ and isinstance(node['argument'].get('value'), (int, float))
111
+ ):
112
+ return None
113
+
114
+ argument = node.get('argument')
115
+ argument = self._simplify_expr(argument)
116
+ value, ok = self._get_resolvable_value(argument)
117
+ if not ok:
118
+ return None
119
+
120
+ try:
121
+ result = self._apply_unary(operator, value)
122
+ except Exception:
123
+ return None
124
+ return self._value_to_node(result)
125
+
126
+ def _simplify_binary(self, node):
127
+ operator = node.get('operator', '')
128
+ if operator not in _RESOLVABLE_BINARY:
129
+ return None
130
+
131
+ left = self._simplify_expr(node.get('left'))
132
+ right = self._simplify_expr(node.get('right'))
133
+
134
+ left_value, left_resolved = self._get_resolvable_value(left)
135
+ right_value, right_resolved = self._get_resolvable_value(right)
136
+
137
+ if not (left_resolved and right_resolved):
138
+ # Convert x - (-y) to x + y
139
+ if operator == '-' and self._is_negative_numeric(right):
140
+ node['right'] = right['argument']
141
+ node['operator'] = '+'
142
+ return node
143
+ return None
144
+
145
+ try:
146
+ result = self._apply_binary(operator, left_value, right_value)
147
+ except Exception:
148
+ return None
149
+ return self._value_to_node(result)
150
+
151
+ def _simplify_expr(self, node):
152
+ if not isinstance(node, dict):
153
+ return node
154
+ match node.get('type', ''):
155
+ case 'UnaryExpression':
156
+ result = self._simplify_unary(node)
157
+ return result if result is not None else node
158
+ case 'BinaryExpression':
159
+ result = self._simplify_binary(node)
160
+ return result if result is not None else node
161
+ return node
162
+
163
+ def _is_negative_numeric(self, node):
164
+ return (
165
+ isinstance(node, dict)
166
+ and node.get('type') == 'UnaryExpression'
167
+ and node.get('operator') == '-'
168
+ and is_literal(node.get('argument'))
169
+ and isinstance(node['argument'].get('value'), (int, float))
170
+ )
171
+
172
+ def _get_resolvable_value(self, node):
173
+ if not isinstance(node, dict):
174
+ return None, False
175
+ match node.get('type', ''):
176
+ case 'Literal':
177
+ if node.get('regex'):
178
+ return None, False
179
+ value = node.get('value')
180
+ # Literal with value None is JS null, not undefined
181
+ return (_JS_NULL if value is None else value), True
182
+ case 'UnaryExpression' if node.get('operator') == '-':
183
+ arg = node.get('argument')
184
+ if is_literal(arg) and isinstance(arg.get('value'), (int, float)):
185
+ return -arg['value'], True
186
+ case 'Identifier' if node.get('name') == 'undefined':
187
+ return None, True
188
+ case 'ArrayExpression' if len(node.get('elements', [])) == 0:
189
+ return [], True
190
+ case 'ObjectExpression' if len(node.get('properties', [])) == 0:
191
+ return {}, True
192
+ return None, False
193
+
194
+ def _apply_unary(self, operator, value):
195
+ match operator:
196
+ case '-':
197
+ return -self._js_to_number(value)
198
+ case '+':
199
+ return self._js_to_number(value)
200
+ case '!':
201
+ return not self._js_truthy(value)
202
+ case '~':
203
+ n = self._js_to_number(value)
204
+ if isinstance(n, float) and math.isnan(n):
205
+ return -1 # ~NaN → -1
206
+ return ~int(n)
207
+ case 'typeof':
208
+ return self._js_typeof(value)
209
+ case 'void':
210
+ return None # JS undefined
211
+
212
+ def _apply_binary(self, operator, left, right):
213
+ match operator:
214
+ case '+':
215
+ if isinstance(left, str) or isinstance(right, str):
216
+ return self._js_to_string(left) + self._js_to_string(right)
217
+ return self._js_to_number(left) + self._js_to_number(right)
218
+ case '-':
219
+ return self._js_to_number(left) - self._js_to_number(right)
220
+ case '*':
221
+ return self._js_to_number(left) * self._js_to_number(right)
222
+ case '/':
223
+ result = self._js_to_number(right)
224
+ if result == 0:
225
+ raise ValueError('division by zero')
226
+ return self._js_to_number(left) / result
227
+ case '%':
228
+ result = self._js_to_number(right)
229
+ if result == 0:
230
+ raise ValueError('mod by zero')
231
+ return self._js_to_number(left) % result
232
+ case '**':
233
+ return self._js_to_number(left) ** self._js_to_number(right)
234
+ case '|':
235
+ return self._js_to_int32(left) | self._js_to_int32(right)
236
+ case '&':
237
+ return self._js_to_int32(left) & self._js_to_int32(right)
238
+ case '^':
239
+ return self._js_to_int32(left) ^ self._js_to_int32(right)
240
+ case '<<':
241
+ return self._js_to_int32(left) << (self._js_to_int32(right) & 31)
242
+ case '>>':
243
+ return self._js_to_int32(left) >> (self._js_to_int32(right) & 31)
244
+ case '>>>':
245
+ left_operand = self._js_to_int32(left) & 0xFFFFFFFF
246
+ result = self._js_to_int32(right) & 31
247
+ return left_operand >> result
248
+ case '==' | '!=':
249
+ eq = self._js_abstract_eq(left, right)
250
+ return eq if operator == '==' else not eq
251
+ case '===' | '!==':
252
+ eq = self._js_strict_eq(left, right)
253
+ return eq if operator == '===' else not eq
254
+ case '<':
255
+ return self._js_compare(left, right) < 0
256
+ case '<=':
257
+ return self._js_compare(left, right) <= 0
258
+ case '>':
259
+ return self._js_compare(left, right) > 0
260
+ case '>=':
261
+ return self._js_compare(left, right) >= 0
262
+ case _:
263
+ raise ValueError(f'Unknown operator: {operator}')
264
+
265
+ def _js_abstract_eq(self, left, right):
266
+ """JS == (null and undefined are equal to each other only)."""
267
+ if (left is None or left is _JS_NULL) and (right is None or right is _JS_NULL):
268
+ return True
269
+ if left is _JS_NULL or right is _JS_NULL:
270
+ return False
271
+ return left == right
272
+
273
+ def _js_strict_eq(self, left, right):
274
+ """JS === (null !== undefined)."""
275
+ if left is _JS_NULL:
276
+ return right is _JS_NULL
277
+ if right is _JS_NULL:
278
+ return False
279
+ return left == right and type(left) == type(right)
280
+
281
+ def _js_truthy(self, value):
282
+ if value is None or value is _JS_NULL:
283
+ return False
284
+ match value:
285
+ case bool():
286
+ return value
287
+ case int() | float():
288
+ return value != 0 and not (isinstance(value, float) and math.isnan(value))
289
+ case str():
290
+ return len(value) > 0
291
+ case list() | dict():
292
+ return True
293
+ case _:
294
+ return bool(value)
295
+
296
+ def _js_typeof(self, value):
297
+ if value is _JS_NULL:
298
+ return 'object' # typeof null === 'object' in JS
299
+ if value is None:
300
+ return 'undefined'
301
+ match value:
302
+ case bool():
303
+ return 'boolean'
304
+ case int() | float():
305
+ return 'number'
306
+ case str():
307
+ return 'string'
308
+ case list() | dict():
309
+ return 'object'
310
+ case _:
311
+ return 'undefined'
312
+
313
+ def _js_to_int32(self, value):
314
+ """Coerce to 32-bit integer (for bitwise ops)."""
315
+ return int(self._js_to_number(value))
316
+
317
+ def _js_to_number(self, value):
318
+ if value is _JS_NULL:
319
+ return 0 # Number(null) → 0
320
+ if value is None:
321
+ return float('nan') # Number(undefined) → NaN
322
+ match value:
323
+ case bool():
324
+ return 1 if value else 0
325
+ case int() | float():
326
+ return value
327
+ case str():
328
+ try:
329
+ return (
330
+ int(value)
331
+ if value.isdigit() or (value.startswith('-') and value[1:].isdigit())
332
+ else float(value)
333
+ )
334
+ except (ValueError, IndexError):
335
+ return 0
336
+ case list():
337
+ return 0
338
+ case _:
339
+ return 0
340
+
341
+ def _js_to_string(self, value):
342
+ if value is _JS_NULL:
343
+ return 'null'
344
+ if value is None:
345
+ return 'undefined'
346
+ match value:
347
+ case bool():
348
+ return 'true' if value else 'false'
349
+ case int() | float():
350
+ return str(int(value)) if isinstance(value, float) and value == int(value) else str(value)
351
+ case str():
352
+ return value
353
+ case list():
354
+ return ''
355
+ case dict():
356
+ return '[object Object]'
357
+ case _:
358
+ return str(value)
359
+
360
+ def _js_compare(self, left, right):
361
+ # JS compares strings lexicographically, not numerically
362
+ if isinstance(left, str) and isinstance(right, str):
363
+ if left < right:
364
+ return -1
365
+ if left > right:
366
+ return 1
367
+ return 0
368
+ left_num = self._js_to_number(left)
369
+ right_num = self._js_to_number(right)
370
+ # NaN comparisons always return false in JS; returning NaN
371
+ # ensures < <= > >= all evaluate to False in the caller.
372
+ if isinstance(left_num, float) and math.isnan(left_num):
373
+ return float('nan')
374
+ if isinstance(right_num, float) and math.isnan(right_num):
375
+ return float('nan')
376
+ if left_num < right_num:
377
+ return -1
378
+ if left_num > right_num:
379
+ return 1
380
+ return 0
381
+
382
+ def _value_to_node(self, value):
383
+ if value is _JS_NULL:
384
+ return make_literal(None) # null literal
385
+ if value is None:
386
+ return {'type': 'Identifier', 'name': 'undefined'}
387
+ match value:
388
+ case bool():
389
+ return make_literal(value)
390
+ case int() | float():
391
+ if isinstance(value, float) and (value != value or math.isinf(value)):
392
+ return None
393
+ if value < 0:
394
+ return {
395
+ 'type': 'UnaryExpression',
396
+ 'operator': '-',
397
+ 'prefix': True,
398
+ 'argument': make_literal(abs(value)),
399
+ }
400
+ return make_literal(value)
401
+ case str():
402
+ return make_literal(value)
403
+ return None
@@ -0,0 +1,68 @@
1
+ """Decode \\xHH hex escape sequences in string literals."""
2
+
3
+ import re
4
+
5
+ from ..traverser import traverse
6
+ from .base import Transform
7
+
8
+
9
+ class HexEscapes(Transform):
10
+ """Pre-AST regex pass to decode hex escape sequences."""
11
+
12
+ def execute(self):
13
+ # This works on the raw source before/after AST
14
+ # But since we operate on AST, we decode hex in string literal raw values
15
+
16
+ def enter(node, parent, key, index):
17
+ if node.get('type') != 'Literal' or not isinstance(node.get('value'), str):
18
+ return
19
+ raw_string = node.get('raw', '')
20
+ if '\\x' not in raw_string and '\\u' not in raw_string:
21
+ return
22
+ # The value is already decoded by parser, just fix raw
23
+ value = node['value']
24
+ new_raw = (
25
+ '"'
26
+ + value.replace('\\', '\\\\')
27
+ .replace('"', '\\"')
28
+ .replace('\n', '\\n')
29
+ .replace('\r', '\\r')
30
+ .replace('\t', '\\t')
31
+ + '"'
32
+ )
33
+ if new_raw != raw_string:
34
+ node['raw'] = new_raw
35
+ self.set_changed()
36
+
37
+ traverse(self.ast, {'enter': enter})
38
+ return self.has_changed()
39
+
40
+
41
+ def decode_hex_escapes_source(code):
42
+ """Decode hex escapes in source code string (pre-parse pass).
43
+
44
+ Only decodes hex escapes that produce printable characters (0x20-0x7e),
45
+ excluding the backslash (0x5c) and quote characters which would break
46
+ string literal syntax. Control characters (newlines, tabs, nulls etc.)
47
+ are left as \\xHH to avoid breaking the parser.
48
+ """
49
+
50
+ def replace_in_string(match_result):
51
+ quote = match_result.group(1)
52
+ content = match_result.group(2)
53
+
54
+ # Decode hex escapes, but skip backslash, both quote chars,
55
+ # and control chars. Quote chars are left for AST-level handling
56
+ # which normalizes to double quotes like Babel.
57
+ def replace_hex_in_context(hex_match):
58
+ value = int(hex_match.group(1), 16)
59
+ if 0x20 <= value <= 0x7E and value not in (0x22, 0x27, 0x5C):
60
+ return chr(value)
61
+ return hex_match.group(0)
62
+
63
+ decoded = re.sub(r'\\x([0-9a-fA-F]{2})', replace_hex_in_context, content)
64
+ return quote + decoded + quote
65
+
66
+ # Match string literals and decode hex escapes within them
67
+ result = re.sub(r"""(['"])((?:(?!\1|\\).|\\.)*?)\1""", replace_in_string, code)
68
+ return result
@@ -0,0 +1,91 @@
1
+ """JJEncode decoder.
2
+
3
+ JJEncode encodes JavaScript using $ and _ variable manipulations.
4
+ This decoder uses Node.js subprocess since JJEncode requires
5
+ JavaScript execution to fully resolve the symbol table.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+
14
+
15
+ # Detection patterns for JJEncode
16
+ _JJENCODE_PATTERNS = [
17
+ re.compile(r'\$=~\[\];'),
18
+ re.compile(r'\$\$=\{___:\+\+\$'),
19
+ re.compile(r'\$\$\$=\(\$\[\$\]\+""\)\[\$\]'),
20
+ re.compile(r'[\$_]{3,}.*[\[\]]{2,}.*[\+!]{2,}'),
21
+ ]
22
+
23
+
24
+ def is_jj_encoded(code):
25
+ """Check if code is JJEncoded."""
26
+ first_line = code.split('\n', 1)[0]
27
+ return any(p.search(first_line) for p in _JJENCODE_PATTERNS)
28
+
29
+
30
+ def jj_decode(code):
31
+ """Decode JJEncoded JavaScript using Node.js with Function interception."""
32
+ if not is_jj_encoded(code):
33
+ return None
34
+ return _run_with_function_intercept(code)
35
+
36
+
37
+ def jj_decode_via_eval(code):
38
+ """Simpler fallback: just run the JJEncode in Node with Function intercepted."""
39
+ return _run_with_function_intercept(code)
40
+
41
+
42
+ def _run_with_function_intercept(code):
43
+ """Run JS code with Function constructor intercepted via Proxy."""
44
+ node = shutil.which('node')
45
+ if not node:
46
+ return None
47
+
48
+ js_wrapper = (
49
+ 'var _captured = [];\n'
50
+ 'var _origFunction = Function;\n'
51
+ 'var _handler = {\n'
52
+ ' apply: function(target, thisArg, args) {\n'
53
+ ' var body = args[args.length - 1];\n'
54
+ ' if (typeof body === "string" && body.length > 0) _captured.push(body);\n'
55
+ ' return target.apply(thisArg, args);\n'
56
+ ' },\n'
57
+ ' construct: function(target, args) {\n'
58
+ ' var body = args[args.length - 1];\n'
59
+ ' if (typeof body === "string" && body.length > 0) _captured.push(body);\n'
60
+ ' return new target(...args);\n'
61
+ ' }\n'
62
+ '};\n'
63
+ 'var _proxyFn = new Proxy(_origFunction, _handler);\n'
64
+ 'Function = _proxyFn;\n'
65
+ 'Function.prototype.constructor = _proxyFn;\n'
66
+ 'Object.getPrototypeOf(function(){}).constructor = _proxyFn;\n'
67
+ 'try {\n' + code + '\n' + '} catch(e) {}\n'
68
+ 'Function = _origFunction;\n'
69
+ 'Function.prototype.constructor = _origFunction;\n'
70
+ 'if (_captured.length > 0) {\n'
71
+ ' console.log(_captured[_captured.length - 1]);\n'
72
+ '}\n'
73
+ )
74
+
75
+ try:
76
+ fd, tmp_path = tempfile.mkstemp(suffix='.js')
77
+ try:
78
+ with os.fdopen(fd, 'w') as f:
79
+ f.write(js_wrapper)
80
+ result = subprocess.run(
81
+ [node, tmp_path],
82
+ capture_output=True,
83
+ text=True,
84
+ timeout=5,
85
+ )
86
+ output = result.stdout.strip()
87
+ return output if output else None
88
+ finally:
89
+ os.unlink(tmp_path)
90
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
91
+ return None
@@ -0,0 +1,93 @@
1
+ """JSFUCK decoder.
2
+
3
+ JSFUCK encodes JavaScript using only []()!+ characters.
4
+ Decoding requires JavaScript execution, so we use Node.js subprocess.
5
+ If Node.js is unavailable, returns None (graceful degradation).
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+
14
+
15
+ # JSFUCK uses only these characters (plus optional whitespace/semicolons)
16
+ _JSFUCK_RE = re.compile(r'^[\s\[\]\(\)!+;]+$')
17
+
18
+
19
+ def is_jsfuck(code):
20
+ """Check if code is JSFUCK-encoded.
21
+
22
+ JSFUCK code consists only of []()!+ characters (with optional whitespace/semicolons).
23
+ We also require minimum length to avoid false positives.
24
+ """
25
+ stripped = code.strip()
26
+ if len(stripped) < 100:
27
+ return False
28
+ # Check if code starts with JSFUCK-style patterns
29
+ # Some JSFUCK variants have a preamble (like $ = String.fromCharCode(...))
30
+ # so we check if the majority of the code is JSFUCK chars
31
+ jsfuck_chars = set('[]()!+ \t\n\r;')
32
+ jsfuck_count = sum(1 for c in stripped if c in jsfuck_chars)
33
+ return jsfuck_count / len(stripped) > 0.9
34
+
35
+
36
+ def jsfuck_decode(code):
37
+ """Decode JSFUCK-encoded JavaScript using Node.js. Returns decoded string or None."""
38
+ node = shutil.which('node')
39
+ if not node:
40
+ return None
41
+
42
+ stripped = code.strip()
43
+
44
+ # Strategy: JSFUCK gets Function via []['flat']['constructor'] or similar
45
+ # prototype chain access. We intercept by patching Function.prototype.constructor
46
+ # and wrapping the code to capture what gets passed to Function().
47
+ js_wrapper = (
48
+ 'var _captured = [];\n'
49
+ 'var _origFunction = Function;\n'
50
+ # Patch the prototype chain so JSFUCK's []['constructor']['constructor'] is intercepted
51
+ 'var _handler = {\n'
52
+ ' apply: function(target, thisArg, args) {\n'
53
+ ' var body = args[args.length - 1];\n'
54
+ ' if (typeof body === "string" && body.length > 0) _captured.push(body);\n'
55
+ ' return target.apply(thisArg, args);\n'
56
+ ' },\n'
57
+ ' construct: function(target, args) {\n'
58
+ ' var body = args[args.length - 1];\n'
59
+ ' if (typeof body === "string" && body.length > 0) _captured.push(body);\n'
60
+ ' return new target(...args);\n'
61
+ ' }\n'
62
+ '};\n'
63
+ 'var _proxyFn = new Proxy(_origFunction, _handler);\n'
64
+ 'Function = _proxyFn;\n'
65
+ 'Function.prototype.constructor = _proxyFn;\n'
66
+ # Patch common prototype chains JSFUCK uses
67
+ 'Object.getPrototypeOf(function(){}).constructor = _proxyFn;\n'
68
+ 'try {\n' + stripped + '\n' + '} catch(e) {}\n'
69
+ 'Function = _origFunction;\n'
70
+ 'Function.prototype.constructor = _origFunction;\n'
71
+ 'if (_captured.length > 0) {\n'
72
+ ' console.log(_captured[_captured.length - 1]);\n'
73
+ '}\n'
74
+ )
75
+
76
+ try:
77
+ # Write to temp file to avoid shell arg length limits
78
+ fd, tmp_path = tempfile.mkstemp(suffix='.js')
79
+ try:
80
+ with os.fdopen(fd, 'w') as f:
81
+ f.write(js_wrapper)
82
+ result = subprocess.run(
83
+ [node, tmp_path],
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=10,
87
+ )
88
+ output = result.stdout.strip()
89
+ return output if output else None
90
+ finally:
91
+ os.unlink(tmp_path)
92
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
93
+ return None