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 +6 -6
- package/package.json +1 -1
- package/{self-scan-v2.11.166.json → self-scan-v2.11.168.json} +32 -4
- package/src/monitor/ingestion.js +41 -1
- package/src/pipeline/executor.js +11 -4
- package/src/response/playbooks.js +4 -0
- package/src/rules/index.js +13 -0
- package/src/scanner/binary-source.js +95 -0
- package/src/scoring.js +9 -2
- package/src/shared/analyze-helper.js +7 -0
- package/src/shared/binary-sniff.js +0 -0
- package/stats.json +7 -7
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-->
|
|
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-->
|
|
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-->
|
|
215
|
+
### <!--stat:rulesTotal-->277<!--/stat:rulesTotal--> detection rules
|
|
216
216
|
|
|
217
|
-
All rules (<!--stat:rulesCore-->
|
|
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-->
|
|
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-->
|
|
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
|
"target": "node_modules",
|
|
3
|
-
"timestamp": "2026-07-
|
|
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":
|
|
2095
|
+
"total": 92,
|
|
2074
2096
|
"critical": 7,
|
|
2075
2097
|
"high": 9,
|
|
2076
|
-
"medium":
|
|
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":
|
|
2702
|
+
"low": 75
|
|
2675
2703
|
},
|
|
2676
2704
|
"perceivedFlagged": 0
|
|
2677
2705
|
},
|
package/src/monitor/ingestion.js
CHANGED
|
@@ -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,
|
package/src/pipeline/executor.js
CHANGED
|
@@ -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.',
|
package/src/rules/index.js
CHANGED
|
@@ -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'
|
|
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.
|
|
4
|
-
"scanners":
|
|
5
|
-
"rulesTotal":
|
|
6
|
-
"rulesCore":
|
|
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":
|
|
10
|
+
"scannerFiles": 36,
|
|
11
11
|
"astDetectors": 15,
|
|
12
12
|
"moduleGraph": 9,
|
|
13
13
|
"pythonAstDetectors": 6,
|
|
14
|
-
"testFiles":
|
|
15
|
-
"tests":
|
|
14
|
+
"testFiles": 152,
|
|
15
|
+
"tests": 4561
|
|
16
16
|
}
|