muaddib-scanner 2.11.6 → 2.11.8
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/.env.example +31 -0
- package/README.md +10 -7
- package/package.json +4 -4
- package/src/ioc/scraper.js +135 -6
- package/src/ioc/updater.js +11 -8
- 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/.env.example
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# MUAD'DIB environment variables — template
|
|
2
|
+
# Copy to .env (local dev) or /opt/muaddib/.env (VPS) and fill in real values.
|
|
3
|
+
# .env files are gitignored. NEVER commit a real token.
|
|
4
|
+
|
|
5
|
+
# ----------------------------------------------------------------------------
|
|
6
|
+
# Threat-feed API tokens (all OPTIONAL — scrapers degrade gracefully if absent)
|
|
7
|
+
# ----------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
# OpenSourceMalware.com — community-verified threat intel
|
|
10
|
+
# Free tier: 60 req/min, /query-latest gives 100 most recent threats per ecosystem.
|
|
11
|
+
# Sign up + generate at: https://opensourcemalware.com/auth → profile → API Tokens
|
|
12
|
+
# Format: osm_<random-32+chars>
|
|
13
|
+
# Used by: src/ioc/scraper.js → scrapeOSMQueryLatest()
|
|
14
|
+
OSM_API_TOKEN=
|
|
15
|
+
|
|
16
|
+
# ----------------------------------------------------------------------------
|
|
17
|
+
# Webhook destinations (optional — monitor alerts)
|
|
18
|
+
# ----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
# Discord webhook for monitor alerts (P1/P2/P3 triage)
|
|
21
|
+
# DISCORD_WEBHOOK_URL=
|
|
22
|
+
|
|
23
|
+
# ----------------------------------------------------------------------------
|
|
24
|
+
# Tuning gates (already documented in deploy/muaddib-monitor.service)
|
|
25
|
+
# ----------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
# MUADDIB_FN_REACHABILITY=1
|
|
28
|
+
# MUADDIB_DECAY=1
|
|
29
|
+
# MUADDIB_MATURE_CAP=1
|
|
30
|
+
# MUADDIB_METADATA_FACTOR=1
|
|
31
|
+
# MUADDIB_DELTA_MODE=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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.8",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"node": ">=18.0.0"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@inquirer/prompts": "8.4.
|
|
49
|
+
"@inquirer/prompts": "8.4.2",
|
|
50
50
|
"acorn": "8.16.0",
|
|
51
51
|
"acorn-walk": "8.3.5",
|
|
52
52
|
"adm-zip": "0.5.17",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@eslint/js": "10.0.1",
|
|
60
|
-
"eslint": "10.
|
|
60
|
+
"eslint": "10.3.0",
|
|
61
61
|
"eslint-plugin-security": "^4.0.0",
|
|
62
|
-
"globals": "17.
|
|
62
|
+
"globals": "17.6.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/ioc/scraper.js
CHANGED
|
@@ -980,8 +980,8 @@ async function scrapeGitHubAdvisory() {
|
|
|
980
980
|
// ============================================
|
|
981
981
|
async function runScraper() {
|
|
982
982
|
console.log('\n' + '='.repeat(60));
|
|
983
|
-
console.log(' MUAD\'DIB IOC Scraper v4.
|
|
984
|
-
console.log(' OSV + OSSF + GenSecAI + DataDog +
|
|
983
|
+
console.log(' MUAD\'DIB IOC Scraper v4.1');
|
|
984
|
+
console.log(' OSV + OSSF + GenSecAI + DataDog + Aikido + OSM');
|
|
985
985
|
console.log('='.repeat(60) + '\n');
|
|
986
986
|
|
|
987
987
|
// Reset aggregated warning counters
|
|
@@ -1038,7 +1038,8 @@ async function runScraper() {
|
|
|
1038
1038
|
scrapeOSSFMaliciousPackages(osvResult.knownIds),
|
|
1039
1039
|
scrapeGitHubAdvisory(),
|
|
1040
1040
|
scrapeOSVPyPIDataDump(),
|
|
1041
|
-
scrapeAikidoMalwareFeed()
|
|
1041
|
+
scrapeAikidoMalwareFeed(),
|
|
1042
|
+
scrapeOSMQueryLatest()
|
|
1042
1043
|
]);
|
|
1043
1044
|
|
|
1044
1045
|
const shaiHuludResult = results[0];
|
|
@@ -1047,6 +1048,7 @@ async function runScraper() {
|
|
|
1047
1048
|
const githubPackages = results[3];
|
|
1048
1049
|
const pypiPackages = results[4];
|
|
1049
1050
|
const aikidoResult = results[5];
|
|
1051
|
+
const osmResult = results[6];
|
|
1050
1052
|
|
|
1051
1053
|
// Log aggregated warnings
|
|
1052
1054
|
if (_noVersionSkipCount > 0) {
|
|
@@ -1060,7 +1062,8 @@ async function runScraper() {
|
|
|
1060
1062
|
...datadogResult.packages,
|
|
1061
1063
|
...ossfPackages,
|
|
1062
1064
|
...githubPackages,
|
|
1063
|
-
...aikidoResult.packages
|
|
1065
|
+
...aikidoResult.packages,
|
|
1066
|
+
...osmResult.packages
|
|
1064
1067
|
];
|
|
1065
1068
|
|
|
1066
1069
|
// Merge all hashes
|
|
@@ -1072,7 +1075,7 @@ async function runScraper() {
|
|
|
1072
1075
|
// Smart deduplication: build map of best entry per key
|
|
1073
1076
|
// For duplicates, keep the one with highest confidence, then most recent date
|
|
1074
1077
|
const dedupSpinner = new Spinner();
|
|
1075
|
-
dedupSpinner.start('Deduplicating ' + allPackages.length + ' npm + ' + (pypiPackages.length + (aikidoResult.pypi_packages || []).length) + ' PyPI entries...');
|
|
1078
|
+
dedupSpinner.start('Deduplicating ' + allPackages.length + ' npm + ' + (pypiPackages.length + (aikidoResult.pypi_packages || []).length + (osmResult.pypi_packages || []).length) + ' PyPI entries...');
|
|
1076
1079
|
const dedupMap = new Map();
|
|
1077
1080
|
|
|
1078
1081
|
// Seed with existing IOCs (with sanitization of stale comma-in-version entries)
|
|
@@ -1173,7 +1176,7 @@ async function runScraper() {
|
|
|
1173
1176
|
}
|
|
1174
1177
|
let addedPyPIPackages = 0;
|
|
1175
1178
|
// Merge Aikido PyPI feed into the same loop
|
|
1176
|
-
const allPyPIPackages = pypiPackages.concat(aikidoResult.pypi_packages || []);
|
|
1179
|
+
const allPyPIPackages = pypiPackages.concat(aikidoResult.pypi_packages || [], osmResult.pypi_packages || []);
|
|
1177
1180
|
for (const pkg of allPyPIPackages) {
|
|
1178
1181
|
if (!validateIOCEntry(pkg.name, pkg.version, 'pypi')) {
|
|
1179
1182
|
skippedInvalid++;
|
|
@@ -1411,6 +1414,131 @@ async function scrapeAikidoMalwareFeed() {
|
|
|
1411
1414
|
return { packages: npmPackages, pypi_packages: pypiPackages };
|
|
1412
1415
|
}
|
|
1413
1416
|
|
|
1417
|
+
// ============================================
|
|
1418
|
+
// SOURCE 7: OpenSourceMalware.com (community-verified threat intel)
|
|
1419
|
+
// Free tier: 60 req/min, /query-latest returns 100 most recent verified threats per
|
|
1420
|
+
// ecosystem. Token stored in OSM_API_TOKEN env var (NEVER hardcoded — public repo).
|
|
1421
|
+
// API: https://api.opensourcemalware.com/functions/v1/query-latest?ecosystem={npm|pypi}
|
|
1422
|
+
// Docs: https://docs.opensourcemalware.com/api/query-latest.md
|
|
1423
|
+
// Rate-limit ref: https://docs.opensourcemalware.com/api/rate-limits.md
|
|
1424
|
+
// ============================================
|
|
1425
|
+
async function scrapeOSMQueryLatest() {
|
|
1426
|
+
console.log('[SCRAPER] OpenSourceMalware.com query-latest...');
|
|
1427
|
+
const token = process.env.OSM_API_TOKEN;
|
|
1428
|
+
if (!token) {
|
|
1429
|
+
console.log('[SCRAPER] OSM_API_TOKEN not set — skipping (graceful, no error).');
|
|
1430
|
+
return { packages: [], pypi_packages: [] };
|
|
1431
|
+
}
|
|
1432
|
+
// Defensive token shape check (don't log the value)
|
|
1433
|
+
if (typeof token !== 'string' || !token.startsWith('osm_') || token.length < 16) {
|
|
1434
|
+
console.log('[SCRAPER] OSM_API_TOKEN malformed (expected osm_<chars>) — skipping.');
|
|
1435
|
+
return { packages: [], pypi_packages: [] };
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const npmPackages = [];
|
|
1439
|
+
const pypiPackages = [];
|
|
1440
|
+
const headers = { Authorization: 'Bearer ' + token };
|
|
1441
|
+
|
|
1442
|
+
// Map OSM severity_level (low/medium/high/critical) to MUAD'DIB severity (lowercase).
|
|
1443
|
+
// OSM doesn't always populate severity; default to 'high' (verified threats are high-confidence by definition).
|
|
1444
|
+
function mapSeverity(s) {
|
|
1445
|
+
if (!s || typeof s !== 'string') return 'high';
|
|
1446
|
+
const v = s.toLowerCase().trim();
|
|
1447
|
+
if (v === 'low' || v === 'medium' || v === 'high' || v === 'critical') return v;
|
|
1448
|
+
return 'high';
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function buildReferences(threat, ecosystem) {
|
|
1452
|
+
const refs = [];
|
|
1453
|
+
if (threat.osv_advisory_url && typeof threat.osv_advisory_url === 'string') refs.push(threat.osv_advisory_url);
|
|
1454
|
+
if (threat.ghsa_advisory_url && typeof threat.ghsa_advisory_url === 'string') refs.push(threat.ghsa_advisory_url);
|
|
1455
|
+
// Canonical OSM page for this threat. Best-effort URL — if 404 it's harmless metadata.
|
|
1456
|
+
if (threat.package_name) {
|
|
1457
|
+
refs.push('https://opensourcemalware.com/' + ecosystem + '/' + encodeURIComponent(threat.package_name));
|
|
1458
|
+
}
|
|
1459
|
+
return refs;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function buildDescription(threat) {
|
|
1463
|
+
const parts = [];
|
|
1464
|
+
if (threat.threat_description) parts.push(String(threat.threat_description));
|
|
1465
|
+
if (Array.isArray(threat.tags) && threat.tags.length > 0) {
|
|
1466
|
+
parts.push('Tags: ' + threat.tags.filter(t => typeof t === 'string').join(', '));
|
|
1467
|
+
}
|
|
1468
|
+
if (threat.researcher) parts.push('Reporter: ' + String(threat.researcher));
|
|
1469
|
+
return parts.length > 0 ? parts.join(' — ') : 'Verified by OpenSourceMalware.com community';
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
async function pull(ecosystem, target) {
|
|
1473
|
+
try {
|
|
1474
|
+
const { status, data } = await fetchJSON(
|
|
1475
|
+
'https://api.opensourcemalware.com/functions/v1/query-latest?ecosystem=' + encodeURIComponent(ecosystem),
|
|
1476
|
+
{ headers }
|
|
1477
|
+
);
|
|
1478
|
+
if (status === 401 || status === 403) {
|
|
1479
|
+
console.log('[SCRAPER] OSM ' + ecosystem + ': HTTP ' + status + ' — token rejected, check OSM_API_TOKEN.');
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (status !== 200) {
|
|
1483
|
+
console.log('[SCRAPER] OSM ' + ecosystem + ' feed: HTTP ' + status);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
if (!data || !Array.isArray(data.threats)) {
|
|
1487
|
+
console.log('[SCRAPER] OSM ' + ecosystem + ' feed: unexpected response shape (no threats[]).');
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
let count = 0;
|
|
1491
|
+
for (const t of data.threats) {
|
|
1492
|
+
if (!t || typeof t.package_name !== 'string' || t.package_name.length === 0) continue;
|
|
1493
|
+
// OSM verifies report_type === 'package' threats. Skip anything else (repository/url/domain).
|
|
1494
|
+
// The query is filtered by ecosystem, but defensive check on registry field.
|
|
1495
|
+
if (t.report_type && t.report_type !== 'package') continue;
|
|
1496
|
+
// Normalize version: OSM uses free-form strings ('all', 'any', 'unknown', null, etc.)
|
|
1497
|
+
// Wildcard placeholders must be MUAD'DIB's canonical '*' so the IOC matcher hits.
|
|
1498
|
+
let ver = '*';
|
|
1499
|
+
if (t.version_info && typeof t.version_info === 'string') {
|
|
1500
|
+
const trimmed = t.version_info.trim();
|
|
1501
|
+
const lc = trimmed.toLowerCase();
|
|
1502
|
+
if (trimmed !== '' && lc !== 'all' && lc !== 'any' && lc !== 'unknown' && lc !== '*' && lc !== 'n/a') {
|
|
1503
|
+
ver = trimmed;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
const severity = mapSeverity(t.severity_level);
|
|
1507
|
+
const addedAt = t.verified_at || t.created_at || t.first_seen || new Date().toISOString();
|
|
1508
|
+
const idPrefix = ecosystem === 'pypi' ? 'OSM-PYPI-' : 'OSM-';
|
|
1509
|
+
target.push({
|
|
1510
|
+
id: idPrefix + t.package_name + '-' + ver,
|
|
1511
|
+
name: t.package_name,
|
|
1512
|
+
version: ver,
|
|
1513
|
+
severity: severity,
|
|
1514
|
+
confidence: 'high',
|
|
1515
|
+
source: 'osm',
|
|
1516
|
+
description: buildDescription(t),
|
|
1517
|
+
references: buildReferences(t, ecosystem),
|
|
1518
|
+
mitre: 'T1195.002',
|
|
1519
|
+
freshness: {
|
|
1520
|
+
added_at: addedAt,
|
|
1521
|
+
source: 'osm',
|
|
1522
|
+
confidence: 'high'
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
count++;
|
|
1526
|
+
}
|
|
1527
|
+
console.log('[SCRAPER] ' + count + ' ' + ecosystem + ' verified threats from OSM');
|
|
1528
|
+
} catch (e) {
|
|
1529
|
+
// Defensive: do NOT echo any token-bearing URL or header in the error.
|
|
1530
|
+
console.log('[SCRAPER] OSM ' + ecosystem + ' error: ' + (e && e.message ? e.message : 'unknown'));
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Sequential (not parallel): keeps us well under the 60 req/min rate limit
|
|
1535
|
+
// and gives clearer logs. Two ecosystems = ~200ms total.
|
|
1536
|
+
await pull('npm', npmPackages);
|
|
1537
|
+
await pull('pypi', pypiPackages);
|
|
1538
|
+
|
|
1539
|
+
return { packages: npmPackages, pypi_packages: pypiPackages };
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1414
1542
|
// ============================================
|
|
1415
1543
|
// SOURCE 5: OSV.dev Lightweight API
|
|
1416
1544
|
// Used by `muaddib update` (fast, no zip download)
|
|
@@ -1529,6 +1657,7 @@ function getSourceConfidence(pkg) {
|
|
|
1529
1657
|
module.exports = {
|
|
1530
1658
|
runScraper, scrapeShaiHuludDetector, scrapeDatadogIOCs,
|
|
1531
1659
|
scrapeAikidoMalwareFeed,
|
|
1660
|
+
scrapeOSMQueryLatest,
|
|
1532
1661
|
scrapeOSVLightweightAPI, queryOSVBatch,
|
|
1533
1662
|
getSourceConfidence,
|
|
1534
1663
|
// Pure utility functions (exported for testing)
|
package/src/ioc/updater.js
CHANGED
|
@@ -39,25 +39,28 @@ async function updateIOCs() {
|
|
|
39
39
|
mergeIOCs(baseIOCs, yamlStandard);
|
|
40
40
|
console.log('[2/4] YAML IOCs: ' + yamlStandard.packages.length + ' packages, ' + yamlStandard.hashes.length + ' hashes');
|
|
41
41
|
|
|
42
|
-
// Step 3: Download additional IOCs from GitHub + OSV API (GenSecAI + DataDog + OSV lightweight)
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// Step 3: Download additional IOCs from GitHub + OSV API (GenSecAI + DataDog + OSV lightweight + OSM)
|
|
43
|
+
// Light path: JSON/REST only, NO heavy zip dumps. Designed to be safe at 15min cadence.
|
|
44
|
+
// For the deep refresh (OSV zip dumps + OSSF + Aikido + GitHub Advisory), use `muaddib scrape` (~5min).
|
|
45
|
+
const { scrapeShaiHuludDetector, scrapeDatadogIOCs, scrapeOSVLightweightAPI, scrapeOSMQueryLatest } = require('./scraper.js');
|
|
46
|
+
console.log('[3/4] Downloading GitHub + OSV API + OSM IOCs...');
|
|
45
47
|
|
|
46
|
-
const [shaiHulud, datadog, osvApi] = await Promise.all([
|
|
48
|
+
const [shaiHulud, datadog, osvApi, osmResult] = await Promise.all([
|
|
47
49
|
scrapeShaiHuludDetector(),
|
|
48
50
|
scrapeDatadogIOCs(),
|
|
49
|
-
scrapeOSVLightweightAPI()
|
|
51
|
+
scrapeOSVLightweightAPI(),
|
|
52
|
+
scrapeOSMQueryLatest()
|
|
50
53
|
]);
|
|
51
54
|
|
|
52
55
|
const githubIOCs = {
|
|
53
|
-
packages: [].concat(shaiHulud.packages, datadog.packages, osvApi),
|
|
54
|
-
pypi_packages: [],
|
|
56
|
+
packages: [].concat(shaiHulud.packages, datadog.packages, osvApi, osmResult.packages),
|
|
57
|
+
pypi_packages: (osmResult.pypi_packages || []).slice(),
|
|
55
58
|
hashes: [].concat(shaiHulud.hashes || [], datadog.hashes || []),
|
|
56
59
|
markers: [],
|
|
57
60
|
files: []
|
|
58
61
|
};
|
|
59
62
|
mergeIOCs(baseIOCs, githubIOCs);
|
|
60
|
-
console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API');
|
|
63
|
+
console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API, +' + osmResult.packages.length + ' OSM npm, +' + (osmResult.pypi_packages || []).length + ' OSM PyPI');
|
|
61
64
|
|
|
62
65
|
// Step 3b: Load existing cache IOCs (from bootstrap download or previous update)
|
|
63
66
|
if (fs.existsSync(CACHE_IOC_FILE)) {
|
|
@@ -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
|
/**
|