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