muaddib-scanner 2.11.135 → 2.11.138
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 +4 -4
- package/package.json +1 -1
- package/{self-scan-v2.11.135.json → self-scan-v2.11.138.json} +1 -1
- package/src/monitor/daemon.js +9 -1
- package/src/monitor/queue.js +19 -14
- package/src/monitor/scan-queue.js +32 -4
- package/src/rules/index.js +26 -0
- package/src/scanner/ast-detectors/anti-evasion.js +90 -0
- package/src/scanner/ast-detectors/handle-member-expression.js +19 -0
- package/src/scanner/ast-detectors/handle-post-walk.js +35 -0
- package/src/scanner/ast-detectors/handle-variable-declarator.js +8 -0
- package/src/scanner/ast.js +9 -0
- package/src/shared/extract-pool-worker.js +30 -0
- package/src/shared/extract-pool.js +217 -0
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 **20 parallel scanners** (
|
|
33
|
+
MUAD'DIB combines **20 parallel scanners** (274 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring** (17 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 Metrics](#evaluation-metrics) → ML Classifier section).
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -202,9 +202,9 @@ muaddib replay # Ground truth validation (90/94 TPR@3, v2.11
|
|
|
202
202
|
| Python Source (PYSRC) | Import-time / install-time RCE patterns in `__init__.py` / `setup.py` (v2.11.41 — closes TrapDoor PyPI gap) |
|
|
203
203
|
| Python AST (PYAST) | Tree-sitter-Python AST with taint-aware detectors (v2.11.42+) |
|
|
204
204
|
|
|
205
|
-
###
|
|
205
|
+
### 274 detection rules
|
|
206
206
|
|
|
207
|
-
All rules (
|
|
207
|
+
All rules (269 RULES + 5 PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v211117) for the complete rules reference.
|
|
208
208
|
|
|
209
209
|
### Detected campaigns
|
|
210
210
|
|
|
@@ -401,7 +401,7 @@ npm test
|
|
|
401
401
|
- [Documentation Index](docs/INDEX.md) - All documentation in one place
|
|
402
402
|
- [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
|
|
403
403
|
- [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
|
|
404
|
-
- [Security Policy](SECURITY.md) - Detection rules reference (
|
|
404
|
+
- [Security Policy](SECURITY.md) - Detection rules reference (269 rules)
|
|
405
405
|
- [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
|
|
406
406
|
- [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
|
|
407
407
|
|
package/package.json
CHANGED
package/src/monitor/daemon.js
CHANGED
|
@@ -635,7 +635,13 @@ function handleMemoryPressure(level, ratio, rssRatio, recentlyScanned, downloads
|
|
|
635
635
|
// first (newest survive — most likely to still exist for re-scan), protected only as
|
|
636
636
|
// a last resort, and LEDGERS every drop. Closes the v2.10.88 gap where the raw
|
|
637
637
|
// splice(0,n) silently dropped protected scans (CLAUDE.md "ne jamais perdre de scan").
|
|
638
|
-
|
|
638
|
+
// IOC-aware anti-spill: never shed a queued name already known-malicious in the IOC DB.
|
|
639
|
+
// loadCachedIOCs() is a 10s-throttled singleton the daemon keeps warm (queue.js calls it
|
|
640
|
+
// per scan), so this is a ref-return — not a fresh alloc — even inside the reclaim path.
|
|
641
|
+
// Best-effort: on any failure the index is null and eviction falls back to prior behavior.
|
|
642
|
+
let _iocIndex = null;
|
|
643
|
+
try { _iocIndex = require('../ioc/updater.js').loadCachedIOCs(); } catch { /* degrade to prior behavior */ }
|
|
644
|
+
const { dropped, droppedProtected, spilled } = evictFromScanQueueBulk(scanQueue, EMERGENCY_QUEUE_KEEP, 'mem_emergency', null, { iocIndex: _iocIndex });
|
|
639
645
|
summary.queueDropped = dropped;
|
|
640
646
|
summary.queueDroppedProtected = droppedProtected;
|
|
641
647
|
summary.queueSpilled = spilled || 0;
|
|
@@ -1040,6 +1046,8 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
1040
1046
|
} catch (e) {
|
|
1041
1047
|
console.error(`[MONITOR] Shutdown in-flight spill failed: ${e.message}`);
|
|
1042
1048
|
}
|
|
1049
|
+
// Terminate the off-thread extraction worker pool (bounded-resource teardown).
|
|
1050
|
+
try { await require('../shared/extract-pool.js').destroyExtractPool(); } catch { /* best-effort */ }
|
|
1043
1051
|
// Persist remaining queue items so they survive the restart
|
|
1044
1052
|
persistQueue(scanQueue, state);
|
|
1045
1053
|
saveRecentlyScanned(recentlyScanned); // Persist dedup set too (avoid re-scan storm on restart)
|
package/src/monitor/queue.js
CHANGED
|
@@ -12,7 +12,8 @@ const os = require('os');
|
|
|
12
12
|
const { Worker } = require('worker_threads');
|
|
13
13
|
const { runSandbox, tryAcquireSandboxSlot } = require('../sandbox/index.js');
|
|
14
14
|
const { sendWebhook } = require('../webhook.js');
|
|
15
|
-
const { downloadToFile, extractArchive,
|
|
15
|
+
const { downloadToFile, extractArchive, sanitizePackageName } = require('../shared/download.js');
|
|
16
|
+
const { extractInPool } = require('../shared/extract-pool.js');
|
|
16
17
|
const { MAX_TARBALL_SIZE, getMaxFileSize } = require('../shared/constants.js');
|
|
17
18
|
const { acquireRegistrySlot, releaseRegistrySlot, awaitRateToken: awaitRateTokenForWorker, signal429: signal429ForWorker } = require('../shared/http-limiter.js');
|
|
18
19
|
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
@@ -179,21 +180,25 @@ const STATIC_SCAN_TIMEOUT_MS = 45_000; // 45s for static analysis only
|
|
|
179
180
|
const LARGE_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
180
181
|
const RECENTLY_SCANNED_MAX = 50_000; // FIFO cap for the dedup Set (P0c — bounded resource)
|
|
181
182
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
// the on-disk tarball size (reliable, unlike
|
|
193
|
-
//
|
|
183
|
+
// Event-loop-stall fix (2026-06-30): ALL archive extraction runs off the main
|
|
184
|
+
// thread via a persistent worker pool (src/shared/extract-pool.js). The previous
|
|
185
|
+
// OOM fix only offloaded archives > 4MB (spawn-per-call) and extracted everything
|
|
186
|
+
// smaller INLINE — but adm-zip extractAllTo is fully synchronous and slow on
|
|
187
|
+
// many-file trees regardless of size, so those inline extractions still wedged the
|
|
188
|
+
// loop for seconds-to-minutes (data/loop-stalls.jsonl: extract:prework up to 229s;
|
|
189
|
+
// a 2MB many-file package blocked 5s+), starving the RSS breaker / memory governor
|
|
190
|
+
// / EMERGENCY purge (all main-thread timers) → restart. The pool's reusable workers
|
|
191
|
+
// pay no per-package spawn cost, so there is no longer a reason to extract inline.
|
|
192
|
+
// MUADDIB_INLINE_EXTRACT_MB keeps a small inline escape hatch (default 0 = always
|
|
193
|
+
// pool). compressedSize is the on-disk tarball size (reliable, unlike registry
|
|
194
|
+
// unpackedSize metadata).
|
|
195
|
+
const INLINE_EXTRACT_MAX_BYTES = (parseInt(process.env.MUADDIB_INLINE_EXTRACT_MB, 10) || 0) * 1024 * 1024;
|
|
196
|
+
|
|
197
|
+
// Always returns a Promise so call sites uniformly `await`. Pool for anything above
|
|
198
|
+
// the (default 0) inline window; the tiny inline path stays available for operators.
|
|
194
199
|
function extractGated(archivePath, destDir, compressedSize) {
|
|
195
200
|
if (compressedSize > INLINE_EXTRACT_MAX_BYTES) {
|
|
196
|
-
return
|
|
201
|
+
return extractInPool(archivePath, destDir);
|
|
197
202
|
}
|
|
198
203
|
return Promise.resolve(extractArchive(archivePath, destDir));
|
|
199
204
|
}
|
|
@@ -32,6 +32,20 @@ function _isProtected(item) {
|
|
|
32
32
|
return !!(item && (item.isIOCMatch || item.isBurst || item.firstPublish || item.atoSignal || item.isATOBurstExtra || item.interrupted));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// IOC-aware anti-spill (2026-07, @longzy DPRK campaign post-mortem): a queued package whose
|
|
36
|
+
// NAME is already in the IOC database is known-malicious, and confirming it is a cheap name
|
|
37
|
+
// lookup — so it must never be shed under memory pressure, even when its enqueue-time
|
|
38
|
+
// `isIOCMatch` flag is stale (the IOC DB refreshed while the item sat in the backlog) or was
|
|
39
|
+
// version-gated. Name-level: a wildcard (all-versions-malicious) OR any specific-version entry
|
|
40
|
+
// counts. Best-effort — a missing/malformed index yields false (the exact prior behavior).
|
|
41
|
+
function _isIOCKnownByName(item, iocIndex) {
|
|
42
|
+
if (!item || !item.name || !iocIndex) return false;
|
|
43
|
+
const wild = iocIndex.wildcardPackages;
|
|
44
|
+
const pkgs = iocIndex.packagesMap;
|
|
45
|
+
return !!((wild && typeof wild.has === 'function' && wild.has(item.name)) ||
|
|
46
|
+
(pkgs && typeof pkgs.has === 'function' && pkgs.has(item.name)));
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
// How far from the head we scan for an unprotected victim. Protected items are a small
|
|
36
50
|
// fraction of the flood, so a victim is almost always found within a few slots; the bound
|
|
37
51
|
// keeps eviction O(window) under sustained overflow (CLAUDE.md §2 bounded resources).
|
|
@@ -113,18 +127,32 @@ function enqueueScan(scanQueue, item, stats, max = MAX_SCAN_QUEUE) {
|
|
|
113
127
|
* the daemon (which holds the same array reference) sees the mutation. Best-effort ledger;
|
|
114
128
|
* never throws. `ledgerFn` is injectable for tests; defaults to state.appendScanLedger.
|
|
115
129
|
*
|
|
130
|
+
* `opts.iocIndex` (optional {packagesMap, wildcardPackages}) additionally protects any queued
|
|
131
|
+
* NAME already in the IOC DB from being shed — a known-malicious package must never be lost to
|
|
132
|
+
* memory pressure (IOC-aware anti-spill). Injected by the daemon (the live 10s-singleton), not
|
|
133
|
+
* auto-loaded here, so callers that omit it keep the exact prior behavior.
|
|
134
|
+
*
|
|
116
135
|
* @returns {{dropped:number, droppedProtected:number}}
|
|
117
136
|
*/
|
|
118
|
-
function evictFromScanQueueBulk(scanQueue, targetKeep, source = 'bulk_evict', ledgerFn = null) {
|
|
137
|
+
function evictFromScanQueueBulk(scanQueue, targetKeep, source = 'bulk_evict', ledgerFn = null, opts = {}) {
|
|
119
138
|
const before = scanQueue.length;
|
|
120
139
|
const keep = Math.max(0, targetKeep | 0);
|
|
121
140
|
if (before <= keep) return { dropped: 0, droppedProtected: 0 };
|
|
122
141
|
const toDrop = before - keep;
|
|
123
142
|
|
|
124
|
-
//
|
|
143
|
+
// IOC-aware anti-spill: on top of the standard _isProtected classes, protect any queued
|
|
144
|
+
// NAME already in the IOC DB. The index is INJECTED (daemon passes the live 10s-singleton;
|
|
145
|
+
// tests pass a fabricated one) — no auto-load here, so callers that omit it keep the exact
|
|
146
|
+
// prior behavior (and unit paths don't drag in the 237MB DB).
|
|
147
|
+
const iocIndex = opts.iocIndex || null;
|
|
148
|
+
const isKept = iocIndex
|
|
149
|
+
? (item) => _isProtected(item) || _isIOCKnownByName(item, iocIndex)
|
|
150
|
+
: _isProtected;
|
|
151
|
+
|
|
152
|
+
// Victim set: oldest unkept first, then (only if short) oldest kept item.
|
|
125
153
|
const dropSet = new Set();
|
|
126
154
|
for (let i = 0; i < before && dropSet.size < toDrop; i++) {
|
|
127
|
-
if (!
|
|
155
|
+
if (!isKept(scanQueue[i])) dropSet.add(i);
|
|
128
156
|
}
|
|
129
157
|
let droppedProtected = 0;
|
|
130
158
|
if (dropSet.size < toDrop) {
|
|
@@ -172,7 +200,7 @@ function evictFromScanQueueBulk(scanQueue, targetKeep, source = 'bulk_evict', le
|
|
|
172
200
|
appendLedger({
|
|
173
201
|
name: item.name, version: item.version, ecosystem: item.ecosystem,
|
|
174
202
|
outcome: spilled ? 'spilled' : 'dropped',
|
|
175
|
-
source: (
|
|
203
|
+
source: (isKept(item) ? `${source}_protected` : source) + (spilled ? '_spill' : ''),
|
|
176
204
|
// AUDIT-A1 observability: record whether a DROPPED item was a first-publish
|
|
177
205
|
// (real coverage loss) vs a burst-extra (version-spam, expected). Lets us
|
|
178
206
|
// measure if the memory breaker is evicting genuine new packages.
|
package/src/rules/index.js
CHANGED
|
@@ -1129,6 +1129,32 @@ const RULES = {
|
|
|
1129
1129
|
references: ['https://attack.mitre.org/techniques/T1497/001/'],
|
|
1130
1130
|
mitre: 'T1497.001'
|
|
1131
1131
|
},
|
|
1132
|
+
anti_analysis_evasion: {
|
|
1133
|
+
id: 'MUADDIB-AST-095',
|
|
1134
|
+
name: 'Anti-Analysis Detonation Gate',
|
|
1135
|
+
severity: 'HIGH',
|
|
1136
|
+
confidence: 'high',
|
|
1137
|
+
domain: 'malware',
|
|
1138
|
+
description: 'Mur de gardes process.exit() silencieuses (>=5) couplees a de la lecture d\'environnement (process.env) et a de l\'obfuscation charcode OU de la reconnaissance hote (hostname/userInfo/networkInterfaces) — le payload refuse de s\'executer sous analyse (sandbox/CI/honeypot). Detection STRUCTURELLE (la forme du mur), agnostique aux marqueurs, donc robuste aux marqueurs inedits. Contre-mesure durable a l\'evasion : evader => detecte en statique ; ne pas evader => detone en sandbox.',
|
|
1139
|
+
references: [
|
|
1140
|
+
'https://attack.mitre.org/techniques/T1497/',
|
|
1141
|
+
'https://attack.mitre.org/techniques/T1497/001/'
|
|
1142
|
+
],
|
|
1143
|
+
mitre: 'T1497'
|
|
1144
|
+
},
|
|
1145
|
+
analyzer_honeytoken_reference: {
|
|
1146
|
+
id: 'MUADDIB-AST-096',
|
|
1147
|
+
name: 'Analyzer Honeytoken Reference',
|
|
1148
|
+
severity: 'CRITICAL',
|
|
1149
|
+
confidence: 'high',
|
|
1150
|
+
domain: 'malware',
|
|
1151
|
+
description: 'Reference DISSIMULEE en charcodes a un marqueur d\'environnement d\'analyse — marqueur de sandbox MUAD\'DIB (MUADDIB_GVISOR), marqueur d\'un analyseur pair, ou nom de poison-token. Seule la forme obfusquee est matchee : le code de sandbox de MUAD\'DIB lui-meme et l\'outillage securite legitime referencent ces marqueurs en clair sans risque. Aucun package legitime n\'encode un check pour ces tripwires. Le marqueur est un honeytoken plante : garde public et stable pour que tout verificateur s\'auto-incrimine.',
|
|
1152
|
+
references: [
|
|
1153
|
+
'https://attack.mitre.org/techniques/T1497/',
|
|
1154
|
+
'https://attack.mitre.org/techniques/T1480/'
|
|
1155
|
+
],
|
|
1156
|
+
mitre: 'T1497'
|
|
1157
|
+
},
|
|
1132
1158
|
detached_process: {
|
|
1133
1159
|
id: 'MUADDIB-AST-012',
|
|
1134
1160
|
name: 'Detached Background Process',
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Anti-analysis / sandbox-evasion detection (2026, born from the @longzy "Contagious
|
|
5
|
+
* Interview" DPRK npm campaign — whose payload charcode-encodes a `MUADDIB_GVISOR` check to
|
|
6
|
+
* refuse detonation inside our sandbox).
|
|
7
|
+
*
|
|
8
|
+
* Design stance: we do NOT try to HIDE the sandbox. That is an unwinnable arms race — gVisor
|
|
9
|
+
* is detectable via /proc, syscall quirks and timing, and an attacker re-reads our public
|
|
10
|
+
* repo. Instead we detect the EVASION ITSELF, statically. That is durable and lose-lose for
|
|
11
|
+
* the attacker: evade → this convicts them (immune to any env-hiding, because it reads code);
|
|
12
|
+
* don't evade → the dynamic sandbox detonates them.
|
|
13
|
+
*
|
|
14
|
+
* Two complementary, marker-AGNOSTIC-first signals (emitted in handlePostWalk):
|
|
15
|
+
*
|
|
16
|
+
* 1. analyzer_honeytoken_reference (CRITICAL, FP~0) — the file hides, behind charcode
|
|
17
|
+
* encoding, a reference to a known analysis-environment tripwire (our own
|
|
18
|
+
* `MUADDIB_GVISOR`, a peer analyzer's marker, a poison-token name). We match ONLY the
|
|
19
|
+
* obfuscated form: legitimate security tooling (incl. MUAD'DIB's own sandbox code)
|
|
20
|
+
* references these markers in the clear, but no benign package charcode-encodes a check
|
|
21
|
+
* for one. This deliberately weaponises our sandbox marker as a HONEYTOKEN — we keep
|
|
22
|
+
* `MUADDIB_GVISOR` public and STABLE precisely so any package that checks for it
|
|
23
|
+
* self-incriminates. Hiding/rotating it would throw the honeytoken away for nothing.
|
|
24
|
+
*
|
|
25
|
+
* 2. the structural "detonation-gate wall" (see countExitGuards / hasHostRecon, combined
|
|
26
|
+
* with env-read + obfuscation in handlePostWalk) — a stack of silent process.exit()
|
|
27
|
+
* guards that read the environment. Keys on the SHAPE, so it catches NOVEL markers a
|
|
28
|
+
* signature list never would. FP-safe by coupling, not breadth.
|
|
29
|
+
*
|
|
30
|
+
* Both are static → immune to sandbox-hiding, and both fire regardless of whether the
|
|
31
|
+
* (secondary, LOG-ONLY) dynamic sandbox ever runs.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// Analysis-environment tripwire markers with ZERO legitimate reason to be *charcode-hidden*
|
|
35
|
+
// inside a package. Curated for near-zero FP: excludes AWS doc example keys
|
|
36
|
+
// (AKIAIOSFODNN7EXAMPLE) which legitimately appear in tutorials/tests. Keep this list stable
|
|
37
|
+
// and public — every entry is a planted honeytoken.
|
|
38
|
+
const ANALYZER_MARKERS = [
|
|
39
|
+
'MUADDIB_GVISOR', // our own sandbox marker — the primary honeytoken
|
|
40
|
+
'DetonationLogFilePath',
|
|
41
|
+
'THREAT_ANALYZER_MODEL',
|
|
42
|
+
'ASPECT_TLOG',
|
|
43
|
+
'PYPI_POISON',
|
|
44
|
+
'SANDYCLAW_',
|
|
45
|
+
'OPENCLAW_',
|
|
46
|
+
'PERMISO_',
|
|
47
|
+
'CHAINRADAR_'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Bound the charcode-array decode so a pathological file can't blow the CPU budget.
|
|
51
|
+
const MAX_DECODE_ARRAYS = 300;
|
|
52
|
+
// A char-code array literal: >=6 small integers, e.g. [77,85,65,68,68,73,66,...].
|
|
53
|
+
const CHARCODE_ARRAY_RE = /\[\s*(\d{1,3}(?:\s*,\s*\d{1,3}){5,})\s*\]/g;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return the first analyzer marker that appears CHARCODE-ENCODED in `content` (i.e. hidden
|
|
57
|
+
* inside a numeric array literal that decodes to a string containing the marker), or null.
|
|
58
|
+
* Plaintext references are intentionally ignored (see module doc). Bounded, side-effect-free.
|
|
59
|
+
*/
|
|
60
|
+
function detectAnalyzerHoneytoken(content) {
|
|
61
|
+
if (!content || content.indexOf('[') === -1) return null;
|
|
62
|
+
CHARCODE_ARRAY_RE.lastIndex = 0;
|
|
63
|
+
let match, seen = 0;
|
|
64
|
+
while ((match = CHARCODE_ARRAY_RE.exec(content)) !== null) {
|
|
65
|
+
if (++seen > MAX_DECODE_ARRAYS) break;
|
|
66
|
+
const nums = match[1].split(',').map(n => parseInt(n, 10));
|
|
67
|
+
// Only decode arrays that are entirely printable ASCII codes (a real hidden string).
|
|
68
|
+
if (nums.some(n => !(n >= 9 && n <= 126))) continue;
|
|
69
|
+
let decoded;
|
|
70
|
+
try { decoded = String.fromCharCode.apply(null, nums); } catch { continue; }
|
|
71
|
+
for (const m of ANALYZER_MARKERS) {
|
|
72
|
+
if (decoded.indexOf(m) !== -1) return m;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Count silent `process.exit(` guards — the magnitude of the "detonation-gate wall". */
|
|
79
|
+
function countExitGuards(content) {
|
|
80
|
+
if (!content) return 0;
|
|
81
|
+
const m = content.match(/process\s*\.\s*exit\s*\(/g);
|
|
82
|
+
return m ? m.length : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Host/environment reconnaissance calls (note: `os` is often aliased, e.g. `_os.hostname()`). */
|
|
86
|
+
function hasHostRecon(content) {
|
|
87
|
+
return !!content && /\.\s*(?:hostname|userInfo|networkInterfaces)\s*\(/.test(content);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { ANALYZER_MARKERS, detectAnalyzerHoneytoken, countExitGuards, hasHostRecon };
|
|
@@ -74,6 +74,25 @@ function handleMemberExpression(node, ctx) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
// AST-018 alias fix (@longzy): `var env = process.env; env[_decode([...])]` reaches env via
|
|
79
|
+
// an alias, so the direct `process.env[x]` match above misses it. Close ONLY the
|
|
80
|
+
// charcode-reconstruction signal for aliases (computed access + String.fromCharCode in the
|
|
81
|
+
// file) — we deliberately do NOT emit the generic env_access MEDIUM for aliases, keeping FPR
|
|
82
|
+
// flat; the HIGH signal fires only on the malicious obfuscated-key shape.
|
|
83
|
+
if (
|
|
84
|
+
node.computed &&
|
|
85
|
+
ctx.hasFromCharCode &&
|
|
86
|
+
node.object?.type === 'Identifier' &&
|
|
87
|
+
ctx.envAliases && ctx.envAliases.has(node.object.name)
|
|
88
|
+
) {
|
|
89
|
+
ctx.threats.push({
|
|
90
|
+
type: 'env_charcode_reconstruction',
|
|
91
|
+
severity: 'HIGH',
|
|
92
|
+
message: 'process.env (accessed via an alias) reads a dynamically reconstructed key (String.fromCharCode obfuscation).',
|
|
93
|
+
file: ctx.relFile
|
|
94
|
+
});
|
|
95
|
+
}
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
|
|
@@ -3,6 +3,41 @@
|
|
|
3
3
|
const { networkDestinationsAllBenign } = require('../../sdk-destination.js');
|
|
4
4
|
|
|
5
5
|
function handlePostWalk(ctx) {
|
|
6
|
+
// ── Anti-analysis / sandbox evasion (2026, @longzy DPRK "Contagious Interview") ──
|
|
7
|
+
// We detect the EVASION rather than trying to hide the sandbox (an unwinnable arms race).
|
|
8
|
+
// (1) analyzer_honeytoken_reference (CRITICAL, FP~0): the file hides — behind charcode
|
|
9
|
+
// encoding — a check for a known analysis-environment tripwire (our own MUADDIB_GVISOR,
|
|
10
|
+
// a peer analyzer marker, a poison-token name). Only the OBFUSCATED form is matched, so
|
|
11
|
+
// MUAD'DIB's own sandbox code (plaintext) and legit security tooling never trip it. The
|
|
12
|
+
// marker is a planted honeytoken: kept public+stable so any checker self-incriminates.
|
|
13
|
+
// (2) anti_analysis_evasion (HIGH): the structural "detonation-gate wall" — >=5 silent
|
|
14
|
+
// process.exit() guards that read the environment, plus charcode obfuscation OR host
|
|
15
|
+
// reconnaissance. Marker-AGNOSTIC (keys on the shape → catches novel markers). FP-safe
|
|
16
|
+
// by coupling: no benign package stacks 5+ process.exit guards with obfuscated env/host
|
|
17
|
+
// recon. Suppressed in dist/build (minified bundles can inflate the raw counts).
|
|
18
|
+
if (ctx.analyzerHoneytokenHit) {
|
|
19
|
+
ctx.threats.push({
|
|
20
|
+
type: 'analyzer_honeytoken_reference',
|
|
21
|
+
severity: 'CRITICAL',
|
|
22
|
+
message: `Charcode-hidden reference to an analysis-environment marker ("${ctx.analyzerHoneytokenHit}") — sandbox/analyzer evasion. No legitimate package obfuscates a check for this tripwire.`,
|
|
23
|
+
file: ctx.relFile
|
|
24
|
+
});
|
|
25
|
+
} else if (
|
|
26
|
+
ctx.antiAnalysisExitCount >= 5 &&
|
|
27
|
+
ctx.hasProcessEnvRead &&
|
|
28
|
+
(ctx.hasFromCharCode || ctx.hasHostRecon)
|
|
29
|
+
) {
|
|
30
|
+
const isDistFile = /^(?:dist|build|out|output)[/\\]/i.test(ctx.relFile) || /\.(?:bundle|min)\.js$/i.test(ctx.relFile);
|
|
31
|
+
if (!isDistFile) {
|
|
32
|
+
ctx.threats.push({
|
|
33
|
+
type: 'anti_analysis_evasion',
|
|
34
|
+
severity: 'HIGH',
|
|
35
|
+
message: `Anti-analysis detonation-gate: ${ctx.antiAnalysisExitCount} silent process.exit() guards reading the environment${ctx.hasFromCharCode ? ', charcode-obfuscated' : ''}${ctx.hasHostRecon ? ', with host reconnaissance' : ''} — payload refuses to run under analysis (sandbox/CI/honeypot evasion).`,
|
|
36
|
+
file: ctx.relFile
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
6
41
|
// SANDWORM_MODE: zlib inflate + base64 decode + eval/Function/Module._compile = obfuscated payload
|
|
7
42
|
if (ctx.hasZlibInflate && ctx.hasBase64Decode && ctx.hasDynamicExec) {
|
|
8
43
|
// FIX 4: dist/build files get LOW severity (bundlers legitimately use zlib+base64+eval)
|
|
@@ -24,6 +24,14 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
24
24
|
ctx.staticAssignments.add(node.id.name);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// AST-018 alias capture (@longzy): `var env = process.env` — record the alias so a later
|
|
28
|
+
// `env[String.fromCharCode(...)]` access resolves back to process.env in the member handler.
|
|
29
|
+
if (ctx.envAliases && node.init?.type === 'MemberExpression' && !node.init.computed &&
|
|
30
|
+
node.init.object?.type === 'Identifier' && node.init.object.name === 'process' &&
|
|
31
|
+
node.init.property?.type === 'Identifier' && node.init.property.name === 'env') {
|
|
32
|
+
ctx.envAliases.add(node.id.name);
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
// v2.10.73 P2: Track WHERE the variable's value originated — used by AST-006
|
|
28
36
|
// to distinguish plugin loaders (LOW) from real obfuscation (HIGH) from
|
|
29
37
|
// credential exfil vectors (CRITICAL). See src/scanner/ast-detectors/handle-call-expression.js
|
package/src/scanner/ast.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
handleWithStatement,
|
|
14
14
|
handlePostWalk
|
|
15
15
|
} = require('./ast-detectors');
|
|
16
|
+
const { detectAnalyzerHoneytoken, countExitGuards, hasHostRecon } = require('./ast-detectors/anti-evasion.js');
|
|
16
17
|
|
|
17
18
|
// Check if credential keywords appear INSIDE regex literals or new RegExp() patterns.
|
|
18
19
|
// Only true when the keyword is part of the regex pattern itself, not just a string elsewhere in the file.
|
|
@@ -152,11 +153,19 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
152
153
|
return names;
|
|
153
154
|
})(),
|
|
154
155
|
evalAliases: new Map(), // B1: variable name → 'eval'|'Function'
|
|
156
|
+
envAliases: new Set(), // AST-018 alias fix: names bound to process.env (`var env = process.env`)
|
|
155
157
|
moduleLoadDirectAliases: new Set(), // B3: destructured _load from require('module')
|
|
156
158
|
objectPropertyMap: new Map(), // B5: objName → Map<propName, stringValue>
|
|
157
159
|
concatValues: new Map(), // B2: varName → { value, operands } for concat strings with ≥3 operands
|
|
158
160
|
stringVarValues: new Map(), // Variable reassignment tracking: varName → string value
|
|
159
161
|
hasFromCharCode: content.includes('fromCharCode'),
|
|
162
|
+
// Anti-analysis / sandbox evasion (2026, @longzy DPRK). Structural "detonation-gate wall"
|
|
163
|
+
// magnitude + host recon + a charcode-hidden analyzer-honeytoken reference. Aggregated in
|
|
164
|
+
// handlePostWalk. See ast-detectors/anti-evasion.js.
|
|
165
|
+
antiAnalysisExitCount: countExitGuards(content),
|
|
166
|
+
hasHostRecon: hasHostRecon(content),
|
|
167
|
+
hasProcessEnvRead: /process\s*\.\s*env\b/.test(content),
|
|
168
|
+
analyzerHoneytokenHit: detectAnalyzerHoneytoken(content),
|
|
160
169
|
hasJsReverseShell: /\bnet\.Socket\b/.test(content) &&
|
|
161
170
|
/\.connect\s*\(/.test(content) &&
|
|
162
171
|
/\.pipe\b/.test(content) &&
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistent worker for the extraction pool (src/shared/extract-pool.js).
|
|
5
|
+
*
|
|
6
|
+
* Unlike extract-worker.js (one-shot, used by download.js extractArchiveOffThread),
|
|
7
|
+
* this worker stays alive and serves a request/response loop: one extraction per
|
|
8
|
+
* message, reusing the loaded extractArchive + adm-zip across packages so the pool
|
|
9
|
+
* pays no per-package Worker-spawn cost. All extraction hardening (zip-slip,
|
|
10
|
+
* zip-bomb uncompressed-size cap) lives in extractArchive and runs HERE, off the
|
|
11
|
+
* parent's event loop. Each reply echoes the job id so the pool matches it to the
|
|
12
|
+
* caller. Never throws to the thread — failures come back as { ok:false, error }.
|
|
13
|
+
*
|
|
14
|
+
* Message in: { id, archivePath, destDir, format? }
|
|
15
|
+
* Message out: { id, ok:true, dir } | { id, ok:false, error }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { parentPort } = require('worker_threads');
|
|
19
|
+
const { extractArchive } = require('./download.js');
|
|
20
|
+
|
|
21
|
+
parentPort.on('message', (job) => {
|
|
22
|
+
if (!job || typeof job.id === 'undefined') return;
|
|
23
|
+
try {
|
|
24
|
+
const opts = job.format ? { format: job.format } : {};
|
|
25
|
+
const dir = extractArchive(job.archivePath, job.destDir, opts);
|
|
26
|
+
parentPort.postMessage({ id: job.id, ok: true, dir });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
parentPort.postMessage({ id: job.id, ok: false, error: err && err.message ? err.message : String(err) });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistent worker-thread pool for off-main-thread archive extraction.
|
|
5
|
+
*
|
|
6
|
+
* Why a pool, not download.js extractArchiveOffThread (spawn-per-call): the
|
|
7
|
+
* monitor extracts thousands of small packages on its main thread. The
|
|
8
|
+
* spawn-per-call offloader pays a fresh Worker (V8 isolate + module load,
|
|
9
|
+
* ~10-40ms) every time, which is why queue.js gated it to archives > 4MB and
|
|
10
|
+
* ran everything smaller INLINE (synchronous extractArchive). Those inline
|
|
11
|
+
* extractions are exactly what wedge the event loop: adm-zip `extractAllTo` is
|
|
12
|
+
* fully synchronous and slow on many-file trees regardless of total size
|
|
13
|
+
* (data/loop-stalls.jsonl: `extract:prework` up to 229s; a 2MB many-file
|
|
14
|
+
* package blocks 5s+). A wedged loop starves the RSS breaker / memory governor /
|
|
15
|
+
* EMERGENCY purge (all main-thread timers) → restart.
|
|
16
|
+
*
|
|
17
|
+
* A pool of N reusable workers removes BOTH costs: no extraction ever runs on
|
|
18
|
+
* the main thread (at any size), and there is no per-package spawn. The size
|
|
19
|
+
* gate in queue.js then becomes unnecessary (its inline window defaults to 0).
|
|
20
|
+
*
|
|
21
|
+
* Each worker runs extract-pool-worker.js — a request/response loop that calls
|
|
22
|
+
* the SAME extractArchive, so all hardening (zip-slip, zip-bomb uncompressed
|
|
23
|
+
* cap) stays centralized there and runs in the worker.
|
|
24
|
+
*
|
|
25
|
+
* Bounded + crash-resilient (CLAUDE.md "Production Engineering"):
|
|
26
|
+
* - POOL_SIZE workers max, spawned lazily on demand; idle workers are `unref`'d
|
|
27
|
+
* so the pool never keeps the process alive on its own.
|
|
28
|
+
* - One job per worker; excess jobs wait in a FIFO bounded to MAX_PENDING —
|
|
29
|
+
* past the cap a job rejects immediately (the caller treats it as an extract
|
|
30
|
+
* failure → size_skip, ledgered) so a slow extractor cannot grow memory
|
|
31
|
+
* without bound.
|
|
32
|
+
* - Per-job timeout terminates and drops a wedged worker and rejects that job —
|
|
33
|
+
* a pathological archive cannot pin a slot forever.
|
|
34
|
+
* - A worker that errors or exits mid-job rejects its in-flight job and is
|
|
35
|
+
* removed; the next dispatch lazily respawns up to POOL_SIZE.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const path = require('path');
|
|
39
|
+
|
|
40
|
+
const POOL_SIZE = (() => {
|
|
41
|
+
const v = parseInt(process.env.MUADDIB_EXTRACT_POOL_SIZE, 10);
|
|
42
|
+
if (Number.isFinite(v) && v >= 1) return v;
|
|
43
|
+
let cores = 4;
|
|
44
|
+
try { cores = require('os').cpus().length || 4; } catch { /* default 4 */ }
|
|
45
|
+
return Math.max(2, Math.min(4, cores - 2));
|
|
46
|
+
})();
|
|
47
|
+
|
|
48
|
+
const JOB_TIMEOUT_MS = (() => {
|
|
49
|
+
const v = parseInt(process.env.MUADDIB_EXTRACT_POOL_TIMEOUT_MS, 10);
|
|
50
|
+
return Number.isFinite(v) && v > 0 ? v : 120_000;
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
const MAX_PENDING = (() => {
|
|
54
|
+
const v = parseInt(process.env.MUADDIB_EXTRACT_POOL_MAX_PENDING, 10);
|
|
55
|
+
return Number.isFinite(v) && v > 0 ? v : 512;
|
|
56
|
+
})();
|
|
57
|
+
|
|
58
|
+
const WORKER_FILE = path.join(__dirname, 'extract-pool-worker.js');
|
|
59
|
+
|
|
60
|
+
// Pool state (lazy-init on first extractInPool). Each worker entry:
|
|
61
|
+
// { worker, busy, job }. `pending` is the FIFO of jobs waiting for a free slot.
|
|
62
|
+
let workers = [];
|
|
63
|
+
let pending = [];
|
|
64
|
+
let jobSeq = 0;
|
|
65
|
+
let destroyed = false;
|
|
66
|
+
|
|
67
|
+
function _settle(job, err, dir) {
|
|
68
|
+
if (job.settled) return;
|
|
69
|
+
job.settled = true;
|
|
70
|
+
if (job.timer) { clearTimeout(job.timer); job.timer = null; }
|
|
71
|
+
if (err) job.reject(err);
|
|
72
|
+
else job.resolve(dir);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _removeWorker(entry) {
|
|
76
|
+
const i = workers.indexOf(entry);
|
|
77
|
+
if (i !== -1) workers.splice(i, 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _spawnWorker() {
|
|
81
|
+
const { Worker } = require('worker_threads');
|
|
82
|
+
const entry = { worker: null, busy: false, job: null };
|
|
83
|
+
const w = new Worker(WORKER_FILE);
|
|
84
|
+
entry.worker = w;
|
|
85
|
+
// Idle workers must not keep the process alive; an in-flight job is kept alive
|
|
86
|
+
// by the caller's awaited Promise, not by the worker handle.
|
|
87
|
+
if (typeof w.unref === 'function') w.unref();
|
|
88
|
+
w.on('message', (msg) => _onMessage(entry, msg));
|
|
89
|
+
w.on('error', (err) => _onWorkerError(entry, err));
|
|
90
|
+
w.on('exit', (code) => _onWorkerExit(entry, code));
|
|
91
|
+
workers.push(entry);
|
|
92
|
+
return entry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _onMessage(entry, msg) {
|
|
96
|
+
const job = entry.job;
|
|
97
|
+
entry.busy = false;
|
|
98
|
+
entry.job = null;
|
|
99
|
+
if (job && msg && msg.id === job.id) {
|
|
100
|
+
if (msg.ok) _settle(job, null, msg.dir);
|
|
101
|
+
else _settle(job, new Error(msg.error || 'extract-pool: worker reported failure'));
|
|
102
|
+
}
|
|
103
|
+
_dispatch();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function _onWorkerError(entry, err) {
|
|
107
|
+
const job = entry.job;
|
|
108
|
+
entry.busy = false;
|
|
109
|
+
entry.job = null;
|
|
110
|
+
_removeWorker(entry);
|
|
111
|
+
if (job) _settle(job, err instanceof Error ? err : new Error(String(err)));
|
|
112
|
+
_dispatch();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _onWorkerExit(entry, code) {
|
|
116
|
+
const job = entry.job;
|
|
117
|
+
entry.busy = false;
|
|
118
|
+
entry.job = null;
|
|
119
|
+
_removeWorker(entry);
|
|
120
|
+
if (job) _settle(job, new Error(`extract-pool: worker exited (${code}) mid-job`));
|
|
121
|
+
_dispatch();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function _assign(entry, job) {
|
|
125
|
+
entry.busy = true;
|
|
126
|
+
entry.job = job;
|
|
127
|
+
job.timer = setTimeout(() => {
|
|
128
|
+
// Worker is wedged on a pathological archive: terminate it (best-effort) and
|
|
129
|
+
// drop it from the pool, reject the job, let _dispatch respawn lazily.
|
|
130
|
+
entry.busy = false;
|
|
131
|
+
entry.job = null;
|
|
132
|
+
_removeWorker(entry);
|
|
133
|
+
try { entry.worker.terminate(); } catch { /* already gone */ }
|
|
134
|
+
_settle(job, new Error(`extract-pool: timeout after ${JOB_TIMEOUT_MS}ms: ${path.basename(job.archivePath)}`));
|
|
135
|
+
_dispatch();
|
|
136
|
+
}, JOB_TIMEOUT_MS);
|
|
137
|
+
if (job.timer && typeof job.timer.unref === 'function') job.timer.unref();
|
|
138
|
+
entry.worker.postMessage({ id: job.id, archivePath: job.archivePath, destDir: job.destDir, format: job.format });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _dispatch() {
|
|
142
|
+
if (destroyed) return;
|
|
143
|
+
while (pending.length > 0) {
|
|
144
|
+
let entry = workers.find((e) => !e.busy);
|
|
145
|
+
if (!entry) {
|
|
146
|
+
if (workers.length < POOL_SIZE) entry = _spawnWorker();
|
|
147
|
+
else break; // all workers busy and at cap — wait for a completion
|
|
148
|
+
}
|
|
149
|
+
_assign(entry, pending.shift());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extract an archive off the main thread via the pool. Same contract as
|
|
155
|
+
* download.js extractArchive (resolves to the extracted package root) but never
|
|
156
|
+
* blocks the caller's event loop.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} archivePath
|
|
159
|
+
* @param {string} destDir - must already exist
|
|
160
|
+
* @param {Object} [options]
|
|
161
|
+
* @param {'targz'|'zip'} [options.format] - override auto-detection
|
|
162
|
+
* @returns {Promise<string>} extracted package root
|
|
163
|
+
*/
|
|
164
|
+
function extractInPool(archivePath, destDir, options = {}) {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
if (destroyed) { reject(new Error('extract-pool: pool destroyed')); return; }
|
|
167
|
+
if (pending.length >= MAX_PENDING) {
|
|
168
|
+
reject(new Error(`extract-pool: pending queue full (${MAX_PENDING}) — extraction is not keeping up`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const job = {
|
|
172
|
+
id: ++jobSeq,
|
|
173
|
+
archivePath,
|
|
174
|
+
destDir,
|
|
175
|
+
format: (options && options.format) || null,
|
|
176
|
+
resolve,
|
|
177
|
+
reject,
|
|
178
|
+
settled: false,
|
|
179
|
+
timer: null,
|
|
180
|
+
};
|
|
181
|
+
pending.push(job);
|
|
182
|
+
_dispatch();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Terminate all workers and reject any in-flight / pending jobs. Idempotent and
|
|
188
|
+
* reusable: after it resolves the pool lazily re-inits on the next extractInPool
|
|
189
|
+
* (the daemon calls this once during gracefulShutdown; tests call it between
|
|
190
|
+
* cases).
|
|
191
|
+
* @returns {Promise<void>}
|
|
192
|
+
*/
|
|
193
|
+
async function destroyExtractPool() {
|
|
194
|
+
destroyed = true;
|
|
195
|
+
const inflight = workers.slice();
|
|
196
|
+
const queued = pending.slice();
|
|
197
|
+
workers = [];
|
|
198
|
+
pending = [];
|
|
199
|
+
for (const job of queued) _settle(job, new Error('extract-pool: pool destroyed'));
|
|
200
|
+
await Promise.all(inflight.map((entry) => {
|
|
201
|
+
if (entry.job) _settle(entry.job, new Error('extract-pool: pool destroyed'));
|
|
202
|
+
try { return entry.worker.terminate(); } catch { return Promise.resolve(); }
|
|
203
|
+
}));
|
|
204
|
+
destroyed = false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Observability snapshot (live workers / busy / pending), all bounded. */
|
|
208
|
+
function getPoolStats() {
|
|
209
|
+
return {
|
|
210
|
+
size: workers.length,
|
|
211
|
+
max: POOL_SIZE,
|
|
212
|
+
busy: workers.filter((e) => e.busy).length,
|
|
213
|
+
pending: pending.length,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = { extractInPool, destroyExtractPool, getPoolStats, POOL_SIZE };
|