muaddib-scanner 2.11.164 → 2.11.167

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 **<!--stat:scanners-->21<!--/stat:scanners--> parallel scanners** (<!--stat:rulesTotal-->275<!--/stat:rulesTotal--> detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring** (<!--stat:compound-->20<!--/stat:compound--> compound rules), and a gVisor/Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages. An XGBoost classifier exists in the codebase but is **currently inactive** (see [Evaluation](#evaluation)).
33
+ MUAD'DIB combines **<!--stat:scanners-->22<!--/stat:scanners--> parallel scanners** (<!--stat:rulesTotal-->277<!--/stat:rulesTotal--> detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring** (<!--stat:compound-->21<!--/stat:compound--> compound rules), and a gVisor/Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages. An XGBoost classifier exists in the codebase but is **currently inactive** (see [Evaluation](#evaluation)).
34
34
 
35
35
  ---
36
36
 
@@ -185,7 +185,7 @@ muaddib replay # Ground truth validation (90/94 TPR@3, v2.11
185
185
 
186
186
  ## Features
187
187
 
188
- ### <!--stat:scanners-->21<!--/stat:scanners--> parallel scanners
188
+ ### <!--stat:scanners-->22<!--/stat:scanners--> parallel scanners
189
189
 
190
190
  | Scanner | Detection |
191
191
  |---------|-----------|
@@ -212,9 +212,9 @@ muaddib replay # Ground truth validation (90/94 TPR@3, v2.11
212
212
  | Python AST (PYAST) | Tree-sitter-Python AST with taint-aware detectors (v2.11.42+) |
213
213
  | Anti-Scanner Injection (ASI) | Prompt-injection text in comments/strings that coerces an LLM code reviewer into a clean verdict or into skipping an obfuscated payload (ASI-001..004, Hades campaign 2026-06) |
214
214
 
215
- ### <!--stat:rulesTotal-->275<!--/stat:rulesTotal--> detection rules
215
+ ### <!--stat:rulesTotal-->277<!--/stat:rulesTotal--> detection rules
216
216
 
217
- All rules (<!--stat:rulesCore-->270<!--/stat:rulesCore--> RULES + <!--stat:rulesParanoid-->5<!--/stat:rulesParanoid--> PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v211139) for the complete rules reference.
217
+ All rules (<!--stat:rulesCore-->272<!--/stat:rulesCore--> RULES + <!--stat:rulesParanoid-->5<!--/stat:rulesParanoid--> PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v211139) for the complete rules reference.
218
218
 
219
219
  ### Detected campaigns
220
220
 
@@ -345,7 +345,7 @@ npm test
345
345
 
346
346
  ### Testing
347
347
 
348
- - **<!--stat:tests-->4540<!--/stat:tests--> tests** across <!--stat:testFiles-->150<!--/stat:testFiles--> modular test files
348
+ - **<!--stat:tests-->4559<!--/stat:tests--> tests** across <!--stat:testFiles-->152<!--/stat:testFiles--> modular test files
349
349
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
350
350
  - **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
351
351
  - **Ground truth validation** - 96 real-world attacks (95.74% TPR@3, 88.30% TPR@20 — v2.11.48 full measure on 94 in-scope)
@@ -366,7 +366,7 @@ npm test
366
366
  - [Documentation Index](docs/INDEX.md) - All documentation in one place
367
367
  - [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
368
368
  - [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
369
- - [Security Policy](SECURITY.md) - Detection rules reference (<!--stat:rulesTotal-->275<!--/stat:rulesTotal--> rules)
369
+ - [Security Policy](SECURITY.md) - Detection rules reference (<!--stat:rulesTotal-->277<!--/stat:rulesTotal--> rules)
370
370
  - [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
371
371
  - [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
372
372
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.164",
3
+ "version": "2.11.167",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-07-10T09:30:28.827Z",
3
+ "timestamp": "2026-07-11T20:50:59.135Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -1048,6 +1048,28 @@
1048
1048
  "playbook": "SharedArrayBuffer + Worker Thread detectes. Canal IPC memoire partagee qui contourne la surveillance. Verifier si les workers manipulent des donnees sensibles. Isoler si combine avec eval/exec.",
1049
1049
  "points": 3
1050
1050
  },
1051
+ {
1052
+ "type": "dangerous_exec",
1053
+ "severity": "MEDIUM",
1054
+ "message": "[quick-scan] exec/spawn call detected in overflow file.",
1055
+ "file": "eslint/lib/rules/eol-last.js",
1056
+ "degraded": true,
1057
+ "quickScan": true,
1058
+ "count": 1,
1059
+ "reductions": [],
1060
+ "originalSeverity": "MEDIUM",
1061
+ "confidenceTier": "low",
1062
+ "rule_id": "MUADDIB-AST-007",
1063
+ "rule_name": "Dangerous Shell Command Execution",
1064
+ "confidence": "high",
1065
+ "domain": "malware",
1066
+ "references": [
1067
+ "https://owasp.org/www-community/attacks/Command_Injection"
1068
+ ],
1069
+ "mitre": "T1059.004",
1070
+ "playbook": "CRITIQUE: Execution de commande shell dangereuse detectee. Isoler la machine. Verifier si la commande a ete executee.",
1071
+ "points": 3
1072
+ },
1051
1073
  {
1052
1074
  "type": "dangerous_exec",
1053
1075
  "severity": "MEDIUM",
@@ -2070,10 +2092,10 @@
2070
2092
  ],
2071
2093
  "python": null,
2072
2094
  "summary": {
2073
- "total": 91,
2095
+ "total": 92,
2074
2096
  "critical": 7,
2075
2097
  "high": 9,
2076
- "medium": 44,
2098
+ "medium": 45,
2077
2099
  "low": 31,
2078
2100
  "riskScore": 35,
2079
2101
  "riskLevel": "MEDIUM",
@@ -2486,6 +2508,12 @@
2486
2508
  "points": 3,
2487
2509
  "reason": "[quick-scan] exec/spawn call detected in overflow file."
2488
2510
  },
2511
+ {
2512
+ "rule": "MUADDIB-AST-007",
2513
+ "type": "dangerous_exec",
2514
+ "points": 3,
2515
+ "reason": "[quick-scan] exec/spawn call detected in overflow file."
2516
+ },
2489
2517
  {
2490
2518
  "rule": "MUADDIB-AST-004",
2491
2519
  "type": "dangerous_call_eval",
@@ -2671,7 +2699,7 @@
2671
2699
  "verified": 0,
2672
2700
  "high": 0,
2673
2701
  "medium": 17,
2674
- "low": 74
2702
+ "low": 75
2675
2703
  },
2676
2704
  "perceivedFlagged": 0
2677
2705
  },
@@ -101,7 +101,11 @@ const HIGH_CONFIDENCE_MALICE_TYPES = new Set([
101
101
  // electron_app_injection (MUADDIB-AST-097): install hook overwrites a THIRD-PARTY Electron app's
102
102
  // core/.asar with injected code (payload-in-write coupling ⇒ FP≈0). Bypasses MT-1 cap like the
103
103
  // other install-time RCE/injection markers above (node_modules_write, systemd_persistence).
104
- 'electron_app_injection'
104
+ 'electron_app_injection',
105
+ // install_native_drop_exec (COMPOUND-020): install-time drop-and-execute of a package-
106
+ // BUNDLED native binary with no network fetch (jscrambler@8.14.0). Quasi-never legit;
107
+ // bypasses MT-1 cap + reputation attenuation like the other install-time RCE markers.
108
+ 'install_native_drop_exec'
105
109
  ]);
106
110
 
107
111
  // Lifecycle compound types that indicate real malicious intent beyond a simple postinstall
@@ -42,6 +42,7 @@ const { normalizePythonName } = require('../scanner/python.js');
42
42
  const { scanPythonSource } = require('../scanner/python-source.js');
43
43
  const { initPythonParser, scanPythonAST } = require('../scanner/python-ast.js');
44
44
  const { scanAntiScannerInjection } = require('../scanner/anti-scanner-injection.js');
45
+ const { scanBinarySource } = require('../scanner/binary-source.js');
45
46
  const { Spinner, listInstalledPackages, wasFilesCapped, getOverflowFiles, debugLog } = require('../utils.js');
46
47
  const { getMaxFileSize } = require('../shared/constants.js');
47
48
  const { scanParanoid } = require('../scanner/paranoid.js');
@@ -253,7 +254,7 @@ async function execute(targetPath, options, pythonDeps, warnings) {
253
254
  'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
254
255
  'scanEntropy', 'scanAIConfig', 'scanIocStrings', 'scanAntiForensic',
255
256
  'scanStubPackage', 'scanMonorepo', 'scanTrustedDepDiff', 'scanPythonSource',
256
- 'scanPythonAST', 'scanAntiScannerInjection'
257
+ 'scanPythonAST', 'scanAntiScannerInjection', 'scanBinarySource'
257
258
  ];
258
259
 
259
260
  // Stage 2 quick_scan subset (monitor-only, set via options.scanMode='quick'
@@ -284,7 +285,8 @@ async function execute(targetPath, options, pythonDeps, warnings) {
284
285
  'scanPythonSource',
285
286
  'scanPythonAST',
286
287
  'scanAIConfig',
287
- 'scanAntiScannerInjection'
288
+ 'scanAntiScannerInjection',
289
+ 'scanBinarySource'
288
290
  ]);
289
291
  const isQuick = options.scanMode === 'quick';
290
292
  function ifEnabled(name, fn) {
@@ -333,7 +335,10 @@ async function execute(targetPath, options, pythonDeps, warnings) {
333
335
  ifEnabled('scanPythonAST', () => yieldThen(() => scanPythonAST(targetPath))),
334
336
  // ASI-001..004 (chantier 2026-06-20). Anti-scanner prompt injection in source text
335
337
  // (comments/strings). Regex-only, cheap; the attack lives in root entry files.
336
- ifEnabled('scanAntiScannerInjection', () => yieldThen(() => scanAntiScannerInjection(targetPath)))
338
+ ifEnabled('scanAntiScannerInjection', () => yieldThen(() => scanAntiScannerInjection(targetPath))),
339
+ // BINSRC-001 (Layer 2): binary masquerading as a .js/.ts source file. Scans dist/build/out
340
+ // (which the other text scanners exclude) reading an 8 KB prefix — catches multi-MB carriers.
341
+ ifEnabled('scanBinarySource', () => yieldThen(() => scanBinarySource(targetPath)))
337
342
  ]);
338
343
 
339
344
  // Extract results: use empty array for rejected scanners, log errors
@@ -366,7 +371,8 @@ async function execute(targetPath, options, pythonDeps, warnings) {
366
371
  trustedDepDiffThreats,
367
372
  pythonSourceThreats,
368
373
  pythonAstThreats,
369
- antiScannerThreats
374
+ antiScannerThreats,
375
+ binarySourceThreats
370
376
  ] = scanResult;
371
377
 
372
378
  // Emit warning if file count cap was hit + quick-scan overflow files
@@ -450,6 +456,7 @@ async function execute(targetPath, options, pythonDeps, warnings) {
450
456
  ...pythonSourceThreats,
451
457
  ...pythonAstThreats,
452
458
  ...antiScannerThreats,
459
+ ...binarySourceThreats,
453
460
  ...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
454
461
  type: f.type,
455
462
  severity: f.severity,
@@ -23,6 +23,7 @@ const { getRule, getRuleDomain } = require('../rules/index.js');
23
23
  const { getPlaybook } = require('../response/playbooks.js');
24
24
  const { computeReachableFiles, computeReachableFunctions } = require('../scanner/reachability.js');
25
25
  const { correlatePhantomGyp } = require('../scanner/phantom-gyp.js');
26
+ const { correlateInstallNativeDropExec } = require('../scanner/native-drop-exec.js');
26
27
  const { applyFPReductions, applyCompoundBoosts, calculateRiskScore, getSeverityWeights, applyContextualFPCaps, applySingleFireCriticalFloor, applyReputationFactor, applyMatureStableCap, applySandboxVerdict, applyDeltaMultiplier } = require('../scoring.js');
27
28
  const { loadPriorVersionSignatures, computeSignatures, saveCachedSignatures } = require('../scoring/delta-multiplier.js');
28
29
  const { annotateConfidenceTiers } = require('../rules/confidence-tiers.js');
@@ -481,6 +482,14 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
481
482
  // ADDS a finding when the invoked file is independently judged malicious).
482
483
  correlatePhantomGyp(deduped, targetPath);
483
484
 
485
+ // install_native_drop_exec compound (COMPOUND-020): a preinstall/install/postinstall
486
+ // hook running `node x.js`, where x.js reads package-BUNDLED bytes (local read /
487
+ // decompress / inline base64), writes them to an executable file, and spawns that
488
+ // written path — with NO network fetch (the stealthy bundled variant, distinct from
489
+ // download-and-execute). The jscrambler@8.14.0 shape (Socket 2026-07-11), which trips
490
+ // zero per-file rules. FP≈0 by construction. Runs here on the full post-FP verdict set.
491
+ correlateInstallNativeDropExec(deduped, targetPath);
492
+
484
493
  // Intent coherence analysis: detect source→sink pairs within files
485
494
  // Pass targetPath for destination-aware SDK pattern detection
486
495
  const intentResult = buildIntentPairs(deduped, targetPath);
@@ -1078,6 +1078,14 @@ const PLAYBOOKS = {
1078
1078
  'CRITIQUE (compound Phase 1b): binding.gyp lance <!(node x.js) / <!(python y.py) au configure-time via node-gyp (aucun script lifecycle requis) ET le fichier invoque est juge malveillant de facon independante par les scanners AST/dataflow/module-graph. ' +
1079
1079
  'Payload install-time confirme — NE PAS installer. Analyser le fichier invoque (nomme dans le message), c\'est lui qui porte la charge. node-gyp l\'execute meme avec --ignore-scripts.',
1080
1080
 
1081
+ install_native_drop_exec:
1082
+ 'CRITIQUE (compound COMPOUND-020): un hook preinstall/install/postinstall lance `node x.js` qui depaquette un binaire natif EMBARQUE dans le package (lecture locale / gunzip / base64), l\'ecrit executable et le spawn — au moment de npm install, sans reseau, sans import (pattern jscrambler@8.14.0, infostealer natif). ' +
1083
+ 'NE PAS installer : --ignore-scripts ne protege pas si deja installe. Le fichier nomme dans le message porte la charge. Roter les credentials accessibles aux environnements dev/CI ayant installe la version, verifier les logs d\'install pour l\'execution du loader, revenir a une version verifiee saine.',
1084
+
1085
+ binary_masquerading_as_source:
1086
+ 'HAUTE: un fichier a extension source (.js/.ts/.cjs/.mjs) a un contenu BINAIRE (executable / archive / conteneur custom), souvent cache dans dist/ ou l\'analyse texte ne regarde pas — porteur de charge utile deguise en source (pattern jscrambler@8.14.0 dist/intro.js, 7.8 Mo). ' +
1087
+ 'Ouvrir le fichier en hexdump, identifier le format (ELF/PE/Mach-O/gzip/CSI). Verifier quel script d\'install ou loader le lit+execute (cf. install_native_drop_exec). NE PAS installer si couple a un hook d\'install.',
1088
+
1081
1089
  string_mutation_obfuscation:
1082
1090
  'HAUTE: Chaine de .replace() reconstruisant des noms d\'API dangereuses (leet-speak). ' +
1083
1091
  'Technique d\'evasion par substitution de caracteres. Decoder la chaine finale. Supprimer si malveillant.',
@@ -828,6 +828,33 @@ const RULES = {
828
828
  ],
829
829
  mitre: 'T1195.002'
830
830
  },
831
+ install_native_drop_exec: {
832
+ id: 'MUADDIB-COMPOUND-020',
833
+ name: 'Install-Time Native Binary Drop-and-Execute',
834
+ severity: 'CRITICAL',
835
+ confidence: 'high',
836
+ domain: 'malware',
837
+ description: 'Un hook preinstall/install/postinstall lance `node x.js`, et x.js lit des octets EMBARQUES dans le package (lecture fichier locale / decompression zlib-brotli / base64-hex inline), les ecrit dans un fichier executable (bit exec via mode 0o7xx, chmod +x, ou extension .exe) puis SPAWN ce chemin ecrit (child_process spawn/exec/execFile/fork sur un chemin calcule, pas une commande litterale) — SANS fetch reseau. Pattern jscrambler@8.14.0 (Socket, 2026-07-11): le loader depaquette un binaire natif (infostealer Rust) et l\'execute au moment de npm install, avant tout code applicatif, sans import requis. Le loader ne declenche AUCUNE regle AST/dataflow par fichier (seul lifecycle_script, cape). Compound FPR≈0 par construction: les installeurs legitimes de binaires natifs TELECHARGENT depuis une URL (reseau), COMPILENT via node-gyp/make/cc invoques par NOM (commande litterale), ou chargent un .node prebuild via require() (jamais spawn) — aucun ne depaquette+chmod+spawn un blob embarque sans reseau. Variante furtive (sans signature reseau) volontairement scopee ici; la variante download-and-execute est couverte ailleurs.',
838
+ references: [
839
+ 'https://github.com/jscrambler/jscrambler/issues/322',
840
+ 'https://attack.mitre.org/techniques/T1195/002/',
841
+ 'https://attack.mitre.org/techniques/T1059/'
842
+ ],
843
+ mitre: 'T1195.002'
844
+ },
845
+ binary_masquerading_as_source: {
846
+ id: 'MUADDIB-BINSRC-001',
847
+ name: 'Binary Masquerading as Source',
848
+ severity: 'HIGH',
849
+ confidence: 'high',
850
+ domain: 'malware',
851
+ description: 'Un fichier avec une extension source (.js/.mjs/.cjs/.jsx/.ts/.tsx) dont le CONTENU est binaire — executable (ELF/PE/Mach-O), archive (zip/gzip/xz/7z/rar), module WebAssembly, ou conteneur custom (octet de controle en tete, ex. le header \\x1bCSI de jscrambler@8.14.0). Un fichier source est du texte par definition ; un contenu binaire = charge utile deguisee en source. Le scanner descend VOLONTAIREMENT dans dist/build/out/output (que les scanners texte AST/dataflow/entropie excluent via EXCLUDED_DIRS) et ne lit qu\'un prefixe de 8 Ko, donc il attrape un porteur de plusieurs Mo (jscrambler dist/intro.js = 7.8 Mo) que tout le reste ignore. Magic-agnostique : renommer le fichier ou changer le magic ne contourne pas (regles octet-NUL + octet de controle en tete). FP≈0 : un bundle minifie dist/*.js reste du texte imprimable. Severite HIGH (ne franchit pas seul le seuil d\'alerte) : se cumule avec le hook d\'install que COMPOUND-020 install_native_drop_exec eleve en CRITICAL.',
852
+ references: [
853
+ 'https://github.com/jscrambler/jscrambler/issues/322',
854
+ 'https://attack.mitre.org/techniques/T1027/002/'
855
+ ],
856
+ mitre: 'T1027.002'
857
+ },
831
858
  crypto_exfil: {
832
859
  id: 'MUADDIB-COMPOUND-019',
833
860
  name: 'Hybrid-Crypto Credential Exfiltration',
@@ -0,0 +1,95 @@
1
+ /*
2
+ * MUAD'DIB — Supply-chain threat detection for npm & PyPI
3
+ * Copyright (C) 2026 DNSZLSK
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License version 3,
7
+ * as published by the Free Software Foundation.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ *
17
+ * SPDX-License-Identifier: AGPL-3.0-only
18
+ */
19
+
20
+ /**
21
+ * binary_masquerading_as_source scanner (MUADDIB-BINSRC-001).
22
+ *
23
+ * Flags a file with a SOURCE extension whose bytes are actually binary — an executable,
24
+ * archive, compressed blob, or custom container. This closes a real blind spot: the shared
25
+ * `findFiles` excludes `dist/`, `build/`, `out/`, `output/` (bundler output), so the AST /
26
+ * dataflow / entropy scanners never look there — yet that is exactly where a disguised
27
+ * native payload hides (jscrambler@8.14.0 shipped its 7.8 MB native-binary container as
28
+ * `dist/intro.js`, which every text scanner skipped). This scanner DELIBERATELY descends
29
+ * into those dirs and reads only an 8 KB prefix per file, so it catches a multi-MB carrier
30
+ * regardless of the 10 MB parse cap.
31
+ *
32
+ * FP≈0: minified/bundled `dist/*.js` is still printable text (no NUL, no leading control
33
+ * byte) — only genuinely binary content trips it. Severity HIGH (not single-fire): on its
34
+ * own it does not cross the alert threshold; it stacks with the install-hook signal, which
35
+ * the COMPOUND-020 `install_native_drop_exec` correlator escalates to CRITICAL.
36
+ */
37
+ 'use strict';
38
+
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+ const { findFiles } = require('../utils.js');
42
+ const { sniffBinaryBuffer } = require('../shared/binary-sniff.js');
43
+
44
+ const SOURCE_EXTS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts'];
45
+ // Keep node_modules/.git out, but INCLUDE dist/build/out/output — payloads hide there and
46
+ // the default EXCLUDED_DIRS skips them.
47
+ const SCAN_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
48
+ const SNIFF_BYTES = 8192;
49
+
50
+ function scanBinarySource(targetPath) {
51
+ const threats = [];
52
+ if (!targetPath) return threats;
53
+
54
+ let files;
55
+ try {
56
+ files = findFiles(targetPath, { extensions: SOURCE_EXTS, excludedDirs: SCAN_EXCLUDED_DIRS });
57
+ } catch { return threats; }
58
+
59
+ for (const file of files) {
60
+ let buf;
61
+ try {
62
+ const fd = fs.openSync(file, 'r');
63
+ try {
64
+ const b = Buffer.alloc(SNIFF_BYTES);
65
+ const n = fs.readSync(fd, b, 0, SNIFF_BYTES, 0);
66
+ buf = b.subarray(0, n);
67
+ } finally {
68
+ fs.closeSync(fd);
69
+ }
70
+ } catch { continue; }
71
+
72
+ const verdict = sniffBinaryBuffer(buf);
73
+ if (!verdict.binary) continue;
74
+
75
+ const rel = path.relative(targetPath, file).replace(/\\/g, '/');
76
+ let sizeStr = '';
77
+ try {
78
+ const mb = fs.statSync(file).size / (1024 * 1024);
79
+ if (mb >= 0.1) sizeStr = ` (${mb.toFixed(1)} MB)`;
80
+ } catch { /* size best-effort */ }
81
+
82
+ threats.push({
83
+ type: 'binary_masquerading_as_source',
84
+ severity: 'HIGH',
85
+ confidence: 'high',
86
+ file: rel,
87
+ message: `File "${rel}" has a source extension but its content is ${verdict.format} — ${verdict.reason}${sizeStr}. A .js/.ts file that is actually binary is a payload carrier disguised as source (e.g. jscrambler@8.14.0 dist/intro.js) and evades text-based static analysis.`,
88
+ mitre: 'T1027.002'
89
+ });
90
+ }
91
+
92
+ return threats;
93
+ }
94
+
95
+ module.exports = { scanBinarySource, SOURCE_EXTS };
@@ -0,0 +1,458 @@
1
+ /*
2
+ * MUAD'DIB — Supply-chain threat detection for npm & PyPI
3
+ * Copyright (C) 2026 DNSZLSK
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License version 3,
7
+ * as published by the Free Software Foundation.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ *
17
+ * SPDX-License-Identifier: AGPL-3.0-only
18
+ */
19
+
20
+ /**
21
+ * install_native_drop_exec compound correlator (MUADDIB-COMPOUND-020).
22
+ *
23
+ * Threat model — the jscrambler@8.14.0 shape (Socket, 2026-07-11): a `preinstall`
24
+ * hook (`node dist/setup.js`) reads a package-BUNDLED blob (`readFileSync(intro.js)`),
25
+ * decompresses it (`gunzipSync`), writes it to a hidden temp file with an EXECUTABLE
26
+ * mode (`writeFileSync(…, {mode: 0o755})`), and spawns THAT written path detached
27
+ * (`spawn(target, [], {detached:true, stdio:'ignore', windowsHide:true}); unref()`).
28
+ * A native infostealer runs on `npm install` before any application code — and the
29
+ * loader itself trips ZERO existing AST/dataflow rules (only PKG-001 `lifecycle_script`
30
+ * fires, capped as base behavior). This closes that gap.
31
+ *
32
+ * Why a compound and not a lone detector: the invariant we key on is
33
+ * (BUNDLED_SOURCE) the executable's bytes originate from WITHIN the package —
34
+ * a local fs read, a zlib/brotli decompress, or an inline
35
+ * base64/hex Buffer — NOT from the network; AND
36
+ * (DROP_EXEC) a file is written with an exec bit / chmod +x / an .exe-class
37
+ * name; AND
38
+ * (EXEC_COMPUTED) child_process spawn/exec/execFile/fork runs a COMPUTED path
39
+ * (a variable/expression, not a bare "git"/"node" literal).
40
+ * Any one of these alone is legitimate somewhere. The three together, reached from an
41
+ * install hook, is the "drop-and-run a bundled native binary at install time" pattern,
42
+ * which legitimate native-module installers never do: they DOWNLOAD prebuilt binaries
43
+ * from a URL (network, not bundled — so BUNDLED_SOURCE is absent), COMPILE via
44
+ * `node-gyp`/`make`/`cc` invoked by NAME (a literal command — EXEC_COMPUTED absent), or
45
+ * ship a prebuilt `.node` addon loaded with `require()` (never spawned). Gating on the
46
+ * install hook + requiring all three signals ⇒ FP≈0 by construction.
47
+ *
48
+ * Variant-robust, not a jscrambler signature: renaming the carrier, changing the custom
49
+ * container magic, swapping gzip→brotli/base64, `spawn`→`exec`/`fork`, tmpdir→cache, or
50
+ * dropping the `.exe` obfuscation all preserve the three invariants and still fire. The
51
+ * one evasion this does NOT cover — fetching the binary from a URL at install — is a
52
+ * different, noisier pattern (network egress + URL IOC surface) detected elsewhere.
53
+ *
54
+ * Runs as a post-processor (like phantom-gyp.js): it re-reads package.json and the
55
+ * invoked loader directly rather than relying on any per-file finding, so a benign
56
+ * package carries no intermediate signal and gains zero findings.
57
+ */
58
+ 'use strict';
59
+
60
+ const fs = require('fs');
61
+ const path = require('path');
62
+
63
+ let acorn = null;
64
+ let walk = null;
65
+ try {
66
+ acorn = require('acorn');
67
+ walk = require('acorn-walk');
68
+ } catch { /* acorn unavailable → correlator is a no-op */ }
69
+
70
+ // Install hooks that npm runs on the CONSUMER from a published registry tarball.
71
+ // Same set package.js escalates on (preinstall/install/postinstall). `prepare` is
72
+ // intentionally excluded: it runs only for git/directory installs, not registry
73
+ // tarballs, and would widen the surface without the coupling gaining precision.
74
+ const INSTALL_HOOKS = ['preinstall', 'install', 'postinstall'];
75
+
76
+ // child_process execution sinks.
77
+ const EXEC_MEMBERS = new Set(['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']);
78
+ // zlib decompression of a bundled payload.
79
+ const DECOMPRESS_MEMBERS = new Set(['gunzipSync', 'gunzip', 'brotliDecompressSync', 'brotliDecompress', 'inflateSync', 'inflate', 'inflateRawSync', 'inflateRaw', 'unzipSync', 'unzip']);
80
+ // fs reads / writes / chmod.
81
+ const READ_MEMBERS = new Set(['readFileSync', 'readFile']);
82
+ const WRITE_MEMBERS = new Set(['writeFileSync', 'writeFile']);
83
+ const CHMOD_MEMBERS = new Set(['chmodSync', 'chmod', 'fchmodSync', 'fchmod', 'lchmodSync', 'lchmod']);
84
+
85
+ const MODULE_CP = new Set(['child_process', 'node:child_process']);
86
+ const MODULE_ZLIB = new Set(['zlib', 'node:zlib']);
87
+ const MODULE_FS = new Set(['fs', 'node:fs', 'fs/promises', 'node:fs/promises']);
88
+ // HTTP download modules. Their presence means the loader FETCHES its bytes — the
89
+ // download-and-execute pattern (network egress + URL IOC surface), handled by other
90
+ // detectors. This compound is intentionally scoped to the STEALTHY no-network variant
91
+ // where the payload is bundled inside the package, so we suppress when any appears.
92
+ const NETWORK_MODULES = new Set([
93
+ 'http', 'https', 'node:http', 'node:https', 'http2', 'node:http2',
94
+ 'axios', 'node-fetch', 'got', 'undici', 'request', 'follow-redirects',
95
+ 'needle', 'superagent', 'phin', 'bent', 'cross-fetch', 'isomorphic-fetch',
96
+ 'make-fetch-happen', 'minipass-fetch'
97
+ ]);
98
+
99
+ const EXEC_EXT_RE = /\.(?:exe|cmd|bat|com|scr|ps1|msi)$/i;
100
+ const ENCODINGS = new Set(['base64', 'base64url', 'hex']);
101
+
102
+ // Loaders are tiny. Skip pathological files (a huge minified index is never an
103
+ // install loader) to bound parse cost.
104
+ const MAX_SCRIPT_BYTES = 1024 * 1024;
105
+
106
+ /** Normalize a relative path for comparison: backslashes→/, strip leading ./ and /. */
107
+ function _normRel(f) {
108
+ return String(f || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
109
+ }
110
+
111
+ /**
112
+ * Extract the local .js/.cjs/.mjs files invoked by `node <file>` in a lifecycle
113
+ * command line. Splits on shell separators so `node a.js && echo ok` yields a.js.
114
+ * Inline-eval forms (`node -e "…"`) and non-node commands yield nothing.
115
+ * @param {string} cmd - raw script command from package.json scripts[hook]
116
+ * @returns {string[]} invoked script file paths (as written in the command)
117
+ */
118
+ function extractInvokedScriptFiles(cmd) {
119
+ const out = [];
120
+ if (!cmd || typeof cmd !== 'string') return out;
121
+ const segments = cmd.split(/&&|\|\||[;&|\n]/);
122
+ for (const seg of segments) {
123
+ const toks = seg.trim().split(/\s+/).filter(Boolean);
124
+ if (!toks.length) continue;
125
+ const bin = toks[0].replace(/^.*[/\\]/, '').toLowerCase();
126
+ if (bin !== 'node' && bin !== 'nodejs') continue;
127
+ for (let i = 1; i < toks.length; i++) {
128
+ const tok = toks[i];
129
+ if (tok.startsWith('-')) {
130
+ // An inline-eval flag means there is no script file in this invocation.
131
+ if (/^--?(?:e|p|c|eval|print|require)$/i.test(tok)) break;
132
+ continue; // some other flag (e.g. --experimental-*) — keep scanning
133
+ }
134
+ const clean = tok.replace(/^['"]+|['"]+$/g, '');
135
+ if (/\.(?:c|m)?js$/i.test(clean)) { out.push(clean); break; }
136
+ break; // first non-flag token is not a script file → nothing to analyze
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /** Try module then script source type. Returns AST or null (obfuscated/non-JS). */
143
+ function _parse(code) {
144
+ if (!acorn) return null;
145
+ for (const sourceType of ['module', 'script']) {
146
+ try {
147
+ return acorn.parse(code, {
148
+ ecmaVersion: 'latest',
149
+ sourceType,
150
+ allowHashBang: true,
151
+ allowReturnOutsideFunction: true,
152
+ allowAwaitOutsideFunction: true
153
+ });
154
+ } catch { /* try next */ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** True when a spawn/exec first argument is a COMPUTED path (not a bare command literal). */
160
+ function _isComputedArg(arg) {
161
+ if (!arg) return false;
162
+ if (arg.type === 'Literal') return typeof arg.value !== 'string' ? false : false; // literal command → not our pattern
163
+ if (arg.type === 'TemplateLiteral') return (arg.expressions && arg.expressions.length > 0);
164
+ // Identifier / MemberExpression / CallExpression / BinaryExpression / ConditionalExpression …
165
+ return true;
166
+ }
167
+
168
+ /** True when the read source is an explicit http(s) URL literal (network, not bundled). */
169
+ function _isUrlArg(arg) {
170
+ if (!arg) return false;
171
+ if (arg.type === 'Literal' && typeof arg.value === 'string') return /^https?:\/\//i.test(arg.value);
172
+ if (arg.type === 'TemplateLiteral' && arg.quasis && arg.quasis[0]) {
173
+ return /^https?:\/\//i.test(arg.quasis[0].value.cooked || arg.quasis[0].value.raw || '');
174
+ }
175
+ // `new URL('./x', import.meta.url)` is the canonical ESM LOCAL-file idiom — only an
176
+ // http(s) first argument makes it a network URL.
177
+ if (arg.type === 'NewExpression' && arg.callee && arg.callee.name === 'URL') {
178
+ const u = arg.arguments && arg.arguments[0];
179
+ return !!(u && u.type === 'Literal' && typeof u.value === 'string' && /^https?:\/\//i.test(u.value));
180
+ }
181
+ return false;
182
+ }
183
+
184
+ /** True when a numeric/string/ternary mode value carries any exec bit (0o111 mask). */
185
+ function _modeHasExecBit(node) {
186
+ if (!node) return false;
187
+ if (node.type === 'Literal') {
188
+ if (typeof node.value === 'number') return (node.value & 0o111) !== 0;
189
+ if (typeof node.value === 'string') {
190
+ const v = parseInt(node.value, 8);
191
+ return Number.isInteger(v) && (v & 0o111) !== 0;
192
+ }
193
+ return false;
194
+ }
195
+ // { mode: platform === 1 ? 0o644 : 0o755 } — check both branches.
196
+ if (node.type === 'ConditionalExpression') {
197
+ return _modeHasExecBit(node.consequent) || _modeHasExecBit(node.alternate);
198
+ }
199
+ return false;
200
+ }
201
+
202
+ /** writeFile(file, data, options?): executable if options.mode has an exec bit, or the target is an .exe-class literal. */
203
+ function _writeIsExecutable(callNode) {
204
+ const args = callNode.arguments || [];
205
+ const opts = args[2];
206
+ if (opts && opts.type === 'ObjectExpression') {
207
+ for (const p of opts.properties) {
208
+ if (p.type === 'Property' && !p.computed && p.key &&
209
+ (p.key.name === 'mode' || p.key.value === 'mode') && _modeHasExecBit(p.value)) {
210
+ return true;
211
+ }
212
+ }
213
+ }
214
+ const target = args[0];
215
+ if (target && target.type === 'Literal' && typeof target.value === 'string' && EXEC_EXT_RE.test(target.value)) {
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ /** Buffer.from(<string literal>, 'base64'|'hex') — an inline-encoded payload. */
222
+ function _isEncodingArg(arg) {
223
+ return !!(arg && arg.type === 'Literal' && typeof arg.value === 'string' && ENCODINGS.has(arg.value.toLowerCase()));
224
+ }
225
+
226
+ /** True when `node` is `require('<module in set>')`. */
227
+ function _isRequireOf(node, moduleSet) {
228
+ return !!(node && node.type === 'CallExpression' && node.callee && node.callee.type === 'Identifier' &&
229
+ node.callee.name === 'require' && node.arguments && node.arguments[0] &&
230
+ node.arguments[0].type === 'Literal' && moduleSet.has(node.arguments[0].value));
231
+ }
232
+
233
+ /**
234
+ * Collect local binding names imported/required from child_process, zlib and fs.
235
+ * Handles ESM named/namespace imports and CJS require destructuring/namespace, plus
236
+ * `const spawn = require('child_process').spawn`.
237
+ */
238
+ function _collectBindings(ast) {
239
+ const b = {
240
+ execFns: new Set(), cpNs: new Set(),
241
+ decompressFns: new Set(), zlibNs: new Set(),
242
+ readFns: new Set(), writeFns: new Set(), chmodFns: new Set(), fsNs: new Set(),
243
+ network: false
244
+ };
245
+ const addNamed = (src, importedName, localName) => {
246
+ if (MODULE_CP.has(src)) {
247
+ if (EXEC_MEMBERS.has(importedName)) b.execFns.add(localName);
248
+ } else if (MODULE_ZLIB.has(src)) {
249
+ if (DECOMPRESS_MEMBERS.has(importedName)) b.decompressFns.add(localName);
250
+ } else if (MODULE_FS.has(src)) {
251
+ if (READ_MEMBERS.has(importedName)) b.readFns.add(localName);
252
+ else if (WRITE_MEMBERS.has(importedName)) b.writeFns.add(localName);
253
+ else if (CHMOD_MEMBERS.has(importedName)) b.chmodFns.add(localName);
254
+ }
255
+ };
256
+ const addNs = (src, localName) => {
257
+ if (MODULE_CP.has(src)) b.cpNs.add(localName);
258
+ else if (MODULE_ZLIB.has(src)) b.zlibNs.add(localName);
259
+ else if (MODULE_FS.has(src)) b.fsNs.add(localName);
260
+ };
261
+
262
+ walk.simple(ast, {
263
+ ImportDeclaration(node) {
264
+ const src = node.source && node.source.value;
265
+ if (!src) return;
266
+ if (NETWORK_MODULES.has(src)) b.network = true;
267
+ for (const spec of node.specifiers || []) {
268
+ if (spec.type === 'ImportSpecifier') addNamed(src, spec.imported.name, spec.local.name);
269
+ else if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') addNs(src, spec.local.name);
270
+ }
271
+ },
272
+ VariableDeclarator(node) {
273
+ const init = node.init;
274
+ if (!init) return;
275
+ // const X = require('mod') | const {a,b} = require('mod')
276
+ if (init.type === 'CallExpression' && init.callee && init.callee.type === 'Identifier' &&
277
+ init.callee.name === 'require' && init.arguments[0] && init.arguments[0].type === 'Literal') {
278
+ const src = init.arguments[0].value;
279
+ if (NETWORK_MODULES.has(src)) b.network = true;
280
+ if (node.id.type === 'Identifier') addNs(src, node.id.name);
281
+ else if (node.id.type === 'ObjectPattern') {
282
+ for (const p of node.id.properties) {
283
+ if (p.type === 'Property' && p.key && p.value && p.value.type === 'Identifier') {
284
+ addNamed(src, p.key.name || p.key.value, p.value.name);
285
+ }
286
+ }
287
+ }
288
+ return;
289
+ }
290
+ // const spawn = require('child_process').spawn
291
+ if (init.type === 'MemberExpression' && !init.computed && init.property.type === 'Identifier' &&
292
+ node.id.type === 'Identifier') {
293
+ const prop = init.property.name;
294
+ if (_isRequireOf(init.object, MODULE_CP) && EXEC_MEMBERS.has(prop)) b.execFns.add(node.id.name);
295
+ else if (_isRequireOf(init.object, MODULE_ZLIB) && DECOMPRESS_MEMBERS.has(prop)) b.decompressFns.add(node.id.name);
296
+ else if (_isRequireOf(init.object, MODULE_FS) && READ_MEMBERS.has(prop)) b.readFns.add(node.id.name);
297
+ else if (_isRequireOf(init.object, MODULE_FS) && WRITE_MEMBERS.has(prop)) b.writeFns.add(node.id.name);
298
+ else if (_isRequireOf(init.object, MODULE_FS) && CHMOD_MEMBERS.has(prop)) b.chmodFns.add(node.id.name);
299
+ }
300
+ }
301
+ });
302
+ return b;
303
+ }
304
+
305
+ /** Record spawn/exec option boosters (not required for firing — they enrich the message). */
306
+ function _spawnBoosters(callNode, sig) {
307
+ const opts = (callNode.arguments || []).find(a => a && a.type === 'ObjectExpression');
308
+ if (!opts) return;
309
+ for (const p of opts.properties) {
310
+ if (p.type !== 'Property' || p.computed || !p.key) continue;
311
+ const k = p.key.name || p.key.value;
312
+ if (k === 'detached' && p.value.type === 'Literal' && p.value.value === true) sig.boosters.add('detached');
313
+ else if (k === 'stdio' && p.value.type === 'Literal' && p.value.value === 'ignore') sig.boosters.add("stdio:'ignore'");
314
+ else if (k === 'windowsHide' && p.value.type === 'Literal' && p.value.value === true) sig.boosters.add('windowsHide');
315
+ }
316
+ }
317
+
318
+ /** Classify one CallExpression against the module bindings, updating the signal set. */
319
+ function _classifyCall(node, b, sig) {
320
+ const callee = node.callee;
321
+ const args = node.arguments || [];
322
+ if (!callee) return;
323
+
324
+ if (callee.type === 'Identifier') {
325
+ const n = callee.name;
326
+ if (n === 'fetch') { sig.network = true; return; }
327
+ if (b.execFns.has(n)) { if (_isComputedArg(args[0])) { sig.exec = true; _spawnBoosters(node, sig); } }
328
+ else if (b.decompressFns.has(n)) sig.boosters.add('decompress');
329
+ else if (b.readFns.has(n)) { if (!_isUrlArg(args[0])) sig.source = true; }
330
+ else if (b.writeFns.has(n)) { if (_writeIsExecutable(node)) sig.drop = true; }
331
+ else if (b.chmodFns.has(n)) { if (_modeHasExecBit(args[1])) sig.drop = true; }
332
+ return;
333
+ }
334
+
335
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
336
+ const prop = callee.property.name;
337
+ const obj = callee.object;
338
+ const objName = obj.type === 'Identifier' ? obj.name : null;
339
+ if (objName === 'Buffer' && prop === 'from' && _isEncodingArg(args[1])) { sig.source = true; return; }
340
+ if (prop === 'unref') { sig.boosters.add('unref'); return; }
341
+
342
+ const objIsCP = (objName && b.cpNs.has(objName)) || _isRequireOf(obj, MODULE_CP);
343
+ const objIsZ = (objName && b.zlibNs.has(objName)) || _isRequireOf(obj, MODULE_ZLIB);
344
+ const objIsFS = (objName && b.fsNs.has(objName)) || _isRequireOf(obj, MODULE_FS);
345
+
346
+ if (objIsCP && EXEC_MEMBERS.has(prop)) { if (_isComputedArg(args[0])) { sig.exec = true; _spawnBoosters(node, sig); } }
347
+ else if (objIsZ && DECOMPRESS_MEMBERS.has(prop)) sig.boosters.add('decompress');
348
+ else if (objIsFS && READ_MEMBERS.has(prop)) { if (!_isUrlArg(args[0])) sig.source = true; }
349
+ else if (objIsFS && WRITE_MEMBERS.has(prop)) { if (_writeIsExecutable(node)) sig.drop = true; }
350
+ else if (objIsFS && CHMOD_MEMBERS.has(prop)) { if (_modeHasExecBit(args[1])) sig.drop = true; }
351
+ }
352
+ }
353
+
354
+ /** Conservative textual scan — only reached when the loader does not parse as JS. */
355
+ function _textualFallback(code) {
356
+ // Network present → download-exec pattern, not our bundled variant. Suppress.
357
+ if (/\bhttps?\b|\bfetch\s*\(|\b(?:axios|node-fetch|got|undici|follow-redirects)\b/.test(code)) {
358
+ return { fired: false, reason: '' };
359
+ }
360
+ const hasCP = /(?:require\(\s*['"](?:node:)?child_process['"]\s*\)|from\s+['"](?:node:)?child_process['"])/.test(code);
361
+ const hasExec = /\b(?:spawn|spawnSync|execFile|execFileSync|fork)\s*\(/.test(code);
362
+ // Bundled source = local file read or inline base64/hex (a bare decompress could be on
363
+ // a network stream, so it is a booster, not a source qualifier).
364
+ const hasSource = /\breadFileSync?\s*\(|Buffer\.from\([^)]*['"](?:base64|hex)['"]/.test(code);
365
+ const hasDrop = /\bchmod\w*\s*\(|mode\s*:\s*0o?[0-7]*[1357]|['"][^'"]*\.exe['"]/.test(code);
366
+ const fired = hasCP && hasExec && hasSource && hasDrop;
367
+ return { fired, reason: fired ? 'reads bundled bytes, writes an executable file and spawns it (textual match on an unparsable loader)' : '' };
368
+ }
369
+
370
+ /** Compose a static human-readable reason from the fired signals + boosters. */
371
+ function _buildReason(sig) {
372
+ const boosters = sig.boosters.size ? ` (${Array.from(sig.boosters).join(', ')})` : '';
373
+ return 'reads package-bundled bytes (local read / decompress / inline base64), writes them to an executable file ' +
374
+ '(exec-bit mode, chmod +x, or .exe) and spawns that written path' + boosters;
375
+ }
376
+
377
+ /**
378
+ * Analyze one install-hook-invoked loader for the drop-exec conjunction.
379
+ * @returns {{fired:boolean, reason?:string}|null} null when the file can't be read/resolved
380
+ */
381
+ function analyzeScriptForDropExec(targetPath, file) {
382
+ const rel = _normRel(file);
383
+ const root = path.resolve(targetPath);
384
+ const abs = path.resolve(root, rel);
385
+ // Contain within the package (no ../ traversal out of the scan root).
386
+ if (abs !== root && !abs.startsWith(root + path.sep)) return null;
387
+
388
+ let code;
389
+ try {
390
+ const st = fs.statSync(abs);
391
+ if (!st.isFile() || st.size > MAX_SCRIPT_BYTES) return null;
392
+ code = fs.readFileSync(abs, 'utf8');
393
+ } catch { return null; }
394
+
395
+ const ast = _parse(code);
396
+ if (!ast) return _textualFallback(code);
397
+
398
+ const bindings = _collectBindings(ast);
399
+ const sig = { exec: false, drop: false, source: false, network: false, boosters: new Set() };
400
+ walk.simple(ast, { CallExpression(node) { _classifyCall(node, bindings, sig); } });
401
+
402
+ // Suppress when the loader fetches its bytes from the network: that is the
403
+ // download-and-execute pattern (network egress + URL IOC surface, handled by other
404
+ // detectors), not the stealthy bundled variant this compound targets.
405
+ if (bindings.network || sig.network) return { fired: false };
406
+ if (!(sig.exec && sig.drop && sig.source)) return { fired: false };
407
+ return { fired: true, reason: _buildReason(sig) };
408
+ }
409
+
410
+ /**
411
+ * install_native_drop_exec compound: for each preinstall/install/postinstall that runs
412
+ * `node x.js`, if x.js bundles+drops+executes a native binary, push one CRITICAL threat.
413
+ * Mutates `threats` in place; idempotent; no-op (no findings added) on benign packages.
414
+ *
415
+ * @param {Array<object>} threats - deduplicated, post-scan threats array
416
+ * @param {string} targetPath - scan target directory (where package.json lives)
417
+ * @returns {object|null} the pushed compound threat, or null if nothing fired
418
+ */
419
+ function correlateInstallNativeDropExec(threats, targetPath) {
420
+ if (!Array.isArray(threats) || !targetPath || !acorn) return null;
421
+ if (threats.some(t => t && t.type === 'install_native_drop_exec')) return null; // idempotent
422
+
423
+ let pkg;
424
+ try {
425
+ const p = path.join(targetPath, 'package.json');
426
+ if (!fs.existsSync(p)) return null;
427
+ pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
428
+ } catch { return null; }
429
+
430
+ const scripts = pkg && pkg.scripts;
431
+ if (!scripts || typeof scripts !== 'object') return null;
432
+
433
+ for (const hook of INSTALL_HOOKS) {
434
+ for (const file of extractInvokedScriptFiles(scripts[hook])) {
435
+ const analysis = analyzeScriptForDropExec(targetPath, file);
436
+ if (analysis && analysis.fired) {
437
+ const compound = {
438
+ type: 'install_native_drop_exec',
439
+ severity: 'CRITICAL',
440
+ message: `package.json "${hook}" runs \`node ${file}\`, which ${analysis.reason} — install-time native-binary drop-and-execute: the payload runs on \`npm install\` before any application code, no import required (compound).`,
441
+ file: _normRel(file),
442
+ compound: true,
443
+ count: 1
444
+ };
445
+ threats.push(compound);
446
+ return compound; // one compound per package is enough
447
+ }
448
+ }
449
+ }
450
+ return null;
451
+ }
452
+
453
+ module.exports = {
454
+ correlateInstallNativeDropExec,
455
+ extractInvokedScriptFiles,
456
+ analyzeScriptForDropExec,
457
+ _normRel
458
+ };
package/src/scoring.js CHANGED
@@ -190,7 +190,13 @@ const SINGLE_FIRE_CRITICAL_TYPES = new Set([
190
190
  // dest in the same file (FP≈0 by construction). Floors a lone CRITICAL to 75 so it is
191
191
  // not buried at 25 on PyPI, where a single CRITICAL does not stack co-signals the way
192
192
  // the npm side does (env_access + credential_regex_harvest + ... → 100).
193
- 'crypto_exfil'
193
+ 'crypto_exfil',
194
+ // install_native_drop_exec (COMPOUND-020): an install hook drops a package-bundled
195
+ // native binary (exec-bit write / chmod +x) and spawns it, with no network fetch
196
+ // (jscrambler@8.14.0). FP≈0 by construction, so it carries the same single-fire weight
197
+ // as the IOC/hash matches and gyp_phantom_exec above — a lone verdict must not be
198
+ // buried at 25 (the loader trips no other rule to stack with).
199
+ 'install_native_drop_exec'
194
200
  ]);
195
201
  const SINGLE_FIRE_CRITICAL_FLOOR = 75;
196
202
  const SINGLE_FIRE_MIN_SEVERITY_RANK = 2; // HIGH
@@ -452,7 +458,11 @@ const DIST_EXEMPT_TYPES = new Set([
452
458
  'lifecycle_file_exec', // B6: lifecycle → malicious file compound
453
459
  'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env',
454
460
  // RT-C1: Boundary-squat compound is never coincidental (dep declared AND require()d)
455
- 'dependency_typosquat_require'
461
+ 'dependency_typosquat_require',
462
+ // BINSRC-001: a binary file disguised as .js SPECIFICALLY hides in dist/ (that is why the
463
+ // scanner descends there). The dist/ downgrade assumes benign bundler output — a genuinely
464
+ // binary file never is, so it must never be reduced.
465
+ 'binary_masquerading_as_source'
456
466
  // P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
457
467
  // fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
458
468
  // fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
@@ -535,7 +545,10 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
535
545
  // no legitimate code reconstructs env var names from character codes. Injected files
536
546
  // (router_init.js) are unreachable via require/import but execute via lifecycle hooks
537
547
  // or optionalDependencies with prepare scripts.
538
- 'env_charcode_reconstruction' // AST-018 — fromCharCode + process.env[computed]
548
+ 'env_charcode_reconstruction', // AST-018 — fromCharCode + process.env[computed]
549
+ // BINSRC-001: a binary blob disguised as .js is read as bytes, never require()d, so it is
550
+ // always "unreachable" via the import graph — the wrong lens for a non-JS carrier.
551
+ 'binary_masquerading_as_source'
539
552
  ]);
540
553
 
541
554
  // ============================================
@@ -19,6 +19,7 @@
19
19
 
20
20
  const path = require('path');
21
21
  const { isDevFile, findJsFiles, forEachSafeFile } = require('../utils.js');
22
+ const { looksBinaryString } = require('./binary-sniff.js');
22
23
 
23
24
  /**
24
25
  * Shared scanner wrapper: iterates JS files, runs analyzeFileFn on original + deobfuscated code,
@@ -56,6 +57,12 @@ function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
56
57
  }).join('\n');
57
58
  }
58
59
 
60
+ // Ingestion guard: a source-extension file whose bytes are actually binary (a payload
61
+ // carrier disguised as source) is not JS — skip the parse instead of handing acorn
62
+ // megabytes of binary that yield no AST and burn the parse budget. binary-source.js
63
+ // reports the finding (it also reaches dist/ where this shared path does not).
64
+ if (looksBinaryString(effectiveContent)) return;
65
+
59
66
  // Analyze original code first (preserves obfuscation-detection rules)
60
67
  const fileThreats = analyzeFileFn(effectiveContent, file, targetPath);
61
68
  threats.push(...fileThreats);
Binary file
package/stats.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "_comment": "GENERATED by scripts/collect-stats.js — do not edit by hand. Run `npm run docs:stats`.",
3
- "version": "2.11.164",
4
- "scanners": 21,
5
- "rulesTotal": 275,
6
- "rulesCore": 270,
3
+ "version": "2.11.167",
4
+ "scanners": 22,
5
+ "rulesTotal": 277,
6
+ "rulesCore": 272,
7
7
  "rulesParanoid": 5,
8
- "compound": 20,
8
+ "compound": 21,
9
9
  "astRules": 97,
10
- "scannerFiles": 34,
10
+ "scannerFiles": 36,
11
11
  "astDetectors": 15,
12
12
  "moduleGraph": 9,
13
13
  "pythonAstDetectors": 6,
14
- "testFiles": 150,
15
- "tests": 4540
14
+ "testFiles": 152,
15
+ "tests": 4559
16
16
  }