muaddib-scanner 2.3.3 → 2.4.0

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.
@@ -1,591 +1,579 @@
1
- 'use strict';
2
-
3
- const acorn = require('acorn');
4
- const walk = require('acorn-walk');
5
- const { ACORN_OPTIONS } = require('../shared/constants.js');
6
-
7
- /**
8
- * Lightweight static deobfuscation pre-processor.
9
- * Resolves common JS obfuscation patterns via AST rewriting (no eval).
10
- *
11
- * @param {string} sourceCode — raw JS source
12
- * @returns {{ code: string, transforms: Array<{type: string, start: number, end: number, before: string, after: string}> }}
13
- */
14
- function deobfuscate(sourceCode) {
15
- const transforms = [];
16
-
17
- // Parse AST — if parsing fails, return source unchanged (fail-safe)
18
- let ast;
19
- try {
20
- ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
21
- } catch {
22
- return { code: sourceCode, transforms };
23
- }
24
-
25
- // Collect replacements as { start, end, value, type, before }
26
- const replacements = [];
27
-
28
- walk.simple(ast, {
29
- // ---- 1. STRING CONCAT FOLDING ----
30
- // 'ch' + 'il' + 'd_' + 'process' → 'child_process'
31
- BinaryExpression(node) {
32
- if (node.operator !== '+') return;
33
- const folded = tryFoldConcat(node);
34
- if (folded === null) return;
35
- // Avoid folding single literals (no transformation needed)
36
- if (node.left.type === 'Literal' && node.right.type === 'Literal' &&
37
- typeof node.left.value === 'string' && typeof node.right.value === 'string') {
38
- // Simple two-literal concat — always fold
39
- } else if (node.type === 'BinaryExpression') {
40
- // Nested concat — only fold if top-level (not already inside a folded parent)
41
- // We check this by not folding if parent already covers this range
42
- }
43
- const before = sourceCode.slice(node.start, node.end);
44
- const after = quoteString(folded);
45
- replacements.push({
46
- start: node.start,
47
- end: node.end,
48
- value: after,
49
- type: 'string_concat',
50
- before
51
- });
52
- },
53
-
54
- // ---- 2. CHARCODE REBUILD + 3. BASE64 DECODE ----
55
- CallExpression(node) {
56
- // String.fromCharCode(99, 104, 105, 108, 100) → "child"
57
- if (isStringFromCharCode(node)) {
58
- const nums = extractNumericArgs(node);
59
- if (nums === null) return;
60
- try {
61
- const decoded = String.fromCharCode(...nums);
62
- const before = sourceCode.slice(node.start, node.end);
63
- const after = quoteString(decoded);
64
- replacements.push({
65
- start: node.start,
66
- end: node.end,
67
- value: after,
68
- type: 'charcode',
69
- before
70
- });
71
- } catch { /* invalid char codes — skip */ }
72
- return;
73
- }
74
-
75
- // Buffer.from('...', 'base64').toString() → decoded string
76
- if (isBufferBase64ToString(node)) {
77
- const b64str = extractBufferBase64Arg(node);
78
- if (b64str === null) return;
79
- try {
80
- const decoded = Buffer.from(b64str, 'base64').toString();
81
- // Sanity: only replace if decoded is printable ASCII/UTF-8
82
- if (!isPrintable(decoded)) return;
83
- const before = sourceCode.slice(node.start, node.end);
84
- const after = quoteString(decoded);
85
- replacements.push({
86
- start: node.start,
87
- end: node.end,
88
- value: after,
89
- type: 'base64',
90
- before
91
- });
92
- } catch { /* decode failure — skip */ }
93
- return;
94
- }
95
-
96
- // Buffer.from('...', 'hex').toString() → decoded string
97
- if (isBufferHexToString(node)) {
98
- const hexStr = extractBufferHexArg(node);
99
- if (hexStr === null) return;
100
- try {
101
- const decoded = Buffer.from(hexStr, 'hex').toString();
102
- if (!isPrintable(decoded)) return;
103
- const before = sourceCode.slice(node.start, node.end);
104
- const after = quoteString(decoded);
105
- replacements.push({
106
- start: node.start,
107
- end: node.end,
108
- value: after,
109
- type: 'hex',
110
- before
111
- });
112
- } catch { /* decode failure — skip */ }
113
- return;
114
- }
115
-
116
- // atob('...') → decoded string
117
- if (isAtobCall(node)) {
118
- const b64str = node.arguments[0]?.value;
119
- if (typeof b64str !== 'string') return;
120
- try {
121
- const decoded = Buffer.from(b64str, 'base64').toString();
122
- if (!isPrintable(decoded)) return;
123
- const before = sourceCode.slice(node.start, node.end);
124
- const after = quoteString(decoded);
125
- replacements.push({
126
- start: node.start,
127
- end: node.end,
128
- value: after,
129
- type: 'base64',
130
- before
131
- });
132
- } catch { /* skip */ }
133
- return;
134
- }
135
-
136
- // ---- 4. HEX ARRAY MAP ----
137
- // [0x63, 0x68, ...].map(c => String.fromCharCode(c)).join('')
138
- const hexResult = tryResolveHexArrayMap(node, sourceCode);
139
- if (hexResult !== null) {
140
- replacements.push(hexResult);
141
- }
142
- }
143
- });
144
-
145
- // De-duplicate: nested BinaryExpression nodes produce overlapping replacements.
146
- // Keep only the outermost (widest) replacement for each overlapping range.
147
- replacements.sort((a, b) => a.start - b.start || b.end - a.end);
148
- const filtered = [];
149
- let lastEnd = -1;
150
- for (const r of replacements) {
151
- if (r.start < lastEnd) continue; // nested inside a wider replacement — skip
152
- filtered.push(r);
153
- lastEnd = r.end;
154
- }
155
-
156
- // Apply replacements from end to start to preserve positions
157
- filtered.sort((a, b) => b.start - a.start);
158
-
159
- let code = sourceCode;
160
- for (const r of filtered) {
161
- code = code.slice(0, r.start) + r.value + code.slice(r.end);
162
- transforms.push({
163
- type: r.type,
164
- start: r.start,
165
- end: r.end,
166
- before: r.before,
167
- after: r.value
168
- });
169
- }
170
-
171
- // Reverse transforms so they're in source order (start ascending)
172
- transforms.reverse();
173
-
174
- // ---- PHASE 2: CONST PROPAGATION ----
175
- // If phase 1 produced transforms, re-parse and propagate const string assignments.
176
- // const a = 'child_'; const b = 'process'; require(a + b) → require('child_' + 'process') → require('child_process')
177
- if (transforms.length > 0) {
178
- const phase2 = propagateConsts(code);
179
- if (phase2.transforms.length > 0) {
180
- code = phase2.code;
181
- transforms.push(...phase2.transforms);
182
- }
183
- }
184
-
185
- return { code, transforms };
186
- }
187
-
188
- /**
189
- * Phase 2: Propagate const string literal assignments into identifier references,
190
- * then fold any resulting string concatenations.
191
- */
192
- function propagateConsts(sourceCode) {
193
- const transforms = [];
194
- let ast;
195
- try {
196
- ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
197
- } catch {
198
- return { code: sourceCode, transforms };
199
- }
200
-
201
- // Collect const declarations: name { value, initStart, initEnd }
202
- const constMap = new Map();
203
- // Track which names are assigned more than once (not safe to propagate)
204
- const reassigned = new Set();
205
-
206
- walk.simple(ast, {
207
- VariableDeclaration(node) {
208
- if (node.kind !== 'const') return;
209
- for (const decl of node.declarations) {
210
- if (decl.id?.type !== 'Identifier') continue;
211
- if (!decl.init) continue;
212
- if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
213
- constMap.set(decl.id.name, {
214
- value: decl.init.value,
215
- declStart: decl.init.start,
216
- declEnd: decl.init.end
217
- });
218
- }
219
- }
220
- },
221
- AssignmentExpression(node) {
222
- if (node.left?.type === 'Identifier') {
223
- reassigned.add(node.left.name);
224
- }
225
- }
226
- });
227
-
228
- // Remove reassigned names from constMap (not safe)
229
- for (const name of reassigned) {
230
- constMap.delete(name);
231
- }
232
-
233
- if (constMap.size === 0) {
234
- return { code: sourceCode, transforms };
235
- }
236
-
237
- // Find all Identifier references to propagate (excluding declarations and property names)
238
- const replacements = [];
239
- walk.simple(ast, {
240
- Identifier(node) {
241
- if (!constMap.has(node.name)) return;
242
- const info = constMap.get(node.name);
243
- // Skip the declaration site itself
244
- if (node.start === info.declStart || (node.start >= info.declStart && node.end <= info.declEnd)) return;
245
- replacements.push({
246
- start: node.start,
247
- end: node.end,
248
- value: quoteString(info.value),
249
- type: 'const_propagation',
250
- before: sourceCode.slice(node.start, node.end)
251
- });
252
- }
253
- });
254
-
255
- // Filter: skip property access identifiers (obj.prop — prop is not a variable ref)
256
- // We detect this by checking if the identifier is a property of a MemberExpression
257
- const propPositions = new Set();
258
- walk.simple(ast, {
259
- MemberExpression(node) {
260
- if (!node.computed && node.property?.type === 'Identifier') {
261
- propPositions.add(node.property.start);
262
- }
263
- },
264
- VariableDeclarator(node) {
265
- // Skip the declaration name itself
266
- if (node.id?.type === 'Identifier') {
267
- propPositions.add(node.id.start);
268
- }
269
- }
270
- });
271
-
272
- const validReplacements = replacements.filter(r => !propPositions.has(r.start));
273
-
274
- if (validReplacements.length === 0) {
275
- return { code: sourceCode, transforms };
276
- }
277
-
278
- // Apply replacements from end to start
279
- validReplacements.sort((a, b) => b.start - a.start);
280
- let code = sourceCode;
281
- for (const r of validReplacements) {
282
- code = code.slice(0, r.start) + r.value + code.slice(r.end);
283
- transforms.push({
284
- type: r.type,
285
- start: r.start,
286
- end: r.end,
287
- before: r.before,
288
- after: r.value
289
- });
290
- }
291
-
292
- // Now re-run concat folding on the propagated code
293
- const phase3 = foldConcatsOnly(code);
294
- if (phase3.transforms.length > 0) {
295
- code = phase3.code;
296
- transforms.push(...phase3.transforms);
297
- }
298
-
299
- transforms.reverse();
300
- return { code, transforms };
301
- }
302
-
303
- /**
304
- * Run only string concat folding on code (phase 3 after const propagation).
305
- */
306
- function foldConcatsOnly(sourceCode) {
307
- const transforms = [];
308
- let ast;
309
- try {
310
- ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
311
- } catch {
312
- return { code: sourceCode, transforms };
313
- }
314
-
315
- const replacements = [];
316
- walk.simple(ast, {
317
- BinaryExpression(node) {
318
- if (node.operator !== '+') return;
319
- const folded = tryFoldConcat(node);
320
- if (folded === null) return;
321
- const before = sourceCode.slice(node.start, node.end);
322
- const after = quoteString(folded);
323
- replacements.push({ start: node.start, end: node.end, value: after, type: 'string_concat', before });
324
- }
325
- });
326
-
327
- // De-duplicate overlapping
328
- replacements.sort((a, b) => a.start - b.start || b.end - a.end);
329
- const filtered = [];
330
- let lastEnd = -1;
331
- for (const r of replacements) {
332
- if (r.start < lastEnd) continue;
333
- filtered.push(r);
334
- lastEnd = r.end;
335
- }
336
-
337
- filtered.sort((a, b) => b.start - a.start);
338
- let code = sourceCode;
339
- for (const r of filtered) {
340
- code = code.slice(0, r.start) + r.value + code.slice(r.end);
341
- transforms.push({ type: r.type, start: r.start, end: r.end, before: r.before, after: r.value });
342
- }
343
-
344
- return { code, transforms };
345
- }
346
-
347
- // ============================================================
348
- // HELPERS
349
- // ============================================================
350
-
351
- /**
352
- * Recursively fold string concat BinaryExpression.
353
- * Returns the concatenated string, or null if any part is not a string literal.
354
- */
355
- function tryFoldConcat(node) {
356
- if (node.type === 'Literal' && typeof node.value === 'string') {
357
- return node.value;
358
- }
359
- if (node.type === 'BinaryExpression' && node.operator === '+') {
360
- const left = tryFoldConcat(node.left);
361
- if (left === null) return null;
362
- const right = tryFoldConcat(node.right);
363
- if (right === null) return null;
364
- return left + right;
365
- }
366
- return null;
367
- }
368
-
369
- /**
370
- * Check if node is String.fromCharCode(...)
371
- */
372
- function isStringFromCharCode(node) {
373
- if (node.type !== 'CallExpression') return false;
374
- const c = node.callee;
375
- if (c.type !== 'MemberExpression') return false;
376
- // String.fromCharCode
377
- if (c.object?.type === 'Identifier' && c.object.name === 'String' &&
378
- c.property?.type === 'Identifier' && c.property.name === 'fromCharCode') {
379
- return true;
380
- }
381
- return false;
382
- }
383
-
384
- /**
385
- * Extract numeric arguments from a call (handles direct numbers and spread of array).
386
- * Returns array of numbers, or null if any argument is non-numeric.
387
- */
388
- function extractNumericArgs(node) {
389
- const nums = [];
390
- for (const arg of node.arguments) {
391
- if (arg.type === 'SpreadElement' && arg.argument?.type === 'ArrayExpression') {
392
- for (const el of arg.argument.elements) {
393
- if (el?.type === 'Literal' && typeof el.value === 'number') {
394
- nums.push(el.value);
395
- } else {
396
- return null; // non-numeric — abort
397
- }
398
- }
399
- } else if (arg.type === 'Literal' && typeof arg.value === 'number') {
400
- nums.push(arg.value);
401
- } else {
402
- return null; // non-numeric argument (variable, expression) — abort
403
- }
404
- }
405
- return nums.length > 0 ? nums : null;
406
- }
407
-
408
- /**
409
- * Check if node is Buffer.from('...', 'base64').toString()
410
- */
411
- function isBufferBase64ToString(node) {
412
- if (node.type !== 'CallExpression') return false;
413
- const callee = node.callee;
414
- // .toString() call
415
- if (callee.type !== 'MemberExpression') return false;
416
- if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
417
- // The object is Buffer.from(str, 'base64')
418
- const inner = callee.object;
419
- if (inner?.type !== 'CallExpression') return false;
420
- const innerCallee = inner.callee;
421
- if (innerCallee?.type !== 'MemberExpression') return false;
422
- if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
423
- if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
424
- // Args: (string, 'base64')
425
- if (inner.arguments.length < 2) return false;
426
- if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'base64') return false;
427
- if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
428
- return true;
429
- }
430
-
431
- /**
432
- * Extract the base64 string argument from Buffer.from(str, 'base64').toString()
433
- */
434
- function extractBufferBase64Arg(node) {
435
- const inner = node.callee.object;
436
- return inner.arguments[0].value;
437
- }
438
-
439
- /**
440
- * Check if node is Buffer.from('...', 'hex').toString()
441
- */
442
- function isBufferHexToString(node) {
443
- if (node.type !== 'CallExpression') return false;
444
- const callee = node.callee;
445
- if (callee.type !== 'MemberExpression') return false;
446
- if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
447
- const inner = callee.object;
448
- if (inner?.type !== 'CallExpression') return false;
449
- const innerCallee = inner.callee;
450
- if (innerCallee?.type !== 'MemberExpression') return false;
451
- if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
452
- if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
453
- if (inner.arguments.length < 2) return false;
454
- if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'hex') return false;
455
- if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
456
- return true;
457
- }
458
-
459
- /**
460
- * Extract the hex string argument from Buffer.from(str, 'hex').toString()
461
- */
462
- function extractBufferHexArg(node) {
463
- const inner = node.callee.object;
464
- return inner.arguments[0].value;
465
- }
466
-
467
- /**
468
- * Check if node is atob('...')
469
- */
470
- function isAtobCall(node) {
471
- if (node.type !== 'CallExpression') return false;
472
- if (node.callee?.type !== 'Identifier' || node.callee.name !== 'atob') return false;
473
- if (node.arguments.length !== 1) return false;
474
- if (node.arguments[0]?.type !== 'Literal' || typeof node.arguments[0].value !== 'string') return false;
475
- return true;
476
- }
477
-
478
- /**
479
- * Try to resolve [0x63, ...].map(c => String.fromCharCode(c)).join('')
480
- * Returns a replacement object or null.
481
- */
482
- function tryResolveHexArrayMap(node, source) {
483
- // Pattern: <expr>.join('') where <expr> is <array>.map(<fn>)
484
- // node is the .join('') call
485
- if (node.type !== 'CallExpression') return null;
486
- const callee = node.callee;
487
- if (callee?.type !== 'MemberExpression') return null;
488
- if (callee.property?.type !== 'Identifier' || callee.property.name !== 'join') return null;
489
- // Verify .join('') or .join("")
490
- if (node.arguments.length !== 1) return null;
491
- if (node.arguments[0]?.type !== 'Literal' || node.arguments[0].value !== '') return null;
492
-
493
- // The object of .join should be a .map(...) call
494
- const mapCall = callee.object;
495
- if (mapCall?.type !== 'CallExpression') return null;
496
- if (mapCall.callee?.type !== 'MemberExpression') return null;
497
- if (mapCall.callee.property?.type !== 'Identifier' || mapCall.callee.property.name !== 'map') return null;
498
-
499
- // The map callback should reference String.fromCharCode
500
- if (mapCall.arguments.length < 1) return null;
501
- const mapFn = mapCall.arguments[0];
502
- if (!containsFromCharCode(mapFn)) return null;
503
-
504
- // The object of .map should be an ArrayExpression of numbers
505
- const arr = mapCall.callee.object;
506
- if (arr?.type !== 'ArrayExpression') return null;
507
- const nums = [];
508
- for (const el of arr.elements) {
509
- if (el?.type === 'Literal' && typeof el.value === 'number') {
510
- nums.push(el.value);
511
- } else {
512
- return null; // non-numeric element — abort
513
- }
514
- }
515
- if (nums.length === 0) return null;
516
-
517
- try {
518
- const decoded = String.fromCharCode(...nums);
519
- const before = source.slice(node.start, node.end);
520
- return {
521
- start: node.start,
522
- end: node.end,
523
- value: quoteString(decoded),
524
- type: 'hex_array',
525
- before
526
- };
527
- } catch {
528
- return null;
529
- }
530
- }
531
-
532
- /**
533
- * Check if an AST node (a function/arrow function) contains a reference to String.fromCharCode.
534
- */
535
- function containsFromCharCode(node) {
536
- if (!node || typeof node !== 'object') return false;
537
-
538
- // Direct check on this node
539
- if (node.type === 'MemberExpression' &&
540
- node.object?.type === 'Identifier' && node.object.name === 'String' &&
541
- node.property?.type === 'Identifier' && node.property.name === 'fromCharCode') {
542
- return true;
543
- }
544
-
545
- // Recurse into child nodes
546
- for (const key of Object.keys(node)) {
547
- if (key === 'type' || key === 'start' || key === 'end' || key === 'range') continue;
548
- const child = node[key];
549
- if (Array.isArray(child)) {
550
- for (const c of child) {
551
- if (c && typeof c === 'object' && containsFromCharCode(c)) return true;
552
- }
553
- } else if (child && typeof child === 'object' && child.type) {
554
- if (containsFromCharCode(child)) return true;
555
- }
556
- }
557
- return false;
558
- }
559
-
560
- /**
561
- * Quote a string value as a JS single-quoted string literal.
562
- */
563
- function quoteString(str) {
564
- const escaped = str
565
- .replace(/\\/g, '\\\\')
566
- .replace(/'/g, "\\'")
567
- .replace(/\n/g, '\\n')
568
- .replace(/\r/g, '\\r')
569
- .replace(/\t/g, '\\t');
570
- return `'${escaped}'`;
571
- }
572
-
573
- /**
574
- * Check if a decoded string is "printable" (no control chars except whitespace).
575
- * Prevents replacing base64 that decodes to binary garbage.
576
- */
577
- function isPrintable(str) {
578
- // Allow printable ASCII + common unicode + whitespace
579
- // Reject if more than 20% of chars are control characters
580
- let controlCount = 0;
581
- for (let i = 0; i < str.length; i++) {
582
- const code = str.charCodeAt(i);
583
- if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
584
- controlCount++;
585
- }
586
- }
587
- if (str.length === 0) return false;
588
- return (controlCount / str.length) < 0.2;
589
- }
590
-
591
- module.exports = { deobfuscate };
1
+ 'use strict';
2
+
3
+ const acorn = require('acorn');
4
+ const walk = require('acorn-walk');
5
+ const { ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
6
+
7
+ /**
8
+ * Lightweight static deobfuscation pre-processor.
9
+ * Resolves common JS obfuscation patterns via AST rewriting (no eval).
10
+ *
11
+ * @param {string} sourceCode — raw JS source
12
+ * @returns {{ code: string, transforms: Array<{type: string, start: number, end: number, before: string, after: string}> }}
13
+ */
14
+ function deobfuscate(sourceCode) {
15
+ const transforms = [];
16
+
17
+ // Parse AST — if parsing fails, return source unchanged (fail-safe)
18
+ let ast = safeParse(sourceCode, { ranges: true });
19
+ if (!ast) return { code: sourceCode, transforms };
20
+
21
+ // Collect replacements as { start, end, value, type, before }
22
+ const replacements = [];
23
+
24
+ walk.simple(ast, {
25
+ // ---- 1. STRING CONCAT FOLDING ----
26
+ // 'ch' + 'il' + 'd_' + 'process' → 'child_process'
27
+ BinaryExpression(node) {
28
+ if (node.operator !== '+') return;
29
+ const folded = tryFoldConcat(node);
30
+ if (folded === null) return;
31
+ // Avoid folding single literals (no transformation needed)
32
+ if (node.left.type === 'Literal' && node.right.type === 'Literal' &&
33
+ typeof node.left.value === 'string' && typeof node.right.value === 'string') {
34
+ // Simple two-literal concat — always fold
35
+ } else if (node.type === 'BinaryExpression') {
36
+ // Nested concat — only fold if top-level (not already inside a folded parent)
37
+ // We check this by not folding if parent already covers this range
38
+ }
39
+ const before = sourceCode.slice(node.start, node.end);
40
+ const after = quoteString(folded);
41
+ replacements.push({
42
+ start: node.start,
43
+ end: node.end,
44
+ value: after,
45
+ type: 'string_concat',
46
+ before
47
+ });
48
+ },
49
+
50
+ // ---- 2. CHARCODE REBUILD + 3. BASE64 DECODE ----
51
+ CallExpression(node) {
52
+ // String.fromCharCode(99, 104, 105, 108, 100) → "child"
53
+ if (isStringFromCharCode(node)) {
54
+ const nums = extractNumericArgs(node);
55
+ if (nums === null) return;
56
+ try {
57
+ const decoded = String.fromCharCode(...nums);
58
+ const before = sourceCode.slice(node.start, node.end);
59
+ const after = quoteString(decoded);
60
+ replacements.push({
61
+ start: node.start,
62
+ end: node.end,
63
+ value: after,
64
+ type: 'charcode',
65
+ before
66
+ });
67
+ } catch { /* invalid char codes — skip */ }
68
+ return;
69
+ }
70
+
71
+ // Buffer.from('...', 'base64').toString() decoded string
72
+ if (isBufferBase64ToString(node)) {
73
+ const b64str = extractBufferBase64Arg(node);
74
+ if (b64str === null) return;
75
+ try {
76
+ const decoded = Buffer.from(b64str, 'base64').toString();
77
+ // Sanity: only replace if decoded is printable ASCII/UTF-8
78
+ if (!isPrintable(decoded)) return;
79
+ const before = sourceCode.slice(node.start, node.end);
80
+ const after = quoteString(decoded);
81
+ replacements.push({
82
+ start: node.start,
83
+ end: node.end,
84
+ value: after,
85
+ type: 'base64',
86
+ before
87
+ });
88
+ } catch { /* decode failure — skip */ }
89
+ return;
90
+ }
91
+
92
+ // Buffer.from('...', 'hex').toString() decoded string
93
+ if (isBufferHexToString(node)) {
94
+ const hexStr = extractBufferHexArg(node);
95
+ if (hexStr === null) return;
96
+ try {
97
+ const decoded = Buffer.from(hexStr, 'hex').toString();
98
+ if (!isPrintable(decoded)) return;
99
+ const before = sourceCode.slice(node.start, node.end);
100
+ const after = quoteString(decoded);
101
+ replacements.push({
102
+ start: node.start,
103
+ end: node.end,
104
+ value: after,
105
+ type: 'hex',
106
+ before
107
+ });
108
+ } catch { /* decode failure — skip */ }
109
+ return;
110
+ }
111
+
112
+ // atob('...') decoded string
113
+ if (isAtobCall(node)) {
114
+ const b64str = node.arguments[0]?.value;
115
+ if (typeof b64str !== 'string') return;
116
+ try {
117
+ const decoded = Buffer.from(b64str, 'base64').toString();
118
+ if (!isPrintable(decoded)) return;
119
+ const before = sourceCode.slice(node.start, node.end);
120
+ const after = quoteString(decoded);
121
+ replacements.push({
122
+ start: node.start,
123
+ end: node.end,
124
+ value: after,
125
+ type: 'base64',
126
+ before
127
+ });
128
+ } catch { /* skip */ }
129
+ return;
130
+ }
131
+
132
+ // ---- 4. HEX ARRAY MAP ----
133
+ // [0x63, 0x68, ...].map(c => String.fromCharCode(c)).join('')
134
+ const hexResult = tryResolveHexArrayMap(node, sourceCode);
135
+ if (hexResult !== null) {
136
+ replacements.push(hexResult);
137
+ }
138
+ }
139
+ });
140
+
141
+ // De-duplicate: nested BinaryExpression nodes produce overlapping replacements.
142
+ // Keep only the outermost (widest) replacement for each overlapping range.
143
+ replacements.sort((a, b) => a.start - b.start || b.end - a.end);
144
+ const filtered = [];
145
+ let lastEnd = -1;
146
+ for (const r of replacements) {
147
+ if (r.start < lastEnd) continue; // nested inside a wider replacement — skip
148
+ filtered.push(r);
149
+ lastEnd = r.end;
150
+ }
151
+
152
+ // Apply replacements from end to start to preserve positions
153
+ filtered.sort((a, b) => b.start - a.start);
154
+
155
+ let code = sourceCode;
156
+ for (const r of filtered) {
157
+ code = code.slice(0, r.start) + r.value + code.slice(r.end);
158
+ transforms.push({
159
+ type: r.type,
160
+ start: r.start,
161
+ end: r.end,
162
+ before: r.before,
163
+ after: r.value
164
+ });
165
+ }
166
+
167
+ // Reverse transforms so they're in source order (start ascending)
168
+ transforms.reverse();
169
+
170
+ // ---- PHASE 2: CONST PROPAGATION ----
171
+ // If phase 1 produced transforms, re-parse and propagate const string assignments.
172
+ // const a = 'child_'; const b = 'process'; require(a + b) → require('child_' + 'process') → require('child_process')
173
+ if (transforms.length > 0) {
174
+ const phase2 = propagateConsts(code);
175
+ if (phase2.transforms.length > 0) {
176
+ code = phase2.code;
177
+ transforms.push(...phase2.transforms);
178
+ }
179
+ }
180
+
181
+ return { code, transforms };
182
+ }
183
+
184
+ /**
185
+ * Phase 2: Propagate const string literal assignments into identifier references,
186
+ * then fold any resulting string concatenations.
187
+ */
188
+ function propagateConsts(sourceCode) {
189
+ const transforms = [];
190
+ let ast = safeParse(sourceCode, { ranges: true });
191
+ if (!ast) return { code: sourceCode, transforms };
192
+
193
+ // Collect const declarations: name → { value, initStart, initEnd }
194
+ const constMap = new Map();
195
+ // Track which names are assigned more than once (not safe to propagate)
196
+ const reassigned = new Set();
197
+
198
+ walk.simple(ast, {
199
+ VariableDeclaration(node) {
200
+ if (node.kind !== 'const') return;
201
+ for (const decl of node.declarations) {
202
+ if (decl.id?.type !== 'Identifier') continue;
203
+ if (!decl.init) continue;
204
+ if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
205
+ constMap.set(decl.id.name, {
206
+ value: decl.init.value,
207
+ declStart: decl.init.start,
208
+ declEnd: decl.init.end
209
+ });
210
+ }
211
+ }
212
+ },
213
+ AssignmentExpression(node) {
214
+ if (node.left?.type === 'Identifier') {
215
+ reassigned.add(node.left.name);
216
+ }
217
+ }
218
+ });
219
+
220
+ // Remove reassigned names from constMap (not safe)
221
+ for (const name of reassigned) {
222
+ constMap.delete(name);
223
+ }
224
+
225
+ if (constMap.size === 0) {
226
+ return { code: sourceCode, transforms };
227
+ }
228
+
229
+ // Find all Identifier references to propagate (excluding declarations and property names)
230
+ const replacements = [];
231
+ walk.simple(ast, {
232
+ Identifier(node) {
233
+ if (!constMap.has(node.name)) return;
234
+ const info = constMap.get(node.name);
235
+ // Skip the declaration site itself
236
+ if (node.start === info.declStart || (node.start >= info.declStart && node.end <= info.declEnd)) return;
237
+ replacements.push({
238
+ start: node.start,
239
+ end: node.end,
240
+ value: quoteString(info.value),
241
+ type: 'const_propagation',
242
+ before: sourceCode.slice(node.start, node.end)
243
+ });
244
+ }
245
+ });
246
+
247
+ // Filter: skip property access identifiers (obj.prop — prop is not a variable ref)
248
+ // We detect this by checking if the identifier is a property of a MemberExpression
249
+ const propPositions = new Set();
250
+ walk.simple(ast, {
251
+ MemberExpression(node) {
252
+ if (!node.computed && node.property?.type === 'Identifier') {
253
+ propPositions.add(node.property.start);
254
+ }
255
+ },
256
+ VariableDeclarator(node) {
257
+ // Skip the declaration name itself
258
+ if (node.id?.type === 'Identifier') {
259
+ propPositions.add(node.id.start);
260
+ }
261
+ }
262
+ });
263
+
264
+ const validReplacements = replacements.filter(r => !propPositions.has(r.start));
265
+
266
+ if (validReplacements.length === 0) {
267
+ return { code: sourceCode, transforms };
268
+ }
269
+
270
+ // Apply replacements from end to start
271
+ validReplacements.sort((a, b) => b.start - a.start);
272
+ let code = sourceCode;
273
+ for (const r of validReplacements) {
274
+ code = code.slice(0, r.start) + r.value + code.slice(r.end);
275
+ transforms.push({
276
+ type: r.type,
277
+ start: r.start,
278
+ end: r.end,
279
+ before: r.before,
280
+ after: r.value
281
+ });
282
+ }
283
+
284
+ // Now re-run concat folding on the propagated code
285
+ const phase3 = foldConcatsOnly(code);
286
+ if (phase3.transforms.length > 0) {
287
+ code = phase3.code;
288
+ transforms.push(...phase3.transforms);
289
+ }
290
+
291
+ transforms.reverse();
292
+ return { code, transforms };
293
+ }
294
+
295
+ /**
296
+ * Run only string concat folding on code (phase 3 after const propagation).
297
+ */
298
+ function foldConcatsOnly(sourceCode) {
299
+ const transforms = [];
300
+ let ast = safeParse(sourceCode, { ranges: true });
301
+ if (!ast) return { code: sourceCode, transforms };
302
+
303
+ const replacements = [];
304
+ walk.simple(ast, {
305
+ BinaryExpression(node) {
306
+ if (node.operator !== '+') return;
307
+ const folded = tryFoldConcat(node);
308
+ if (folded === null) return;
309
+ const before = sourceCode.slice(node.start, node.end);
310
+ const after = quoteString(folded);
311
+ replacements.push({ start: node.start, end: node.end, value: after, type: 'string_concat', before });
312
+ }
313
+ });
314
+
315
+ // De-duplicate overlapping
316
+ replacements.sort((a, b) => a.start - b.start || b.end - a.end);
317
+ const filtered = [];
318
+ let lastEnd = -1;
319
+ for (const r of replacements) {
320
+ if (r.start < lastEnd) continue;
321
+ filtered.push(r);
322
+ lastEnd = r.end;
323
+ }
324
+
325
+ filtered.sort((a, b) => b.start - a.start);
326
+ let code = sourceCode;
327
+ for (const r of filtered) {
328
+ code = code.slice(0, r.start) + r.value + code.slice(r.end);
329
+ transforms.push({ type: r.type, start: r.start, end: r.end, before: r.before, after: r.value });
330
+ }
331
+
332
+ return { code, transforms };
333
+ }
334
+
335
+ // ============================================================
336
+ // HELPERS
337
+ // ============================================================
338
+
339
+ /**
340
+ * Recursively fold string concat BinaryExpression.
341
+ * Returns the concatenated string, or null if any part is not a string literal.
342
+ */
343
+ function tryFoldConcat(node) {
344
+ if (node.type === 'Literal' && typeof node.value === 'string') {
345
+ return node.value;
346
+ }
347
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
348
+ const left = tryFoldConcat(node.left);
349
+ if (left === null) return null;
350
+ const right = tryFoldConcat(node.right);
351
+ if (right === null) return null;
352
+ return left + right;
353
+ }
354
+ return null;
355
+ }
356
+
357
+ /**
358
+ * Check if node is String.fromCharCode(...)
359
+ */
360
+ function isStringFromCharCode(node) {
361
+ if (node.type !== 'CallExpression') return false;
362
+ const c = node.callee;
363
+ if (c.type !== 'MemberExpression') return false;
364
+ // String.fromCharCode
365
+ if (c.object?.type === 'Identifier' && c.object.name === 'String' &&
366
+ c.property?.type === 'Identifier' && c.property.name === 'fromCharCode') {
367
+ return true;
368
+ }
369
+ return false;
370
+ }
371
+
372
+ /**
373
+ * Extract numeric arguments from a call (handles direct numbers and spread of array).
374
+ * Returns array of numbers, or null if any argument is non-numeric.
375
+ */
376
+ function extractNumericArgs(node) {
377
+ const nums = [];
378
+ for (const arg of node.arguments) {
379
+ if (arg.type === 'SpreadElement' && arg.argument?.type === 'ArrayExpression') {
380
+ for (const el of arg.argument.elements) {
381
+ if (el?.type === 'Literal' && typeof el.value === 'number') {
382
+ nums.push(el.value);
383
+ } else {
384
+ return null; // non-numeric — abort
385
+ }
386
+ }
387
+ } else if (arg.type === 'Literal' && typeof arg.value === 'number') {
388
+ nums.push(arg.value);
389
+ } else {
390
+ return null; // non-numeric argument (variable, expression) — abort
391
+ }
392
+ }
393
+ return nums.length > 0 ? nums : null;
394
+ }
395
+
396
+ /**
397
+ * Check if node is Buffer.from('...', 'base64').toString()
398
+ */
399
+ function isBufferBase64ToString(node) {
400
+ if (node.type !== 'CallExpression') return false;
401
+ const callee = node.callee;
402
+ // .toString() call
403
+ if (callee.type !== 'MemberExpression') return false;
404
+ if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
405
+ // The object is Buffer.from(str, 'base64')
406
+ const inner = callee.object;
407
+ if (inner?.type !== 'CallExpression') return false;
408
+ const innerCallee = inner.callee;
409
+ if (innerCallee?.type !== 'MemberExpression') return false;
410
+ if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
411
+ if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
412
+ // Args: (string, 'base64')
413
+ if (inner.arguments.length < 2) return false;
414
+ if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'base64') return false;
415
+ if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
416
+ return true;
417
+ }
418
+
419
+ /**
420
+ * Extract the base64 string argument from Buffer.from(str, 'base64').toString()
421
+ */
422
+ function extractBufferBase64Arg(node) {
423
+ const inner = node.callee.object;
424
+ return inner.arguments[0].value;
425
+ }
426
+
427
+ /**
428
+ * Check if node is Buffer.from('...', 'hex').toString()
429
+ */
430
+ function isBufferHexToString(node) {
431
+ if (node.type !== 'CallExpression') return false;
432
+ const callee = node.callee;
433
+ if (callee.type !== 'MemberExpression') return false;
434
+ if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
435
+ const inner = callee.object;
436
+ if (inner?.type !== 'CallExpression') return false;
437
+ const innerCallee = inner.callee;
438
+ if (innerCallee?.type !== 'MemberExpression') return false;
439
+ if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
440
+ if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
441
+ if (inner.arguments.length < 2) return false;
442
+ if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'hex') return false;
443
+ if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
444
+ return true;
445
+ }
446
+
447
+ /**
448
+ * Extract the hex string argument from Buffer.from(str, 'hex').toString()
449
+ */
450
+ function extractBufferHexArg(node) {
451
+ const inner = node.callee.object;
452
+ return inner.arguments[0].value;
453
+ }
454
+
455
+ /**
456
+ * Check if node is atob('...')
457
+ */
458
+ function isAtobCall(node) {
459
+ if (node.type !== 'CallExpression') return false;
460
+ if (node.callee?.type !== 'Identifier' || node.callee.name !== 'atob') return false;
461
+ if (node.arguments.length !== 1) return false;
462
+ if (node.arguments[0]?.type !== 'Literal' || typeof node.arguments[0].value !== 'string') return false;
463
+ return true;
464
+ }
465
+
466
+ /**
467
+ * Try to resolve [0x63, ...].map(c => String.fromCharCode(c)).join('')
468
+ * Returns a replacement object or null.
469
+ */
470
+ function tryResolveHexArrayMap(node, source) {
471
+ // Pattern: <expr>.join('') where <expr> is <array>.map(<fn>)
472
+ // node is the .join('') call
473
+ if (node.type !== 'CallExpression') return null;
474
+ const callee = node.callee;
475
+ if (callee?.type !== 'MemberExpression') return null;
476
+ if (callee.property?.type !== 'Identifier' || callee.property.name !== 'join') return null;
477
+ // Verify .join('') or .join("")
478
+ if (node.arguments.length !== 1) return null;
479
+ if (node.arguments[0]?.type !== 'Literal' || node.arguments[0].value !== '') return null;
480
+
481
+ // The object of .join should be a .map(...) call
482
+ const mapCall = callee.object;
483
+ if (mapCall?.type !== 'CallExpression') return null;
484
+ if (mapCall.callee?.type !== 'MemberExpression') return null;
485
+ if (mapCall.callee.property?.type !== 'Identifier' || mapCall.callee.property.name !== 'map') return null;
486
+
487
+ // The map callback should reference String.fromCharCode
488
+ if (mapCall.arguments.length < 1) return null;
489
+ const mapFn = mapCall.arguments[0];
490
+ if (!containsFromCharCode(mapFn)) return null;
491
+
492
+ // The object of .map should be an ArrayExpression of numbers
493
+ const arr = mapCall.callee.object;
494
+ if (arr?.type !== 'ArrayExpression') return null;
495
+ const nums = [];
496
+ for (const el of arr.elements) {
497
+ if (el?.type === 'Literal' && typeof el.value === 'number') {
498
+ nums.push(el.value);
499
+ } else {
500
+ return null; // non-numeric element — abort
501
+ }
502
+ }
503
+ if (nums.length === 0) return null;
504
+
505
+ try {
506
+ const decoded = String.fromCharCode(...nums);
507
+ const before = source.slice(node.start, node.end);
508
+ return {
509
+ start: node.start,
510
+ end: node.end,
511
+ value: quoteString(decoded),
512
+ type: 'hex_array',
513
+ before
514
+ };
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Check if an AST node (a function/arrow function) contains a reference to String.fromCharCode.
522
+ */
523
+ function containsFromCharCode(node) {
524
+ if (!node || typeof node !== 'object') return false;
525
+
526
+ // Direct check on this node
527
+ if (node.type === 'MemberExpression' &&
528
+ node.object?.type === 'Identifier' && node.object.name === 'String' &&
529
+ node.property?.type === 'Identifier' && node.property.name === 'fromCharCode') {
530
+ return true;
531
+ }
532
+
533
+ // Recurse into child nodes
534
+ for (const key of Object.keys(node)) {
535
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'range') continue;
536
+ const child = node[key];
537
+ if (Array.isArray(child)) {
538
+ for (const c of child) {
539
+ if (c && typeof c === 'object' && containsFromCharCode(c)) return true;
540
+ }
541
+ } else if (child && typeof child === 'object' && child.type) {
542
+ if (containsFromCharCode(child)) return true;
543
+ }
544
+ }
545
+ return false;
546
+ }
547
+
548
+ /**
549
+ * Quote a string value as a JS single-quoted string literal.
550
+ */
551
+ function quoteString(str) {
552
+ const escaped = str
553
+ .replace(/\\/g, '\\\\')
554
+ .replace(/'/g, "\\'")
555
+ .replace(/\n/g, '\\n')
556
+ .replace(/\r/g, '\\r')
557
+ .replace(/\t/g, '\\t');
558
+ return `'${escaped}'`;
559
+ }
560
+
561
+ /**
562
+ * Check if a decoded string is "printable" (no control chars except whitespace).
563
+ * Prevents replacing base64 that decodes to binary garbage.
564
+ */
565
+ function isPrintable(str) {
566
+ // Allow printable ASCII + common unicode + whitespace
567
+ // Reject if more than 20% of chars are control characters
568
+ let controlCount = 0;
569
+ for (let i = 0; i < str.length; i++) {
570
+ const code = str.charCodeAt(i);
571
+ if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
572
+ controlCount++;
573
+ }
574
+ }
575
+ if (str.length === 0) return false;
576
+ return (controlCount / str.length) < 0.2;
577
+ }
578
+
579
+ module.exports = { deobfuscate };