muaddib-scanner 2.2.4 → 2.2.6

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