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,1259 @@
1
+ """String array detection, rotation, and decoding for obfuscator.io patterns."""
2
+
3
+ import math
4
+ import re
5
+
6
+ from ..generator import generate
7
+ from ..scope import build_scope_tree
8
+ from ..traverser import REMOVE
9
+ from ..traverser import find_parent
10
+ from ..traverser import simple_traverse
11
+ from ..traverser import traverse
12
+ from ..utils.ast_helpers import is_identifier
13
+ from ..utils.ast_helpers import is_numeric_literal
14
+ from ..utils.ast_helpers import is_string_literal
15
+ from ..utils.ast_helpers import make_literal
16
+ from ..utils.string_decoders import Base64StringDecoder
17
+ from ..utils.string_decoders import BasicStringDecoder
18
+ from ..utils.string_decoders import DecoderType
19
+ from ..utils.string_decoders import Rc4StringDecoder
20
+ from .base import Transform
21
+
22
+
23
+ _BASE_64_REGEX = re.compile(r"""['"]abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\+/=['"]""")
24
+ _RC4_REGEX = re.compile(r"""fromCharCode.{0,30}\^""")
25
+
26
+
27
+ def _eval_numeric(node):
28
+ """Evaluate an AST node to a numeric value if it's a constant expression."""
29
+ if not isinstance(node, dict):
30
+ return None
31
+ match node.get('type', ''):
32
+ case 'Literal':
33
+ val = node.get('value')
34
+ return val if isinstance(val, (int, float)) else None
35
+ case 'UnaryExpression':
36
+ arg = _eval_numeric(node.get('argument'))
37
+ if arg is None:
38
+ return None
39
+ match node.get('operator'):
40
+ case '-':
41
+ return -arg
42
+ case '+':
43
+ return +arg
44
+ return None
45
+ case 'BinaryExpression':
46
+ left = _eval_numeric(node.get('left'))
47
+ right = _eval_numeric(node.get('right'))
48
+ if left is None or right is None:
49
+ return None
50
+ return _apply_arith(node.get('operator'), left, right)
51
+ return None
52
+
53
+
54
+ def _js_parse_int(s):
55
+ """Mimic JavaScript's parseInt: extract leading integer from string."""
56
+ if not isinstance(s, str):
57
+ return float('nan')
58
+ s = s.strip()
59
+ m = re.match(r'^[+-]?\d+', s)
60
+ if m:
61
+ return int(m.group())
62
+ return float('nan')
63
+
64
+
65
+ def _apply_arith(operator, left, right):
66
+ """Apply a binary arithmetic operator."""
67
+ match operator:
68
+ case '+':
69
+ return left + right
70
+ case '-':
71
+ return left - right
72
+ case '*':
73
+ return left * right
74
+ case '/':
75
+ return left / right if right != 0 else None
76
+ case '%':
77
+ return left % right if right != 0 else None
78
+ case _:
79
+ return None
80
+
81
+
82
+ def _collect_object_literals(ast):
83
+ """Collect simple object literal assignments: var o = {a: 0x1b1, b: 'str'}.
84
+
85
+ Returns a dict mapping (object_name, property_name) -> value (int or str).
86
+ """
87
+ result = {}
88
+
89
+ def visitor(node, parent):
90
+ if node.get('type') != 'VariableDeclarator':
91
+ return
92
+ name_node = node.get('id')
93
+ init = node.get('init')
94
+ if not is_identifier(name_node) or not init or init.get('type') != 'ObjectExpression':
95
+ return
96
+ object_name = name_node['name']
97
+ for prop in init.get('properties', []):
98
+ if prop.get('type') != 'Property':
99
+ continue
100
+ key = prop.get('key')
101
+ value = prop.get('value')
102
+ if not key or not value:
103
+ continue
104
+ if is_identifier(key):
105
+ property_name = key.get('name')
106
+ elif is_string_literal(key):
107
+ property_name = key.get('value')
108
+ else:
109
+ continue
110
+ numeric_value = _eval_numeric(value)
111
+ if numeric_value is not None:
112
+ result[(object_name, property_name)] = numeric_value
113
+ elif is_string_literal(value):
114
+ result[(object_name, property_name)] = value['value']
115
+
116
+ simple_traverse(ast, visitor)
117
+ return result
118
+
119
+
120
+ def _resolve_arg_value(arg, object_literals):
121
+ """Try to resolve a call argument to a numeric value.
122
+
123
+ Handles numeric literals, string hex literals, and member expressions
124
+ referencing known object literal properties.
125
+ """
126
+ numeric_value = _eval_numeric(arg)
127
+ if numeric_value is not None:
128
+ return numeric_value
129
+
130
+ if is_string_literal(arg):
131
+ try:
132
+ string_value = arg['value']
133
+ return int(string_value, 16) if string_value.startswith('0x') else int(string_value)
134
+ except (ValueError, TypeError):
135
+ pass
136
+
137
+ if arg.get('type') == 'MemberExpression' and not arg.get('computed'):
138
+ object_node = arg.get('object')
139
+ property_node = arg.get('property')
140
+ if is_identifier(object_node) and is_identifier(property_node):
141
+ looked_up = object_literals.get((object_node['name'], property_node['name']))
142
+ if isinstance(looked_up, (int, float)):
143
+ return looked_up
144
+ if isinstance(looked_up, str):
145
+ try:
146
+ return int(looked_up, 16) if looked_up.startswith('0x') else int(looked_up)
147
+ except (ValueError, TypeError):
148
+ pass
149
+
150
+ return None
151
+
152
+
153
+ def _resolve_string_arg(arg, object_literals):
154
+ """Try to resolve a call argument to a string value.
155
+
156
+ Handles string literals and member expressions referencing known object properties.
157
+ """
158
+ if is_string_literal(arg):
159
+ return arg['value']
160
+ if arg.get('type') == 'MemberExpression' and not arg.get('computed'):
161
+ object_node = arg.get('object')
162
+ property_node = arg.get('property')
163
+ if is_identifier(object_node) and is_identifier(property_node):
164
+ looked_up = object_literals.get((object_node['name'], property_node['name']))
165
+ if isinstance(looked_up, str):
166
+ return looked_up
167
+ return None
168
+
169
+
170
+ class WrapperInfo:
171
+ """Info about a wrapper function that calls the decoder."""
172
+
173
+ def __init__(self, name, param_index, wrapper_offset, func_node, key_param_index=None):
174
+ self.name = name
175
+ self.param_index = param_index
176
+ self.wrapper_offset = wrapper_offset
177
+ self.func_node = func_node
178
+ self.key_param_index = key_param_index
179
+
180
+ def get_effective_index(self, call_args):
181
+ """Given call argument values, compute the effective decoder index."""
182
+ if self.param_index >= len(call_args):
183
+ return None
184
+ value = call_args[self.param_index]
185
+ if not isinstance(value, (int, float)):
186
+ return None
187
+ return int(value) + self.wrapper_offset
188
+
189
+ def get_key(self, call_args):
190
+ """Get the RC4 key argument if applicable."""
191
+ if self.key_param_index is not None and self.key_param_index < len(call_args):
192
+ return call_args[self.key_param_index]
193
+ return None
194
+
195
+
196
+ class StringRevealer(Transform):
197
+ """Decode obfuscated string arrays and replace wrapper calls with literals."""
198
+
199
+ rebuild_scope = True
200
+ _rotation_locals = {}
201
+
202
+ def execute(self):
203
+ scope_tree, node_scope = build_scope_tree(self.ast)
204
+
205
+ # Strategy 1: Direct string array declarations (var arr = ["a","b","c"])
206
+ self._process_direct_arrays(scope_tree)
207
+
208
+ # Strategy 2: Obfuscator.io function-wrapped string arrays
209
+ self._process_obfuscatorio_pattern()
210
+
211
+ # Strategy 2b: Var-based string array with rotation + decoder
212
+ self._process_var_array_pattern()
213
+
214
+ # Strategy 3: Simple static array unpacking (js-deob --su)
215
+ self._process_static_arrays()
216
+
217
+ return self.has_changed()
218
+
219
+ # ================================================================
220
+ # Strategy 2: Obfuscator.io pattern
221
+ # ================================================================
222
+
223
+ def _process_obfuscatorio_pattern(self):
224
+ """Handle obfuscator.io: array func -> decoder func(s) -> wrapper funcs -> rotation."""
225
+ body = self.ast.get('body', [])
226
+
227
+ # Step 1: Find string array function
228
+ array_func_name, string_array, array_func_idx = self._find_string_array_function(body)
229
+ if array_func_name is None:
230
+ return
231
+
232
+ # Step 2: Find ALL decoder functions that call the array function
233
+ decoder_infos = self._find_all_decoder_functions(body, array_func_name)
234
+ if not decoder_infos:
235
+ return
236
+
237
+ # Step 3: Create decoders, wrappers, and aliases for each decoder function
238
+ decoders = {} # decoder_name -> decoder instance
239
+ decoder_wrappers = {} # decoder_name -> {wrapper_name: WrapperInfo}
240
+ all_wrappers = {} # all wrappers combined
241
+ all_decoder_aliases = set()
242
+ decoder_indices = set()
243
+ for d_name, d_offset, d_idx, d_type in decoder_infos:
244
+ decoder = self._create_base_decoder(string_array, d_offset, d_type)
245
+ decoders[d_name] = decoder
246
+ decoder_indices.add(d_idx)
247
+ wrappers = self._find_all_wrappers(d_name)
248
+ decoder_wrappers[d_name] = wrappers
249
+ all_wrappers.update(wrappers)
250
+ all_decoder_aliases.update(self._find_decoder_aliases(d_name))
251
+
252
+ # Use the first decoder as the primary (for rotation — all share the same array)
253
+ primary_decoder = decoders[decoder_infos[0][0]]
254
+
255
+ # Build a combined alias-to-decoder map for rotation evaluation
256
+ alias_decoder_map = {}
257
+ for d_name, decoder in decoders.items():
258
+ alias_decoder_map[d_name] = decoder
259
+ for alias in self._find_decoder_aliases(d_name):
260
+ alias_decoder_map[alias] = decoder
261
+
262
+ # Step 5: Find and execute rotation
263
+ rotation_result = self._find_and_execute_rotation(
264
+ body,
265
+ array_func_name,
266
+ string_array,
267
+ primary_decoder,
268
+ all_wrappers,
269
+ all_decoder_aliases,
270
+ alias_decoder_map=alias_decoder_map,
271
+ all_decoders=decoders,
272
+ )
273
+
274
+ # Update the AST array to reflect rotation so future passes
275
+ # re-extract the correct (rotated) array.
276
+ if rotation_result is not None:
277
+ self._update_ast_array(body[array_func_idx], string_array)
278
+
279
+ # Collect object literals for member expression resolution
280
+ obj_literals = _collect_object_literals(self.ast)
281
+
282
+ # Step 6-8: Replace calls and remove aliases for each decoder
283
+ for d_name, decoder in decoders.items():
284
+ aliases_for_decoder = self._find_decoder_aliases(d_name)
285
+
286
+ self._replace_all_wrapper_calls(decoder_wrappers[d_name], decoder, obj_literals)
287
+ self._replace_direct_decoder_calls(d_name, decoder, aliases_for_decoder, obj_literals)
288
+ self._remove_decoder_aliases(d_name, aliases_for_decoder)
289
+
290
+ # Step 9: Remove rotation IIFE, decoder and array functions
291
+ indices_to_remove = set()
292
+ if rotation_result is not None:
293
+ rotation_idx, rotation_call_expr = rotation_result
294
+ if rotation_call_expr is not None:
295
+ # Rotation was inside a SequenceExpression — remove only that sub-expression
296
+ seq_expr = body[rotation_idx]['expression']
297
+ expressions = seq_expr.get('expressions', [])
298
+ try:
299
+ expressions.remove(rotation_call_expr)
300
+ except ValueError:
301
+ pass
302
+ # If only one expression remains, unwrap the SequenceExpression
303
+ if len(expressions) == 1:
304
+ body[rotation_idx]['expression'] = expressions[0]
305
+ else:
306
+ indices_to_remove.add(rotation_idx)
307
+ # Only remove decoder/array funcs if we actually decoded strings
308
+ if self.has_changed():
309
+ indices_to_remove.update(decoder_indices)
310
+ indices_to_remove.add(array_func_idx)
311
+ self._remove_body_indices(body, *indices_to_remove)
312
+
313
+ def _find_string_array_function(self, body):
314
+ """Find the string array function declaration.
315
+
316
+ Pattern: function X() { var a = ['s1','s2',...]; X = function(){return a;}; return X(); }
317
+ """
318
+ for i, stmt in enumerate(body):
319
+ if stmt.get('type') != 'FunctionDeclaration':
320
+ continue
321
+ func_name = stmt.get('id', {}).get('name')
322
+ if not func_name:
323
+ continue
324
+ func_body = stmt.get('body', {}).get('body', [])
325
+ if len(func_body) < 2:
326
+ continue
327
+
328
+ string_array = self._extract_array_from_statement(func_body[0])
329
+ if string_array is not None and len(string_array) >= 2:
330
+ return func_name, string_array, i
331
+
332
+ return None, None, None
333
+
334
+ @staticmethod
335
+ def _string_array_from_expression(node):
336
+ """Return list of string values if node is an ArrayExpression of all string literals."""
337
+ if not node or node.get('type') != 'ArrayExpression':
338
+ return None
339
+ elements = node.get('elements', [])
340
+ if not elements or not all(is_string_literal(e) for e in elements):
341
+ return None
342
+ return [e['value'] for e in elements]
343
+
344
+ def _extract_array_from_statement(self, stmt):
345
+ """Extract string array from a variable declaration or assignment."""
346
+ if stmt.get('type') == 'VariableDeclaration':
347
+ for declaration in stmt.get('declarations', []):
348
+ result = self._string_array_from_expression(declaration.get('init'))
349
+ if result is not None:
350
+ return result
351
+ elif stmt.get('type') == 'ExpressionStatement':
352
+ expr = stmt.get('expression')
353
+ if expr and expr.get('type') == 'AssignmentExpression':
354
+ return self._string_array_from_expression(expr.get('right'))
355
+ return None
356
+
357
+ def _find_all_decoder_functions(self, body, array_func_name):
358
+ """Find all decoder functions that call the array function.
359
+
360
+ Returns list of (func_name, offset, body_index, decoder_type) tuples.
361
+ """
362
+ results = []
363
+ for i, stmt in enumerate(body):
364
+ if stmt.get('type') != 'FunctionDeclaration':
365
+ continue
366
+ func_name = stmt.get('id', {}).get('name')
367
+ if not func_name or func_name == array_func_name:
368
+ continue
369
+
370
+ if not self._function_calls(stmt, array_func_name):
371
+ continue
372
+
373
+ offset = self._extract_decoder_offset(stmt)
374
+
375
+ source = generate(stmt)
376
+ if _BASE_64_REGEX.search(source):
377
+ dtype = DecoderType.RC4 if _RC4_REGEX.search(source) else DecoderType.BASE_64
378
+ else:
379
+ dtype = DecoderType.BASIC
380
+
381
+ results.append((func_name, offset, i, dtype))
382
+
383
+ return results
384
+
385
+ def _function_calls(self, func_node, callee_name):
386
+ """Check if a function body contains a call to callee_name."""
387
+ found = [False]
388
+
389
+ def visitor(node, parent):
390
+ if found[0]:
391
+ return
392
+ if (
393
+ node.get('type') == 'CallExpression'
394
+ and is_identifier(node.get('callee'))
395
+ and node['callee']['name'] == callee_name
396
+ ):
397
+ found[0] = True
398
+
399
+ simple_traverse(func_node, visitor)
400
+ return found[0]
401
+
402
+ def _extract_decoder_offset(self, func_node):
403
+ """Extract offset from decoder's inner param = param OP EXPR pattern."""
404
+ found_offset = [None]
405
+
406
+ def find_offset(node, parent):
407
+ if found_offset[0] is not None:
408
+ return
409
+ if node.get('type') != 'AssignmentExpression':
410
+ return
411
+ left = node.get('left')
412
+ right = node.get('right')
413
+ if not left or not right:
414
+ return
415
+ if not is_identifier(left):
416
+ return
417
+ if right.get('type') != 'BinaryExpression':
418
+ return
419
+ if not (is_identifier(right.get('left')) and right['left']['name'] == left['name']):
420
+ return
421
+ operator = right.get('operator')
422
+ if operator not in ('+', '-'):
423
+ return
424
+ right_value = _eval_numeric(right.get('right'))
425
+ if right_value is not None:
426
+ found_offset[0] = int(-right_value) if operator == '-' else int(right_value)
427
+
428
+ simple_traverse(func_node, find_offset)
429
+ return found_offset[0] if found_offset[0] is not None else 0
430
+
431
+ def _create_base_decoder(self, string_array, offset, dtype):
432
+ """Create the appropriate decoder instance."""
433
+ match dtype:
434
+ case DecoderType.RC4:
435
+ return Rc4StringDecoder(string_array, offset)
436
+ case DecoderType.BASE_64:
437
+ return Base64StringDecoder(string_array, offset)
438
+ case _:
439
+ return BasicStringDecoder(string_array, offset)
440
+
441
+ def _find_all_wrappers(self, decoder_name):
442
+ """Find all wrapper functions throughout the AST that call the decoder.
443
+
444
+ Pattern: function W(p0,..,pN) { return DECODER(p_i OP OFFSET, p_j); }
445
+ """
446
+ wrappers = {}
447
+
448
+ def visitor(node, parent):
449
+ if node.get('type') == 'FunctionDeclaration':
450
+ info = self._analyze_wrapper(node, decoder_name)
451
+ if info:
452
+ wrappers[info.name] = info
453
+ elif node.get('type') == 'VariableDeclarator':
454
+ init = node.get('init')
455
+ name_node = node.get('id')
456
+ if (
457
+ init
458
+ and init.get('type') in ('FunctionExpression', 'ArrowFunctionExpression')
459
+ and is_identifier(name_node)
460
+ ):
461
+ info = self._analyze_wrapper_expr(name_node['name'], init, decoder_name)
462
+ if info:
463
+ wrappers[info.name] = info
464
+
465
+ simple_traverse(self.ast, visitor)
466
+ return wrappers
467
+
468
+ def _analyze_wrapper(self, func_node, decoder_name):
469
+ """Check if a FunctionDeclaration is a wrapper. Returns WrapperInfo or None."""
470
+ func_name = func_node.get('id', {}).get('name')
471
+ if not func_name:
472
+ return None
473
+ return self._analyze_wrapper_expr(func_name, func_node, decoder_name)
474
+
475
+ def _analyze_wrapper_expr(self, func_name, func_node, decoder_name):
476
+ """Analyze a function node (declaration or expression) as a potential wrapper."""
477
+ func_body = func_node.get('body', {})
478
+ if func_body.get('type') == 'BlockStatement':
479
+ statements = func_body.get('body', [])
480
+ else:
481
+ return None
482
+
483
+ if len(statements) != 1:
484
+ return None
485
+
486
+ return_statement = statements[0]
487
+ if return_statement.get('type') != 'ReturnStatement':
488
+ return None
489
+
490
+ argument = return_statement.get('argument')
491
+ if not argument or argument.get('type') != 'CallExpression':
492
+ return None
493
+
494
+ callee = argument.get('callee')
495
+ if not (is_identifier(callee) and callee['name'] == decoder_name):
496
+ return None
497
+
498
+ call_args = argument.get('arguments', [])
499
+ if not call_args:
500
+ return None
501
+
502
+ params = func_node.get('params', [])
503
+ param_names = [p['name'] for p in params if is_identifier(p)]
504
+
505
+ param_index, wrapper_offset = self._extract_wrapper_offset(call_args[0], param_names)
506
+ if param_index is None:
507
+ return None
508
+
509
+ key_param_index = None
510
+ if len(call_args) > 1:
511
+ second = call_args[1]
512
+ if is_identifier(second) and second['name'] in param_names:
513
+ key_param_index = param_names.index(second['name'])
514
+
515
+ return WrapperInfo(func_name, param_index, wrapper_offset, func_node, key_param_index)
516
+
517
+ def _extract_wrapper_offset(self, expr, param_names):
518
+ """Extract (param_index, offset) from wrapper's first argument to decoder.
519
+
520
+ Handles: p_N, p_N + LIT, p_N - LIT, p_N - -LIT, p_N + -LIT
521
+ """
522
+ if is_identifier(expr) and expr['name'] in param_names:
523
+ return param_names.index(expr['name']), 0
524
+
525
+ if expr.get('type') != 'BinaryExpression':
526
+ return None, None
527
+ operator = expr.get('operator')
528
+ if operator not in ('+', '-'):
529
+ return None, None
530
+
531
+ left = expr.get('left')
532
+ if not (is_identifier(left) and left['name'] in param_names):
533
+ return None, None
534
+
535
+ right_value = _eval_numeric(expr.get('right'))
536
+ if right_value is None:
537
+ return None, None
538
+
539
+ param_idx = param_names.index(left['name'])
540
+ offset = int(-right_value) if operator == '-' else int(right_value)
541
+ return param_idx, offset
542
+
543
+ def _remove_decoder_aliases(self, decoder_name, aliases):
544
+ """Remove variable declarations that are aliases for the decoder.
545
+
546
+ Removes: const _0xABC = _0x22e6; and transitive: const _0xDEF = _0xABC;
547
+ """
548
+ if not aliases:
549
+ return
550
+ # The set of names to remove includes the decoder and all aliases
551
+ removable_inits = aliases | {decoder_name}
552
+
553
+ def enter(node, parent, key, index):
554
+ if node.get('type') != 'VariableDeclaration':
555
+ return
556
+ decls = node.get('declarations', [])
557
+ i = 0
558
+ while i < len(decls):
559
+ declaration = decls[i]
560
+ name_node = declaration.get('id')
561
+ init = declaration.get('init')
562
+ if (
563
+ is_identifier(name_node)
564
+ and name_node['name'] in aliases
565
+ and init
566
+ and is_identifier(init)
567
+ and init['name'] in removable_inits
568
+ ):
569
+ decls.pop(i)
570
+ self.set_changed()
571
+ else:
572
+ i += 1
573
+ if not decls:
574
+ return REMOVE
575
+
576
+ traverse(self.ast, {'enter': enter})
577
+
578
+ def _find_decoder_aliases(self, decoder_name):
579
+ """Find all variable declarations that are aliases for the decoder.
580
+
581
+ Handles transitive aliases: const a = decoder; const b = a; const c = b;
582
+ Returns a set of all alias names.
583
+ """
584
+ # First pass: collect all simple assignments (const x = y)
585
+ assignments = {} # name -> init_name
586
+
587
+ def visitor(node, parent):
588
+ if node.get('type') == 'VariableDeclarator':
589
+ init = node.get('init')
590
+ name_node = node.get('id')
591
+ if init and is_identifier(init) and is_identifier(name_node):
592
+ assignments[name_node['name']] = init['name']
593
+
594
+ simple_traverse(self.ast, visitor)
595
+
596
+ # Resolve transitively: follow chains back to decoder_name
597
+ aliases = set()
598
+ for name, init_name in assignments.items():
599
+ # Walk the chain: name -> init_name -> ... -> decoder_name?
600
+ seen = set()
601
+ current = init_name
602
+ while current and current not in seen:
603
+ if current == decoder_name:
604
+ aliases.add(name)
605
+ break
606
+ seen.add(current)
607
+ current = assignments.get(current)
608
+
609
+ return aliases
610
+
611
+ # ---- Rotation ----
612
+
613
+ def _find_and_execute_rotation(
614
+ self,
615
+ body,
616
+ array_func_name,
617
+ string_array,
618
+ decoder,
619
+ wrappers,
620
+ decoder_aliases=None,
621
+ alias_decoder_map=None,
622
+ all_decoders=None,
623
+ ):
624
+ """Find rotation IIFE and execute it.
625
+
626
+ Returns (body_index, rotation_call_expr_or_none) on success, or None.
627
+ When the rotation is inside a SequenceExpression, rotation_call_expr is
628
+ the specific sub-expression to remove (not the whole statement).
629
+ """
630
+ for i, stmt in enumerate(body):
631
+ if stmt.get('type') != 'ExpressionStatement':
632
+ continue
633
+ expr = stmt.get('expression')
634
+ if not expr:
635
+ continue
636
+
637
+ if expr.get('type') == 'CallExpression':
638
+ if self._try_execute_rotation_call(
639
+ expr,
640
+ array_func_name,
641
+ string_array,
642
+ decoder,
643
+ wrappers,
644
+ decoder_aliases,
645
+ alias_decoder_map=alias_decoder_map,
646
+ all_decoders=all_decoders,
647
+ ):
648
+ return (i, None)
649
+
650
+ elif expr.get('type') == 'SequenceExpression':
651
+ for sub in expr.get('expressions', []):
652
+ if sub.get('type') != 'CallExpression':
653
+ continue
654
+ if self._try_execute_rotation_call(
655
+ sub,
656
+ array_func_name,
657
+ string_array,
658
+ decoder,
659
+ wrappers,
660
+ decoder_aliases,
661
+ alias_decoder_map=alias_decoder_map,
662
+ all_decoders=all_decoders,
663
+ ):
664
+ return (i, sub)
665
+
666
+ return None
667
+
668
+ def _try_execute_rotation_call(
669
+ self,
670
+ call_expr,
671
+ array_func_name,
672
+ string_array,
673
+ decoder,
674
+ wrappers,
675
+ decoder_aliases,
676
+ alias_decoder_map=None,
677
+ all_decoders=None,
678
+ ):
679
+ """Try to parse and execute a single rotation call expression. Returns True on success."""
680
+ callee = call_expr.get('callee')
681
+ args = call_expr.get('arguments', [])
682
+
683
+ if not callee or callee.get('type') != 'FunctionExpression':
684
+ return False
685
+ if len(args) != 2:
686
+ return False
687
+ if not (is_identifier(args[0]) and args[0]['name'] == array_func_name):
688
+ return False
689
+
690
+ stop_value = _eval_numeric(args[1])
691
+ if stop_value is None:
692
+ return False
693
+ stop_value = int(stop_value)
694
+
695
+ rotation_expr = self._extract_rotation_expression(callee)
696
+ if rotation_expr is None:
697
+ return False
698
+
699
+ # Collect local object literals from the rotation IIFE for argument resolution
700
+ self._rotation_locals = self._collect_rotation_locals(callee)
701
+
702
+ operation = self._parse_rotation_op(rotation_expr, wrappers, decoder_aliases)
703
+ if operation is None:
704
+ return False
705
+
706
+ self._execute_rotation(
707
+ string_array,
708
+ operation,
709
+ wrappers,
710
+ decoder,
711
+ stop_value,
712
+ alias_decoder_map=alias_decoder_map,
713
+ )
714
+ return True
715
+
716
+ @staticmethod
717
+ def _collect_rotation_locals(iife_func):
718
+ """Collect local object literal assignments from the rotation IIFE.
719
+
720
+ Returns dict: var_name -> {prop_name: value}.
721
+ Handles: var J = {A: 0xb9, S: 0xa7, D: 'M8Y&'};
722
+ """
723
+ result = {}
724
+ func_body = iife_func.get('body', {}).get('body', [])
725
+ for stmt in func_body:
726
+ if stmt.get('type') != 'VariableDeclaration':
727
+ continue
728
+ for decl in stmt.get('declarations', []):
729
+ name_node = decl.get('id')
730
+ init = decl.get('init')
731
+ if not is_identifier(name_node) or not init or init.get('type') != 'ObjectExpression':
732
+ continue
733
+ obj = {}
734
+ for prop in init.get('properties', []):
735
+ key = prop.get('key')
736
+ value = prop.get('value')
737
+ if not key or not value:
738
+ continue
739
+ if is_identifier(key):
740
+ k = key['name']
741
+ elif is_string_literal(key):
742
+ k = key['value']
743
+ else:
744
+ continue
745
+ num = _eval_numeric(value)
746
+ if num is not None:
747
+ obj[k] = int(num)
748
+ elif is_string_literal(value):
749
+ obj[k] = value['value']
750
+ if obj:
751
+ result[name_node['name']] = obj
752
+ return result
753
+
754
+ def _extract_rotation_expression(self, iife_func):
755
+ """Extract the arithmetic expression from the try block in the rotation loop."""
756
+ func_body = iife_func.get('body', {}).get('body', [])
757
+ if not func_body:
758
+ return None
759
+
760
+ loop = None
761
+ for stmt in func_body:
762
+ if stmt.get('type') in ('WhileStatement', 'ForStatement'):
763
+ loop = stmt
764
+
765
+ if loop is None:
766
+ return None
767
+
768
+ loop_body = loop.get('body', {})
769
+ stmts = loop_body.get('body', []) if loop_body.get('type') == 'BlockStatement' else [loop_body]
770
+
771
+ for stmt in stmts:
772
+ if stmt.get('type') != 'TryStatement':
773
+ continue
774
+ block = stmt.get('block', {}).get('body', [])
775
+ if not block:
776
+ continue
777
+ result = self._expression_from_try_block(block[0])
778
+ if result is not None:
779
+ return result
780
+ return None
781
+
782
+ @staticmethod
783
+ def _expression_from_try_block(first_statement):
784
+ """Extract the init/rhs expression from the first statement in a try block."""
785
+ if first_statement.get('type') == 'VariableDeclaration':
786
+ decls = first_statement.get('declarations', [])
787
+ return decls[0].get('init') if decls else None
788
+ if first_statement.get('type') == 'ExpressionStatement':
789
+ expr = first_statement.get('expression')
790
+ if expr and expr.get('type') == 'AssignmentExpression':
791
+ return expr.get('right')
792
+ return None
793
+
794
+ def _parse_rotation_op(self, expr, wrappers, decoder_aliases=None):
795
+ """Parse a rotation expression into an operation tree."""
796
+ if not isinstance(expr, dict):
797
+ return None
798
+ aliases = decoder_aliases or set()
799
+
800
+ match expr.get('type', ''):
801
+ case 'Literal' if isinstance(expr.get('value'), (int, float)):
802
+ return {'op': 'literal', 'value': expr['value']}
803
+
804
+ case 'UnaryExpression' if expr.get('operator') == '-':
805
+ child = self._parse_rotation_op(expr.get('argument'), wrappers, decoder_aliases)
806
+ return {'op': 'negate', 'child': child} if child else None
807
+
808
+ case 'BinaryExpression' if expr.get('operator') in (
809
+ '+',
810
+ '-',
811
+ '*',
812
+ '/',
813
+ '%',
814
+ ):
815
+ left = self._parse_rotation_op(expr.get('left'), wrappers, decoder_aliases)
816
+ right = self._parse_rotation_op(expr.get('right'), wrappers, decoder_aliases)
817
+ if left and right:
818
+ return {
819
+ 'op': 'binary',
820
+ 'operator': expr['operator'],
821
+ 'left': left,
822
+ 'right': right,
823
+ }
824
+ return None
825
+
826
+ case 'CallExpression':
827
+ return self._parse_parseInt_call(expr, wrappers, aliases)
828
+
829
+ return None
830
+
831
+ def _parse_parseInt_call(self, expr, wrappers, aliases):
832
+ """Parse parseInt(wrapperOrDecoder(...)) into an operation node."""
833
+ callee = expr.get('callee')
834
+ args = expr.get('arguments', [])
835
+ if not (is_identifier(callee) and callee['name'] == 'parseInt' and len(args) == 1):
836
+ return None
837
+ inner = args[0]
838
+ if inner.get('type') != 'CallExpression':
839
+ return None
840
+ inner_callee = inner.get('callee')
841
+ if not is_identifier(inner_callee):
842
+ return None
843
+ cname = inner_callee['name']
844
+ arg_values = []
845
+ for a in inner.get('arguments', []):
846
+ resolved = self._resolve_rotation_arg(a)
847
+ if resolved is not None:
848
+ arg_values.append(resolved)
849
+ else:
850
+ return None
851
+ if cname in wrappers:
852
+ return {'op': 'call', 'wrapper_name': cname, 'args': arg_values}
853
+ if cname in aliases:
854
+ return {'op': 'direct_decoder_call', 'alias_name': cname, 'args': arg_values}
855
+ return None
856
+
857
+ def _resolve_rotation_arg(self, arg):
858
+ """Resolve a rotation call argument to a numeric or string value.
859
+
860
+ Handles literals, string hex, and MemberExpression referencing local objects.
861
+ """
862
+ val = _eval_numeric(arg)
863
+ if val is not None:
864
+ return int(val)
865
+ if is_string_literal(arg):
866
+ s = arg['value']
867
+ try:
868
+ return int(s, 16) if s.startswith('0x') else int(s)
869
+ except (ValueError, TypeError):
870
+ return s
871
+ # MemberExpression: J.A or J['A']
872
+ if arg.get('type') == 'MemberExpression':
873
+ obj = arg.get('object')
874
+ prop = arg.get('property')
875
+ if is_identifier(obj) and obj['name'] in self._rotation_locals:
876
+ local_obj = self._rotation_locals[obj['name']]
877
+ if not arg.get('computed') and is_identifier(prop):
878
+ return local_obj.get(prop['name'])
879
+ elif is_string_literal(prop):
880
+ return local_obj.get(prop['value'])
881
+ return None
882
+
883
+ def _decode_and_parse_int(self, decoder, idx, key=None):
884
+ """Decode a string and parse it as an integer. Raises on failure."""
885
+ decoded = decoder.get_string(int(idx), key) if key is not None else decoder.get_string(int(idx))
886
+ if decoded is None:
887
+ raise ValueError('Decoder returned None')
888
+ result = _js_parse_int(decoded)
889
+ if math.isnan(result):
890
+ raise ValueError('NaN from parseInt')
891
+ return result
892
+
893
+ def _apply_rotation_op(self, operation, wrappers, decoder, alias_decoder_map=None):
894
+ """Evaluate a parsed rotation operation tree."""
895
+ match operation['op']:
896
+ case 'literal':
897
+ return operation['value']
898
+ case 'negate':
899
+ return -self._apply_rotation_op(operation['child'], wrappers, decoder, alias_decoder_map)
900
+ case 'binary':
901
+ left = self._apply_rotation_op(operation['left'], wrappers, decoder, alias_decoder_map)
902
+ right = self._apply_rotation_op(operation['right'], wrappers, decoder, alias_decoder_map)
903
+ return _apply_arith(operation['operator'], left, right)
904
+ case 'call':
905
+ wrapper = wrappers[operation['wrapper_name']]
906
+ call_args = operation['args']
907
+ effective_idx = wrapper.get_effective_index(call_args)
908
+ if effective_idx is None:
909
+ raise ValueError('Invalid wrapper args')
910
+ return self._decode_and_parse_int(decoder, effective_idx, wrapper.get_key(call_args))
911
+ case 'direct_decoder_call':
912
+ call_args = operation['args']
913
+ if not call_args:
914
+ raise ValueError('No args for direct decoder call')
915
+ key = call_args[1] if len(call_args) > 1 else None
916
+ # Use alias-specific decoder if available
917
+ alias_name = operation.get('alias_name')
918
+ target_decoder = decoder
919
+ if alias_name and alias_decoder_map and alias_name in alias_decoder_map:
920
+ target_decoder = alias_decoder_map[alias_name]
921
+ return self._decode_and_parse_int(target_decoder, int(call_args[0]), key)
922
+ case _:
923
+ raise ValueError(f'Unknown op: {operation["op"]}')
924
+
925
+ def _execute_rotation(self, string_array, operation, wrappers, decoder, stop_value, alias_decoder_map=None):
926
+ """Rotate array until the expression evaluates to stop_value."""
927
+ # Collect all decoders that need cache clearing on each rotation
928
+ all_decoders = set()
929
+ all_decoders.add(decoder)
930
+ if alias_decoder_map:
931
+ all_decoders.update(alias_decoder_map.values())
932
+
933
+ for _ in range(100001):
934
+ try:
935
+ value = self._apply_rotation_op(operation, wrappers, decoder, alias_decoder_map)
936
+ if int(value) == stop_value:
937
+ return True
938
+ string_array.append(string_array.pop(0))
939
+ except Exception:
940
+ string_array.append(string_array.pop(0))
941
+ # Clear decoder caches after rotation since array contents shifted
942
+ for d in all_decoders:
943
+ if hasattr(d, '_cache'):
944
+ d._cache.clear()
945
+ return False
946
+
947
+ # ---- Replacement ----
948
+
949
+ def _replace_all_wrapper_calls(self, wrappers, decoder, obj_literals=None):
950
+ """Replace all calls to wrapper functions with decoded string literals."""
951
+ if not wrappers:
952
+ return True
953
+ all_replaced = [True]
954
+ _obj_literals = obj_literals or {}
955
+
956
+ def enter(node, parent, key, index):
957
+ if node.get('type') != 'CallExpression':
958
+ return
959
+ callee = node.get('callee')
960
+ if not is_identifier(callee):
961
+ return
962
+ if callee['name'] not in wrappers:
963
+ return
964
+
965
+ wrapper = wrappers[callee['name']]
966
+ call_args = node.get('arguments', [])
967
+
968
+ # Only need the active param (index) and optionally the key param
969
+ if wrapper.param_index >= len(call_args):
970
+ all_replaced[0] = False
971
+ return
972
+
973
+ index_value = _resolve_arg_value(call_args[wrapper.param_index], _obj_literals)
974
+ if index_value is None:
975
+ all_replaced[0] = False
976
+ return
977
+
978
+ effective_idx = int(index_value) + wrapper.wrapper_offset
979
+
980
+ key = None
981
+ if wrapper.key_param_index is not None and wrapper.key_param_index < len(call_args):
982
+ key = _resolve_string_arg(call_args[wrapper.key_param_index], _obj_literals)
983
+
984
+ try:
985
+ decoded = (
986
+ decoder.get_string(int(effective_idx), key)
987
+ if key is not None
988
+ else decoder.get_string(int(effective_idx))
989
+ )
990
+ if isinstance(decoded, str):
991
+ self.set_changed()
992
+ return make_literal(decoded)
993
+ all_replaced[0] = False
994
+ except Exception:
995
+ all_replaced[0] = False
996
+
997
+ traverse(self.ast, {'enter': enter})
998
+ return all_replaced[0]
999
+
1000
+ def _replace_direct_decoder_calls(self, decoder_name, decoder, decoder_aliases=None, obj_literals=None):
1001
+ """Replace direct calls to the decoder function (and its aliases) with literals."""
1002
+ names = {decoder_name}
1003
+ if decoder_aliases:
1004
+ names.update(decoder_aliases)
1005
+ _obj_literals = obj_literals or {}
1006
+
1007
+ def enter(node, parent, key, index):
1008
+ if node.get('type') != 'CallExpression':
1009
+ return
1010
+ callee = node.get('callee')
1011
+ if not (is_identifier(callee) and callee['name'] in names):
1012
+ return
1013
+ args = node.get('arguments', [])
1014
+ if not args:
1015
+ return
1016
+
1017
+ first_val = _resolve_arg_value(args[0], _obj_literals)
1018
+ if first_val is None:
1019
+ return
1020
+
1021
+ key = None
1022
+ if len(args) > 1:
1023
+ key = _resolve_string_arg(args[1], _obj_literals)
1024
+
1025
+ try:
1026
+ decoded = (
1027
+ decoder.get_string(int(first_val), key) if key is not None else decoder.get_string(int(first_val))
1028
+ )
1029
+ if isinstance(decoded, str):
1030
+ self.set_changed()
1031
+ return make_literal(decoded)
1032
+ except Exception:
1033
+ pass
1034
+
1035
+ traverse(self.ast, {'enter': enter})
1036
+
1037
+ @staticmethod
1038
+ def _find_array_expression_in_statement(stmt):
1039
+ """Find the first ArrayExpression node in a variable declaration or assignment."""
1040
+ if stmt.get('type') == 'VariableDeclaration':
1041
+ for declaration in stmt.get('declarations', []):
1042
+ init = declaration.get('init')
1043
+ if init and init.get('type') == 'ArrayExpression':
1044
+ return init
1045
+ elif stmt.get('type') == 'ExpressionStatement':
1046
+ expr = stmt.get('expression')
1047
+ if expr and expr.get('type') == 'AssignmentExpression':
1048
+ right = expr.get('right')
1049
+ if right and right.get('type') == 'ArrayExpression':
1050
+ return right
1051
+ return None
1052
+
1053
+ def _update_ast_array(self, func_node, rotated_array):
1054
+ """Update the AST's array function to contain the rotated string array."""
1055
+ func_body = func_node.get('body', {}).get('body', [])
1056
+ if not func_body:
1057
+ return
1058
+ arr_expr = self._find_array_expression_in_statement(func_body[0])
1059
+ if arr_expr is not None:
1060
+ arr_expr['elements'] = [make_literal(s) for s in rotated_array]
1061
+
1062
+ def _remove_body_indices(self, body, *indices):
1063
+ """Remove statements at given indices from body."""
1064
+ for idx in sorted(set(i for i in indices if i is not None), reverse=True):
1065
+ if 0 <= idx < len(body):
1066
+ body.pop(idx)
1067
+ self.set_changed()
1068
+
1069
+ # ================================================================
1070
+ # Strategy 2b: Var-based string array + rotation IIFE + decoder
1071
+ # Pattern:
1072
+ # var _0xARR = ['s1', 's2', ...];
1073
+ # (function(arr, count) { var f = function(n) { while(--n) arr.push(arr.shift()); }; f(++count); })(_0xARR, 0xNN);
1074
+ # var _0xDEC = function(a, b) { a = a - OFFSET; var x = _0xARR[a]; return x; };
1075
+ # ================================================================
1076
+
1077
+ def _process_var_array_pattern(self):
1078
+ """Handle var-based string array with simple rotation and decoder."""
1079
+ body = self.ast.get('body', [])
1080
+ if len(body) < 3:
1081
+ return
1082
+
1083
+ # Step 1: Find var _0x... = [string array] at top level
1084
+ array_name, string_array, array_idx = self._find_var_string_array(body)
1085
+ if array_name is None:
1086
+ return
1087
+
1088
+ # Step 2: Find rotation IIFE that references the array var
1089
+ rotation_idx, rotation_count = self._find_simple_rotation(body, array_name)
1090
+
1091
+ # Step 3: Execute rotation if found
1092
+ if rotation_idx is not None and rotation_count is not None:
1093
+ for _ in range(rotation_count):
1094
+ string_array.append(string_array.pop(0))
1095
+
1096
+ # Step 4: Find decoder function (var _0x... = function(a,b) { a=a-OFFSET; ... _0xARR[a] ... })
1097
+ decoder_name, decoder_offset, decoder_idx = self._find_var_decoder(body, array_name)
1098
+ if decoder_name is None:
1099
+ return
1100
+
1101
+ # Step 5: Create decoder and find wrappers
1102
+ decoder = BasicStringDecoder(string_array, decoder_offset)
1103
+ wrappers = self._find_all_wrappers(decoder_name)
1104
+ decoder_aliases = self._find_decoder_aliases(decoder_name)
1105
+ object_literals = _collect_object_literals(self.ast)
1106
+
1107
+ # Step 6: Replace wrapper calls
1108
+ self._replace_all_wrapper_calls(wrappers, decoder, object_literals)
1109
+
1110
+ # Step 7: Replace direct decoder calls (including aliases)
1111
+ self._replace_direct_decoder_calls(decoder_name, decoder, decoder_aliases, object_literals)
1112
+
1113
+ # Step 8: Remove aliases
1114
+ self._remove_decoder_aliases(decoder_name, decoder_aliases)
1115
+
1116
+ # Step 9: Remove infrastructure (array, rotation, decoder)
1117
+ if self.has_changed():
1118
+ indices_to_remove = {array_idx, decoder_idx}
1119
+ if rotation_idx is not None:
1120
+ indices_to_remove.add(rotation_idx)
1121
+ self._remove_body_indices(body, *indices_to_remove)
1122
+
1123
+ def _find_var_string_array(self, body):
1124
+ """Find var _0x... = ['s1', 's2', ...] at top of body."""
1125
+ for i, stmt in enumerate(body[:3]):
1126
+ if stmt.get('type') != 'VariableDeclaration':
1127
+ continue
1128
+ for declaration in stmt.get('declarations', []):
1129
+ name_node = declaration.get('id')
1130
+ if not is_identifier(name_node):
1131
+ continue
1132
+ init = declaration.get('init')
1133
+ if not init or init.get('type') != 'ArrayExpression':
1134
+ continue
1135
+ elements = init.get('elements', [])
1136
+ if len(elements) < 3:
1137
+ continue
1138
+ if not all(is_string_literal(e) for e in elements):
1139
+ continue
1140
+ return name_node['name'], [e['value'] for e in elements], i
1141
+ return None, None, None
1142
+
1143
+ def _find_simple_rotation(self, body, array_name):
1144
+ """Find (function(arr, count) { ...push/shift... })(array, N) rotation IIFE."""
1145
+ for i, stmt in enumerate(body):
1146
+ if stmt.get('type') != 'ExpressionStatement':
1147
+ continue
1148
+ expr = stmt.get('expression')
1149
+ if not expr:
1150
+ continue
1151
+
1152
+ candidates = []
1153
+ if expr.get('type') == 'CallExpression':
1154
+ candidates.append(expr)
1155
+ elif expr.get('type') == 'SequenceExpression':
1156
+ candidates.extend(sub for sub in expr.get('expressions', []) if sub.get('type') == 'CallExpression')
1157
+
1158
+ for call_expr in candidates:
1159
+ callee = call_expr.get('callee')
1160
+ args = call_expr.get('arguments', [])
1161
+ if not callee or callee.get('type') != 'FunctionExpression':
1162
+ continue
1163
+ if len(args) != 2:
1164
+ continue
1165
+ if not (is_identifier(args[0]) and args[0]['name'] == array_name):
1166
+ continue
1167
+
1168
+ count_val = _eval_numeric(args[1])
1169
+ if count_val is None:
1170
+ continue
1171
+
1172
+ src = generate(callee)
1173
+ if 'push' in src and 'shift' in src:
1174
+ return i, int(count_val)
1175
+
1176
+ return None, None
1177
+
1178
+ def _find_var_decoder(self, body, array_name):
1179
+ """Find var _0xDEC = function(a) { a = a - OFFSET; var x = ARR[a]; return x; }."""
1180
+ for i, stmt in enumerate(body):
1181
+ if stmt.get('type') != 'VariableDeclaration':
1182
+ continue
1183
+ for declaration in stmt.get('declarations', []):
1184
+ name_node = declaration.get('id')
1185
+ if not is_identifier(name_node):
1186
+ continue
1187
+ init = declaration.get('init')
1188
+ if not init or init.get('type') != 'FunctionExpression':
1189
+ continue
1190
+ source = generate(init)
1191
+ if array_name not in source:
1192
+ continue
1193
+ offset = self._extract_decoder_offset(init)
1194
+ return name_node['name'], offset, i
1195
+ return None, None, None
1196
+
1197
+ # ================================================================
1198
+ # Strategy 1: Direct string array declarations
1199
+ # ================================================================
1200
+
1201
+ def _try_replace_array_access(self, ref_parent, ref_key, string_array):
1202
+ """Replace arr[N] member expression with the string literal if valid."""
1203
+ if not ref_parent or ref_parent.get('type') != 'MemberExpression':
1204
+ return
1205
+ if ref_key != 'object' or not ref_parent.get('computed'):
1206
+ return
1207
+ prop = ref_parent.get('property')
1208
+ if not is_numeric_literal(prop):
1209
+ return
1210
+ idx = int(prop['value'])
1211
+ if not (0 <= idx < len(string_array)):
1212
+ return
1213
+ self._replace_node_in_ast(ref_parent, make_literal(string_array[idx]))
1214
+ self.set_changed()
1215
+
1216
+ def _process_direct_arrays(self, scope_tree):
1217
+ """Find direct array declarations and replace indexed accesses."""
1218
+ for name, binding in list(scope_tree.bindings.items()):
1219
+ node = binding.node
1220
+ if not isinstance(node, dict) or node.get('type') != 'VariableDeclarator':
1221
+ continue
1222
+ init = node.get('init')
1223
+ if not init or init.get('type') != 'ArrayExpression':
1224
+ continue
1225
+ elements = init.get('elements', [])
1226
+ if not elements or not all(is_string_literal(e) for e in elements):
1227
+ continue
1228
+
1229
+ string_array = [e['value'] for e in elements]
1230
+ for reference_node, reference_parent, reference_key, ref_index in binding.references[:]:
1231
+ self._try_replace_array_access(reference_parent, reference_key, string_array)
1232
+ for child in scope_tree.children:
1233
+ self._process_direct_arrays_in_scope(child, name, string_array)
1234
+
1235
+ def _process_direct_arrays_in_scope(self, scope, name, string_array):
1236
+ """Process direct array accesses in child scopes."""
1237
+ binding = scope.get_binding(name)
1238
+ if not binding:
1239
+ return
1240
+ for reference_node, reference_parent, reference_key, ref_index in binding.references[:]:
1241
+ self._try_replace_array_access(reference_parent, reference_key, string_array)
1242
+
1243
+ def _replace_node_in_ast(self, target, replacement):
1244
+ """Replace a node in the AST with a replacement."""
1245
+ result = find_parent(self.ast, target)
1246
+ if result:
1247
+ parent, key, index = result
1248
+ if index is not None:
1249
+ parent[key][index] = replacement
1250
+ else:
1251
+ parent[key] = replacement
1252
+
1253
+ # ================================================================
1254
+ # Strategy 3: Simple static array unpacking
1255
+ # ================================================================
1256
+
1257
+ def _process_static_arrays(self):
1258
+ """No-op: static array unpacking is handled by _process_direct_arrays."""
1259
+ pass