muaddib-scanner 2.11.6 → 2.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
32
32
 
33
- MUAD'DIB combines **14 parallel scanners** (209 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring**, **ML classifiers** (XGBoost), and gVisor/Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages.
33
+ MUAD'DIB combines **16 parallel scanners** (223 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring**, **ML classifiers** (XGBoost), and gVisor/Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages.
34
34
 
35
35
  ---
36
36
 
@@ -176,7 +176,7 @@ muaddib replay # Ground truth validation (61/65 TPR@3)
176
176
 
177
177
  ## Features
178
178
 
179
- ### 14 parallel scanners
179
+ ### 16 parallel scanners
180
180
 
181
181
  | Scanner | Detection |
182
182
  |---------|-----------|
@@ -194,8 +194,11 @@ muaddib replay # Ground truth validation (61/65 TPR@3)
194
194
  | Package/Dependencies | Lifecycle scripts, IOC matching (225K+ packages) |
195
195
  | GitHub Actions | Shai-Hulud backdoor detection |
196
196
  | Hash Scanner | Known malicious file hashes |
197
+ | IOC Strings (intel-triage P1.1) | YARA-style string matching (Axios 2026, TeamPCP, GlassWorm, CanisterSprawl) |
198
+ | Anti-Forensic AST (intel-triage P1.2) | XOR loop + self-delete + decoy write compound (csec autodelete) |
199
+ | Stub Package (intel-triage P1.3) | Tiny main file + external dep URL + lifecycle hook (ltidi chain) |
197
200
 
198
- ### 209 detection rules
201
+ ### 223 detection rules
199
202
 
200
203
  All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v21021) for the complete rules reference.
201
204
 
@@ -271,7 +274,7 @@ With pre-commit framework:
271
274
  ```yaml
272
275
  repos:
273
276
  - repo: https://github.com/DNSZLSK/muad-dib
274
- rev: v2.10.97
277
+ rev: v2.11.6
275
278
  hooks:
276
279
  - id: muaddib-scan
277
280
  ```
@@ -292,7 +295,7 @@ repos:
292
295
  | **FPR** (Benign random, v2.10.95 measure) | **7.0%** (14/200) | 200 random npm packages, stratified sampling |
293
296
  | **ADR** (Adversarial + Holdout) | **96.3%** (103/107) | 67 adversarial + 40 holdout (107 available on disk), global threshold=20 |
294
297
 
295
- **3280 tests** across 69 files. **209 rules** (204 RULES + 5 PARANOID).
298
+ **3529 tests** across 89 files. **223 rules** (218 RULES + 5 PARANOID).
296
299
 
297
300
  > **ML retrain methodology (v2.10.51):**
298
301
  > - Ground truth: 377 confirmed_malicious via auto-labeler (OSSF malicious-packages, GitHub Advisory Database, npm registry takedown correlation)
@@ -340,7 +343,7 @@ npm test
340
343
 
341
344
  ### Testing
342
345
 
343
- - **3280 tests** across 69 modular test files
346
+ - **3529 tests** across 89 modular test files
344
347
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
345
348
  - **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
346
349
  - **Ground truth validation** - 67 real-world attacks (93.85% TPR@3, 86.2% TPR@20 — v2.10.95 measure)
@@ -361,7 +364,7 @@ npm test
361
364
  - [Documentation Index](docs/INDEX.md) - All documentation in one place
