muaddib-scanner 2.11.130 → 2.11.132
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/classify.js
CHANGED
|
@@ -405,6 +405,17 @@ function evaluateCacheTrigger(name, docMeta, doc, opts = {}) {
|
|
|
405
405
|
return { shouldCache: true, reason: 'first_publish', retentionDays: TARBALL_CACHE_DEFAULT_RETENTION_DAYS };
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
// Trigger 4: account-takeover / burst -- 30-day retention (high-risk fast-takedown class).
|
|
409
|
+
// These are batch/registry-derived (published version vs dist-tags.latest; per-name recent
|
|
410
|
+
// version count), NOT knowable from name+docMeta at ingestion, so the caller
|
|
411
|
+
// (preResolveNpmBatch) computes them from _npmInfo and passes opts.{atoSignal,isBurst}.
|
|
412
|
+
// Closes the Leo Platform gap (June 2026): 20 existing packages re-published at non-latest
|
|
413
|
+
// versions matched neither typosquat nor first_publish -> never prefetched -> the malicious
|
|
414
|
+
// tarballs were 404'd before scan. ATO catches that class; burst catches Miasma-style floods.
|
|
415
|
+
if (opts.atoSignal || opts.isBurst) {
|
|
416
|
+
return { shouldCache: true, reason: opts.atoSignal ? 'ato' : 'burst', retentionDays: TARBALL_CACHE_HIGH_RISK_RETENTION_DAYS };
|
|
417
|
+
}
|
|
418
|
+
|
|
408
419
|
return { shouldCache: false, reason: '', retentionDays: 0 };
|
|
409
420
|
}
|
|
410
421
|
|
package/src/monitor/ingestion.js
CHANGED
|
@@ -598,6 +598,14 @@ function preResolveShouldShed(scanQueue) {
|
|
|
598
598
|
// in-flight work, not all the chunks that already completed. When scanQueue
|
|
599
599
|
// is omitted (unit tests, lib usage), items are only mutated in place and the
|
|
600
600
|
// caller decides when to push.
|
|
601
|
+
// Burst threshold for the capture-at-publish prefetch trigger. Mirrors
|
|
602
|
+
// BURST_PREALERT_MIN_VERSIONS (queue.js:212) and reads the SAME env var so ops tunes
|
|
603
|
+
// one knob. Per-name version count in the recent-publish window.
|
|
604
|
+
const BURST_MIN_VERSIONS_PREFETCH = (() => {
|
|
605
|
+
const n = parseInt(process.env.MUADDIB_BURST_MIN_VERSIONS, 10);
|
|
606
|
+
return Number.isFinite(n) && n >= 2 ? n : 10;
|
|
607
|
+
})();
|
|
608
|
+
|
|
601
609
|
async function preResolveNpmBatch(items, stats, scanQueue) {
|
|
602
610
|
if (!items || items.length === 0) return;
|
|
603
611
|
const start = Date.now();
|
|
@@ -644,6 +652,25 @@ async function preResolveNpmBatch(items, stats, scanQueue) {
|
|
|
644
652
|
// weekly_downloads (from getWeeklyDownloads) so the triage block in
|
|
645
653
|
// queue.js can read meta directly without re-fetching.
|
|
646
654
|
item._npmInfo = npmInfo;
|
|
655
|
+
// Capture-at-publish trigger for the fast-takedown class (Leo Platform / Miasma,
|
|
656
|
+
// June 2026). ATO (published version != dist-tags.latest) and per-name burst are
|
|
657
|
+
// only knowable from the registry data fetched here, so the name-only
|
|
658
|
+
// evaluateCacheTrigger at ingestion (change.doc is null post-2025) cannot set them.
|
|
659
|
+
// Feed them in now so the burst/ATO subset is prefetched too — only when no
|
|
660
|
+
// higher-priority (ioc/typosquat) trigger already fired. queue.js still computes
|
|
661
|
+
// the same signals later for scan-queue protection + extras enqueue (idempotent).
|
|
662
|
+
if (!item._cacheTrigger) {
|
|
663
|
+
const atoSignal = !!(npmInfo.latestTagVersion && item.version &&
|
|
664
|
+
item.version !== npmInfo.latestTagVersion);
|
|
665
|
+
const burstCount = Number.isFinite(npmInfo.recentWindowCount)
|
|
666
|
+
? npmInfo.recentWindowCount
|
|
667
|
+
: ((Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions.length : 0) + 1);
|
|
668
|
+
const isBurst = burstCount >= BURST_MIN_VERSIONS_PREFETCH;
|
|
669
|
+
if (atoSignal || isBurst) {
|
|
670
|
+
const trig = evaluateCacheTrigger(item.name, null, null, { atoSignal, isBurst });
|
|
671
|
+
if (trig.shouldCache) item._cacheTrigger = trig;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
647
674
|
resolved++;
|
|
648
675
|
} else {
|
|
649
676
|
failed++;
|
|
@@ -918,6 +945,18 @@ async function pollNpmChanges(state, scanQueue, stats) {
|
|
|
918
945
|
// resolveTarballAndScan() picks up the slack — zero scan loss.
|
|
919
946
|
await preResolveNpmBatch(newItems, stats, scanQueue);
|
|
920
947
|
|
|
948
|
+
// Capture-at-publish (Miasma / Leo, June 2026): prefetch the high-value
|
|
949
|
+
// subset's tarballs into the local cache NOW — before the (often backlogged)
|
|
950
|
+
// scan downloads them — so we win the race against fast-takedown unpublishes.
|
|
951
|
+
// Best-effort, bounded, non-blocking; the scan path consumes the cache
|
|
952
|
+
// transparently (scanPackage cache-hit) or falls back to its own download.
|
|
953
|
+
// See src/monitor/tarball-prefetch.js.
|
|
954
|
+
try {
|
|
955
|
+
require('./tarball-prefetch.js').schedulePrefetch(newItems, { stats });
|
|
956
|
+
} catch (err) {
|
|
957
|
+
console.warn(`[MONITOR] prefetch schedule failed: ${err.message}`);
|
|
958
|
+
}
|
|
959
|
+
|
|
921
960
|
// Update seq in memory only — disk persistence is handled by daemon.js
|
|
922
961
|
// after both queue and seq are saved atomically (prevents data loss on crash).
|
|
923
962
|
if (data.last_seq != null) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capture-at-publish tarball prefetch.
|
|
5
|
+
*
|
|
6
|
+
* PROBLEM (Miasma / Leo Platform, June 2026). Fast-takedown supply-chain
|
|
7
|
+
* campaigns unpublish the malicious version within minutes-to-hours of
|
|
8
|
+
* publishing it. The scan path downloads the tarball at DEQUEUE time
|
|
9
|
+
* (queue.js `scanPackage` -> `downloadToFile`), which can sit hours behind
|
|
10
|
+
* ingestion when the scan queue is backlogged. By then npm returns E404 for
|
|
11
|
+
* the malicious version and the worker scans the surviving benign neighbour
|
|
12
|
+
* instead. Measured on the Leo compromise: malicious `leo-sdk@6.0.19` ->
|
|
13
|
+
* E404 (unpublished); we only ever saw `7.1.21` (clean, scored 4/100), and
|
|
14
|
+
* the Phantom Gyp detector had no malicious `binding.gyp` to fire on.
|
|
15
|
+
*
|
|
16
|
+
* Pure throughput cannot fix this: even with zero backlog you would still
|
|
17
|
+
* race npm's takedown at scan time. This is a CAPTURE problem, not a COMPUTE
|
|
18
|
+
* problem.
|
|
19
|
+
*
|
|
20
|
+
* FIX. Capture the bytes the moment the publish event is ingested — ingestion
|
|
21
|
+
* (reading the npm changes feed) runs ahead of the scan queue, so it wins the
|
|
22
|
+
* race against the takedown. The bytes are stored in the SAME tarball cache
|
|
23
|
+
* the scan path already prefers (the cache-hit branch in `scanPackage`), so a
|
|
24
|
+
* successful prefetch is consumed transparently with NO scan-side change. If
|
|
25
|
+
* anything fails, the scan falls back to its own download exactly as today
|
|
26
|
+
* (zero scan loss).
|
|
27
|
+
*
|
|
28
|
+
* SCOPE & BOUNDS (CLAUDE.md "Bounded resources" / "Defensive by default").
|
|
29
|
+
* - Only the `cacheTrigger.shouldCache` subset (ioc_match / typosquat_signal
|
|
30
|
+
* / first_publish) is captured — NOT the ~108k publishes/day. This is the
|
|
31
|
+
* exact population fast-takedown campaigns fall into (a coordinated burst
|
|
32
|
+
* from one maintainer is `first_publish` for the new lines).
|
|
33
|
+
* - Bounded concurrency (MUADDIB_PREFETCH_CONCURRENCY, default 4) gated by
|
|
34
|
+
* the shared registry semaphore so it cannot thunder-herd the registry.
|
|
35
|
+
* - Bounded in-flight set (MUADDIB_PREFETCH_MAX_INFLIGHT, default 256); a
|
|
36
|
+
* burst past the cap is dropped (counted), degrading to scan-time download.
|
|
37
|
+
* - Per-tarball timeout + size cap inherited from `downloadToFile`.
|
|
38
|
+
* - Best-effort & non-blocking: `schedulePrefetch()` returns immediately and
|
|
39
|
+
* never throws; the downloads run in a background pool.
|
|
40
|
+
* - Kill switch: MUADDIB_PREFETCH=0 disables it entirely.
|
|
41
|
+
*
|
|
42
|
+
* The synchronous ingestion path stays I/O-free: eligibility + dedup are pure
|
|
43
|
+
* in-memory checks, and the only filesystem touch (the "already cached" probe)
|
|
44
|
+
* happens inside the bounded async task, not in the tight ingestion loop.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const os = require('os');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
|
|
51
|
+
// Capture-at-publish is ON by default. It is best-effort with graceful
|
|
52
|
+
// fallback (worst case == today's behaviour), so the safe default is "on".
|
|
53
|
+
// Set MUADDIB_PREFETCH=0 (or "false") to disable for a gated rollout.
|
|
54
|
+
const DEFAULT_ENABLED = true;
|
|
55
|
+
|
|
56
|
+
function isEnabled() {
|
|
57
|
+
const v = process.env.MUADDIB_PREFETCH;
|
|
58
|
+
if (v === undefined || v === '') return DEFAULT_ENABLED;
|
|
59
|
+
return v !== '0' && v.toLowerCase() !== 'false';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Read limits dynamically so they stay tunable (and testable) at runtime.
|
|
63
|
+
function getConcurrency() {
|
|
64
|
+
return Math.max(1, parseInt(process.env.MUADDIB_PREFETCH_CONCURRENCY, 10) || 4);
|
|
65
|
+
}
|
|
66
|
+
function getMaxInflight() {
|
|
67
|
+
const c = getConcurrency();
|
|
68
|
+
return Math.max(c, parseInt(process.env.MUADDIB_PREFETCH_MAX_INFLIGHT, 10) || 256);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- bounded async pool state ---
|
|
72
|
+
const _pending = []; // FIFO of queued capture tasks
|
|
73
|
+
const _inFlightKeys = new Set(); // dedup: cache keys currently pending or active
|
|
74
|
+
let _active = 0; // tasks currently downloading
|
|
75
|
+
let _idleResolvers = []; // resolvers awaiting an idle pool (test seam)
|
|
76
|
+
|
|
77
|
+
// Lazily-resolved real dependencies. Lazy require avoids any load-time cycle
|
|
78
|
+
// (ingestion -> tarball-prefetch -> state) and keeps unit tests from pulling in
|
|
79
|
+
// the whole monitor: tests pass their own `deps`.
|
|
80
|
+
let _realDeps = null;
|
|
81
|
+
function realDeps() {
|
|
82
|
+
if (_realDeps) return _realDeps;
|
|
83
|
+
const dl = require('../shared/download.js');
|
|
84
|
+
const state = require('./state.js');
|
|
85
|
+
const limiter = require('../shared/http-limiter.js');
|
|
86
|
+
_realDeps = {
|
|
87
|
+
downloadToFile: dl.downloadToFile,
|
|
88
|
+
cacheTarball: state.cacheTarball,
|
|
89
|
+
tarballCacheKey: state.tarballCacheKey,
|
|
90
|
+
tarballCachePath: state.tarballCachePath,
|
|
91
|
+
acquireRegistrySlot: limiter.acquireRegistrySlot,
|
|
92
|
+
releaseRegistrySlot: limiter.releaseRegistrySlot,
|
|
93
|
+
tmpDir: os.tmpdir()
|
|
94
|
+
};
|
|
95
|
+
return _realDeps;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An item is eligible iff ingestion flagged it for caching AND it has been
|
|
100
|
+
* resolved enough to fetch and key. `_cacheTrigger` carries {reason,
|
|
101
|
+
* retentionDays}; `tarballUrl` + `version` are filled by preResolve*Batch.
|
|
102
|
+
*/
|
|
103
|
+
function _eligible(item) {
|
|
104
|
+
return !!(item
|
|
105
|
+
&& item._cacheTrigger
|
|
106
|
+
&& item._cacheTrigger.shouldCache
|
|
107
|
+
&& item.tarballUrl
|
|
108
|
+
&& item.version);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Schedule a best-effort prefetch for every eligible item in `items`.
|
|
113
|
+
* Non-blocking: returns synchronously with a small summary. Downloads run in
|
|
114
|
+
* the bounded background pool.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<Object>} items - ingestion items (post tarballUrl resolution)
|
|
117
|
+
* @param {Object} [opts]
|
|
118
|
+
* @param {Object} [opts.stats] - monitor stats object for counters
|
|
119
|
+
* @param {Object} [opts.deps] - dependency overrides (tests)
|
|
120
|
+
* @returns {{scheduled:number, skipped:number}}
|
|
121
|
+
*/
|
|
122
|
+
function schedulePrefetch(items, opts = {}) {
|
|
123
|
+
if (!isEnabled()) return { scheduled: 0, skipped: 0 };
|
|
124
|
+
const d = opts.deps || realDeps();
|
|
125
|
+
const stats = opts.stats || null;
|
|
126
|
+
const maxInflight = getMaxInflight();
|
|
127
|
+
|
|
128
|
+
let scheduled = 0;
|
|
129
|
+
let skipped = 0;
|
|
130
|
+
|
|
131
|
+
for (const item of items || []) {
|
|
132
|
+
if (!_eligible(item)) { skipped++; continue; }
|
|
133
|
+
|
|
134
|
+
const key = d.tarballCacheKey(item.name, item.version);
|
|
135
|
+
if (_inFlightKeys.has(key)) { skipped++; continue; } // already pending/active this session
|
|
136
|
+
|
|
137
|
+
if (_pending.length + _active >= maxInflight) {
|
|
138
|
+
// Bound hit: drop (the scan-time download remains the fallback).
|
|
139
|
+
if (stats) stats.prefetchDropped = (stats.prefetchDropped || 0) + 1;
|
|
140
|
+
skipped++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_inFlightKeys.add(key);
|
|
145
|
+
_pending.push({
|
|
146
|
+
name: item.name,
|
|
147
|
+
version: item.version,
|
|
148
|
+
tarballUrl: item.tarballUrl,
|
|
149
|
+
reason: item._cacheTrigger.reason,
|
|
150
|
+
retentionDays: item._cacheTrigger.retentionDays,
|
|
151
|
+
key,
|
|
152
|
+
deps: d,
|
|
153
|
+
stats
|
|
154
|
+
});
|
|
155
|
+
scheduled++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_pump();
|
|
159
|
+
return { scheduled, skipped };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _pump() {
|
|
163
|
+
const concurrency = getConcurrency();
|
|
164
|
+
while (_active < concurrency && _pending.length > 0) {
|
|
165
|
+
const task = _pending.shift();
|
|
166
|
+
_active++;
|
|
167
|
+
_runTask(task).catch(() => { /* _runTask never rejects; defensive */ }).then(() => {
|
|
168
|
+
_active--;
|
|
169
|
+
_inFlightKeys.delete(task.key);
|
|
170
|
+
_pump();
|
|
171
|
+
_settleIdle();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// No work in flight at all -> notify any idle waiters.
|
|
175
|
+
if (_active === 0 && _pending.length === 0) _settleIdle();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function _runTask(task) {
|
|
179
|
+
const { name, version, tarballUrl, reason, retentionDays, key, deps: d, stats } = task;
|
|
180
|
+
|
|
181
|
+
// "Already cached" probe lives here (off the synchronous ingestion path).
|
|
182
|
+
try {
|
|
183
|
+
if (fs.existsSync(d.tarballCachePath(key))) {
|
|
184
|
+
if (stats) stats.prefetchAlreadyCached = (stats.prefetchAlreadyCached || 0) + 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
} catch { /* probe failure is non-fatal — proceed to download */ }
|
|
188
|
+
|
|
189
|
+
const tmp = path.join(d.tmpDir, `muaddib-prefetch-${key}-${process.pid}.tgz`);
|
|
190
|
+
await d.acquireRegistrySlot();
|
|
191
|
+
try {
|
|
192
|
+
await d.downloadToFile(tarballUrl, tmp);
|
|
193
|
+
d.cacheTarball(name, version, tmp, reason, retentionDays);
|
|
194
|
+
if (stats) stats.prefetchCaptured = (stats.prefetchCaptured || 0) + 1;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// Best-effort: a miss here just means the scan path downloads later (or the
|
|
197
|
+
// version is already gone, which is the case this whole module mitigates).
|
|
198
|
+
if (stats) stats.prefetchFailed = (stats.prefetchFailed || 0) + 1;
|
|
199
|
+
console.warn(`[MONITOR] PREFETCH miss for ${name}@${version}: ${err.message}`);
|
|
200
|
+
} finally {
|
|
201
|
+
d.releaseRegistrySlot();
|
|
202
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* tmp cleanup best-effort */ }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function _settleIdle() {
|
|
207
|
+
if (_active === 0 && _pending.length === 0 && _idleResolvers.length > 0) {
|
|
208
|
+
const resolvers = _idleResolvers;
|
|
209
|
+
_idleResolvers = [];
|
|
210
|
+
for (const r of resolvers) r();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Observability: current pool occupancy. */
|
|
215
|
+
function getStats() {
|
|
216
|
+
return { pending: _pending.length, active: _active, inFlight: _inFlightKeys.size };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Test seam: resolve once the pool is idle. */
|
|
220
|
+
function _drain() {
|
|
221
|
+
if (_active === 0 && _pending.length === 0) return Promise.resolve();
|
|
222
|
+
return new Promise(resolve => { _idleResolvers.push(resolve); });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Test seam: clear pool state between tests. */
|
|
226
|
+
function _reset() {
|
|
227
|
+
_pending.length = 0;
|
|
228
|
+
_inFlightKeys.clear();
|
|
229
|
+
_active = 0;
|
|
230
|
+
_idleResolvers = [];
|
|
231
|
+
_realDeps = null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
schedulePrefetch,
|
|
236
|
+
isEnabled,
|
|
237
|
+
getStats,
|
|
238
|
+
// test seams
|
|
239
|
+
_drain,
|
|
240
|
+
_reset,
|
|
241
|
+
_eligible
|
|
242
|
+
};
|