muaddib-scanner 2.5.15 → 2.5.17

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 CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
32
32
 
33
- MUAD'DIB combines static analysis + **deobfuscation engine** (v2.2.5) + **inter-module dataflow** (v2.2.6) + **per-file max scoring** (v2.2.11) + dynamic analysis (Docker sandbox with **monkey-patching preload** for time-bomb detection, v2.4.9) + **behavioral anomaly detection** (v2.0) + **ground truth validation** (v2.1) + **security audit** (41 issues remediated, v2.5.0–v2.5.6) to detect threats AND guide your response — even before they appear in any IOC database.
33
+ MUAD'DIB combines static analysis + **deobfuscation engine** (v2.2.5) + **inter-module dataflow** (v2.2.6) + **per-file max scoring** (v2.2.11) + dynamic analysis (Docker sandbox with **monkey-patching preload** for time-bomb detection, v2.4.9) + **behavioral anomaly detection** (v2.0) + **ground truth validation** (v2.1) + **security audit** (41 issues remediated, v2.5.0–v2.5.6) + **audit hardening** (v2.5.13–v2.5.14) + **FP reduction P5/P6** (v2.5.15–v2.5.16) to detect threats AND guide your response — even before they appear in any IOC database.
34
34
 
35
35
  ---
36
36
 
@@ -286,7 +286,7 @@ Add to `.pre-commit-config.yaml`:
286
286
  ```yaml
287
287
  repos:
288
288
  - repo: https://github.com/DNSZLSK/muad-dib
289
- rev: v2.5.8
289
+ rev: v2.5.17
290
290
  hooks:
291
291
  - id: muaddib-scan # Scan all threats
292
292
  # - id: muaddib-diff # Or: only new threats
@@ -335,7 +335,7 @@ muaddib replay
335
335
  muaddib ground-truth
336
336
  ```
337
337
 
338
- 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
+ Replay real-world supply-chain attacks against the scanner to validate detection coverage. Current results: **46/49 detected (93.9% TPR)** from 51 samples (49 active).
339
339
 
340
340
  4 out-of-scope misses: lottie-player, polyfill-io, trojanized-jquery (browser-only DOM attacks), websocket-rat (FP-risky pattern).
341
341
 
@@ -642,7 +642,7 @@ Alerts appear in Security > Code scanning alerts.
642
642
  ## Architecture
643
643
 
