muaddib-scanner 2.10.47 → 2.10.49

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.10.47",
3
+ "version": "2.10.49",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -411,6 +411,14 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
411
411
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
412
412
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
413
413
 
414
+ // ─── Deferred sandbox worker ───
415
+ // Started BEFORE the first processQueue so it can process T1b/T2 packages
416
+ // that get deferred during the initial batch (which blocks for 30min-2h).
417
+ if (isSandboxEnabled() && sandboxAvailableRef.value) {
418
+ startDeferredWorker(stats);
419
+ console.log('[MONITOR] Deferred sandbox worker started (30s interval, dedicated slot)');
420
+ }
421
+
414
422
  // Initial poll + scan (sequential for first run)
415
423
  await poll(state, scanQueue, stats);
416
424
  saveState(state, stats);
@@ -448,14 +456,6 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
448
456
  persistDeferredQueue(); // Piggyback: persist deferred sandbox queue on same interval
449
457
  }, QUEUE_PERSIST_INTERVAL);
450
458
 
451
- // ─── Deferred sandbox worker ───
452
- // Retries T1b/T2 packages that were skipped when sandbox slots were full.
453
- // Runs every 30s, processes at most 1 item per tick, yields to T1a.
454
- if (isSandboxEnabled() && sandboxAvailableRef.value) {
455
- startDeferredWorker(stats);
456
- console.log('[MONITOR] Deferred sandbox worker started (30s interval, T1a-safe)');
457
- }
458
-
459
459
  // ─── Continuous processing loop ───
460
460
  // Consumes scanQueue independently of polling. Workers inside processQueue
461
461
  // check scanQueue.length > 0 after each item, so items added by a concurrent
@@ -6,11 +6,15 @@
6
6
  * Items are sorted by riskScore DESC (highest-risk first) to defend
7
7
  * against queue-poisoning attacks.
8
8
  *
9
- * The worker reserves 1 sandbox slot for T1a (never uses the last slot).
9
+ * The worker owns a dedicated sandbox slot (_deferredSlotBusy) that is
10
+ * completely independent from the shared semaphore used by T1a/T1b/T2.
11
+ * This guarantees the deferred worker can always process, regardless of
12
+ * how many main-path sandboxes are running. The VPS supports N+1
13
+ * concurrent gVisor containers (3 main + 1 deferred).
10
14
  */
11
15
  const fs = require('fs');
12
16
  const path = require('path');
13
- const { runSandbox, getSandboxSemaphore, SANDBOX_CONCURRENCY_MAX } = require('../sandbox/index.js');
17
+ const { runSandbox } = require('../sandbox/index.js');
14
18
  const { isCanaryEnabled } = require('./classify.js');
15
19
  const { getWebhookUrl, alertedPackageRules, persistAlert, buildAlertData } = require('./webhook.js');
16
20
  const { sendWebhook } = require('../webhook.js');
@@ -22,15 +26,13 @@ const DEFERRED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
22
26
  const DEFERRED_MAX_RETRIES = 2;
23
27
  const DEFERRED_WORKER_INTERVAL_MS = 30_000; // 30s
24
28
  const DEFERRED_STATE_FILE = path.join(__dirname, '..', '..', 'data', 'deferred-queue.json');
25
- const SKIP_LOG_INTERVAL = 10; // Log every N skipped ticks (throttle)
26
- const ANTI_STARVATION_TICKS = 20; // Force processing after N consecutive skips (~10min)
27
29
 
28
30
  // ── Mutable state ──
29
31
  const _deferredQueue = [];
30
32
  const _deferredSeen = new Set(); // name@version dedup
31
33
  let _workerHandle = null;
32
34
  let _stats = null; // reference to shared stats object
33
- let _consecutiveSkips = 0; // Tracks consecutive yield-skips for anti-starvation
35
+ let _deferredSlotBusy = false; // Dedicated slot: true while deferred sandbox is running
34
36
 
35
37
  // ── Queue management ──
36
38
 
