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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.130",
3
+ "version": "2.11.132",
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-24T10:50:42.278Z",
3
+ "timestamp": "2026-06-28T11:47:00.100Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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
 
@@ -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
+ };