muaddib-scanner 2.11.129 → 2.11.131

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.129",
3
+ "version": "2.11.131",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-24T09:39:17.849Z",
3
+ "timestamp": "2026-06-27T16:14:41.754Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -918,6 +918,18 @@ async function pollNpmChanges(state, scanQueue, stats) {
918
918
  // resolveTarballAndScan() picks up the slack — zero scan loss.
919
919
  await preResolveNpmBatch(newItems, stats, scanQueue);
920
920
 
921
+ // Capture-at-publish (Miasma / Leo, June 2026): prefetch the high-value
922
+ // subset's tarballs into the local cache NOW — before the (often backlogged)
923
+ // scan downloads them — so we win the race against fast-takedown unpublishes.
924
+ // Best-effort, bounded, non-blocking; the scan path consumes the cache
925
+ // transparently (scanPackage cache-hit) or falls back to its own download.
926
+ // See src/monitor/tarball-prefetch.js.
927
+ try {
928
+ require('./tarball-prefetch.js').schedulePrefetch(newItems, { stats });
929
+ } catch (err) {
930
+ console.warn(`[MONITOR] prefetch schedule failed: ${err.message}`);
931
+ }
932
+
921
933
  // Update seq in memory only — disk persistence is handled by daemon.js
922
934
  // after both queue and seq are saved atomically (prevents data loss on crash).
923
935
  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
+ };