muaddib-scanner 2.11.124 → 2.11.125

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.124",
3
+ "version": "2.11.125",
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-19T13:30:20.182Z",
3
+ "timestamp": "2026-06-19T14:00:25.646Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -115,7 +115,7 @@ function httpsGet(url, timeoutMs = 30_000, deadlineMs = Math.max(timeoutMs * 2,
115
115
  // Coordinated backoff: drain the SHARED token bucket so every in-flight registry fetch
116
116
  // slows together. This high-volume packument/changes path must signal 429 like the
117
117
  // metadata path (npm-registry.js) does — not just acquire a slot (CLAUDE.md 429 storm).
118
- try { require('../shared/http-limiter.js').signal429(); } catch { /* limiter best-effort */ }
118
+ try { const _l = require('../shared/http-limiter.js'); _l.signal429(_l.hostForUrl(url)); } catch { /* limiter best-effort */ }
119
119
  return done(new Error(`HTTP 429 rate limited for ${url}`));
120
120
  }
121
121
  if (res.statusCode < 200 || res.statusCode >= 300) {
@@ -177,7 +177,7 @@ function httpsPost(url, body, headers = {}, timeoutMs = 30_000, deadlineMs = Mat
177
177
  req = _deps.https.request(options, (res) => {
178
178
  if (res.statusCode === 429) {
179
179
  res.resume();
180
- try { require('../shared/http-limiter.js').signal429(); } catch { /* limiter best-effort */ }
180
+ try { const _l = require('../shared/http-limiter.js'); _l.signal429(_l.hostForUrl(url)); } catch { /* limiter best-effort */ }
181
181
  return done(new Error(`HTTP 429 rate limited for POST ${url}`));
182
182
  }
183
183
  if (res.statusCode < 200 || res.statusCode >= 300) {
@@ -84,7 +84,7 @@ const {
84
84
 
85
85
  // From ./ingestion.js
86
86
  const { getNpmLatestTarball, getPyPITarballUrl } = require('./ingestion.js');
87
- const { enqueueScan, dequeueScan } = require('./scan-queue.js');
87
+ const { enqueueScan, dequeueScan, shouldPullNewest } = require('./scan-queue.js');
88
88
 
89
89
  // From ./tarball-archive.js
90
90
  const { archiveSuspectTarball } = require('./tarball-archive.js');
@@ -98,6 +98,7 @@ const { BASE_CONCURRENCY, MIN_CONCURRENCY, MAX_CONCURRENCY } = require('./adapti
98
98
 
99
99
  // SCAN_CONCURRENCY kept as getter for backward compatibility (tests, logging)
100
100
  let _targetConcurrency = BASE_CONCURRENCY;
101
+ let _dequeueSeq = 0; // shared monotonic cursor for fresh-first round-robin (Phase 2)
101
102
  const SCAN_CONCURRENCY = BASE_CONCURRENCY; // legacy export — tests check this value
102
103
  let _activeWorkers = 0;
103
104
  const _workerPromises = new Set();
@@ -1780,7 +1781,10 @@ async function _spawnWorker(scanQueue, stats, dailyAlerts, recentlyScanned, down
1780
1781
  // admission (1 scan when nothing is in flight) can still flow.
1781
1782
  if (isGovernorFrozen() && (getGovernorState().outstandingCount > 0 || _activeWorkers > 1)) break;
1782
1783
  // AUDIT A2: FIFO by default; priority dequeue when MUADDIB_PRIORITY_DEQUEUE=1.
1783
- const item = dequeueScan(scanQueue);
1784
+ // Phase 2: when MUADDIB_FRESH_FIRST=1 and a backlog exists, most lanes take the
1785
+ // newest item (fresh-first) while a reserved few keep draining the oldest.
1786
+ const newest = shouldPullNewest(_dequeueSeq++, scanQueue.length, _targetConcurrency);
1787
+ const item = dequeueScan(scanQueue, { newest });
1784
1788
  if (!item) break;
1785
1789
  _inFlightItems.add(item);
1786
1790
  try {
@@ -201,6 +201,26 @@ const PRIORITY_DEQUEUE_WINDOW = (() => {
201
201
  return Number.isFinite(v) && v > 0 ? v : 2048;
202
202
  })();
203
203
 
204
+ // ── Fresh-first scheduling (throughput plan Phase 2; gated OFF by default) ───
205
+ // When MUADDIB_FRESH_FIRST=1 AND a real backlog exists (queue > split threshold),
206
+ // most worker lanes dequeue the NEWEST item (tail) so a freshly-published package
207
+ // is scanned in minutes instead of aging ~hours behind a FIFO backlog, while a
208
+ // reserved `drainLanes` keep draining the OLDEST (anti-starvation). Below the
209
+ // threshold all lanes stay strict FIFO ("back to the normal flow" once drained).
210
+ // Pure ordering change — every item is still scanned; inert until the flag is on.
211
+ const FRESH_FIRST = (() => {
212
+ const v = process.env.MUADDIB_FRESH_FIRST;
213
+ return v === '1' || v === 'true';
214
+ })();
215
+ const BACKLOG_DRAIN_LANES = (() => {
216
+ const v = parseInt(process.env.MUADDIB_BACKLOG_DRAIN_LANES, 10);
217
+ return Number.isFinite(v) && v > 0 ? v : 2;
218
+ })();
219
+ const BACKLOG_SPLIT_THRESHOLD = (() => {
220
+ const v = parseInt(process.env.MUADDIB_BACKLOG_SPLIT_THRESHOLD, 10);
221
+ return Number.isFinite(v) && v > 0 ? v : 5000;
222
+ })();
223
+
204
224
  function _isPriority(item) {
205
225
  return !!(item && (item.firstPublish || item.isIOCMatch || (item.isBurst && !item.isATOBurstExtra)));
206
226
  }
@@ -213,8 +233,12 @@ function _isPriority(item) {
213
233
  * @param {{priority?: boolean, window?: number}} [opts] test overrides
214
234
  */
215
235
  function dequeueScan(scanQueue, opts = {}) {
236
+ if (scanQueue.length === 0) return scanQueue.shift();
237
+ // Fresh-first lanes take the NEWEST item (tail). Takes precedence over priority:
238
+ // the freshest publish is the one we most want to be first on.
239
+ if (opts.newest) return scanQueue.pop();
216
240
  const priority = opts.priority !== undefined ? opts.priority : PRIORITY_DEQUEUE;
217
- if (!priority || scanQueue.length === 0) return scanQueue.shift();
241
+ if (!priority) return scanQueue.shift();
218
242
  const win = Math.min(scanQueue.length, opts.window || PRIORITY_DEQUEUE_WINDOW);
219
243
  for (let i = 0; i < win; i++) {
220
244
  if (_isPriority(scanQueue[i])) return i === 0 ? scanQueue.shift() : scanQueue.splice(i, 1)[0];
@@ -222,4 +246,23 @@ function dequeueScan(scanQueue, opts = {}) {
222
246
  return scanQueue.shift();
223
247
  }
224
248
 
225
- module.exports = { enqueueScan, evictFromScanQueueBulk, dequeueScan, isProtected: _isProtected, MAX_SCAN_QUEUE };
249
+ /**
250
+ * Fresh-first dequeue decision (Phase 2, gated by MUADDIB_FRESH_FIRST). True → this
251
+ * dequeue should take the NEWEST item (tail); false → oldest (FIFO/priority head).
252
+ * Splits only above BACKLOG_SPLIT_THRESHOLD; below it everything stays FIFO. Of
253
+ * `target` concurrent lanes, `drainLanes` keep pulling oldest (anti-starvation), the
254
+ * rest go fresh. `seq` is a shared monotonic dequeue counter (single-threaded →
255
+ * consistent). Pure; `opts` override the env gates for tests.
256
+ */
257
+ function shouldPullNewest(seq, queueLen, target, opts = {}) {
258
+ const enabled = opts.enabled !== undefined ? opts.enabled : FRESH_FIRST;
259
+ if (!enabled) return false;
260
+ const threshold = opts.threshold !== undefined ? opts.threshold : BACKLOG_SPLIT_THRESHOLD;
261
+ if (queueLen <= threshold) return false;
262
+ const drainLanes = opts.drainLanes !== undefined ? opts.drainLanes : BACKLOG_DRAIN_LANES;
263
+ const lanes = Math.max(2, target || 2);
264
+ const drain = Math.min(drainLanes, lanes - 1); // always leave ≥1 fresh lane
265
+ return (seq % lanes) >= drain; // first `drain` slots → oldest; rest → newest
266
+ }
267
+
268
+ module.exports = { enqueueScan, evictFromScanQueueBulk, dequeueScan, shouldPullNewest, isProtected: _isProtected, MAX_SCAN_QUEUE };
@@ -1,6 +1,6 @@
1
1
  const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
2
2
  const { debugLog } = require('../utils.js');
3
- const { acquireRegistrySlot, releaseRegistrySlot, awaitRateToken, signal429, hostForUrl } = require('../shared/http-limiter.js');
3
+ const { acquireRegistrySlot, releaseRegistrySlot, awaitRateToken, signal429, hostForUrl, isHostBackedOff } = require('../shared/http-limiter.js');
4
4
  const { registryAuthHeaders } = require('../shared/registry-auth.js');
5
5
  const { computeAdvancedRegistrySignals } = require('../integrations/registry-signals.js');
6
6
 
@@ -223,8 +223,30 @@ async function getPackageMetadata(packageName) {
223
223
  return count;
224
224
  }
225
225
 
226
+ // Downloads-count (api.npmjs.org) is a SEPARATE, aggressively rate-limited host and
227
+ // is OFF the detection path — it only feeds the reputation/ML weekly_downloads
228
+ // feature. The ingest pre-resolve already fetches + 24h-caches it (classify.js
229
+ // downloadsCache). So prefer the warm cache; if cold AND api.npmjs.org is in 429
230
+ // backoff, SKIP rather than park the scan on the token gate (a level-12 60s pause
231
+ // was stalling every cold scan ~20s). Same graceful degradation as the author-count
232
+ // kill-switch: weekly_downloads stays absent, never blocks throughput.
233
+ let cachedDownloads;
234
+ try {
235
+ const { downloadsCache, DOWNLOADS_CACHE_TTL } = require('../monitor/classify.js');
236
+ const hit = downloadsCache.get(packageName);
237
+ if (hit && (Date.now() - hit.fetchedAt) < DOWNLOADS_CACHE_TTL && hit.downloads >= 0) {
238
+ cachedDownloads = hit.downloads;
239
+ }
240
+ } catch { /* classify unavailable (scanner-only context) — fall through to a guarded fetch */ }
241
+
242
+ const downloadsPromise = (typeof cachedDownloads === 'number')
243
+ ? Promise.resolve({ downloads: cachedDownloads })
244
+ : (isHostBackedOff(hostForUrl(downloadsUrl))
245
+ ? Promise.resolve(null) // host hammered → skip; do not stall the scan
246
+ : fetchWithRetry(downloadsUrl, { noRetryOn429: true }));
247
+
226
248
  const [downloadsData, authorPackageCount] = await Promise.all([
227
- fetchWithRetry(downloadsUrl, { noRetryOn429: true }), // api.npmjs.org — rate-limited; best-effort single shot (no retry storm, correct-host backoff)
249
+ downloadsPromise, // api.npmjs.org — cached / skipped-under-backoff / best-effort
228
250
  getAuthorPackageCount() // registry.npmjs.org search — cached + kill-switchable
229
251
  ]);
230
252
 
@@ -162,6 +162,19 @@ function hostForUrl(url) {
162
162
  try { return new URL(url).hostname || DEFAULT_HOST; } catch { return DEFAULT_HOST; }
163
163
  }
164
164
 
165
+ /**
166
+ * True when a host is in (or near) a 429 backoff — fetching it now would park on
167
+ * the token gate. Lets best-effort callers (downloads-count) SKIP the fetch under a
168
+ * storm instead of stalling the scan. "Near" = an active pause OR a high backoff
169
+ * level (the host is 429ing essentially every probe). Pure read: never allocates a
170
+ * bucket (an unseen host is, by definition, not backed off).
171
+ */
172
+ function isHostBackedOff(host, levelThreshold = 6) {
173
+ const b = _buckets.get(host || DEFAULT_HOST);
174
+ if (!b) return false;
175
+ return Date.now() < b.bo.pauseUntil || b.bo.level >= levelThreshold;
176
+ }
177
+
165
178
  function _effectiveRate(level = 0) {
166
179
  let rate = RATE_LIMIT_PER_SEC;
167
180
  if (BOOT_SLOWSTART_MS > 0 && Date.now() - _bootAt < BOOT_SLOWSTART_MS) {
@@ -463,6 +476,7 @@ module.exports = {
463
476
  getBrainState,
464
477
  computeBackoffTransition,
465
478
  hostForUrl,
479
+ isHostBackedOff,
466
480
  resetLimiter,
467
481
  _restartBootSlowStartForTests,
468
482
  getActiveSemaphore