644
644
  ```
645
- MUAD'DIB 2.5.8 Scanner
645
+ MUAD'DIB 2.5.17 Scanner
646
646
  |
647
647
  +-- IOC Match (225,000+ packages, JSON DB)
648
648
  | +-- OSV.dev npm dump (200K+ MAL-* entries)
@@ -664,7 +664,7 @@ MUAD'DIB 2.5.8 Scanner
664
664
  | +-- 3-hop re-export chains, class method analysis
665
665
  | +-- Cross-file credential read -> network sink detection
666
666
  |
667
- +-- 14 Parallel Scanners (113 rules)
667
+ +-- 14 Parallel Scanners (121 rules)
668
668
  | +-- AST Parse (acorn) — eval/Function, credential CLI theft, binary droppers, prototype hooks
669
669
  | +-- Pattern Matching (shell, scripts)
670
670
  | +-- Obfuscation Detection (skip .min.js, ignore hex/unicode alone)
@@ -685,20 +685,22 @@ MUAD'DIB 2.5.8 Scanner
685
685
  |
686
686
  +-- Validation & Observability (v2.1)
687
687
  | +-- Datadog 17K Benchmark (88.2% raw, ~100% JS/Node.js adjusted)
688
- | +-- Ground Truth Dataset (51 real-world attacks, 91.8% TPR)
688
+ | +-- Ground Truth Dataset (51 real-world attacks, 93.9% TPR)
689
689
  | +-- Detection Time Logging (first_seen tracking, lead time metrics)
690
690
  | +-- FP Rate Tracking (daily stats, false positive rate)
691
691
  | +-- Score Breakdown (explainable per-rule scoring)
692
692
  | +-- Threat Feed API (HTTP server, JSON feed for SIEM)
693
693
  |
694
- +-- FP Reduction Post-processing (v2.2.8-v2.2.9, v2.3.0-v2.3.1, v2.5.7-v2.5.8)
694
+ +-- FP Reduction Post-processing (v2.2.8-v2.3.1, v2.5.7-v2.5.8, v2.5.15-v2.5.16)
695
695
  | +-- Count-based severity downgrade (dynamic_require, dataflow, module_compile, etc.)
696
696
  | +-- Framework prototype scoring cap + HTTP client whitelist
697
- | +-- Obfuscation in dist/build/.cjs/.mjs → LOW
698
- | +-- Safe env var + prefix filtering
697
+ | +-- Obfuscation in dist/build/.cjs/.mjs/.js >100KB → LOW
698
+ | +-- Safe env var + prefix filtering + DATAFLOW_SAFE_ENV_VARS
699
699
  | +-- Dataflow telemetry source categorization (os.platform/arch → telemetry_read)
700
700
  | +-- DEP whitelist (es5-ext, bootstrap-sass) + npm alias skip
701
701
  | +-- IOC wildcard audit (v2.5.8): FPR 10.8% → 6.0%
702
+ | +-- P5 heuristic precision (v2.5.15): 7 fixes
703
+ | +-- P6 compound detection precision (v2.5.16): 6 fixes
702
704
  |
703
705
  +-- Per-File Max Scoring (v2.2.11)
704
706
  | +-- Score = max(file_scores) + package_level_score
@@ -714,6 +716,14 @@ MUAD'DIB 2.5.8 Scanner
714
716
  | +-- 41 issues remediated (14 CRITICAL, 18 HIGH, 9 MEDIUM)
715
717
  | +-- Native addon path traversal, atomic writes, AST bypasses
716
718
  |
719
+ +-- Audit Hardening (v2.5.13-v2.5.14)
720
+ | +-- Scoring: plugin loader threshold, lifecycle CRITICAL floor, percentage guard 40%
721
+ | +-- AST: eval alias, globalThis indirect, require(obj.prop), variable reassignment
722
+ | +-- Dataflow: Promise .then() tainting, JSON taint propagation
723
+ | +-- Shell: mkfifo+nc, base64|bash, wget+base64 (3 new patterns)
724
+ | +-- Entropy: fragment cluster, windowed analysis
725
+ | +-- 8 new rules (SHELL-013 to 015, ENTROPY-004, +4 audit fixes)
726
+ |
717
727
  +-- Paranoid Mode (ultra-strict)
718
728
  +-- Docker Sandbox (behavioral analysis, network capture, canary tokens, CI-aware, preload)
719
729
  +-- Zero-Day Monitor (internal: npm + PyPI RSS polling, Discord alerts, daily report)
@@ -735,9 +745,9 @@ Output (CLI, JSON, HTML, SARIF, Webhook, Threat Feed)
735
745
  | Metric | Result | Details |
736
746
  |--------|--------|---------|
737
747
  | **Wild TPR** (Datadog 17K) | **88.2%** raw · **~100%** adjusted | 17,922 real malware samples. 2,077 misses are all out-of-scope (see below) |
738
- | **TPR** (Ground Truth) | **91.8%** (45/49) | 51 real-world attacks (49 active). 4 out-of-scope: browser-only (3) + FP-risky (1) |
739
- | **FPR** (Benign, global) | **6.0%** (32/529) | 529 npm packages, real source code via `npm pack`, threshold > 20 |
740
- | **ADR** (Adversarial + Holdout) | **98.8%** (82/83) | 43 adversarial + 40 holdout evasive samples. 1 documented miss: `require-cache-poison` (accepted trade-off) |
748
+ | **TPR** (Ground Truth) | **93.9%** (46/49) | 51 real-world attacks (49 active). 3 out-of-scope: browser-only (3) |
749
+ | **FPR** (Benign, global) | **12.3%** (65/529) | 529 npm packages, real source code via `npm pack`, threshold > 20 |
750
+ | **ADR** (Adversarial + Holdout) | **94.0%** (63/67) | 62 adversarial + 40 holdout evasive samples. 4 misses: `require-cache-poison` (P3 trade-off), `getter-defineProperty-exfil`, `setTimeout-eval-chain`, `setter-trap-exfil` |
741
751
 
742
752
  **Datadog 17K benchmark** — [DataDog Malicious Software Packages Dataset](https://github.com/DataDog/malicious-software-packages-dataset), 17,922 real malware samples (npm). Raw TPR: 88.2% (15,810/17,922). The 2,077 misses (score=0) were manually categorized:
743
753
 
@@ -758,7 +768,9 @@ All 2,077 misses lack Node.js malware patterns. MUAD'DIB performs AST-based Node
758
768
  | Large (50-100 JS files) | 40 | 10 | 25.0% |
759
769
  | Very large (100+ JS files) | 62 | 25 | 40.3% |
760
770
 
761
- **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) → 8.9% (v2.3.0, P2) → 7.4% (v2.3.1, P3) → **6.0%** (v2.5.8, P4 + IOC wildcard audit)
771
+ **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) → 8.9% (v2.3.0, P2) → 7.4% (v2.3.1, P3) → 6.0% (v2.5.8, P4 + IOC wildcard audit) → ~13.6% (v2.5.14, audit hardening added stricter detection) → **12.3%** (v2.5.16, P5 + P6)
772
+
773
+ > **Note on FPR evolution:** The historic 6.0% FPR (v2.5.8) relied on a `BENIGN_PACKAGE_WHITELIST` that excluded certain known packages from scoring — a data leakage bias removed in v2.5.10. The current 12.3% FPR is an honest measurement without whitelisting, against 529 real benign packages. The P5/P6 reductions (setTimeout precision, dist/ two-notch downgrade, credential_regex count-based, env segment matching, etc.) are detector precision improvements, not whitelisting.
762
774
 
763
775
  **Holdout progression** (pre-tuning scores, rules frozen):
764
776
 
@@ -771,12 +783,12 @@ All 2,077 misses lack Node.js malware patterns. MUAD'DIB performs AST-based Node
771
783
  | v5 | 50% (5/10) | Inter-module dataflow (new scanner) |
772
784
 
773
785
  - **Wild TPR** (Datadog Benchmark): detection rate on 17,922 real malware packages from the [DataDog Malicious Software Packages Dataset](https://github.com/DataDog/malicious-software-packages-dataset). Raw 88.2% (15,810/17,922). Adjusted ~100% on JS/Node.js malware when excluding out-of-scope samples (1,233 phishing HTML pages, 824 native binaries, 20 corrected libraries). See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md#14-datadog-17k-benchmark).
774
- - **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).
786
+ - **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). 3 misses are browser-only (lottie-player, polyfill-io, trojanized-jquery) — see [Threat Model](docs/threat-model.md).
775
787
  - **FPR** (False Positive Rate): packages scoring > 20 out of 529 real npm packages (source code scanned, not empty dirs).
776
- - **ADR** (Adversarial Detection Rate): detection rate on 83 evasive malicious samples — 43 adversarial + 40 holdout (5 batches of 10, testing obfuscation, inter-module dataflow, etc.). 1 documented miss: `require-cache-poison` (score 10 < threshold 20, accepted trade-off from FP reduction P3).
788
+ - **ADR** (Adversarial Detection Rate): detection rate on 102 evasive malicious samples — 62 adversarial + 40 holdout (5 adversarial waves + 4 holdout batches). 4 misses on available samples: `require-cache-poison` (P3 trade-off), `getter-defineProperty-exfil`, `setTimeout-eval-chain`, `setter-trap-exfil`.
777
789
  - **Holdout** (pre-tuning): detection rate on 10 unseen samples with rules frozen (measures generalization)
778
790
 
779
- Datasets: 17,922 Datadog malware samples, 529 npm + 132 PyPI benign packages, 83 adversarial/holdout samples, 51 ground-truth attacks (65 documented malware packages). **1656 tests**, 86% code coverage.
791
+ Datasets: 17,922 Datadog malware samples, 529 npm + 132 PyPI benign packages, 102 adversarial/holdout samples, 51 ground-truth attacks (65 documented malware packages). **1869 tests**, 86% code coverage.
780
792
 
781
793
  See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) for the full experimental protocol.
782
794
 
@@ -812,12 +824,12 @@ npm test
812
824
 
813
825
  ### Testing
814
826
 
815
- - **1656 unit/integration tests** across 42 modular test files - 86% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
827
+ - **1869 unit/integration tests** across 43 modular test files - 86% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
816
828
  - **56 fuzz tests** - Malformed YAML, invalid JSON, binary files, ReDoS, unicode, 10MB inputs
817
829
  - **Datadog 17K benchmark** - 17,922 real malware samples, 88.2% raw TPR, ~100% on JS/Node.js malware (2,077 out-of-scope misses: phishing, binaries, corrected libs)
818
- - **83 adversarial/holdout samples** - 43 adversarial + 40 holdout, 82/83 detection rate (98.8% ADR). 1 documented miss: `require-cache-poison` (accepted trade-off)
819
- - **Ground truth validation** - 51 real-world attacks (45/49 detected = 91.8% TPR). 4 out-of-scope: browser-only (3) + FP-risky (1)
820
- - **False positive validation** - 6.0% FPR global (32/529) on real npm source code via `npm pack`
830
+ - **102 adversarial/holdout samples** - 62 adversarial + 40 holdout, 63/67 detection rate on available samples (94.0% ADR). 4 misses: `require-cache-poison` (P3 trade-off), `getter-defineProperty-exfil`, `setTimeout-eval-chain`, `setter-trap-exfil`
831
+ - **Ground truth validation** - 51 real-world attacks (46/49 detected = 93.9% TPR). 3 out-of-scope: browser-only (lottie-player, polyfill-io, trojanized-jquery)
832
+ - **False positive validation** - 12.3% FPR global (65/529) on real npm source code via `npm pack`
821
833
  - **ESLint security audit** - `eslint-plugin-security` with 14 rules enabled
822
834
 
823
835
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.5.15",
3
+ "version": "2.5.17",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -23,7 +23,7 @@ const { ensureIOCs } = require('./ioc/bootstrap.js');
23
23
  const { scanEntropy } = require('./scanner/entropy.js');
24
24
  const { scanAIConfig } = require('./scanner/ai-config.js');
25
25
  const { deobfuscate } = require('./scanner/deobfuscate.js');
26
- const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
26
+ const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows, annotateSinkExports, detectCallbackCrossFileFlows } = require('./scanner/module-graph.js');
27
27
  const { computeReachableFiles } = require('./scanner/reachability.js');
28
28
  const { runTemporalAnalyses } = require('./temporal-runner.js');
29
29
  const { formatOutput } = require('./output-formatter.js');
@@ -362,6 +362,10 @@ async function run(targetPath, options = {}) {
362
362
  const graph = await yieldThen(() => buildModuleGraph(targetPath));
363
363
  const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
364
364
  crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
365
+ // Callback-based cross-file flow detection
366
+ const sinkAnnotations = await yieldThen(() => annotateSinkExports(graph, targetPath));
367
+ const callbackFlows = await yieldThen(() => detectCallbackCrossFileFlows(graph, tainted, sinkAnnotations, targetPath));
368
+ crossFileFlows = crossFileFlows.concat(callbackFlows);
365
369
  } catch (e) {
366
370
  // Graceful fallback — module graph is best-effort
367
371
  debugLog('[MODULE-GRAPH] Error:', e && e.message);
@@ -461,6 +461,31 @@ const PLAYBOOKS = {
461
461
  fragmented_high_entropy_cluster:
462
462
  'Cluster de chaines courtes a haute entropie detecte. Possible fragmentation de payload pour eviter la detection. ' +
463
463
  'Reconstituer les fragments et analyser le contenu combine. Verifier si les chaines sont concatenees ou reassemblees a l\'execution.',
464
+
465
+ wasm_host_sink:
466
+ 'CRITIQUE: Module WebAssembly charge avec des imports host contenant des sinks reseau. Le flux de controle est cache dans le binaire WASM, ' +
467
+ 'rendant l\'analyse statique impossible. Le WASM peut lire des fichiers sensibles et exfiltrer via les callbacks host. ' +
468
+ 'Supprimer le package immediatement. Analyser le fichier WASM avec wasm2wat pour comprendre le flux. Regenerer tous les secrets.',
469
+ credential_regex_harvest:
470
+ 'Code contient des regex de detection de credentials (Bearer, password, token, API key) combine avec un appel reseau. ' +
471
+ 'Technique de harvesting: scanne les donnees en transit (streams HTTP, fichiers) pour extraire des secrets et les exfiltrer. ' +
472
+ 'Supprimer le package. Auditer le trafic reseau sortant.',
473
+ builtin_override_exfil:
474
+ 'Code remplace une methode built-in (console.log/warn/error, Object.defineProperty) et contient un appel reseau. ' +
475
+ 'Technique de monkey-patching: intercepte les donnees passant par les APIs natives pour les exfiltrer. ' +
476
+ 'Supprimer le package. Verifier si d\'autres methodes natives ont ete modifiees.',
477
+ stream_credential_intercept:
478
+ 'Classe stream (Transform/Duplex/Writable) avec regex de credentials et appel reseau. ' +
479
+ 'Technique de wiretap: le stream intercepte les donnees en transit, scanne pour des secrets (Bearer, password, token) ' +
480
+ 'et les exfiltre via un appel reseau. Supprimer le package.',
481
+ remote_code_load:
482
+ 'CRITIQUE: Fetch reseau + eval/new Function() dans le meme fichier. ' +
483
+ 'Technique multi-stage: le package telecharge un payload depuis un serveur distant (SVG, HTML, JSON) puis l\'execute. ' +
484
+ 'Supprimer le package. Bloquer le domaine C2 au niveau firewall.',
485
+ proxy_data_intercept:
486
+ 'CRITIQUE: Un Proxy JavaScript avec trap set/get/apply est combine avec un appel reseau. ' +
487
+ 'Technique d\'interception: le Proxy capture toutes les ecritures de proprietes (credentials, tokens, config) ' +
488
+ 'et les exfiltre via HTTPS/fetch/dgram. Supprimer le package. Auditer tous les modules qui importent ce package.',
464
489
  };
465
490
 
466
491
  function getPlaybook(threatType) {
@@ -703,7 +703,7 @@ const RULES = {
703
703
  module_compile: {
704
704
  id: 'MUADDIB-AST-023',
705
705
  name: 'Module Compile Execution',
706
- severity: 'CRITICAL',
706
+ severity: 'HIGH',
707
707
  confidence: 'high',
708
708
  description: 'module._compile() detecte. Execution de code arbitraire a partir d\'une chaine dans le contexte module. Technique cle de flatmap-stream.',
709
709
  references: [
@@ -729,7 +729,7 @@ const RULES = {
729
729
  module_compile_dynamic: {
730
730
  id: 'MUADDIB-AST-025',
731
731
  name: 'Dynamic Module Compile Execution',
732
- severity: 'CRITICAL',
732
+ severity: 'HIGH',
733
733
  confidence: 'high',
734
734
  description: 'Module._compile() avec argument dynamique (non-literal). Execution de code en memoire sans ecriture sur disque. Technique d\'evasion malware courante.',
735
735
  references: [
@@ -1285,6 +1285,78 @@ const RULES = {
1285
1285
  ],
1286
1286
  mitre: 'T1059'
1287
1287
  },
1288
+ wasm_host_sink: {
1289
+ id: 'MUADDIB-AST-042',
1290
+ name: 'WASM Host Import Sink',
1291
+ severity: 'CRITICAL',
1292
+ confidence: 'high',
1293
+ description: 'Module WebAssembly charge avec des callbacks host contenant des sinks reseau (fetch/http.request). Le WASM peut invoquer ces callbacks pour exfiltrer des donnees tout en cachant le flux de controle. Aucun package npm legitime ne combine WASM + callbacks reseau host.',
1294
+ references: [
1295
+ 'https://attack.mitre.org/techniques/T1059/',
1296
+ 'https://attack.mitre.org/techniques/T1027/'
1297
+ ],
1298
+ mitre: 'T1059'
1299
+ },
1300
+ credential_regex_harvest: {
1301
+ id: 'MUADDIB-AST-041',
1302
+ name: 'Credential Regex Harvesting',
1303
+ severity: 'HIGH',
1304
+ confidence: 'high',
1305
+ description: 'Regex de detection de credentials (token/password/secret/Bearer) combine avec un appel reseau. Technique de harvesting: le code scanne les donnees de flux (streams, requetes) a la recherche de credentials et les exfiltre.',
1306
+ references: [
1307
+ 'https://attack.mitre.org/techniques/T1552/',
1308
+ 'https://attack.mitre.org/techniques/T1041/'
1309
+ ],
1310
+ mitre: 'T1552'
1311
+ },
1312
+ builtin_override_exfil: {
1313
+ id: 'MUADDIB-AST-044',
1314
+ name: 'Built-in Method Override Exfiltration',
1315
+ severity: 'HIGH',
1316
+ confidence: 'high',
1317
+ description: 'Override de methode built-in (console.log/warn/error, Object.defineProperty) combine avec un appel reseau. Technique de monkey-patching: le code remplace une API native pour intercepter les donnees en transit et les exfiltrer.',
1318
+ references: [
1319
+ 'https://attack.mitre.org/techniques/T1557/',
1320
+ 'https://attack.mitre.org/techniques/T1041/'
1321
+ ],
1322
+ mitre: 'T1557'
1323
+ },
1324
+ stream_credential_intercept: {
1325
+ id: 'MUADDIB-AST-045',
1326
+ name: 'Stream Credential Interception',
1327
+ severity: 'HIGH',
1328
+ confidence: 'high',
1329
+ description: 'Classe stream (Transform/Duplex/Writable) avec regex de credentials et appel reseau. Technique de wiretap: le stream intercepte les donnees en transit, scanne pour des credentials (Bearer, password, token) et les exfiltre.',
1330
+ references: [
1331
+ 'https://attack.mitre.org/techniques/T1557/',
1332
+ 'https://attack.mitre.org/techniques/T1552/'
1333
+ ],
1334
+ mitre: 'T1557'
1335
+ },
1336
+ remote_code_load: {
1337
+ id: 'MUADDIB-AST-040',
1338
+ name: 'Remote Code Loading',
1339
+ severity: 'CRITICAL',
1340
+ confidence: 'high',
1341
+ description: 'Fetch reseau + eval/Function dans le meme fichier. Technique multi-stage: le code telecharge un payload distant (SVG, HTML, JSON) et l\'execute dynamiquement. Aucun package npm legitime ne combine fetch + eval/Function.',
1342
+ references: [
1343
+ 'https://attack.mitre.org/techniques/T1105/',
1344
+ 'https://attack.mitre.org/techniques/T1059/'
1345
+ ],
1346
+ mitre: 'T1105'
1347
+ },
1348
+ proxy_data_intercept: {
1349
+ id: 'MUADDIB-AST-043',
1350
+ name: 'Proxy Data Interception',
1351
+ severity: 'CRITICAL',
1352
+ confidence: 'high',
1353
+ description: 'Proxy trap (set/get/apply) combine avec un appel reseau dans le meme fichier. Technique d\'interception de donnees: le Proxy capture toutes les ecritures/lectures de proprietes et les exfiltre via le reseau. Utilise pour voler des credentials passees via module.exports.',
1354
+ references: [
1355
+ 'https://attack.mitre.org/techniques/T1557/',
1356
+ 'https://attack.mitre.org/techniques/T1041/'
1357
+ ],
1358
+ mitre: 'T1557'
1359
+ },
1288
1360
  };
1289
1361
 
1290
1362
  function getRule(type) {
@@ -26,17 +26,56 @@ const SAFE_ENV_VARS = [
26
26
  'LANG', 'TERM', 'CI', 'DEBUG', 'VERBOSE', 'LOG_LEVEL',
27
27
  'SHELL', 'USER', 'LOGNAME', 'EDITOR', 'TZ',
28
28
  'NODE_DEBUG', 'NODE_PATH', 'NODE_OPTIONS',
29
- 'DISPLAY', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 'TERM_PROGRAM'
29
+ 'DISPLAY', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 'TERM_PROGRAM',
30
+ // CI environment metadata (non-sensitive)
31
+ 'GITHUB_REPOSITORY', 'GITHUB_SHA', 'GITHUB_REF', 'GITHUB_WORKSPACE',
32
+ 'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_ACTOR', 'GITHUB_EVENT_NAME',
33
+ 'GITHUB_WORKFLOW', 'GITHUB_ACTION', 'GITHUB_JOB', 'GITHUB_SERVER_URL',
34
+ 'GITLAB_CI', 'TRAVIS', 'CIRCLECI', 'JENKINS_URL',
35
+ // Build tool config
36
+ 'NODE_TLS_REJECT_UNAUTHORIZED', 'BABEL_ENV', 'WEBPACK_MODE'
30
37
  ];
31
38
 
32
- // Env var prefixes that are safe (npm metadata, locale settings)
33
- const SAFE_ENV_PREFIXES = ['npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_', 'muaddib_'];
39
+ // Env var prefixes that are safe (npm metadata, locale settings, framework public vars)
40
+ const SAFE_ENV_PREFIXES = [
41
+ 'npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_', 'muaddib_',
42
+ 'next_public_', 'vite_', 'react_app_'
43
+ ];
34
44
 
35
45
  // Env var keywords to detect sensitive environment access (separate from SENSITIVE_STRINGS)
36
46
  const ENV_SENSITIVE_KEYWORDS = [
37
47
  'TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH'
38
48
  ];
39
49
 
50
+ // Non-sensitive qualifiers: when a keyword is preceded by one of these in the env var name,
51
+ // it is config metadata, not a real secret (e.g., PUBLIC_KEY, CACHE_KEY, SORT_KEY)
52
+ const ENV_NON_SENSITIVE_QUALIFIERS = new Set([
53
+ 'PUBLIC', 'CACHE', 'PRIMARY', 'FOREIGN', 'SORT', 'PARTITION', 'INDEX', 'ENCRYPTION'
54
+ ]);
55
+
56
+ /**
57
+ * Check if an env var name contains a sensitive keyword as a full _-delimited segment,
58
+ * not preceded by a non-sensitive qualifier.
59
+ * e.g., NPM_TOKEN → TOKEN is full segment → true
60
+ * PUBLIC_KEY → KEY preceded by PUBLIC → false
61
+ * CACHE_KEY → KEY preceded by CACHE → false
62
+ * GITHUB_TOKEN → TOKEN is full segment, preceded by GITHUB (not a qualifier) → true
63
+ */
64
+ function isEnvSensitive(envVar) {
65
+ const upper = envVar.toUpperCase();
66
+ const segments = upper.split('_');
67
+ for (let i = 0; i < segments.length; i++) {
68
+ if (ENV_SENSITIVE_KEYWORDS.includes(segments[i])) {
69
+ // Check if preceded by a non-sensitive qualifier
70
+ if (i > 0 && ENV_NON_SENSITIVE_QUALIFIERS.has(segments[i - 1])) {
71
+ continue;
72
+ }
73
+ return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+
40
79
  // AI agent dangerous flags — disable security controls (s1ngularity/Nx, Aug 2025)
41
80
  const AI_AGENT_DANGEROUS_FLAGS = [
42
81
  '--dangerously-skip-permissions',
@@ -85,10 +124,11 @@ const HOOKABLE_NATIVES = [
85
124
  ];
86
125
 
87
126
  // Node.js core module classes targeted for prototype hooking
88
- const NODE_HOOKABLE_MODULES = ['http', 'https', 'net', 'tls', 'stream'];
127
+ const NODE_HOOKABLE_MODULES = ['http', 'https', 'net', 'tls', 'stream', 'events', 'dgram'];
89
128
  const NODE_HOOKABLE_CLASSES = [
90
129
  'IncomingMessage', 'ServerResponse', 'ClientRequest',
91
- 'OutgoingMessage', 'Socket', 'Server', 'Agent'
130
+ 'OutgoingMessage', 'Socket', 'Server', 'Agent',
131
+ 'EventEmitter'
92
132
  ];
93
133
 
94
134
  // AI/MCP config paths targeted for config injection (SANDWORM_MODE)
@@ -423,7 +463,7 @@ function handleVariableDeclarator(node, ctx) {
423
463
  if (SAFE_ENV_VARS.includes(envVar)) continue;
424
464
  const envLower = envVar.toLowerCase();
425
465
  if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) continue;
426
- if (ENV_SENSITIVE_KEYWORDS.some(s => envVar.toUpperCase().includes(s))) {
466
+ if (isEnvSensitive(envVar)) {
427
467
  ctx.threats.push({
428
468
  type: 'env_access',
429
469
  severity: 'HIGH',
@@ -538,7 +578,7 @@ function handleCallExpression(node, ctx) {
538
578
  }
539
579
  }
540
580
  if (!resolved) {
541
- ctx.threats.push({ type: 'dynamic_require', severity: 'HIGH',
581
+ ctx.threats.push({ type: 'dynamic_require', severity: 'MEDIUM',
542
582
  message: 'Dynamic require() with member expression argument (object property obfuscation).',
543
583
  file: ctx.relFile });
544
584
  }
@@ -985,9 +1025,9 @@ function handleCallExpression(node, ctx) {
985
1025
 
986
1026
  if (callName === 'eval') {
987
1027
  ctx.hasEvalInFile = true;
988
- ctx.hasDynamicExec = true;
989
1028
  // Detect staged eval decode
990
1029
  if (node.arguments.length === 1 && hasDecodeArg(node.arguments[0])) {
1030
+ ctx.hasDynamicExec = true;
991
1031
  ctx.threats.push({
992
1032
  type: 'staged_eval_decode',
993
1033
  severity: 'CRITICAL',
@@ -1007,9 +1047,15 @@ function handleCallExpression(node, ctx) {
1007
1047
  if (/\b(require|import|exec|execSync|spawn|child_process|\.readFile|\.writeFile|process\.env|\.homedir)\b/.test(val)) {
1008
1048
  severity = 'HIGH';
1009
1049
  message = `eval() with dangerous API in string literal: "${val.substring(0, 100)}"`;
1050
+ ctx.hasDynamicExec = true;
1010
1051
  }
1011
1052
  }
1012
1053
 
1054
+ // Only set hasDynamicExec for non-constant (dynamic) eval
1055
+ if (!isConstant) {
1056
+ ctx.hasDynamicExec = true;
1057
+ }
1058
+
1013
1059
  ctx.threats.push({
1014
1060
  type: 'dangerous_call_eval',
1015
1061
  severity,
@@ -1039,6 +1085,25 @@ function handleCallExpression(node, ctx) {
1039
1085
  }
1040
1086
  }
1041
1087
 
1088
+ // setTimeout/setInterval with string argument = eval equivalent
1089
+ // setTimeout("require('child_process').exec('whoami')", 100) executes the string as code
1090
+ // Only string Literal and TemplateLiteral are eval-equivalent; Identifier/MemberExpression
1091
+ // are function references (callbacks), not code strings.
1092
+ if ((callName === 'setTimeout' || callName === 'setInterval') && node.arguments.length >= 1) {
1093
+ const firstArg = node.arguments[0];
1094
+ if ((firstArg.type === 'Literal' && typeof firstArg.value === 'string') ||
1095
+ firstArg.type === 'TemplateLiteral') {
1096
+ ctx.hasEvalInFile = true;
1097
+ ctx.hasDynamicExec = true;
1098
+ ctx.threats.push({
1099
+ type: 'dangerous_call_eval',
1100
+ severity: 'HIGH',
1101
+ message: `${callName}() with string argument — eval equivalent, executes the string as code.`,
1102
+ file: ctx.relFile
1103
+ });
1104
+ }
1105
+ }
1106
+
1042
1107
  // Detect eval.call(null, code) / eval.apply(null, [code]) / Function.call/apply
1043
1108
  if (node.callee.type === 'MemberExpression' && !node.callee.computed &&
1044
1109
  node.callee.property?.type === 'Identifier' &&
@@ -1229,7 +1294,9 @@ function handleCallExpression(node, ctx) {
1229
1294
  ctx.hasDynamicExec = true;
1230
1295
  ctx.threats.push({
1231
1296
  type: 'module_compile',
1232
- severity: 'CRITICAL',
1297
+ // P6: Baseline HIGH — single module._compile() in build tools (@babel/core, art-template)
1298
+ // is framework behavior. Compound detections (zlib_inflate_eval, fetch_decrypt_exec) stay CRITICAL.
1299
+ severity: 'HIGH',
1233
1300
  message: 'module._compile() detected — executes arbitrary code from string in module context (flatmap-stream pattern).',
1234
1301
  file: ctx.relFile
1235
1302
  });
@@ -1237,7 +1304,7 @@ function handleCallExpression(node, ctx) {
1237
1304
  if (node.arguments.length >= 1 && !hasOnlyStringLiteralArgs(node)) {
1238
1305
  ctx.threats.push({
1239
1306
  type: 'module_compile_dynamic',
1240
- severity: 'CRITICAL',
1307
+ severity: 'HIGH',
1241
1308
  message: 'In-memory code execution via Module._compile(). Common malware evasion technique.',
1242
1309
  file: ctx.relFile
1243
1310
  });
@@ -1447,6 +1514,20 @@ function handleNewExpression(node, ctx) {
1447
1514
  file: ctx.relFile
1448
1515
  });
1449
1516
  }
1517
+ // Detect new Proxy(obj, handler) where handler has set/get traps — data interception
1518
+ // Real-world technique: export a Proxy that intercepts all property sets/gets to exfiltrate
1519
+ // data flowing through the module. Combined with network (hasNetworkInFile) → credential theft.
1520
+ if (!target.type?.includes('MemberExpression') || target.property?.name !== 'env') {
1521
+ const handler = node.arguments[1];
1522
+ if (handler?.type === 'ObjectExpression') {
1523
+ const hasTrap = handler.properties?.some(p =>
1524
+ p.key?.type === 'Identifier' && ['set', 'get', 'apply', 'construct'].includes(p.key.name)
1525
+ );
1526
+ if (hasTrap) {
1527
+ ctx.hasProxyTrap = true;
1528
+ }
1529
+ }
1530
+ }
1450
1531
  }
1451
1532
 
1452
1533
  // Batch 2: new Worker(code, { eval: true }) — worker_threads code execution
@@ -1630,6 +1711,19 @@ function handleAssignmentExpression(node, ctx) {
1630
1711
  }
1631
1712
  }
1632
1713
 
1714
+ // JSON.stringify = ... or JSON.parse = ... — global API hooking
1715
+ // Real-world technique: override JSON.stringify to intercept all serialization and exfiltrate data
1716
+ if (left.object?.type === 'Identifier' && left.object.name === 'JSON' &&
1717
+ left.property?.type === 'Identifier' &&
1718
+ ['stringify', 'parse'].includes(left.property.name)) {
1719
+ ctx.threats.push({
1720
+ type: 'prototype_hook',
1721
+ severity: 'HIGH',
1722
+ message: `JSON.${left.property.name} overridden — global API hooking to intercept all JSON serialization/deserialization.`,
1723
+ file: ctx.relFile
1724
+ });
1725
+ }
1726
+
1633
1727
  // XMLHttpRequest.prototype.send = ... or Response.prototype.json = ...
1634
1728
  if (left.object?.type === 'MemberExpression' &&
1635
1729
  left.object.property?.type === 'Identifier' &&
@@ -1723,7 +1817,7 @@ function handleMemberExpression(node, ctx) {
1723
1817
  if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) {
1724
1818
  return;
1725
1819
  }
1726
- if (ENV_SENSITIVE_KEYWORDS.some(s => envVar.toUpperCase().includes(s))) {
1820
+ if (isEnvSensitive(envVar)) {
1727
1821
  ctx.threats.push({
1728
1822
  type: 'env_access',
1729
1823
  severity: 'HIGH',
@@ -1828,6 +1922,17 @@ function handlePostWalk(ctx) {
1828
1922
  });
1829
1923
  }
1830
1924
 
1925
+ // Remote code loading: fetch + eval/Function in same file = multi-stage payload
1926
+ // Distinct from fetch_decrypt_exec which also requires crypto. This catches SVG/HTML payload extraction.
1927
+ if (ctx.hasRemoteFetch && ctx.hasDynamicExec && !ctx.hasCryptoDecipher) {
1928
+ ctx.threats.push({
1929
+ type: 'remote_code_load',
1930
+ severity: 'CRITICAL',
1931
+ message: 'Remote code loading: network fetch + dynamic eval/Function in same file — multi-stage payload execution.',
1932
+ file: ctx.relFile
1933
+ });
1934
+ }
1935
+
1831
1936
  // Wave 4: Remote fetch + crypto decrypt + dynamic eval = steganographic payload chain
1832
1937
  if (ctx.hasRemoteFetch && ctx.hasCryptoDecipher && ctx.hasDynamicExec) {
1833
1938
  ctx.threats.push({
@@ -1861,6 +1966,67 @@ function handlePostWalk(ctx) {
1861
1966
  });
1862
1967
  }
1863
1968
 
1969
+ // WASM payload detection: WebAssembly.compile/instantiate + readFileSync/https in same file
1970
+ // WASM host import objects can contain callback functions that read credentials and exfiltrate.
1971
+ // This pattern is never legitimate in npm packages — WASM should use pure computation, not host I/O.
1972
+ if (ctx.hasWasmLoad && ctx.hasNetworkCallInFile) {
1973
+ ctx.threats.push({
1974
+ type: 'wasm_host_sink',
1975
+ severity: 'CRITICAL',
1976
+ message: 'WebAssembly module with network-capable host imports. WASM can invoke host callbacks to exfiltrate data while hiding control flow.',
1977
+ file: ctx.relFile
1978
+ });
1979
+ }
1980
+
1981
+ // Credential regex harvesting: credential-matching regex + network call in same file
1982
+ // Real-world pattern: Transform/stream that scans data for tokens/passwords and exfiltrates
1983
+ if (ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
1984
+ ctx.threats.push({
1985
+ type: 'credential_regex_harvest',
1986
+ severity: 'HIGH',
1987
+ message: 'Credential regex patterns (token/password/secret/Bearer) + network call in same file — stream data credential harvesting.',
1988
+ file: ctx.relFile
1989
+ });
1990
+ }
1991
+
1992
+ // Built-in method override + network: console.X = function or Object.defineProperty = function
1993
+ // combined with network calls. Monkey-patching built-in APIs for data interception.
1994
+ if (ctx.hasBuiltinOverride && ctx.hasNetworkCallInFile) {
1995
+ ctx.threats.push({
1996
+ type: 'builtin_override_exfil',
1997
+ severity: 'HIGH',
1998
+ message: 'Built-in method override (console/Object.defineProperty) + network call — runtime API hijacking for data interception and exfiltration.',
1999
+ file: ctx.relFile
2000
+ });
2001
+ }
2002
+
2003
+ // Stream credential interception: Transform/Duplex/Writable stream + credential regex + network
2004
+ // Wiretap pattern: intercepts data in transit, scans for credentials, exfiltrates matches.
2005
+ if (ctx.hasStreamInterceptor && ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
2006
+ ctx.threats.push({
2007
+ type: 'stream_credential_intercept',
2008
+ severity: 'HIGH',
2009
+ message: 'Stream class (Transform/Duplex/Writable) with credential regex scanning + network call — data-in-transit credential wiretap.',
2010
+ file: ctx.relFile
2011
+ });
2012
+ }
2013
+
2014
+ // Proxy data interception: new Proxy(obj, { set/get }) + network in same file
2015
+ // Real-world pattern: export a Proxy that exfiltrates all property assignments via network
2016
+ // CRITICAL only when credential signals co-occur (env_access, suspicious_dataflow),
2017
+ // otherwise HIGH — bare Proxy + fetch is insufficient evidence.
2018
+ if (ctx.hasProxyTrap && ctx.hasNetworkCallInFile) {
2019
+ const hasCredentialSignal = ctx.threats.some(t =>
2020
+ t.type === 'env_access' || t.type === 'suspicious_dataflow'
2021
+ );
2022
+ ctx.threats.push({
2023
+ type: 'proxy_data_intercept',
2024
+ severity: hasCredentialSignal ? 'CRITICAL' : 'HIGH',
2025
+ message: 'Proxy trap (set/get/apply) with network call in same file — data interception and exfiltration via Proxy handler.',
2026
+ file: ctx.relFile
2027
+ });
2028
+ }
2029
+
1864
2030
  // Wave 4: MCP content keywords in file with writeFileSync = MCP injection signal
1865
2031
  if (ctx.hasMcpContentKeywords && !ctx.threats.some(t => t.type === 'mcp_config_injection')) {
1866
2032
  ctx.threats.push({
@@ -15,6 +15,24 @@ const {
15
15
  handlePostWalk
16
16
  } = require('./ast-detectors.js');
17
17
 
18
+ // Check if credential keywords appear INSIDE regex literals or new RegExp() patterns.
19
+ // Only true when the keyword is part of the regex pattern itself, not just a string elsewhere in the file.
20
+ const CREDENTIAL_REGEX_KEYWORDS = /bearer|password|secret|token|credential|api.?key/i;
21
+ function hasCredentialInsideRegex(content) {
22
+ // Check regex literals: /...pattern.../flags
23
+ const regexLiteralRe = /\/(?!\*)(?:[^/\\]|\\.)+\/[gimsuy]*/g;
24
+ let m;
25
+ while ((m = regexLiteralRe.exec(content)) !== null) {
26
+ if (CREDENTIAL_REGEX_KEYWORDS.test(m[0])) return true;
27
+ }
28
+ // Check new RegExp('pattern') — keyword must be in the string argument
29
+ const newRegExpRe = /new\s+RegExp\s*\(\s*(['"`])((?:[^\\]|\\.)*?)\1/g;
30
+ while ((m = newRegExpRe.exec(content)) !== null) {
31
+ if (CREDENTIAL_REGEX_KEYWORDS.test(m[2])) return true;
32
+ }
33
+ return false;
34
+ }
35
+
18
36
  const EXCLUDED_FILES = [
19
37
  'src/scanner/ast.js',
20
38
  'src/scanner/shell.js',
@@ -93,6 +111,15 @@ function analyzeFile(content, filePath, basePath) {
93
111
  hasEnvEnumeration: false, // Object.entries/keys/values(process.env)
94
112
  hasEnvHarvestPattern: /\b(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|NPM|AWS|SSH|WEBHOOK)\b/.test(content),
95
113
  hasNetworkCallInFile: /\b(fetch|https?\.request|https?\.get|dns\.resolve)\b/.test(content),
114
+ // Credential regex harvesting: regex literals or new RegExp() whose PATTERN contains credential keywords
115
+ // Must check that the keyword is inside the regex, not just anywhere in the file
116
+ hasCredentialRegex: hasCredentialInsideRegex(content),
117
+ // Built-in method override: console.X = function or Object.defineProperty = function
118
+ hasBuiltinOverride: /\bconsole\s*\.\s*\w+\s*=\s*function/.test(content) ||
119
+ /\bconsole\s*\[\s*\w+\s*\]\s*=\s*function/.test(content) ||
120
+ /\bObject\s*\.\s*defineProperty\s*=\s*function/.test(content),
121
+ // Stream interceptor: class extending Transform/Duplex/Writable (data wiretap pattern)
122
+ hasStreamInterceptor: /\bextends\s+(Transform|Duplex|Writable)\b/.test(content),
96
123
  // SANDWORM_MODE P2: DNS exfiltration co-occurrence
97
124
  hasDnsRequire: /\brequire\s*\(\s*['"]dns['"]\s*\)/.test(content) || /\bdns\s*\.\s*resolve/.test(content),
98
125
  hasBase64Encode: /\.toString\s*\(\s*['"]base64(url)?['"]\s*\)/.test(content),
@@ -123,7 +150,11 @@ function analyzeFile(content, filePath, basePath) {
123
150
  hasModuleImport: /require\s*\(\s*['"]module['"]\s*\)/.test(content) || /module\.constructor/.test(content),
124
151
  hasMcpContentKeywords: (/\bmcpServers\b/.test(content) || /\bmcp\.json\b/.test(content) || /\bclaude_desktop_config\b/.test(content)) &&
125
152
  /\bwriteFileSync\b|\bwriteFile\s*\(/.test(content) &&
126
- (/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content))
153
+ (/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content)),
154
+ // WASM payload detection: WebAssembly.compile/instantiate with host import sinks
155
+ hasWasmLoad: /\bWebAssembly\s*\.\s*(compile|instantiate|compileStreaming|instantiateStreaming)\b/.test(content),
156
+ hasWasmHostSink: false, // set in handleCallExpression when WASM import object contains network/fs sinks
157
+ hasProxyTrap: false // set in handleNewExpression when Proxy has set/get/apply trap
127
158
  };
128
159
 
129
160
  // Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
@@ -9,9 +9,11 @@ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
9
9
  // Module classification maps for intra-file taint tracking
10
10
  const MODULE_SOURCE_METHODS = {
11
11
  os: {
12
- homedir: 'fingerprint_read', hostname: 'fingerprint_read',
12
+ homedir: 'fingerprint_read',
13
13
  networkInterfaces: 'fingerprint_read', userInfo: 'fingerprint_read',
14
- platform: 'telemetry_read', arch: 'telemetry_read'
14
+ hostname: 'telemetry_read', platform: 'telemetry_read', arch: 'telemetry_read',
15
+ type: 'telemetry_read', release: 'telemetry_read',
16
+ cpus: 'telemetry_read', totalmem: 'telemetry_read', freemem: 'telemetry_read'
15
17
  },
16
18
  fs: {
17
19
  readFileSync: 'credential_read', readFile: 'credential_read',
@@ -356,21 +358,17 @@ function analyzeFile(content, filePath, basePath) {
356
358
  }
357
359
  }
358
360
 
359
- // os.hostname(), os.networkInterfaces(), os.userInfo(), os.homedir() as fingerprint sources
360
- // os.platform(), os.arch() as telemetry sources (lower severity)
361
+ // os.* methods classified via MODULE_SOURCE_METHODS for consistent categorization
362
+ // fingerprint_read: homedir, networkInterfaces, userInfo (real exfil targets)
363
+ // telemetry_read: hostname, platform, arch, type, release, cpus, totalmem, freemem
361
364
  if (node.callee.type === 'MemberExpression') {
362
365
  const obj = node.callee.object;
363
366
  const prop = node.callee.property;
364
367
  if (obj?.type === 'Identifier' && obj.name === 'os' && prop?.type === 'Identifier') {
365
- if (['hostname', 'networkInterfaces', 'userInfo', 'homedir'].includes(prop.name)) {
368
+ const osClassification = MODULE_SOURCE_METHODS.os?.[prop.name];
369
+ if (osClassification) {
366
370
  sources.push({
367
- type: 'fingerprint_read',
368
- name: `os.${prop.name}`,
369
- line: node.loc?.start?.line
370
- });
371
- } else if (['platform', 'arch'].includes(prop.name)) {
372
- sources.push({
373
- type: 'telemetry_read',
371
+ type: osClassification,
374
372
  name: `os.${prop.name}`,
375
373
  line: node.loc?.start?.line
376
374
  });
@@ -742,8 +740,9 @@ const SENSITIVE_PATH_PATTERNS = [
742
740
  '.ethereum', '.electrum', '.config/solana', '.exodus',
743
741
  '.atomic', '.metamask', '.ledger-live', '.trezor',
744
742
  '.bitcoin', '.monero', '.gnupg',
745
- '_cacache', '.cache/yarn', '.cache/pip',
746
- 'discord', 'leveldb'
743
+ '_cacache', '.cache/yarn', '.cache/pip'
744
+ // P6: Removed discord, leveldb — data directories, not credential paths.
745
+ // _cacache/.cache kept — real cache poisoning vectors (T1195.002).
747
746
  ];
748
747
 
749
748
  function isSensitivePath(val) {
@@ -818,8 +817,20 @@ const SYSTEM_IDENTITY_ENVS = new Set([
818
817
  // Env var prefixes for tool-internal configuration (not external credentials)
819
818
  const SAFE_ENV_PREFIXES = ['MUADDIB_', 'npm_config_', 'npm_lifecycle_', 'npm_package_'];
820
819
 
820
+ // P6: Node.js runtime config env vars that are not credentials.
821
+ // NODE_TLS_REJECT_UNAUTHORIZED matches "AUTH" in "UNAUTHORIZED" → false positive.
822
+ // Real credential exfiltration targets API_KEY, TOKEN, SECRET, PASSWORD.
823
+ const DATAFLOW_SAFE_ENV_VARS = new Set([
824
+ 'NODE_TLS_REJECT_UNAUTHORIZED', 'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS',
825
+ 'NODE_ENV', 'NODE_PATH', 'NODE_DEBUG',
826
+ 'DEBUG', 'CI', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY',
827
+ 'LANG', 'TZ', 'PORT', 'HOST'
828
+ // Note: HOME, USER, HOSTNAME stay sensitive — fingerprint exfiltration detection.
829
+ ]);
830
+
821
831
  function isSensitiveEnv(name) {
822
832
  const upper = name.toUpperCase();
833
+ if (DATAFLOW_SAFE_ENV_VARS.has(upper)) return false;
823
834
  if (SYSTEM_IDENTITY_ENVS.has(upper)) return true;
824
835
  if (SAFE_ENV_PREFIXES.some(p => upper.startsWith(p))) return false;
825
836
  const sensitive = ['TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'NPM', 'AWS', 'AZURE', 'GCP'];
@@ -266,7 +266,7 @@ function scanEntropy(targetPath, options = {}) {
266
266
  }
267
267
 
268
268
  // B11: Fragment cluster — many short high-entropy strings = payload fragmentation
269
- const FRAG_MIN = 8, FRAG_MAX = 49, FRAG_COUNT = 5, FRAG_ENTROPY = 4.5;
269
+ const FRAG_MIN = 8, FRAG_MAX = 49, FRAG_COUNT = 10, FRAG_ENTROPY = 5.0;
270
270
  const frags = strings.filter(s =>
271
271
  s.length >= FRAG_MIN && s.length <= FRAG_MAX &&
272
272
  !SOURCE_MAP_REGEX.test(s) && !SHA256_HEX_REGEX.test(s) && !MD5_HEX_REGEX.test(s) &&
@@ -5,7 +5,7 @@ const { findFiles, EXCLUDED_DIRS } = require('../utils');
5
5
  const { ACORN_OPTIONS: BASE_ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
6
6
 
7
7
  // --- Sensitive source patterns ---
8
- const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
8
+ const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os', 'dgram']);
9
9
 
10
10
  const ACORN_OPTIONS = {
11
11
  ...BASE_ACORN_OPTIONS,
@@ -151,10 +151,15 @@ function analyzeExports(filePath) {
151
151
 
152
152
  // Track class declarations: class Foo { ... }
153
153
  const classDefs = {};
154
+ // Track function declarations: function foo() { ... }
155
+ const funcDefs = {};
154
156
  walkAST(ast, (node) => {
155
157
  if (node.type === 'ClassDeclaration' && node.id && node.id.name) {
156
158
  classDefs[node.id.name] = node;
157
159
  }
160
+ if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
161
+ funcDefs[node.id.name] = node;
162
+ }
158
163
  });
159
164
 
160
165
  // First pass: collect require assignments, ES imports, and tainted variable assignments
@@ -309,6 +314,16 @@ function analyzeExports(filePath) {
309
314
  } else if (prop.value.type === 'Identifier' && taintedVars[prop.value.name]) {
310
315
  const t = taintedVars[prop.value.name];
311
316
  exports[propName] = { tainted: true, source: t.source, detail: t.detail };
317
+ } else if (prop.value.type === 'Identifier' && funcDefs[prop.value.name]) {
318
+ // Shorthand property referencing a FunctionDeclaration: { readConfig }
319
+ const fnNode = funcDefs[prop.value.name];
320
+ const fnBody = fnNode.body && fnNode.body.type === 'BlockStatement' ? fnNode.body.body : null;
321
+ if (fnBody) {
322
+ const bodyTaint = scanBodyForTaint(fnBody, moduleVars, taintedVars);
323
+ if (bodyTaint) {
324
+ exports[propName] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
325
+ }
326
+ }
312
327
  }
313
328
  }
314
329
  }
@@ -1081,8 +1096,283 @@ function toRel(abs, packagePath) {
1081
1096
  return path.relative(packagePath, abs).replace(/\\/g, '/');
1082
1097
  }
1083
1098
 
1099
+ // =============================================================================
1100
+ // STEP 4 — Sink export annotation (for callback-based cross-file detection)
1101
+ // =============================================================================
1102
+
1103
+ /**
1104
+ * Annotate exports that contain network/exec sinks in their function body.
1105
+ * This is the inverse of annotateTaintedExports — finds "where data goes out".
1106
+ * Used to detect callback-based cross-file exfiltration:
1107
+ * reader.js exports readConfig() (tainted source)
1108
+ * sender.js exports sendData() (sink export)
1109
+ * index.js connects them via callback: readConfig((data) => sendData(data))
1110
+ */
1111
+ function annotateSinkExports(graph, packagePath) {
1112
+ const result = {};
1113
+ for (const relFile of Object.keys(graph)) {
1114
+ const absFile = path.resolve(packagePath, relFile);
1115
+ result[relFile] = analyzeSinkExports(absFile);
1116
+ }
1117
+ return result;
1118
+ }
1119
+
1120
+ function analyzeSinkExports(filePath) {
1121
+ const ast = parseFile(filePath);
1122
+ if (!ast) return {};
1123
+
1124
+ const sinkExports = {};
1125
+
1126
+ // Track function declarations for shorthand property resolution
1127
+ const localFuncDefs = {};
1128
+ walkAST(ast, (node) => {
1129
+ if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
1130
+ localFuncDefs[node.id.name] = node;
1131
+ }
1132
+ });
1133
+
1134
+ // Collect require assignments for sink module detection
1135
+ const sinkModuleVars = {};
1136
+ walkAST(ast, (node) => {
1137
+ if (node.type === 'VariableDeclaration') {
1138
+ for (const decl of node.declarations) {
1139
+ if (!decl.init || !decl.id || decl.id.type !== 'Identifier') continue;
1140
+ if (isRequireCall(decl.init)) {
1141
+ const mod = decl.init.arguments[0].value;
1142
+ if (mod === 'http' || mod === 'https' || mod === 'net' || mod === 'dgram') {
1143
+ sinkModuleVars[decl.id.name] = mod;
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
1149
+ const mod = node.source.value;
1150
+ if (mod === 'http' || mod === 'https' || mod === 'net' || mod === 'dgram') {
1151
+ for (const spec of node.specifiers) {
1152
+ sinkModuleVars[spec.local.name] = mod;
1153
+ }
1154
+ }
1155
+ }
1156
+ });
1157
+
1158
+ function bodyHasSink(body) {
1159
+ let found = null;
1160
+ walkAST({ type: 'Program', body }, (node) => {
1161
+ if (found) return;
1162
+ if (node.type === 'CallExpression') {
1163
+ // fetch(), eval()
1164
+ if (node.callee.type === 'Identifier' && SINK_CALLEE_NAMES.has(node.callee.name)) {
1165
+ found = node.callee.name + '()';
1166
+ return;
1167
+ }
1168
+ // https.request(), http.get()
1169
+ if (node.callee.type === 'MemberExpression') {
1170
+ const chain = getMemberChain(node.callee);
1171
+ if (SINK_MEMBER_METHODS.has(chain)) {
1172
+ found = chain + '()';
1173
+ return;
1174
+ }
1175
+ // Variable-based: const h = require('https'); h.request()
1176
+ if (node.callee.object.type === 'Identifier' && sinkModuleVars[node.callee.object.name]) {
1177
+ const method = node.callee.property.name || node.callee.property.value;
1178
+ if (method === 'request' || method === 'get') {
1179
+ found = sinkModuleVars[node.callee.object.name] + '.' + method + '()';
1180
+ return;
1181
+ }
1182
+ }
1183
+ // .write(), .send(), .connect()
1184
+ const method = node.callee.property.name || node.callee.property.value;
1185
+ if (SINK_INSTANCE_METHODS.has(method)) {
1186
+ found = method + '()';
1187
+ return;
1188
+ }
1189
+ }
1190
+ }
1191
+ });
1192
+ return found;
1193
+ }
1194
+
1195
+ // Check module.exports = { fn: function() { ... sink ... } }
1196
+ walkAST(ast, (node) => {
1197
+ if (isModuleExportsAssign(node)) {
1198
+ const value = node.expression.right;
1199
+ const exportName = getExportName(node.expression.left);
1200
+
1201
+ if (value.type === 'ObjectExpression' && exportName === 'default') {
1202
+ for (const prop of value.properties) {
1203
+ if (!prop.key) continue;
1204
+ const propName = prop.key.name || prop.key.value || 'unknown';
1205
+ let funcBody = getFunctionBody(prop.value);
1206
+ // Shorthand property referencing a FunctionDeclaration: { reportData }
1207
+ if (!funcBody && prop.value.type === 'Identifier' && localFuncDefs[prop.value.name]) {
1208
+ const fnNode = localFuncDefs[prop.value.name];
1209
+ funcBody = fnNode.body && fnNode.body.type === 'BlockStatement' ? fnNode.body.body : null;
1210
+ }
1211
+ if (funcBody) {
1212
+ const sink = bodyHasSink(funcBody);
1213
+ if (sink) {
1214
+ sinkExports[propName] = { hasSink: true, sink };
1215
+ }
1216
+ }
1217
+ }
1218
+ } else {
1219
+ const funcBody = getFunctionBody(value);
1220
+ if (funcBody) {
1221
+ const sink = bodyHasSink(funcBody);
1222
+ if (sink) {
1223
+ sinkExports[exportName] = { hasSink: true, sink };
1224
+ }
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ // export function foo() { ... sink ... }
1230
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
1231
+ const decl = node.declaration;
1232
+ if (decl.type === 'FunctionDeclaration' && decl.id) {
1233
+ const funcBody = decl.body && decl.body.type === 'BlockStatement' ? decl.body.body : null;
1234
+ if (funcBody) {
1235
+ const sink = bodyHasSink(funcBody);
1236
+ if (sink) {
1237
+ sinkExports[decl.id.name] = { hasSink: true, sink };
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ });
1243
+
1244
+ return sinkExports;
1245
+ }
1246
+
1247
+ /**
1248
+ * Detect callback-based cross-file flows.
1249
+ * Pattern: file imports tainted source fn + sink fn, connects them via callback.
1250
+ * Example: readConfig((err, data) => { sendData(data); })
1251
+ * Also: const data = readConfig(); sendData(data);
1252
+ */
1253
+ function detectCallbackCrossFileFlows(graph, taintedExports, sinkExports, packagePath) {
1254
+ const expandedTaint = expandTaintThroughReexports(graph, taintedExports, packagePath);
1255
+ const flows = [];
1256
+
1257
+ for (const relFile of Object.keys(graph)) {
1258
+ const absFile = path.resolve(packagePath, relFile);
1259
+ const ast = parseFile(absFile);
1260
+ if (!ast) continue;
1261
+
1262
+ const fileDir = path.dirname(absFile);
1263
+
1264
+ // Collect imported tainted source functions and imported sink functions
1265
+ const importedSources = {}; // varName → { sourceFile, source, detail }
1266
+ const importedSinks = {}; // varName → { sinkFile, sink }
1267
+
1268
+ walkAST(ast, (node) => {
1269
+ if (node.type !== 'VariableDeclaration') return;
1270
+ for (const decl of node.declarations) {
1271
+ if (!decl.init || !decl.id) continue;
1272
+
1273
+ // const { readConfig } = require('./reader')
1274
+ if (isRequireCall(decl.init) && isLocalImport(decl.init.arguments[0].value)) {
1275
+ const spec = decl.init.arguments[0].value;
1276
+ const resolved = resolveLocal(fileDir, spec, packagePath);
1277
+ if (!resolved) continue;
1278
+
1279
+ if (decl.id.type === 'ObjectPattern') {
1280
+ for (const prop of decl.id.properties) {
1281
+ const key = prop.key && (prop.key.name || prop.key.value);
1282
+ const localName = prop.value && prop.value.name;
1283
+ if (!key || !localName) continue;
1284
+
1285
+ // Check if this is a tainted source export
1286
+ if (expandedTaint[resolved] && expandedTaint[resolved][key] && expandedTaint[resolved][key].tainted) {
1287
+ const t = expandedTaint[resolved][key];
1288
+ importedSources[localName] = {
1289
+ sourceFile: t.sourceFile || resolved,
1290
+ source: t.source,
1291
+ detail: t.detail || ''
1292
+ };
1293
+ }
1294
+
1295
+ // Check if this is a sink export
1296
+ if (sinkExports[resolved] && sinkExports[resolved][key] && sinkExports[resolved][key].hasSink) {
1297
+ importedSinks[localName] = {
1298
+ sinkFile: resolved,
1299
+ sink: sinkExports[resolved][key].sink
1300
+ };
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ if (decl.id.type === 'Identifier') {
1306
+ // Whole module import: const reader = require('./reader')
1307
+ // Check default taint
1308
+ if (expandedTaint[resolved] && expandedTaint[resolved]['default'] && expandedTaint[resolved]['default'].tainted) {
1309
+ const t = expandedTaint[resolved]['default'];
1310
+ importedSources[decl.id.name] = {
1311
+ sourceFile: t.sourceFile || resolved,
1312
+ source: t.source,
1313
+ detail: t.detail || ''
1314
+ };
1315
+ }
1316
+ // Check default sink
1317
+ if (sinkExports[resolved] && sinkExports[resolved]['default'] && sinkExports[resolved]['default'].hasSink) {
1318
+ importedSinks[decl.id.name] = {
1319
+ sinkFile: resolved,
1320
+ sink: sinkExports[resolved]['default'].sink
1321
+ };
1322
+ }
1323
+ }
1324
+ }
1325
+ }
1326
+ });
1327
+
1328
+ // If we have both imported sources and sinks, check for callback connections
1329
+ if (Object.keys(importedSources).length === 0 || Object.keys(importedSinks).length === 0) continue;
1330
+
1331
+ // Pattern 1: sourceFn(function(err, data) { sinkFn(data); })
1332
+ // Pattern 2: const result = sourceFn(); sinkFn(result);
1333
+ walkAST(ast, (node) => {
1334
+ if (node.type !== 'CallExpression') return;
1335
+
1336
+ // Check if the call is to an imported source
1337
+ const calleeName = node.callee.type === 'Identifier' ? node.callee.name : null;
1338
+ if (!calleeName || !importedSources[calleeName]) return;
1339
+
1340
+ // Check if any argument is a callback that calls an imported sink
1341
+ for (const arg of node.arguments) {
1342
+ if (arg.type === 'FunctionExpression' || arg.type === 'ArrowFunctionExpression') {
1343
+ const body = arg.body.type === 'BlockStatement' ? arg.body.body : [arg.body];
1344
+ walkAST({ type: 'Program', body }, (inner) => {
1345
+ if (inner.type !== 'CallExpression') return;
1346
+ const innerCallee = inner.callee.type === 'Identifier' ? inner.callee.name : null;
1347
+ if (innerCallee && importedSinks[innerCallee]) {
1348
+ const src = importedSources[calleeName];
1349
+ const snk = importedSinks[innerCallee];
1350
+ // Avoid duplicates
1351
+ const key = `${src.sourceFile}→${relFile}→${snk.sinkFile}`;
1352
+ if (!flows.some(f => `${f.sourceFile}→${f.sinkFile}→${snk.sinkFile}` === key)) {
1353
+ flows.push({
1354
+ severity: 'CRITICAL',
1355
+ type: 'cross_file_dataflow',
1356
+ sourceFile: src.sourceFile,
1357
+ source: `${src.source}${src.detail ? '(' + src.detail + ')' : ''}`,
1358
+ sinkFile: relFile,
1359
+ sink: snk.sink,
1360
+ description: `Credential read in ${src.sourceFile} passed via callback to network sink (${snk.sink}) imported from ${snk.sinkFile} in ${relFile}`,
1361
+ });
1362
+ }
1363
+ }
1364
+ });
1365
+ }
1366
+ }
1367
+ });
1368
+ }
1369
+
1370
+ return flows;
1371
+ }
1372
+
1084
1373
  module.exports = {
1085
1374
  buildModuleGraph, annotateTaintedExports, detectCrossFileFlows,
1375
+ annotateSinkExports, detectCallbackCrossFileFlows,
1086
1376
  resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists,
1087
1377
  tryResolveConcatRequire
1088
1378
  };
@@ -20,7 +20,10 @@ function detectObfuscation(targetPath) {
20
20
  const pathParts = relativePath.split(path.sep);
21
21
  const isInDistOrBuild = pathParts.some(p => p === 'dist' || p === 'build');
22
22
  const isLargeCjsMjs = (basename.endsWith('.cjs') || basename.endsWith('.mjs')) && content.length > 100 * 1024;
23
- const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs;
23
+ // P6: Any JS file > 100KB is overwhelmingly bundled output regardless of directory name.
24
+ // Real obfuscated malware is typically small (<50KB). Catches prettier plugins/, svelte compiler/, etc.
25
+ const isLargeJs = basename.endsWith('.js') && content.length > 100 * 1024;
26
+ const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs || isLargeJs;
24
27
 
25
28
  // 1. Ratio code sur une seule ligne (skip .min.js — minification, not obfuscation)
26
29
  if (!isMinified) {
package/src/scoring.js CHANGED
@@ -108,8 +108,8 @@ const FP_COUNT_THRESHOLDS = {
108
108
  require_cache_poison: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
109
109
  suspicious_dataflow: { maxCount: 3, to: 'LOW' },
110
110
  obfuscation_detected: { maxCount: 3, to: 'LOW' },
111
- module_compile_dynamic: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
112
- module_compile: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
111
+ module_compile_dynamic: { maxCount: 3, from: 'HIGH', to: 'LOW' },
112
+ module_compile: { maxCount: 3, from: 'HIGH', to: 'LOW' },
113
113
  zlib_inflate_eval: { maxCount: 2, from: 'CRITICAL', to: 'LOW' },
114
114
  // Build tools (webpack, jest) legitimately use vm.runInThisContext for module evaluation
115
115
  vm_code_execution: { maxCount: 3, from: 'HIGH', to: 'LOW' },
@@ -118,7 +118,12 @@ const FP_COUNT_THRESHOLDS = {
118
118
  // P4: hash algorithms contain bit manipulation that triggers obfuscation heuristics
119
119
  js_obfuscation_pattern: { maxCount: 1, from: 'HIGH', to: 'LOW' },
120
120
  // P4: bundled credential_tampering from minified alias resolution (jspdf, lerna)
121
- credential_tampering: { maxCount: 5, to: 'LOW' }
121
+ credential_tampering: { maxCount: 5, to: 'LOW' },
122
+ // B1 FP reduction: bundled code aliases eval/Function (sinon, storybook, vitest)
123
+ dangerous_call_eval: { maxCount: 3, from: 'MEDIUM', to: 'LOW' },
124
+ // P6: HTTP client libraries (undici, aws-sdk, nodemailer, jsdom) parse Authorization/Bearer headers
125
+ // with 5+ credential regexes. Real harvesters use 1-2 targeted regexes.
126
+ credential_regex_harvest: { maxCount: 4, from: 'HIGH', to: 'LOW' }
122
127
  };
123
128
 
124
129
  // Types exempt from dist/ downgrade — IOC matches, lifecycle scripts, and
@@ -134,11 +139,25 @@ const DIST_EXEMPT_TYPES = new Set([
134
139
  'cross_file_dataflow', // credential read → network exfil across files
135
140
  'staged_eval_decode', // eval(atob(...)) (explicit payload staging)
136
141
  'reverse_shell' // net.Socket + connect + pipe (always malicious)
142
+ // P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
143
+ // fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
144
+ // fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
137
145
  ]);
138
146
 
139
147
  // Regex matching dist/build/minified/bundled file paths
140
148
  const DIST_FILE_RE = /(?:^|[/\\])(?:dist|build)[/\\]|\.min\.js$|\.bundle\.js$/i;
141
149
 
150
+ // Bundler artifact types: get two-notch downgrade in dist/ files (CRITICAL→MEDIUM, HIGH→LOW).
151
+ // These are individual pattern signals that bundlers routinely produce (eval for globalThis,
152
+ // dynamic require for code-splitting, minification obfuscation, etc.)
153
+ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
154
+ 'dangerous_call_eval', 'dangerous_call_function',
155
+ 'dynamic_require', 'dynamic_import',
156
+ 'obfuscation_detected', 'high_entropy_string', 'possible_obfuscation',
157
+ 'js_obfuscation_pattern', 'vm_code_execution',
158
+ 'module_compile', 'module_compile_dynamic'
159
+ ]);
160
+
142
161
  // Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types.
143
162
  // NOTE: Uses the base IOC/lifecycle exempt set, NOT full DIST_EXEMPT_TYPES.
144
163
  // Compound detections (zlib_inflate_eval, staged_eval_decode, etc.) should still be
@@ -244,13 +263,23 @@ function applyFPReductions(threats, reachableFiles, packageName) {
244
263
  }
245
264
  }
246
265
 
247
- // Dist/build/minified files: bundler artifacts get severity downgraded one notch.
248
- // Reduced from two-notch (audit fix): 2-notch made dist/ attacks invisible (CRITICAL→MEDIUM=3pts).
266
+ // Dist/build/minified files: severity downgrade for bundler output.
249
267
  // Compound detections are exempt (DIST_EXEMPT_TYPES).
268
+ // Bundler artifact types (eval, dynamic_require, obfuscation) get two-notch downgrade
269
+ // (CRITICAL→MEDIUM, HIGH→LOW) since bundlers routinely produce these patterns.
270
+ // Other non-exempt types keep one-notch downgrade.
250
271
  if (t.file && !DIST_EXEMPT_TYPES.has(t.type) && DIST_FILE_RE.test(t.file)) {
251
- if (t.severity === 'CRITICAL') t.severity = 'HIGH';
252
- else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
253
- else if (t.severity === 'MEDIUM') t.severity = 'LOW';
272
+ if (DIST_BUNDLER_ARTIFACT_TYPES.has(t.type)) {
273
+ // Two-notch downgrade for bundler artifacts
274
+ if (t.severity === 'CRITICAL') t.severity = 'MEDIUM';
275
+ else if (t.severity === 'HIGH') t.severity = 'LOW';
276
+ else if (t.severity === 'MEDIUM') t.severity = 'LOW';
277
+ } else {
278
+ // One-notch downgrade for other non-exempt types
279
+ if (t.severity === 'CRITICAL') t.severity = 'HIGH';
280
+ else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
281
+ else if (t.severity === 'MEDIUM') t.severity = 'LOW';
282
+ }
254
283
  }
255
284
 
256
285
  // Reachability: findings in files not reachable from entry points → LOW