@@ -129,25 +131,12 @@ async function processDeferredItem(stats) {
129
131
 
130
132
  if (_deferredQueue.length === 0) return null;
131
133
 
132
- // 2. Yield check: reserve 1 slot for T1a (unless anti-starvation kicks in)
133
- const sem = getSandboxSemaphore();
134
- const slotsFullForDeferred = sem.active >= SANDBOX_CONCURRENCY_MAX - 1;
135
- const forceAntiStarvation = _consecutiveSkips >= ANTI_STARVATION_TICKS && sem.active < SANDBOX_CONCURRENCY_MAX;
136
-
137
- if (slotsFullForDeferred && !forceAntiStarvation) {
138
- _consecutiveSkips++;
134
+ // 2. Dedicated slot check completely independent from main semaphore
135
+ if (_deferredSlotBusy) {
139
136
  if (stats) stats.deferredSkipped = (stats.deferredSkipped || 0) + 1;
140
- if (_consecutiveSkips % SKIP_LOG_INTERVAL === 0) {
141
- console.log(`[DEFERRED] YIELD: ${_consecutiveSkips} consecutive skips (slots=${sem.active}/${SANDBOX_CONCURRENCY_MAX}, pending=${_deferredQueue.length})`);
142
- }
143
137
  return null;
144
138
  }
145
139
 
146
- if (forceAntiStarvation) {
147
- console.log(`[DEFERRED] ANTI-STARVATION: forcing processing after ${_consecutiveSkips} skips (slots=${sem.active}/${SANDBOX_CONCURRENCY_MAX}, pending=${_deferredQueue.length})`);
148
- }
149
- _consecutiveSkips = 0;
150
-
151
140
  // 3. Pick highest-score item
152
141
  const item = _deferredQueue.shift();
153
142
  const key = `${item.name}@${item.version}`;
@@ -155,11 +144,12 @@ async function processDeferredItem(stats) {
155
144
 
156
145
  console.log(`[DEFERRED] PROCESSING: ${key} (tier=${item.tier === 2 ? 'T2' : 'T1b'}, score=${item.riskScore}, retries=${item.retries})`);
157
146
 
158
- // 4. Run sandbox
147
+ // 4. Run sandbox on dedicated slot (bypasses shared semaphore)
148
+ _deferredSlotBusy = true;
159
149
  let sandboxResult;
160
150
  try {
161
151
  const canary = isCanaryEnabled();
162
- sandboxResult = await runSandbox(item.name, { canary });
152
+ sandboxResult = await runSandbox(item.name, { canary, skipSemaphore: true });
163
153
  console.log(`[DEFERRED] SANDBOX COMPLETE: ${key} -> score=${sandboxResult.score}, severity=${sandboxResult.severity}`);
164
154
  } catch (err) {
165
155
  console.error(`[DEFERRED] SANDBOX ERROR: ${key} — ${err.message}`);
@@ -174,6 +164,8 @@ async function processDeferredItem(stats) {
174
164
  console.log(`[DEFERRED] RE-ENQUEUED: ${key} for retry (attempt ${item.retries + 1}/${DEFERRED_MAX_RETRIES})`);
175
165
  }
176
166
  return null;
167
+ } finally {
168
+ _deferredSlotBusy = false;
177
169
  }
178
170
 
179
171
  // 5. Follow-up webhook if sandbox found something
@@ -391,10 +383,14 @@ function _resetDeferredQueue() {
391
383
  _deferredQueue.length = 0;
392
384
  _deferredSeen.clear();
393
385
  _stats = null;
394
- _consecutiveSkips = 0;
386
+ _deferredSlotBusy = false;
395
387
  stopDeferredWorker();
396
388
  }
397
389
 
390
+ function isDeferredSlotBusy() {
391
+ return _deferredSlotBusy;
392
+ }
393
+
398
394
  module.exports = {
399
395
  enqueueDeferred,
400
396
  getDeferredQueue,
@@ -406,12 +402,11 @@ module.exports = {
406
402
  restoreDeferredQueue,
407
403
  buildDeferredFollowUpEmbed,
408
404
  pruneExpired,
405
+ isDeferredSlotBusy,
409
406
  _resetDeferredQueue,
410
407
  DEFERRED_QUEUE_MAX,
411
408
  DEFERRED_TTL_MS,
412
409
  DEFERRED_MAX_RETRIES,
413
410
  DEFERRED_WORKER_INTERVAL_MS,
414
- DEFERRED_STATE_FILE,
415
- SKIP_LOG_INTERVAL,
416
- ANTI_STARVATION_TICKS
411
+ DEFERRED_STATE_FILE
417
412
  };
@@ -690,6 +690,7 @@ function loadDailyStats(stats, dailyAlerts) {
690
690
  stats.mlFiltered = data.mlFiltered || 0;
691
691
  stats.llmAnalyzed = data.llmAnalyzed || 0;
692
692
  stats.llmSuppressed = data.llmSuppressed || 0;
693
+ stats.changesStreamPackages = data.changesStreamPackages || 0;
693
694
  if (Array.isArray(data.dailyAlerts)) {
694
695
  dailyAlerts.length = 0;
695
696
  dailyAlerts.push(...data.dailyAlerts);
@@ -716,6 +717,7 @@ function saveDailyStats(stats, dailyAlerts) {
716
717
  mlFiltered: stats.mlFiltered,
717
718
  llmAnalyzed: stats.llmAnalyzed || 0,
718
719
  llmSuppressed: stats.llmSuppressed || 0,
720
+ changesStreamPackages: stats.changesStreamPackages || 0,
719
721
  dailyAlerts: dailyAlerts.slice()
720
722
  };
721
723
  atomicWriteFileSync(DAILY_STATS_FILE, JSON.stringify(data, null, 2));
@@ -23,7 +23,8 @@ const CONTAINER_TIMEOUT = 120000; // 120 seconds
23
23
  const SINGLE_RUN_TIMEOUT = 90000; // 90 seconds per run in multi-run mode (gVisor ~30% I/O overhead)
24
24
 
25
25
  // ── Sandbox concurrency limiter ──
26
- // Prevents Docker container saturation under load (16 workers × 3 runs = 48 containers).
26
+ // Prevents Docker container saturation under load (main-path T1a/T1b/T2).
27
+ // The deferred worker manages its own dedicated slot outside this semaphore.
27
28
  // Pattern: same semaphore as src/shared/http-limiter.js.
28
29
  const SANDBOX_CONCURRENCY_MAX = Math.max(1, parseInt(process.env.MUADDIB_SANDBOX_CONCURRENCY, 10) || 3);
29
30
 
@@ -565,16 +566,21 @@ async function runSandbox(packageName, options = {}) {
565
566
 
566
567
  const mode = strict ? 'strict' : 'permissive';
567
568
 
568
- // Acquire sandbox slot — blocks if SANDBOX_CONCURRENCY_MAX containers already running
569
- const queueLen = _sandboxSemaphore.queue.length;
570
- if (queueLen > 0) {
571
- console.log(`[SANDBOX] Waiting for sandbox slot (${_sandboxSemaphore.active}/${SANDBOX_CONCURRENCY_MAX} active, ${queueLen} queued)...`);
569
+ // Acquire sandbox slot — blocks if SANDBOX_CONCURRENCY_MAX containers already running.
570
+ // skipSemaphore: deferred worker manages its own dedicated slot outside this semaphore.
571
+ const skipSem = options.skipSemaphore === true;
572
+ if (!skipSem) {
573
+ const queueLen = _sandboxSemaphore.queue.length;
574
+ if (queueLen > 0) {
575
+ console.log(`[SANDBOX] Waiting for sandbox slot (${_sandboxSemaphore.active}/${SANDBOX_CONCURRENCY_MAX} active, ${queueLen} queued)...`);
576
+ }
577
+ await acquireSandboxSlot();
572
578
  }
573
- await acquireSandboxSlot();
574
579
 
575
580
  try {
576
581
  const runtimeLabel = useGvisor ? 'gvisor' : 'docker';
577
- console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}, runtime: ${runtimeLabel}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''}, runs: ${TIME_OFFSETS.length}, slots: ${_sandboxSemaphore.active}/${SANDBOX_CONCURRENCY_MAX})...`);
582
+ const slotInfo = skipSem ? 'deferred-slot' : `${_sandboxSemaphore.active}/${SANDBOX_CONCURRENCY_MAX}`;
583
+ console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}, runtime: ${runtimeLabel}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''}, runs: ${TIME_OFFSETS.length}, slots: ${slotInfo})...`);
578
584
 
579
585
  const allRuns = [];
580
586
  let bestResult = cleanResult;
@@ -639,7 +645,7 @@ async function runSandbox(packageName, options = {}) {
639
645
  displayResults(bestResult);
640
646
  return bestResult;
641
647
  } finally {
642
- releaseSandboxSlot();
648
+ if (!skipSem) releaseSandboxSlot();
643
649
  }
644
650
  }
645
651