muaddib-scanner 2.11.163 → 2.11.166

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-->21<!--/stat:scanners--> parallel scanners** (<!--stat:rulesTotal-->276<!--/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
 
@@ -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-->276<!--/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-->271<!--/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-->4550<!--/stat:tests--> tests** across <!--stat:testFiles-->151<!--/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-->276<!--/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.163",
3
+ "version": "2.11.166",
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-10T08:26:38.815Z",
3
+ "timestamp": "2026-07-11T19:59:54.765Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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
@@ -24,6 +24,7 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
+ const readline = require('readline');
27
28
  const { isMainThread, threadId } = require('worker_threads');
28
29
  const { sanitizePackageName } = require('../shared/download.js');
29
30
 
@@ -1054,6 +1055,12 @@ const MAX_SCAN_LEDGER = (() => {
1054
1055
  })();
1055
1056
  const SCAN_LEDGER_COMPACT_INTERVAL = 2000;
1056
1057
  let _scanLedgerAppendedSinceCompact = 0;
1058
+ // Async-compaction coordination. `_scanLedgerCompacting` guards re-entrancy (a second
1059
+ // trigger while a compaction is in flight is skipped — the cap is soft, the next
1060
+ // interval retries). The promise lets tests + graceful shutdown await an in-flight
1061
+ // compaction settling. See _compactScanLedgerJsonl (async, streaming, race-safe).
1062
+ let _scanLedgerCompacting = false;
1063
+ let _scanLedgerCompactionPromise = Promise.resolve();
1057
1064
 
1058
1065
  // Terminal outcomes a dequeued package can reach. Unknown values normalize to 'clean'
1059
1066
  // so a typo at a call site can never crash the pipeline.
@@ -1124,7 +1131,10 @@ function appendScanLedger(e) {
1124
1131
  _scanLedgerAppendedSinceCompact++;
1125
1132
  if (_scanLedgerAppendedSinceCompact >= SCAN_LEDGER_COMPACT_INTERVAL) {
1126
1133
  _scanLedgerAppendedSinceCompact = 0;
1127
- _compactScanLedgerJsonl();
1134
+ // Fire the async compaction (non-blocking — keeps the RSS-breaker poll loop
1135
+ // reactive). Guarded + fully caught internally so it never rejects; stored so
1136
+ // tests and graceful shutdown can await an in-flight compaction settling.
1137
+ _scanLedgerCompactionPromise = _compactScanLedgerJsonl();
1128
1138
  }
1129
1139
  } catch (err) {
1130
1140
  if (err.code === 'EROFS' || err.code === 'EACCES' || err.code === 'EPERM') return;
@@ -1140,31 +1150,99 @@ function appendScanLedger(e) {
1140
1150
  * Compact the scan-ledger JSONL: keep only the most recent MAX_SCAN_LEDGER entries.
1141
1151
  * No-op when already under cap. Streams (never loads the whole file at once).
1142
1152
  */
1143
- function _compactScanLedgerJsonl() {
1144
- // Leading loop-stall suspect (see require note above): count + parse + re-stringify
1145
- // + join + 127MB write + rename, all synchronous over ~500K entries. Instrument the
1146
- // whole body (the count pass can block too) so a stall NAMES 'compact:scan-ledger'.
1147
- _runInstrumented('compact:scan-ledger', { cap: MAX_SCAN_LEDGER }, () => {
1148
- try {
1149
- const total = _countJsonlLines(SCAN_LEDGER_FILE);
1150
- if (total <= MAX_SCAN_LEDGER) return;
1151
- const toDrop = total - MAX_SCAN_LEDGER;
1152
- let skipped = 0;
1153
- const kept = [];
1154
- _iterateJsonlSync(SCAN_LEDGER_FILE, (entry) => {
1155
- if (skipped < toDrop) { skipped++; return; }
1156
- kept.push(JSON.stringify(entry));
1157
- });
1158
- const tmpFile = SCAN_LEDGER_FILE + '.tmp';
1159
- fs.writeFileSync(tmpFile, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
1160
- fs.renameSync(tmpFile, SCAN_LEDGER_FILE);
1161
- console.log(`[MONITOR] COMPACT scan-ledger: ${total} -> ${kept.length} entries`);
1162
- } catch (err) {
1163
- console.error(`[MONITOR] Scan-ledger compaction failed: ${err.message}`);
1164
- }
1153
+ // Stream-count newline-terminated lines in [0, endByte) without loading the file or
1154
+ // parsing JSON. Async yields between 64KB chunks so it never wedges the poll loop.
1155
+ function _streamCountLines(filePath, endByte) {
1156
+ return new Promise((resolve, reject) => {
1157
+ if (!endByte || endByte <= 0) { resolve(0); return; }
1158
+ let count = 0;
1159
+ const rs = fs.createReadStream(filePath, { start: 0, end: endByte - 1 });
1160
+ rs.on('error', (e) => ((e && e.code === 'ENOENT') ? resolve(0) : reject(e)));
1161
+ rs.on('data', (buf) => { for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) count++; });
1162
+ rs.on('end', () => resolve(count));
1163
+ });
1164
+ }
1165
+
1166
+ // Stream [0, endByte): skip the first `skip` lines, copy the rest VERBATIM (no parse,
1167
+ // no re-stringify) to tmp. Bounded memory: one line at a time + write backpressure
1168
+ // (pause the reader until the write buffer drains). Returns the kept line count.
1169
+ function _streamKeepTailToTmp(filePath, skip, endByte, tmpFile) {
1170
+ return new Promise((resolve, reject) => {
1171
+ let seen = 0, kept = 0, failed = false, draining = false;
1172
+ const rs = fs.createReadStream(filePath, { encoding: 'utf8', start: 0, end: endByte - 1 });
1173
+ const ws = fs.createWriteStream(tmpFile, { encoding: 'utf8' }); // truncates any orphan tmp
1174
+ const rl = readline.createInterface({ input: rs, crlfDelay: Infinity });
1175
+ const fail = (e) => { if (failed) return; failed = true; try { rl.close(); } catch { /* */ } try { rs.destroy(); } catch { /* */ } try { ws.destroy(); } catch { /* */ } reject(e); };
1176
+ rs.on('error', fail); ws.on('error', fail);
1177
+ rl.on('line', (line) => {
1178
+ if (seen++ < skip) return;
1179
+ if (!line) return;
1180
+ const ok = ws.write(line + '\n');
1181
+ kept++;
1182
+ // Backpressure: pause the reader until the write buffer drains. The `draining`
1183
+ // guard is required — readline emits lines ALREADY buffered from the current
1184
+ // chunk synchronously even after pause(), so without it each such line would
1185
+ // stack another 'drain' listener (MaxListenersExceededWarning + redundant resumes).
1186
+ if (!ok && !draining) {
1187
+ draining = true;
1188
+ rl.pause();
1189
+ ws.once('drain', () => { draining = false; rl.resume(); });
1190
+ }
1191
+ });
1192
+ rl.on('close', () => { if (!failed) ws.end(() => resolve(kept)); });
1165
1193
  });
1166
1194
  }
1167
1195
 
1196
+ // Async, streaming, race-safe compaction of the scan-ledger. Replaces the old
1197
+ // SYNCHRONOUS 127MB rewrite that wedged the main-thread poll loop for minutes — and
1198
+ // because the RSS circuit breaker runs on that same loop, a wedged loop is a BLIND
1199
+ // breaker → RSS climbs unchecked → cgroup OOM. Design (why it can't lose appends):
1200
+ // • two async streaming passes over the immutable snapshot [0, preSize): count, then
1201
+ // copy the last MAX lines to a .tmp. Bounded memory (no 500K-entry array / 127MB
1202
+ // join). Non-blocking: appends keep flowing to the LIVE file during these passes.
1203
+ // • the ONLY destructive step — fold the live tail [preSize, nowSize) onto .tmp, then
1204
+ // rename .tmp over the ledger — runs with NO `await` between the tail read and the
1205
+ // rename, so it is ATOMIC w.r.t. the single-threaded event loop: no concurrent
1206
+ // appendScanLedger can interleave, hence the rename can never clobber an un-folded
1207
+ // append. (appendFileSync writes whole lines, so preSize is always line-aligned.)
1208
+ // • crash-safe: the live ledger is untouched until the atomic rename; a crash leaves
1209
+ // it intact plus a stale .tmp (overwritten next run). No sidecar, no recovery hook.
1210
+ // Never throws (fire-and-forget from appendScanLedger).
1211
+ async function _compactScanLedgerJsonl() {
1212
+ if (_scanLedgerCompacting) return; // re-entrancy guard
1213
+ _scanLedgerCompacting = true;
1214
+ const tmpFile = SCAN_LEDGER_FILE + '.tmp';
1215
+ try {
1216
+ if (!fs.existsSync(SCAN_LEDGER_FILE)) return;
1217
+ const preSize = fs.statSync(SCAN_LEDGER_FILE).size; // immutable snapshot boundary
1218
+ if (preSize === 0) return;
1219
+ const total = await _streamCountLines(SCAN_LEDGER_FILE, preSize);
1220
+ if (total <= MAX_SCAN_LEDGER) return;
1221
+ const toDrop = total - MAX_SCAN_LEDGER;
1222
+ const t0 = Date.now();
1223
+ const kept = await _streamKeepTailToTmp(SCAN_LEDGER_FILE, toDrop, preSize, tmpFile);
1224
+ // ── SYNCHRONOUS finalize (atomic w.r.t. the event loop — no await below) ──
1225
+ const nowSize = fs.statSync(SCAN_LEDGER_FILE).size;
1226
+ let foldedBytes = 0;
1227
+ if (nowSize > preSize) { // appends landed during the async passes
1228
+ const fd = fs.openSync(SCAN_LEDGER_FILE, 'r');
1229
+ try {
1230
+ foldedBytes = nowSize - preSize;
1231
+ const tail = Buffer.allocUnsafe(foldedBytes);
1232
+ fs.readSync(fd, tail, 0, foldedBytes, preSize);
1233
+ fs.appendFileSync(tmpFile, tail); // preserve concurrent appends (whole lines)
1234
+ } finally { fs.closeSync(fd); }
1235
+ }
1236
+ fs.renameSync(tmpFile, SCAN_LEDGER_FILE); // atomic swap
1237
+ console.log(`[MONITOR] COMPACT scan-ledger: ${total} -> ${kept} entries (async, ${Date.now() - t0}ms, +${foldedBytes}B live tail)`);
1238
+ } catch (err) {
1239
+ console.error(`[MONITOR] Scan-ledger compaction failed: ${err.message}`);
1240
+ try { fs.unlinkSync(tmpFile); } catch { /* nothing to clean */ }
1241
+ } finally {
1242
+ _scanLedgerCompacting = false;
1243
+ }
1244
+ }
1245
+
1168
1246
  /** Stream the scan-ledger into an array (tests + Phase 0b rollup). */
1169
1247
  function loadScanLedger() {
1170
1248
  const entries = [];
@@ -1880,6 +1958,7 @@ module.exports = {
1880
1958
  loadScanLedger,
1881
1959
  computeLedgerRollup,
1882
1960
  _compactScanLedgerJsonl,
1961
+ _whenScanLedgerCompactionSettles: () => _scanLedgerCompactionPromise,
1883
1962
  getDetectionStats,
1884
1963
  runStateMigrations,
1885
1964
  // Internal — exported for tests and for the daemon hourly housekeeping.
@@ -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,10 @@ 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
+
1081
1085
  string_mutation_obfuscation:
1082
1086
  'HAUTE: Chaine de .replace() reconstruisant des noms d\'API dangereuses (leet-speak). ' +
1083
1087
  'Technique d\'evasion par substitution de caracteres. Decoder la chaine finale. Supprimer si malveillant.',
@@ -828,6 +828,20 @@ 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
+ },
831
845
  crypto_exfil: {
832
846
  id: 'MUADDIB-COMPOUND-019',
833
847
  name: 'Hybrid-Crypto Credential Exfiltration',
@@ -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
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.163",
3
+ "version": "2.11.166",
4
4
  "scanners": 21,
5
- "rulesTotal": 275,
6
- "rulesCore": 270,
5
+ "rulesTotal": 276,
6
+ "rulesCore": 271,
7
7
  "rulesParanoid": 5,
8
- "compound": 20,
8
+ "compound": 21,
9
9
  "astRules": 97,
10
- "scannerFiles": 34,
10
+ "scannerFiles": 35,
11
11
  "astDetectors": 15,
12
12
  "moduleGraph": 9,
13
13
  "pythonAstDetectors": 6,
14
- "testFiles": 150,
15
- "tests": 4540
14
+ "testFiles": 151,
15
+ "tests": 4550
16
16
  }