muaddib-scanner 2.11.166 → 2.11.168

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-->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)).
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-->276<!--/stat:rulesTotal--> detection rules
215
+ ### <!--stat:rulesTotal-->277<!--/stat:rulesTotal--> detection rules
216
216
 
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.
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-->4550<!--/stat:tests--> tests** across <!--stat:testFiles-->151<!--/stat:testFiles--> modular test files
348
+ - **<!--stat:tests-->4561<!--/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-->276<!--/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.166",
3
+ "version": "2.11.168",
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-11T19:59:54.765Z",
3
+ "timestamp": "2026-07-11T21:48:05.101Z",
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
  },
@@ -383,6 +383,31 @@ function parsePyPIRss(xml) {
383
383
  const RECENT_PUBLISH_WINDOW_MS = 24 * 60 * 60 * 1000;
384
384
  const RECENT_PUBLISH_MAX = 5;
385
385
 
386
+ // Burst versions beyond RECENT_PUBLISH_MAX are dropped by recency. But a buried burst
387
+ // version that ADDS an install hook the latest lacks is exactly the "bury the payload in a
388
+ // publish burst" evasion (jscrambler@8.14.0 added a preinstall absent from 8.13.0). We keep
389
+ // such signal-bearing versions for scanning instead of dropping them — bounded by
390
+ // BURST_SIGNAL_KEEP_MAX so an adversarial 1000-version burst can't blow the enqueue list.
391
+ const INSTALL_HOOK_KEYS = ['preinstall', 'install', 'postinstall'];
392
+ const BURST_SIGNAL_KEEP_MAX = 10;
393
+
394
+ /**
395
+ * True when `scripts` carries an install hook (preinstall/install/postinstall) that the
396
+ * baseline (latest) manifest lacks or defines differently — a cheap manifest-level delta
397
+ * that flags a burst version worth scanning even past the recency cap.
398
+ * @param {Object} scripts - candidate version's package.json scripts
399
+ * @param {Object} baseScripts - the latest (baseline) version's scripts
400
+ * @returns {boolean}
401
+ */
402
+ function _hasInstallHookDelta(scripts, baseScripts) {
403
+ const s = scripts || {};
404
+ const b = baseScripts || {};
405
+ for (const h of INSTALL_HOOK_KEYS) {
406
+ if (s[h] && s[h] !== b[h]) return true;
407
+ }
408
+ return false;
409
+ }
410
+
386
411
  /**
387
412
  * Pure function: pick the most-recently-published version from a packument and
388
413
  * return its metadata, plus context useful for ATO detection.
@@ -460,20 +485,35 @@ function selectMostRecentVersion(packument, options = {}) {
460
485
  // so a 96-version Miasma burst is distinguishable from a legit 3-5 patch-release day (the
461
486
  // capped list alone tops out at maxRecent+1 and can't tell them apart).
462
487
  result.recentWindowCount = 1; // includes the most-recent version itself
488
+ let signalKeeps = 0; // burst versions kept past the recency cap for an install-hook delta
463
489
  if (versionTimes.length > 1) {
464
490
  const cutoff = versionTimes[0][1] - recentWindowMs;
465
491
  for (let i = 1; i < versionTimes.length; i++) {
466
492
  const [v, ts] = versionTimes[i];
467
493
  if (ts < cutoff) break; // sorted desc, so once we cross the cutoff we're done
468
494
  result.recentWindowCount++;
495
+ const vData = versions[v];
469
496
  if (result.recentVersions.length >= maxRecent) {
497
+ // Past the recency cap → normally a coverage loss. But KEEP a version that adds an
498
+ // install hook the latest lacks (the "bury the payload in a publish burst" evasion,
499
+ // jscrambler shape), bounded by BURST_SIGNAL_KEEP_MAX so a burst can't blow the list.
500
+ if (vData && signalKeeps < BURST_SIGNAL_KEEP_MAX && _hasInstallHookDelta(vData.scripts, result.scripts)) {
501
+ signalKeeps++;
502
+ result.recentVersions.push({
503
+ version: vData.version || v,
504
+ tarball: (vData.dist && vData.dist.tarball) || null,
505
+ unpackedSize: (vData.dist && vData.dist.unpackedSize) || 0,
506
+ scripts: vData.scripts || {},
507
+ keptForSignal: true,
508
+ });
509
+ continue;
510
+ }
470
511
  // Burst beyond the enqueue cap: collect the version so the caller ledgers it as a
471
512
  // coverage loss (it is never enqueued/scanned). Keeps a Miasma-style burst that
472
513
  // outruns maxRecent visible instead of vanishing silently (CLAUDE.md "no silent caps").
473
514
  result.droppedBurstVersions.push(v);
474
515
  continue; // enqueue list capped; count continues
475
516
  }
476
- const vData = versions[v];
477
517
  if (!vData) continue;
478
518
  result.recentVersions.push({
479
519
  version: vData.version || v,
@@ -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,
@@ -1082,6 +1082,10 @@ const PLAYBOOKS = {
1082
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
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
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
+
1085
1089
  string_mutation_obfuscation:
1086
1090
  'HAUTE: Chaine de .replace() reconstruisant des noms d\'API dangereuses (leet-speak). ' +
1087
1091
  'Technique d\'evasion par substitution de caracteres. Decoder la chaine finale. Supprimer si malveillant.',
@@ -842,6 +842,19 @@ const RULES = {
842
842
  ],
843
843
  mitre: 'T1195.002'
844
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
+ },
845
858
  crypto_exfil: {
846
859
  id: 'MUADDIB-COMPOUND-019',
847
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 };
package/src/scoring.js CHANGED
@@ -458,7 +458,11 @@ const DIST_EXEMPT_TYPES = new Set([
458
458
  'lifecycle_file_exec', // B6: lifecycle → malicious file compound
459
459
  'lifecycle_dataflow', 'lifecycle_dangerous_exec', 'obfuscated_lifecycle_env',
460
460
  // RT-C1: Boundary-squat compound is never coincidental (dep declared AND require()d)
461
- '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'
462
466
  // P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
463
467
  // fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
464
468
  // fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
@@ -541,7 +545,10 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
541
545
  // no legitimate code reconstructs env var names from character codes. Injected files
542
546
  // (router_init.js) are unreachable via require/import but execute via lifecycle hooks
543
547
  // or optionalDependencies with prepare scripts.
544
- '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'
545
552
  ]);
546
553
 
547
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.166",
4
- "scanners": 21,
5
- "rulesTotal": 276,
6
- "rulesCore": 271,
3
+ "version": "2.11.168",
4
+ "scanners": 22,
5
+ "rulesTotal": 277,
6
+ "rulesCore": 272,
7
7
  "rulesParanoid": 5,
8
8
  "compound": 21,
9
9
  "astRules": 97,
10
- "scannerFiles": 35,
10
+ "scannerFiles": 36,
11
11
  "astDetectors": 15,
12
12
  "moduleGraph": 9,
13
13
  "pythonAstDetectors": 6,
14
- "testFiles": 151,
15
- "tests": 4550
14
+ "testFiles": 152,
15
+ "tests": 4561
16
16
  }