muaddib-scanner 2.11.75 → 2.11.76
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/package.json
CHANGED
package/src/monitor/ingestion.js
CHANGED
|
@@ -370,7 +370,8 @@ const RECENT_PUBLISH_MAX = 5;
|
|
|
370
370
|
* @returns {Object|null} - {
|
|
371
371
|
* version, tarball, unpackedSize, scripts, homepage, description,
|
|
372
372
|
* latestTagVersion, // dist-tags.latest (may differ from `version` under ATO)
|
|
373
|
-
* recentVersions: [{ version, tarball, unpackedSize, scripts }, ...]
|
|
373
|
+
* recentVersions: [{ version, tarball, unpackedSize, scripts }, ...], // capped at maxRecent
|
|
374
|
+
* recentWindowCount, // TRUE (uncapped) count of versions in the window (Phase 2b burst)
|
|
374
375
|
* } or null if no usable version found
|
|
375
376
|
*/
|
|
376
377
|
function selectMostRecentVersion(packument, options = {}) {
|
|
@@ -419,14 +420,19 @@ function selectMostRecentVersion(packument, options = {}) {
|
|
|
419
420
|
recentVersions: [],
|
|
420
421
|
};
|
|
421
422
|
|
|
422
|
-
// Burst extras: other versions published within the recent window, excluding
|
|
423
|
-
//
|
|
424
|
-
//
|
|
423
|
+
// Burst extras: other versions published within the recent window, excluding the
|
|
424
|
+
// most-recent one. The enqueue list is bounded by maxRecent, but recentWindowCount is
|
|
425
|
+
// the TRUE (uncapped) number of versions in the window — Phase 2b burst detection uses it
|
|
426
|
+
// so a 96-version Miasma burst is distinguishable from a legit 3-5 patch-release day (the
|
|
427
|
+
// capped list alone tops out at maxRecent+1 and can't tell them apart).
|
|
428
|
+
result.recentWindowCount = 1; // includes the most-recent version itself
|
|
425
429
|
if (versionTimes.length > 1) {
|
|
426
430
|
const cutoff = versionTimes[0][1] - recentWindowMs;
|
|
427
|
-
for (let i = 1; i < versionTimes.length
|
|
431
|
+
for (let i = 1; i < versionTimes.length; i++) {
|
|
428
432
|
const [v, ts] = versionTimes[i];
|
|
429
433
|
if (ts < cutoff) break; // sorted desc, so once we cross the cutoff we're done
|
|
434
|
+
result.recentWindowCount++;
|
|
435
|
+
if (result.recentVersions.length >= maxRecent) continue; // enqueue list capped; count continues
|
|
430
436
|
const vData = versions[v];
|
|
431
437
|
if (!vData) continue;
|
|
432
438
|
result.recentVersions.push({
|
|
@@ -819,6 +825,7 @@ async function pollNpmChanges(state, scanQueue, stats) {
|
|
|
819
825
|
unpackedSize: docMeta ? docMeta.unpackedSize : 0,
|
|
820
826
|
registryScripts: docMeta ? docMeta.scripts : null,
|
|
821
827
|
_cacheTrigger: cacheTrigger.shouldCache ? cacheTrigger : null,
|
|
828
|
+
firstPublish: cacheTrigger.shouldCache && cacheTrigger.reason === 'first_publish',
|
|
822
829
|
isIOCMatch: isKnownIOC
|
|
823
830
|
});
|
|
824
831
|
queued++;
|
package/src/monitor/queue.js
CHANGED
|
@@ -57,6 +57,7 @@ const {
|
|
|
57
57
|
buildAlertData,
|
|
58
58
|
persistAlert,
|
|
59
59
|
sendIOCPreAlert,
|
|
60
|
+
sendBurstPreAlert,
|
|
60
61
|
matchVersionedIOC,
|
|
61
62
|
buildCanaryExfiltrationWebhookEmbed,
|
|
62
63
|
getWebhookUrl,
|
|
@@ -130,6 +131,21 @@ const RECENTLY_SCANNED_MAX = 50_000; // FIFO cap for the dedup Set (P0c — boun
|
|
|
130
131
|
const FIRST_PUBLISH_SANDBOX_MAX_QUEUE = parseInt(process.env.MUADDIB_FIRST_PUBLISH_SANDBOX_MAX_QUEUE, 10) || 10;
|
|
131
132
|
const FIRST_PUBLISH_SANDBOX_ENABLED = process.env.MUADDIB_FIRST_PUBLISH_SANDBOX !== '0';
|
|
132
133
|
|
|
134
|
+
// Phase 2b: burst (Miasma) pre-alert. A burst = >= this many versions of ONE name in the
|
|
135
|
+
// recent-publish window (the TRUE uncapped count, selectMostRecentVersion.recentWindowCount).
|
|
136
|
+
// Default 10: detection is PER-NAME, so legit multi-PLATFORM publishers (different names,
|
|
137
|
+
// e.g. @opencode-ai/cli-*-* binaries) are never caught; legit same-name release days rarely
|
|
138
|
+
// reach 10; Miasma's 96-in-72s clears it easily. Per-name + deduped + non-scoring (Discord
|
|
139
|
+
// heads-up only, no FPR impact). Env-tunable up if a feed proves noisy.
|
|
140
|
+
const BURST_PREALERT_MIN_VERSIONS = (() => {
|
|
141
|
+
const n = parseInt(process.env.MUADDIB_BURST_MIN_VERSIONS, 10);
|
|
142
|
+
return Number.isFinite(n) && n >= 2 ? n : 10;
|
|
143
|
+
})();
|
|
144
|
+
// Dedup burst pings: one per name per process window (bounded — cleared at the cap so it
|
|
145
|
+
// can never grow without limit, CLAUDE.md §2).
|
|
146
|
+
const _burstAlerted = new Set();
|
|
147
|
+
const BURST_ALERTED_MAX = 20_000;
|
|
148
|
+
|
|
133
149
|
// Stage 3 — sandbox gate. Static-score threshold below which T1b/T2 packages
|
|
134
150
|
// are NOT sandboxed (static result alone is authoritative). Tightens the prior
|
|
135
151
|
// "T1b sandbox if score >= 25 or queue < 20" to remove low-signal sandbox runs
|
|
@@ -1429,6 +1445,25 @@ async function resolveTarballAndScan(item, stats, dailyAlerts, recentlyScanned,
|
|
|
1429
1445
|
// only scan whichever version happened to be the most recent at resolution
|
|
1430
1446
|
// time, racing the publish stream.
|
|
1431
1447
|
const recents = Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions : [];
|
|
1448
|
+
// Phase 2b: burst = TRUE count of versions of this name in the recent window
|
|
1449
|
+
// (uncapped recentWindowCount), NOT the capped extras list — so a 96-version Miasma
|
|
1450
|
+
// burst is distinguishable from a legit multi-version day. At/above the threshold,
|
|
1451
|
+
// flag the item (protects it + its extras from queue-cap eviction) and fire ONE
|
|
1452
|
+
// burst pre-alert per name (deduped, bounded).
|
|
1453
|
+
const burstCount = Number.isFinite(npmInfo.recentWindowCount) ? npmInfo.recentWindowCount : (recents.length + 1);
|
|
1454
|
+
const isBurst = burstCount >= BURST_PREALERT_MIN_VERSIONS;
|
|
1455
|
+
if (isBurst) {
|
|
1456
|
+
item.isBurst = true;
|
|
1457
|
+
if (!_burstAlerted.has(item.name)) {
|
|
1458
|
+
if (_burstAlerted.size >= BURST_ALERTED_MAX) _burstAlerted.clear();
|
|
1459
|
+
_burstAlerted.add(item.name);
|
|
1460
|
+
stats.burstPreAlerts = (stats.burstPreAlerts || 0) + 1;
|
|
1461
|
+
console.log(`[MONITOR] BURST PRE-ALERT: ${item.name} — ${burstCount} versions in the recent window`);
|
|
1462
|
+
sendBurstPreAlert(item.name, burstCount, item.ecosystem).catch(err => {
|
|
1463
|
+
console.error(`[MONITOR] burst pre-alert webhook failed for ${item.name}: ${err.message}`);
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1432
1467
|
for (const recent of recents) {
|
|
1433
1468
|
if (!recent || !recent.tarball || !recent.version) continue;
|
|
1434
1469
|
const dedupeKey = `${item.name}@${recent.version}`;
|
|
@@ -1441,6 +1476,7 @@ async function resolveTarballAndScan(item, stats, dailyAlerts, recentlyScanned,
|
|
|
1441
1476
|
unpackedSize: recent.unpackedSize || 0,
|
|
1442
1477
|
registryScripts: recent.scripts || null,
|
|
1443
1478
|
atoSignal: item.atoSignal === true,
|
|
1479
|
+
isBurst,
|
|
1444
1480
|
isATOBurstExtra: true,
|
|
1445
1481
|
}, stats);
|
|
1446
1482
|
}
|
|
@@ -24,32 +24,58 @@ const MAX_SCAN_QUEUE = (() => {
|
|
|
24
24
|
const HARD_DROP_LOG_INTERVAL_MS = 10_000;
|
|
25
25
|
let _lastHardDropLog = 0;
|
|
26
26
|
|
|
27
|
+
// Phase 2b: classes we never want to drop blindly when the queue caps out — the
|
|
28
|
+
// specifically-targeted scans (known-malicious, burst/ATO, first-publish). Eviction drops
|
|
29
|
+
// the oldest UNPROTECTED item instead; only if a bounded head-window is entirely protected
|
|
30
|
+
// do we fall back to strict-oldest (still ledgered, with a distinct source).
|
|
31
|
+
function _isProtected(item) {
|
|
32
|
+
return !!(item && (item.isIOCMatch || item.isBurst || item.firstPublish || item.atoSignal || item.isATOBurstExtra));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// How far from the head we scan for an unprotected victim. Protected items are a small
|
|
36
|
+
// fraction of the flood, so a victim is almost always found within a few slots; the bound
|
|
37
|
+
// keeps eviction O(window) under sustained overflow (CLAUDE.md §2 bounded resources).
|
|
38
|
+
const PROTECTED_EVICTION_SCAN_MAX = (() => {
|
|
39
|
+
const v = parseInt(process.env.MUADDIB_PROTECTED_EVICTION_SCAN_MAX, 10);
|
|
40
|
+
return Number.isFinite(v) && v > 0 ? v : 1024;
|
|
41
|
+
})();
|
|
42
|
+
|
|
27
43
|
/**
|
|
28
|
-
* Push an item onto the scan queue, enforcing the hard cap
|
|
29
|
-
*
|
|
30
|
-
*
|
|
44
|
+
* Push an item onto the scan queue, enforcing the hard cap when at capacity. Evicts the
|
|
45
|
+
* oldest UNPROTECTED item (within a bounded head-window), falling back to strict-oldest if
|
|
46
|
+
* that window is all-protected. `max` defaults to MAX_SCAN_QUEUE (overridable for tests).
|
|
47
|
+
* Returns true iff an item was dropped to make room.
|
|
31
48
|
*/
|
|
32
49
|
function enqueueScan(scanQueue, item, stats, max = MAX_SCAN_QUEUE) {
|
|
33
50
|
let dropped = false;
|
|
34
51
|
if (scanQueue.length >= max) {
|
|
35
|
-
|
|
52
|
+
// Victim = oldest unprotected item within the bounded head-window; else strict oldest.
|
|
53
|
+
let victimIdx = -1;
|
|
54
|
+
const scanLimit = Math.min(scanQueue.length, PROTECTED_EVICTION_SCAN_MAX);
|
|
55
|
+
for (let i = 0; i < scanLimit; i++) {
|
|
56
|
+
if (!_isProtected(scanQueue[i])) { victimIdx = i; break; }
|
|
57
|
+
}
|
|
58
|
+
const protectedFallback = victimIdx === -1;
|
|
59
|
+
const evicted = protectedFallback ? scanQueue.shift() : scanQueue.splice(victimIdx, 1)[0];
|
|
36
60
|
dropped = true;
|
|
37
61
|
if (stats) stats.queueHardDrops = (stats.queueHardDrops || 0) + 1;
|
|
38
62
|
// Phase 0a: record the dropped item so a coverage loss keeps an identity — answers
|
|
39
63
|
// "which versions were never scanned" (e.g. the Miasma 72s/96-version burst). Lazy
|
|
40
64
|
// require avoids any top-level coupling with state.js; best-effort, never throws.
|
|
65
|
+
// A dropped PROTECTED item (all-protected head-window) gets a distinct source so the
|
|
66
|
+
// rare case stays visible in the 0b ledger rollup.
|
|
41
67
|
try {
|
|
42
68
|
if (evicted && evicted.name) {
|
|
43
69
|
require('./state.js').appendScanLedger({
|
|
44
70
|
name: evicted.name, version: evicted.version, ecosystem: evicted.ecosystem,
|
|
45
|
-
outcome: 'dropped', source: 'queue_cap'
|
|
71
|
+
outcome: 'dropped', source: protectedFallback ? 'queue_cap_protected' : 'queue_cap'
|
|
46
72
|
});
|
|
47
73
|
}
|
|
48
74
|
} catch { /* ledger is best-effort */ }
|
|
49
75
|
const now = Date.now();
|
|
50
76
|
if (now - _lastHardDropLog > HARD_DROP_LOG_INTERVAL_MS) {
|
|
51
77
|
_lastHardDropLog = now;
|
|
52
|
-
console.warn(`[MONITOR] QUEUE_HARD_DROP: scan queue at cap ${max} — dropping oldest item(s) (total dropped this session: ${stats ? stats.queueHardDrops : '?'}). Ingestion is outrunning scanning.`);
|
|
78
|
+
console.warn(`[MONITOR] QUEUE_HARD_DROP: scan queue at cap ${max} — dropping ${protectedFallback ? 'OLDEST (head-window all protected)' : 'oldest unprotected'} item(s) (total dropped this session: ${stats ? stats.queueHardDrops : '?'}). Ingestion is outrunning scanning.`);
|
|
53
79
|
}
|
|
54
80
|
}
|
|
55
81
|
scanQueue.push(item);
|
package/src/monitor/webhook.js
CHANGED
|
@@ -240,6 +240,43 @@ async function sendCampaignPreAlert(name, campaign, ecosystem = 'npm') {
|
|
|
240
240
|
await sendWebhook(url, buildCampaignPreAlertEmbed(name, campaign, ecosystem), { rawPayload: true });
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Layer 1c: Build the burst pre-alert embed (pure — no network). Exported for tests.
|
|
245
|
+
* Fires when ≥K versions of one package land in a short window (account-takeover /
|
|
246
|
+
* "Miasma" burst-publish). Amber to distinguish from IOC (red) and campaign (orange).
|
|
247
|
+
* @param {string} name - Package name
|
|
248
|
+
* @param {number} count - Number of versions seen in the burst window
|
|
249
|
+
* @param {string} [ecosystem='npm'] - 'npm' | 'pypi' | 'crates' (link target)
|
|
250
|
+
*/
|
|
251
|
+
function buildBurstPreAlertEmbed(name, count, ecosystem = 'npm') {
|
|
252
|
+
return {
|
|
253
|
+
embeds: [{
|
|
254
|
+
title: '⚠️ BURST PRE-ALERT — Rapid Multi-Version Publish',
|
|
255
|
+
color: 0xf39c12,
|
|
256
|
+
fields: [
|
|
257
|
+
{ name: 'Package', value: `[${ecosystem}/${name}](${registryLink(ecosystem, name)})`, inline: true },
|
|
258
|
+
{ name: 'Versions', value: `${count} in a short window`, inline: true },
|
|
259
|
+
{ name: 'Detection', value: 'Burst-publish (possible ATO / Miasma)', inline: true },
|
|
260
|
+
{ name: 'Status', value: 'Multiple versions published rapidly — every version queued for scan and protected from queue-cap eviction. Treat as suspect until verdicts land.', inline: false }
|
|
261
|
+
],
|
|
262
|
+
footer: {
|
|
263
|
+
text: `MUAD'DIB Burst Pre-Alert | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
|
|
264
|
+
},
|
|
265
|
+
timestamp: new Date().toISOString()
|
|
266
|
+
}]
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Layer 1c: Send a burst pre-alert webhook. Fire-and-forget; callers dedupe per
|
|
272
|
+
* name/window so a burst pings once, not once per version.
|
|
273
|
+
*/
|
|
274
|
+
async function sendBurstPreAlert(name, count, ecosystem = 'npm') {
|
|
275
|
+
const url = getWebhookUrl();
|
|
276
|
+
if (!url) return;
|
|
277
|
+
await sendWebhook(url, buildBurstPreAlertEmbed(name, count, ecosystem), { rawPayload: true });
|
|
278
|
+
}
|
|
279
|
+
|
|
243
280
|
/**
|
|
244
281
|
* Check if a specific package@version matches a versioned IOC entry.
|
|
245
282
|
* Returns the matching IOC entry or null.
|
|
@@ -1399,6 +1436,8 @@ module.exports = {
|
|
|
1399
1436
|
sendIOCPreAlert,
|
|
1400
1437
|
buildCampaignPreAlertEmbed,
|
|
1401
1438
|
sendCampaignPreAlert,
|
|
1439
|
+
buildBurstPreAlertEmbed,
|
|
1440
|
+
sendBurstPreAlert,
|
|
1402
1441
|
matchVersionedIOC,
|
|
1403
1442
|
computeRiskLevel,
|
|
1404
1443
|
computeRiskScore,
|