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 +32 -20
- package/package.json +1 -1
- package/src/index.js +5 -1
- package/src/response/playbooks.js +25 -0
- package/src/rules/index.js +74 -2
- package/src/scanner/ast-detectors.js +177 -11
- package/src/scanner/ast.js +32 -1
- package/src/scanner/dataflow.js +25 -14
- package/src/scanner/entropy.js +1 -1
- package/src/scanner/module-graph.js +291 -1
- package/src/scanner/obfuscation.js +4 -1
- package/src/scoring.js +37 -8
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.
|
|
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: **
|
|
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.
|
|
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 (
|
|
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,
|
|
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.
|
|
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) | **
|
|
739
|
-
| **FPR** (Benign, global) | **
|
|
740
|
-
| **ADR** (Adversarial + Holdout) | **
|
|
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) →
|
|
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).
|
|
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
|
|
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,
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
819
|
-
- **Ground truth validation** - 51 real-world attacks (
|
|
820
|
-
- **False positive validation** -
|
|
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
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) {
|
package/src/rules/index.js
CHANGED
|
@@ -703,7 +703,7 @@ const RULES = {
|
|
|
703
703
|
module_compile: {
|
|
704
704
|
id: 'MUADDIB-AST-023',
|
|
705
705
|
name: 'Module Compile Execution',
|
|
706
|
-
severity: '
|
|
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: '
|
|
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 = [
|
|
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 (
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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 (
|
|
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({
|
package/src/scanner/ast.js
CHANGED
|
@@ -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
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -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',
|
|
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
|
|
360
|
-
//
|
|
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
|
-
|
|
368
|
+
const osClassification = MODULE_SOURCE_METHODS.os?.[prop.name];
|
|
369
|
+
if (osClassification) {
|
|
366
370
|
sources.push({
|
|
367
|
-
type:
|
|
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
|
-
|
|
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'];
|
package/src/scanner/entropy.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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: '
|
|
112
|
-
module_compile: { maxCount: 3, from: '
|
|
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:
|
|
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.
|
|
252
|
-
|
|
253
|
-
|
|
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
|