362
365
  - [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
363
366
  - [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
364
- - [Security Policy](SECURITY.md) - Detection rules reference (209 rules)
367
+ - [Security Policy](SECURITY.md) - Detection rules reference (223 rules)
365
368
  - [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
366
369
  - [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
367
370
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.6",
3
+ "version": "2.11.7",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -149,6 +149,9 @@ const PLAYBOOKS = {
149
149
  stub_with_string_ioc:
150
150
  'CRITIQUE: Package stub + IOC string connu = staging chain-attack confirme. Bloquer le package + sa dep externe. Regenerer secrets si install effectue.',
151
151
 
152
+ staged_remote_loader:
153
+ 'CRITIQUE: Staged remote loader detecte (Function.constructor("require", body) + process shadow). Le payload reel est sur un pastebin externe (jsonkeeper.com ou autre). Pattern campagne chai-* / poxios-chain. Bloquer le package, isoler les machines qui ont fait `npm install`, regenerer credentials. Inspecter l\'URL paste-service decodee depuis la base64.',
154
+
152
155
  known_malicious_hash:
153
156
  'CRITIQUE: Fichier malveillant confirme par hash. Supprimer immediatement. Considerer la machine compromise.',
154
157
 
@@ -261,6 +261,18 @@ const RULES = {
261
261
  ],
262
262
  mitre: 'T1195.002'
263
263
  },
264
+ staged_remote_loader: {
265
+ id: 'MUADDIB-COMPOUND-012',
266
+ name: 'Staged Remote Loader (Function.constructor + shadowed process)',
267
+ severity: 'CRITICAL',
268
+ confidence: 'high',
269
+ description: 'Compound: new Function.constructor("require", body) co-occurs with `const process = {...}` shadowing in the same file. Pattern observed in the chai-* / poxios-chain / express-guardrail / justenv campaign (semaine 2026-05-04 a 2026-05-09): fork de pino avec caller.js qui decode une URL base64 (jsonkeeper.com), fetch le payload distant via axios, et l\'execute via Function.constructor en passant require comme parametre. Le tarball npm ne contient aucun code malveillant statique — la charge utile est externalisee sur un pastebin.',
270
+ references: [
271
+ 'project_detection_gap_chai_staged_loader memory entry',
272
+ 'data/security-review-2026-05-04-10.md'
273
+ ],
274
+ mitre: 'T1059.007'
275
+ },
264
276
  lifecycle_script_dependency: {
265
277
  id: 'MUADDIB-DEP-004',
266
278
  name: 'Lifecycle Script in Dependency',
@@ -1798,7 +1810,7 @@ const RULES = {
1798
1810
  },
1799
1811
  dangerous_constructor: {
1800
1812
  id: 'MUADDIB-AST-057',
1801
- name: 'Prototype Chain Constructor Access',
1813
+ name: 'AsyncFunction/GeneratorFunction Constructor via Prototype Chain',
1802
1814
  severity: 'CRITICAL',
1803
1815
  confidence: 'high',
1804
1816
  description: 'Acces au constructeur AsyncFunction ou GeneratorFunction via Object.getPrototypeOf(). Technique d\'evasion permettant d\'executer du code arbitraire sans reference directe a eval() ou Function().',
@@ -2312,7 +2324,7 @@ const RULES = {
2312
2324
  },
2313
2325
  prototype_chain_constructor: {
2314
2326
  id: 'MUADDIB-AST-081',
2315
- name: 'Prototype Chain Constructor Access',
2327
+ name: 'Prototype Chain Constructor Access via Variable',
2316
2328
  severity: 'CRITICAL',
2317
2329
  confidence: 'high',
2318
2330
  description: 'Object.getPrototypeOf(variable).constructor extrait dans une variable — traversee de la chaine de prototypes pour atteindre le constructeur Function et executer du code arbitraire.',
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * monitor-feed.js — Aggregator for /monitor HTTP endpoints.
5
+ *
6
+ * Reads the same persistent files the monitor writes to data/ and exposes
7
+ * three views consumed by muad-api -> muad-front:
8
+ * - buildMonitorDaily() today's stats from daily-stats.json
9
+ * - buildMonitorWindow(range) per-day rollup from scan-stats.json
10
+ * - buildMonitorAll() all-time totals + detection breakdown
11
+ *
12
+ * Defensive: every read is wrapped — missing files yield zeros, never throws.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const {
19
+ DAILY_STATS_FILE,
20
+ SCAN_STATS_FILE,
21
+ STATE_FILE,
22
+ LAST_DAILY_REPORT_FILE,
23
+ loadScanStats,
24
+ loadStateRaw,
25
+ loadLastDailyReportDate,
26
+ getDetectionStats,
27
+ getParisDateString
28
+ } = require('../monitor/state.js');
29
+
30
+ const pkg = require('../../package.json');
31
+
32
+ const SUPPORTED_RANGES = new Set(['7d', '30d', 'all']);
33
+ const RANGE_DAYS = { '7d': 7, '30d': 30 };
34
+
35
+ function safeReadJson(file) {
36
+ try {
37
+ if (!fs.existsSync(file)) return null;
38
+ const raw = fs.readFileSync(file, 'utf8');
39
+ return JSON.parse(raw);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function emptyToday(date) {
46
+ return {
47
+ date,
48
+ scanned: 0,
49
+ clean: 0,
50
+ suspect: 0,
51
+ suspectByTier: { t1: 0, t1a: 0, t1b: 0, t2: 0, t3: 0 },
52
+ errors: 0,
53
+ errorsByType: { too_large: 0, tar_failed: 0, http_error: 0, timeout: 0, static_timeout: 0, other: 0 },
54
+ totalTimeMs: 0,
55
+ mlFiltered: 0,
56
+ llmAnalyzed: 0,
57
+ llmSuppressed: 0,
58
+ changesStreamPackages: 0
59
+ };
60
+ }
61
+
62
+ function readToday() {
63
+ const date = getParisDateString();
64
+ const data = safeReadJson(DAILY_STATS_FILE);
65
+ if (!data || typeof data.scanned !== 'number') return emptyToday(date);
66
+ return {
67
+ date,
68
+ scanned: data.scanned || 0,
69
+ clean: data.clean || 0,
70
+ suspect: data.suspect || 0,
71
+ suspectByTier: {
72
+ t1: (data.suspectByTier && data.suspectByTier.t1) || 0,
73
+ t1a: (data.suspectByTier && data.suspectByTier.t1a) || 0,
74
+ t1b: (data.suspectByTier && data.suspectByTier.t1b) || 0,
75
+ t2: (data.suspectByTier && data.suspectByTier.t2) || 0,
76
+ t3: (data.suspectByTier && data.suspectByTier.t3) || 0
77
+ },
78
+ errors: data.errors || 0,
79
+ errorsByType: {
80
+ too_large: (data.errorsByType && data.errorsByType.too_large) || 0,
81
+ tar_failed: (data.errorsByType && data.errorsByType.tar_failed) || 0,
82
+ http_error: (data.errorsByType && data.errorsByType.http_error) || 0,
83
+ timeout: (data.errorsByType && data.errorsByType.timeout) || 0,
84
+ static_timeout: (data.errorsByType && data.errorsByType.static_timeout) || 0,
85
+ other: (data.errorsByType && data.errorsByType.other) || 0
86
+ },
87
+ totalTimeMs: data.totalTimeMs || 0,
88
+ mlFiltered: data.mlFiltered || 0,
89
+ llmAnalyzed: data.llmAnalyzed || 0,
90
+ llmSuppressed: data.llmSuppressed || 0,
91
+ changesStreamPackages: data.changesStreamPackages || 0
92
+ };
93
+ }
94
+
95
+ function readLastReportAt() {
96
+ const fromFile = safeReadJson(LAST_DAILY_REPORT_FILE);
97
+ if (fromFile && typeof fromFile.lastReportDate === 'string') return fromFile.lastReportDate;
98
+ const date = loadLastDailyReportDate();
99
+ return date || null;
100
+ }
101
+
102
+ function readMonitorState() {
103
+ try {
104
+ return loadStateRaw() || {};
105
+ } catch {
106
+ return {};
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Build the /monitor/daily payload.
112
+ */
113
+ function buildMonitorDaily() {
114
+ const today = readToday();
115
+ const lastReportAt = readLastReportAt();
116
+ const monitorState = readMonitorState();
117
+
118
+ return {
119
+ generated_at: new Date().toISOString(),
120
+ engineVersion: pkg.version,
121
+ today,
122
+ lastReportAt,
123
+ monitor: {
124
+ npmLastPackage: monitorState.npmLastPackage || null,
125
+ pypiLastPackage: monitorState.pypiLastPackage || null,
126
+ lastDailyReportDate: monitorState.lastDailyReportDate || null
127
+ }
128
+ };
129
+ }
130
+
131
+ function emptyDayEntry(date) {
132
+ return {
133
+ date,
134
+ scanned: 0,
135
+ clean: 0,
136
+ suspect: 0,
137
+ false_positive: 0,
138
+ confirmed: 0,
139
+ sandbox_inconclusive: 0,
140
+ fp_rate: 0
141
+ };
142
+ }
143
+
144
+ function aggregateDays(days) {
145
+ const totals = { scanned: 0, clean: 0, suspect: 0, false_positive: 0, confirmed: 0, sandbox_inconclusive: 0 };
146
+ let fpRateSum = 0;
147
+ let fpRateCount = 0;
148
+ for (const d of days) {
149
+ totals.scanned += d.scanned || 0;
150
+ totals.clean += d.clean || 0;
151
+ totals.suspect += d.suspect || 0;
152
+ totals.false_positive += d.false_positive || 0;
153
+ totals.confirmed += d.confirmed || 0;
154
+ totals.sandbox_inconclusive += d.sandbox_inconclusive || 0;
155
+ if (typeof d.fp_rate === 'number' && d.fp_rate >= 0) {
156
+ fpRateSum += d.fp_rate;
157
+ fpRateCount++;
158
+ }
159
+ }
160
+ const fp_rate_avg = fpRateCount > 0 ? fpRateSum / fpRateCount : 0;
161
+ return { ...totals, fp_rate_avg: Math.round(fp_rate_avg * 1000) / 1000 };
162
+ }
163
+
164
+ /**
165
+ * Build the /monitor/window payload for a given range ('7d' | '30d').
166
+ */
167
+ function buildMonitorWindow(range) {
168
+ if (!SUPPORTED_RANGES.has(range) || range === 'all') {
169
+ throw new Error(`Unsupported range: ${range}. Use 7d or 30d.`);
170
+ }
171
+ const days = RANGE_DAYS[range];
172
+ const data = loadScanStats();
173
+ const allDaily = Array.isArray(data.daily) ? data.daily : [];
174
+
175
+ const today = getParisDateString();
176
+ const todayMs = Date.parse(`${today}T00:00:00Z`);
177
+ const cutoffMs = todayMs - (days - 1) * 24 * 60 * 60 * 1000;
178
+
179
+ const inRange = allDaily.filter(d => {
180
+ if (!d || typeof d.date !== 'string') return false;
181
+ const ms = Date.parse(`${d.date}T00:00:00Z`);
182
+ return Number.isFinite(ms) && ms >= cutoffMs && ms <= todayMs;
183
+ });
184
+
185
+ const dateIndex = new Map(inRange.map(d => [d.date, d]));
186
+ const byDay = [];
187
+ for (let i = days - 1; i >= 0; i--) {
188
+ const ms = todayMs - i * 24 * 60 * 60 * 1000;
189
+ const dateStr = new Date(ms).toISOString().slice(0, 10);
190
+ byDay.push(dateIndex.get(dateStr) || emptyDayEntry(dateStr));
191
+ }
192
+
193
+ return {
194
+ generated_at: new Date().toISOString(),
195
+ engineVersion: pkg.version,
196
+ range,
197
+ from: byDay[0].date,
198
+ to: byDay[byDay.length - 1].date,
199
+ totals: aggregateDays(byDay),
200
+ byDay
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Build the /monitor/stats payload (all-time totals + detection breakdown).
206
+ */
207
+ function buildMonitorAll() {
208
+ const data = loadScanStats();
209
+ const stats = data.stats || {};
210
+ let detection = { total: 0, bySeverity: {}, byEcosystem: {}, leadTime: null };
211
+ try {
212
+ detection = getDetectionStats();
213
+ } catch {
214
+ // keep defaults
215
+ }
216
+ return {
217
+ generated_at: new Date().toISOString(),
218
+ engineVersion: pkg.version,
219
+ allTime: {
220
+ total_scanned: stats.total_scanned || 0,
221
+ clean: stats.clean || 0,
222
+ suspect: stats.suspect || 0,
223
+ false_positive: stats.false_positive || 0,
224
+ confirmed_malicious: stats.confirmed_malicious || 0,
225
+ sandbox_inconclusive: stats.sandbox_inconclusive || 0,
226
+ sandbox_unconfirmed: stats.sandbox_unconfirmed || 0
227
+ },
228
+ detectionStats: detection
229
+ };
230
+ }
231
+
232
+ module.exports = {
233
+ buildMonitorDaily,
234
+ buildMonitorWindow,
235
+ buildMonitorAll,
236
+ SUPPORTED_RANGES,
237
+ // exported for tests
238
+ _safeReadJson: safeReadJson,
239
+ _readToday: readToday,
240
+ _aggregateDays: aggregateDays
241
+ };
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ // Sandbox-friendly compound triggers.
4
+ // Surgical activation of the Docker sandbox only on threat patterns where
5
+ // dynamic observation provides signal beyond static AST/regex analysis.
6
+ // Targets 2026 attacks: Shai-Hulud, Axios 2026 (OrDeR_7077), GlassWorm,
7
+ // PhantomRaven, CanisterWorm, ltidi chain.
8
+ //
9
+ // Activation rule: a compound matches AND preliminary score in [15, 35].
10
+ // score < 15 -> clean, no need to sandbox
11
+ // score > 35 -> already definitive, no second-tier verdict needed
12
+
13
+ const SANDBOX_TRIGGER_MIN_SCORE = 15;
14
+ const SANDBOX_TRIGGER_MAX_SCORE = 35;
15
+
16
+ const TRIGGERS = [
17
+ {
18
+ name: 'lifecycle_install_chain',
19
+ description: 'Lifecycle script + credential tampering or harvest pattern',
20
+ target: 'Shai-Hulud, PhantomRaven',
21
+ watchpoints: ['honey_npmrc_read', 'honey_ssh_read', 'execve_chain_depth', 'outbound_non_registry'],
22
+ matches(threats) {
23
+ const hasLifecycle = threats.some(t =>
24
+ t.type === 'lifecycle_script' ||
25
+ t.type === 'lifecycle_added_critical' ||
26
+ t.type === 'lifecycle_added_high' ||
27
+ t.type === 'lifecycle_modified' ||
28
+ t.type === 'lifecycle_inline_exec' ||
29
+ t.type === 'lifecycle_remote_require' ||
30
+ t.type === 'lifecycle_dataflow' ||
31
+ t.type === 'lifecycle_dangerous_exec' ||
32
+ t.type === 'obfuscated_lifecycle_env' ||
33
+ t.type === 'lifecycle_typosquat' ||
34
+ t.type === 'lifecycle_shell_pipe' ||
35
+ t.type === 'lifecycle_hidden_payload'
36
+ );
37
+ const hasCredHarvest = threats.some(t =>
38
+ t.type === 'credential_regex_harvest' ||
39
+ t.type === 'credential_tampering' ||
40
+ t.type === 'credential_command_exec' ||
41
+ t.type === 'env_harvesting_dynamic' ||
42
+ t.type === 'curl_env_exfil' ||
43
+ t.type === 'env_proxy_intercept' ||
44
+ t.type === 'npmrc_access' ||
45
+ t.type === 'github_token_access' ||
46
+ t.type === 'aws_credential_access' ||
47
+ t.type === 'ssh_access'
48
+ );
49
+ return hasLifecycle && hasCredHarvest;
50
+ }
51
+ },
52
+ {
53
+ name: 'stub_with_external_dep',
54
+ description: 'Stub package with external HTTPS dep (ltidi chain)',
55
+ target: 'ltidi chain attack',
56
+ watchpoints: ['outbound_non_registry', 'fs_created_outside_install', 'execve_chain_depth'],
57
+ matches(threats) {
58
+ const hasStub = threats.some(t =>
59
+ t.type === 'stub_package_external_payload' ||
60
+ t.type === 'stub_package_external_dep' ||
61
+ t.type === 'stub_with_string_ioc'
62
+ );
63
+ const hasExternalDep = threats.some(t =>
64
+ t.type === 'external_tarball_dep' ||
65
+ t.type === 'dependency_url_suspicious' ||
66
+ t.type === 'git_dependency_rce' ||
67
+ t.type === 'lifecycle_script_dependency'
68
+ );
69
+ return hasStub && hasExternalDep;
70
+ }
71
+ },
72
+ {
73
+ name: 'obfuscated_oversize',
74
+ description: 'Obfuscation + large file + execution path',
75
+ target: 'Shai-Hulud bun_environment.js (10MB)',
76
+ watchpoints: ['runtime_deobfuscation_executed', 'execve_chain_depth', 'outbound_non_registry'],
77
+ matches(threats, fileSizes) {
78
+ const hasObf = threats.some(t =>
79
+ t.type === 'obfuscation_detected' ||
80
+ t.type === 'js_obfuscation_pattern' ||
81
+ t.type === 'possible_obfuscation' ||
82
+ t.type === 'split_entropy_payload' ||
83
+ t.type === 'fragmented_high_entropy_cluster' ||
84
+ t.type === 'high_entropy_string'
85
+ );
86
+ const hasExec = threats.some(t =>
87
+ t.type === 'dangerous_call_exec' ||
88
+ t.type === 'dangerous_exec' ||
89
+ t.type === 'detached_process' ||
90
+ t.type === 'staged_payload' ||
91
+ t.type === 'staged_binary_payload' ||
92
+ t.type === 'binary_dropper' ||
93
+ t.type === 'bun_runtime_evasion'
94
+ );
95
+ if (!hasObf || !hasExec) return false;
96
+ // Specificity gate: this compound only matches when at least one file
97
+ // exceeds 1MB. Without that, decrypt_then_execute below is more
98
+ // appropriate. Returns false (not undefined) when no size info to keep
99
+ // the more specific decrypt_then_execute match available.
100
+ if (!fileSizes || Object.keys(fileSizes).length === 0) return false;
101
+ return Object.values(fileSizes).some(size => typeof size === 'number' && size > 1024 * 1024);
102
+ }
103
+ },
104
+ {
105
+ name: 'decrypt_then_execute',
106
+ description: 'Obfuscation or XOR decoding + new Function or eval',
107
+ target: 'Axios 2026 OrDeR_7077',
108
+ watchpoints: ['runtime_deobfuscation_executed', 'outbound_non_registry'],
109
+ matches(threats) {
110
+ const hasDecrypt = threats.some(t =>
111
+ t.type === 'base64_decode' ||
112
+ t.type === 'base64_decode_exec' ||
113
+ t.type === 'obfuscation_detected' ||
114
+ t.type === 'js_obfuscation_pattern' ||
115
+ t.type === 'crypto_decipher' ||
116
+ t.type === 'staged_eval_decode' ||
117
+ t.type === 'env_charcode_reconstruction' ||
118
+ t.type === 'string_mutation_obfuscation' ||
119
+ t.type === 'self_destruct_eval' ||
120
+ t.type === 'anti_forensic_xor_autodelete' ||
121
+ t.type === 'anti_forensic_partial' ||
122
+ t.type === 'wget_base64_decode'
123
+ );
124
+ const hasExec = threats.some(t =>
125
+ t.type === 'dangerous_call_eval' ||
126
+ t.type === 'dangerous_call_function' ||
127
+ t.type === 'dangerous_constructor' ||
128
+ t.type === 'function_runtime_args' ||
129
+ t.type === 'function_constructor_require' ||
130
+ t.type === 'staged_payload' ||
131
+ t.type === 'fetch_decrypt_exec' ||
132
+ t.type === 'vm_dynamic_code' ||
133
+ t.type === 'vm_code_execution' ||
134
+ t.type === 'reflect_code_execution' ||
135
+ t.type === 'callback_exec_rce' ||
136
+ t.type === 'eval_usage'
137
+ );
138
+ return hasDecrypt && hasExec;
139
+ }
140
+ },
141
+ {
142
+ name: 'invisible_blockchain',
143
+ description: 'Unicode invisible decoder + blockchain RPC endpoint',
144
+ target: 'GlassWorm',
145
+ watchpoints: ['outbound_blockchain_rpc', 'runtime_deobfuscation_executed'],
146
+ matches(threats) {
147
+ const hasInvisible = threats.some(t =>
148
+ t.type === 'unicode_invisible_injection' ||
149
+ t.type === 'unicode_variation_decoder'
150
+ );
151
+ const hasBlockchain = threats.some(t =>
152
+ t.type === 'blockchain_c2_resolution' ||
153
+ t.type === 'blockchain_rpc_endpoint'
154
+ );
155
+ return hasInvisible && hasBlockchain;
156
+ }
157
+ },
158
+ {
159
+ name: 'npm_token_self_use',
160
+ description: 'npmrc access + outbound HTTP or npm CLI invocation pattern',
161
+ target: 'CanisterWorm',
162
+ watchpoints: ['npm_self_invoke', 'honey_npmrc_read', 'outbound_non_registry'],
163
+ matches(threats) {
164
+ const hasNpmrc = threats.some(t =>
165
+ t.type === 'npmrc_access' ||
166
+ t.type === 'npmrc_git_override' ||
167
+ t.type === 'npm_token_steal' ||
168
+ t.type === 'npm_publish_worm'
169
+ );
170
+ const hasOutbound = threats.some(t =>
171
+ t.type === 'curl_exfiltration' ||
172
+ t.type === 'curl_env_exfil' ||
173
+ t.type === 'github_api_call' ||
174
+ t.type === 'remote_code_load' ||
175
+ t.type === 'network_require' ||
176
+ t.type === 'websocket_credential_exfil' ||
177
+ t.type === 'websocket_c2' ||
178
+ t.type === 'dns_chunk_exfiltration' ||
179
+ t.type === 'staged_payload' ||
180
+ t.type === 'fetch_decrypt_exec'
181
+ );
182
+ return hasNpmrc && hasOutbound;
183
+ }
184
+ }
185
+ ];
186
+
187
+ /**
188
+ * Evaluate whether the static threat set warrants sandbox activation.
189
+ *
190
+ * @param {Array<{type:string,severity:string}>} threats - Deduplicated static threats.
191
+ * @param {number} score - Preliminary static score.
192
+ * @param {object} [fileSizes] - Map relative-path -> bytes (used by obfuscated_oversize).
193
+ * @returns {{shouldRun:boolean, compound:string|null, watchpoints:string[], reason:string}}
194
+ */
195
+ function evaluateSandboxTrigger(threats, score, fileSizes) {
196
+ if (!Array.isArray(threats)) {
197
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'no threats array' };
198
+ }
199
+ if (typeof score !== 'number' || Number.isNaN(score)) {
200
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'invalid score' };
201
+ }
202
+ if (score < SANDBOX_TRIGGER_MIN_SCORE) {
203
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'score below window' };
204
+ }
205
+ if (score > SANDBOX_TRIGGER_MAX_SCORE) {
206
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'score above window' };
207
+ }
208
+ for (const trigger of TRIGGERS) {
209
+ let matched = false;
210
+ try {
211
+ matched = trigger.matches(threats, fileSizes || {});
212
+ } catch (e) {
213
+ matched = false;
214
+ }
215
+ if (matched) {
216
+ return {
217
+ shouldRun: true,
218
+ compound: trigger.name,
219
+ watchpoints: trigger.watchpoints.slice(),
220
+ reason: trigger.description
221
+ };
222
+ }
223
+ }
224
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'no compound matched' };
225
+ }
226
+
227
+ module.exports = {
228
+ evaluateSandboxTrigger,
229
+ TRIGGERS,
230
+ SANDBOX_TRIGGER_MIN_SCORE,
231
+ SANDBOX_TRIGGER_MAX_SCORE
232
+ };
package/src/scoring.js CHANGED
@@ -447,7 +447,16 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
447
447
  'pypi_malicious_package',
448
448
  'ai_config_injection', 'ai_config_injection_compound',
449
449
  'detached_credential_exfil', // DPRK/Lazarus: invoked via lifecycle, not require/import
450
- 'native_addon_install' // binding.gyp executes during npm install but isn't require()'d
450
+ 'native_addon_install', // binding.gyp executes during npm install but isn't require()'d
451
+ // Staged loader pattern (chai-* / poxios-chain campaign 2026-05): the malicious
452
+ // file is loaded indirectly (transport.js requires caller.js) and reachability
453
+ // resolution can fail, demoting CRITICAL to LOW. These types are unambiguously
454
+ // malicious — no legitimate code shadows process, calls Function.constructor("require"),
455
+ // or self-destructs after running new Function(...).
456
+ 'function_constructor_require', // AST-086 — Function.constructor("require", body)
457
+ 'process_variable_shadow', // AST-087 — const process = {env:{...}}
458
+ 'function_runtime_args', // AST-090 — new Function('require','__dirname',...)
459
+ 'self_destruct_eval' // AST-089 — dynamic exec + unlink __filename
451
460
  ]);
452
461
 
453
462
  // ============================================
@@ -569,6 +578,20 @@ const SCORING_COMPOUNDS = [
569
578
  message: 'Stub package with external URL dep + known string IOC — chain-attack staging package (scoring compound).',
570
579
  fileFrom: 'ioc_string_match'
571
580
  },
581
+ // Security review 2026-05-09 — chai-* / poxios-chain / express-guardrail / justenv
582
+ // campaign. Pattern: fork pino + caller.js with `const process = {env: {DEV_API_KEY: <base64>}}`
583
+ // + axios.get(decoded URL) + new Function.constructor("require", body). The package
584
+ // body is otherwise legitimate pino code — only the injected file is malicious.
585
+ // Each individual signal is already CRITICAL/HIGH but reachability/per-file scoring
586
+ // can demote them. The compound recovers the signal when 2+ co-occur in the same file.
587
+ {
588
+ type: 'staged_remote_loader',
589
+ requires: ['function_constructor_require', 'process_variable_shadow'],
590
+ severity: 'CRITICAL',
591
+ message: 'Function.constructor("require", body) + shadowed process env in same file — staged remote loader (chai-* / poxios-chain pattern). Payload fetched at runtime from external paste service.',
592
+ fileFrom: 'function_constructor_require',
593
+ sameFile: true
594
+ },
572
595
  ];
573
596
 
574
597
  /**