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 +10 -7
- package/package.json +1 -1
- package/src/response/playbooks.js +3 -0
- package/src/rules/index.js +14 -2
- package/src/runtime/monitor-feed.js +241 -0
- package/src/sandbox/compound-triggers.js +232 -0
- package/src/scoring.js +24 -1
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 **
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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.
|
|
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
|
-
**
|
|
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
|
-
- **
|
|
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 (
|
|
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
|
@@ -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
|
|
package/src/rules/index.js
CHANGED
|
@@ -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
|
|
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
|
/**
|