muaddib-scanner 2.10.45 → 2.10.46

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.45",
3
+ "version": "2.10.46",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@ const { pendingGrouped, flushScopeGroup, sendDailyReport, DAILY_REPORT_HOUR } =
10
10
  const { poll } = require('./ingestion.js');
11
11
  const { processQueue, SCAN_CONCURRENCY } = require('./queue.js');
12
12
  const { startHealthcheck } = require('./healthcheck.js');
13
+ const { startDeferredWorker, stopDeferredWorker, persistDeferredQueue, restoreDeferredQueue } = require('./deferred-sandbox.js');
13
14
 
14
15
  const POLL_INTERVAL = 60_000;
15
16
  const PROCESS_LOOP_INTERVAL = 2_000; // Queue check interval when empty
@@ -149,6 +150,11 @@ function reportStats(stats) {
149
150
  if (stats.tarballCacheHits) {
150
151
  console.log(`[MONITOR] Tarball cache hits: ${stats.tarballCacheHits}`);
151
152
  }
153
+ if (stats.sandboxDeferred || stats.deferredProcessed) {
154
+ const { getDeferredQueueStats } = require('./deferred-sandbox.js');
155
+ const dq = getDeferredQueueStats();
156
+ console.log(`[MONITOR] Deferred sandbox: ${stats.sandboxDeferred || 0} enqueued, ${stats.deferredProcessed || 0} processed, ${stats.deferredExpired || 0} expired, ${dq.size} pending`);
157
+ }
152
158
  stats.lastReportTime = Date.now();
153
159
  }
154
160
 
@@ -262,6 +268,12 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
262
268
  console.log(`[MONITOR] ${restoredCount} packages pre-loaded from previous session`);
263
269
  }
264
270
 
271
+ // Restore deferred sandbox queue from previous run
272
+ const deferredRestored = restoreDeferredQueue();
273
+ if (deferredRestored > 0) {
274
+ console.log(`[MONITOR] ${deferredRestored} deferred sandbox items restored from previous session`);
275
+ }
276
+
265
277
  // Graceful shutdown handler (shared by SIGINT and SIGTERM)
266
278
  // Daily report is NEVER sent on shutdown — it only fires at 08:00 Paris time.
267
279
  // Counters are persisted to disk so they survive the restart.
@@ -278,6 +290,9 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
278
290
  }
279
291
  // Persist remaining queue items so they survive the restart
280
292
  persistQueue(scanQueue, state);
293
+ // Stop deferred sandbox worker and persist its queue
294
+ stopDeferredWorker();
295
+ persistDeferredQueue();
281
296
  healthcheck.stop();
282
297
  // Flush all pending scope groups before exit
283
298
  for (const [scope, group] of pendingGrouped) {
@@ -329,8 +344,17 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
329
344
  queuePersistHandle = setInterval(() => {
330
345
  if (!running) return;
331
346
  persistQueue(scanQueue, state);
347
+ persistDeferredQueue(); // Piggyback: persist deferred sandbox queue on same interval
332
348
  }, QUEUE_PERSIST_INTERVAL);
333
349
 
350
+ // ─── Deferred sandbox worker ───
351
+ // Retries T1b/T2 packages that were skipped when sandbox slots were full.
352
+ // Runs every 30s, processes at most 1 item per tick, yields to T1a.
353
+ if (isSandboxEnabled() && sandboxAvailableRef.value) {
354
+ startDeferredWorker(stats);
355
+ console.log('[MONITOR] Deferred sandbox worker started (30s interval, T1a-safe)');
356
+ }
357
+
334
358
  // ─── Continuous processing loop ───
335
359
  // Consumes scanQueue independently of polling. Workers inside processQueue
336
360
  // check scanQueue.length > 0 after each item, so items added by a concurrent
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Deferred Sandbox Queue
3
+ *
4
+ * When T1b/T2 packages are skipped due to sandbox slot pressure,
5
+ * they are enqueued here and retried when slots free up.
6
+ * Items are sorted by riskScore DESC (highest-risk first) to defend
7
+ * against queue-poisoning attacks.
8
+ *
9
+ * The worker reserves 1 sandbox slot for T1a (never uses the last slot).
10
+ */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { runSandbox, getSandboxSemaphore, SANDBOX_CONCURRENCY_MAX } = require('../sandbox/index.js');
14
+ const { isCanaryEnabled } = require('./classify.js');
15
+ const { getWebhookUrl, alertedPackageRules, persistAlert, buildAlertData } = require('./webhook.js');
16
+ const { sendWebhook } = require('../webhook.js');
17
+ const { atomicWriteFileSync } = require('./state.js');
18
+
19
+ // ── Constants ──
20
+ const DEFERRED_QUEUE_MAX = 500;
21
+ const DEFERRED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
22
+ const DEFERRED_MAX_RETRIES = 2;
23
+ const DEFERRED_WORKER_INTERVAL_MS = 30_000; // 30s
24
+ const DEFERRED_STATE_FILE = path.join(__dirname, '..', '..', 'data', 'deferred-queue.json');
25
+
26
+ // ── Mutable state ──
27
+ const _deferredQueue = [];
28
+ const _deferredSeen = new Set(); // name@version dedup
29
+ let _workerHandle = null;
30
+ let _stats = null; // reference to shared stats object
31
+
32
+ // ── Queue management ──
33
+
34
+ /**
35
+ * Enqueue a T1b/T2 package for deferred sandbox analysis.
36
+ * Items are sorted by riskScore DESC (highest risk first).
37
+ * When the queue is full, the lowest-score item is evicted if the new item scores higher.
38
+ *
39
+ * @param {object} item - Package to defer
40
+ * @returns {boolean} true if enqueued, false if rejected
41
+ */
42
+ function enqueueDeferred(item) {
43
+ // Guard: only T1b and T2 are allowed
44
+ if (item.tier !== '1b' && item.tier !== 2) {
45
+ console.error(`[DEFERRED] REJECTED: ${item.name}@${item.version} — tier ${item.tier} not eligible`);
46
+ return false;
47
+ }
48
+
49
+ const key = `${item.name}@${item.version}`;
50
+
51
+ // Dedup
52
+ if (_deferredSeen.has(key)) {
53
+ console.log(`[DEFERRED] DEDUP: ${key} already in deferred queue`);
54
+ return false;
55
+ }
56
+
57
+ // Queue full — evict lowest or reject
58
+ if (_deferredQueue.length >= DEFERRED_QUEUE_MAX) {
59
+ const lowest = _deferredQueue[_deferredQueue.length - 1];
60
+ if (item.riskScore > lowest.riskScore) {
61
+ const evictKey = `${lowest.name}@${lowest.version}`;
62
+ _deferredQueue.pop();
63
+ _deferredSeen.delete(evictKey);
64
+ console.log(`[DEFERRED] EVICTED: ${evictKey} (score=${lowest.riskScore}) to make room for ${key} (score=${item.riskScore})`);
65
+ } else {
66
+ console.log(`[DEFERRED] QUEUE FULL: ${key} (score=${item.riskScore}) rejected — all ${DEFERRED_QUEUE_MAX} items have higher scores`);
67
+ return false;
68
+ }
69
+ }
70
+
71
+ _deferredQueue.push(item);
72
+ _deferredSeen.add(key);
73
+ // Sort by riskScore DESC (highest first)
74
+ _deferredQueue.sort((a, b) => b.riskScore - a.riskScore);
75
+ console.log(`[DEFERRED] ENQUEUED: ${key} (tier=${item.tier === 2 ? 'T2' : 'T1b'}, score=${item.riskScore}, queue=${_deferredQueue.length})`);
76
+ return true;
77
+ }
78
+
79
+ function getDeferredQueue() {
80
+ return _deferredQueue;
81
+ }
82
+
83
+ function getDeferredQueueStats() {
84
+ const tierBreakdown = { t1b: 0, t2: 0 };
85
+ for (const item of _deferredQueue) {
86
+ if (item.tier === '1b') tierBreakdown.t1b++;
87
+ else if (item.tier === 2) tierBreakdown.t2++;
88
+ }
89
+ return {
90
+ size: _deferredQueue.length,
91
+ oldest: _deferredQueue.length > 0
92
+ ? _deferredQueue[_deferredQueue.length - 1].enqueuedAt
93
+ : null,
94
+ tierBreakdown
95
+ };
96
+ }
97
+
98
+ // ── TTL pruning ──
99
+
100
+ function pruneExpired(stats) {
101
+ const now = Date.now();
102
+ let pruned = 0;
103
+ for (let i = _deferredQueue.length - 1; i >= 0; i--) {
104
+ if (now - _deferredQueue[i].enqueuedAt > DEFERRED_TTL_MS) {
105
+ const item = _deferredQueue[i];
106
+ const key = `${item.name}@${item.version}`;
107
+ _deferredQueue.splice(i, 1);
108
+ _deferredSeen.delete(key);
109
+ if (stats) stats.deferredExpired = (stats.deferredExpired || 0) + 1;
110
+ console.log(`[DEFERRED] EXPIRED: ${key} (age=${((now - item.enqueuedAt) / 3600000).toFixed(1)}h)`);
111
+ pruned++;
112
+ }
113
+ }
114
+ return pruned;
115
+ }
116
+
117
+ // ── Worker ──
118
+
119
+ /**
120
+ * Process one deferred item. Exported for testing.
121
+ * @returns {object|null} sandboxResult or null if nothing processed
122
+ */
123
+ async function processDeferredItem(stats) {
124
+ // 1. Prune expired items
125
+ pruneExpired(stats);
126
+
127
+ if (_deferredQueue.length === 0) return null;
128
+
129
+ // 2. Yield check: reserve 1 slot for T1a
130
+ const sem = getSandboxSemaphore();
131
+ if (sem.active >= SANDBOX_CONCURRENCY_MAX - 1) {
132
+ return null; // All slots busy or only 1 free — keep it for T1a
133
+ }
134
+
135
+ // 3. Pick highest-score item
136
+ const item = _deferredQueue.shift();
137
+ const key = `${item.name}@${item.version}`;
138
+ _deferredSeen.delete(key);
139
+
140
+ console.log(`[DEFERRED] PROCESSING: ${key} (tier=${item.tier === 2 ? 'T2' : 'T1b'}, score=${item.riskScore}, retries=${item.retries})`);
141
+
142
+ // 4. Run sandbox
143
+ let sandboxResult;
144
+ try {
145
+ const canary = isCanaryEnabled();
146
+ sandboxResult = await runSandbox(item.name, { canary });
147
+ console.log(`[DEFERRED] SANDBOX COMPLETE: ${key} -> score=${sandboxResult.score}, severity=${sandboxResult.severity}`);
148
+ } catch (err) {
149
+ console.error(`[DEFERRED] SANDBOX ERROR: ${key} — ${err.message}`);
150
+ item.retries = (item.retries || 0) + 1;
151
+ if (item.retries >= DEFERRED_MAX_RETRIES) {
152
+ console.log(`[DEFERRED] DROPPED: ${key} after ${item.retries} failed attempts`);
153
+ } else {
154
+ // Re-enqueue for retry
155
+ _deferredQueue.push(item);
156
+ _deferredSeen.add(key);
157
+ _deferredQueue.sort((a, b) => b.riskScore - a.riskScore);
158
+ console.log(`[DEFERRED] RE-ENQUEUED: ${key} for retry (attempt ${item.retries + 1}/${DEFERRED_MAX_RETRIES})`);
159
+ }
160
+ return null;
161
+ }
162
+
163
+ // 5. Follow-up webhook if sandbox found something
164
+ if (stats) stats.deferredProcessed = (stats.deferredProcessed || 0) + 1;
165
+
166
+ if (sandboxResult && sandboxResult.score > 0) {
167
+ const deferredDedupKey = 'deferred_sandbox';
168
+ const previousRules = alertedPackageRules.get(item.name);
169
+ const alreadySentFollowUp = previousRules && previousRules.has(deferredDedupKey);
170
+
171
+ if (!alreadySentFollowUp) {
172
+ const url = getWebhookUrl();
173
+ if (url) {
174
+ try {
175
+ const embed = buildDeferredFollowUpEmbed(
176
+ item.name, item.version, item.ecosystem,
177
+ sandboxResult,
178
+ item.riskScore
179
+ );
180
+ await sendWebhook(url, embed, { rawPayload: true });
181
+ console.log(`[DEFERRED] FOLLOW-UP WEBHOOK: ${key} (sandbox score=${sandboxResult.score})`);
182
+
183
+ // Track in dedup map
184
+ if (previousRules) {
185
+ previousRules.add(deferredDedupKey);
186
+ } else {
187
+ alertedPackageRules.set(item.name, new Set([deferredDedupKey]));
188
+ }
189
+ } catch (webhookErr) {
190
+ console.error(`[DEFERRED] FOLLOW-UP WEBHOOK FAILED: ${key} — ${webhookErr.message}`);
191
+ }
192
+ }
193
+
194
+ // Persist updated alert with sandbox data
195
+ try {
196
+ const alertData = buildAlertData(
197
+ item.name, item.version, item.ecosystem,
198
+ item.staticResult, sandboxResult
199
+ );
200
+ persistAlert(item.name, item.version, item.ecosystem, alertData);
201
+ console.log(`[DEFERRED] ALERT PERSISTED: ${key} (with sandbox data)`);
202
+ } catch (persistErr) {
203
+ console.error(`[DEFERRED] ALERT PERSIST FAILED: ${key} — ${persistErr.message}`);
204
+ }
205
+ } else {
206
+ console.log(`[DEFERRED] DEDUP: follow-up already sent for ${item.name}`);
207
+ }
208
+ } else {
209
+ console.log(`[DEFERRED] CLEAN: ${key} (sandbox score=0, static score=${item.riskScore})`);
210
+ }
211
+
212
+ return sandboxResult;
213
+ }
214
+
215
+ /**
216
+ * Build Discord embed for deferred sandbox follow-up.
217
+ */
218
+ function buildDeferredFollowUpEmbed(name, version, ecosystem, sandboxResult, staticScore) {
219
+ const npmLink = ecosystem === 'npm'
220
+ ? `https://www.npmjs.com/package/${encodeURIComponent(name)}`
221
+ : `https://pypi.org/project/${encodeURIComponent(name)}/`;
222
+
223
+ const color = sandboxResult.score >= 80 ? 0xe74c3c // red: critical
224
+ : sandboxResult.score >= 30 ? 0xe67e22 // orange: high
225
+ : 0xf1c40f; // yellow: moderate
226
+
227
+ const fields = [
228
+ { name: 'Package', value: `[${name}@${version}](${npmLink})`, inline: true },
229
+ { name: 'Ecosystem', value: ecosystem.toUpperCase(), inline: true },
230
+ { name: 'Sandbox Score', value: `**${sandboxResult.score}/100** (${sandboxResult.severity})`, inline: true },
231
+ { name: 'Static Score', value: String(staticScore), inline: true },
232
+ { name: 'Status', value: 'Deferred sandbox completed after initial static-only alert', inline: false }
233
+ ];
234
+
235
+ // Top sandbox findings (max 5)
236
+ if (sandboxResult.findings && sandboxResult.findings.length > 0) {
237
+ const findingLines = sandboxResult.findings.slice(0, 5)
238
+ .map(f => `- [${f.severity || 'UNKNOWN'}] ${f.type}: ${(f.detail || '').slice(0, 100)}`)
239
+ .join('\n');
240
+ fields.push({ name: 'Sandbox Findings', value: findingLines.slice(0, 1024), inline: false });
241
+ }
242
+
243
+ return {
244
+ embeds: [{
245
+ title: `SANDBOX FOLLOW-UP \u2014 ${name}@${version}`,
246
+ color,
247
+ fields,
248
+ footer: {
249
+ text: `MUAD'DIB Deferred Sandbox | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
250
+ },
251
+ timestamp: new Date().toISOString()
252
+ }]
253
+ };
254
+ }
255
+
256
+ // ── Worker lifecycle ──
257
+
258
+ function startDeferredWorker(stats) {
259
+ _stats = stats;
260
+ if (_workerHandle) return _workerHandle;
261
+ console.log(`[DEFERRED] Worker started (interval=${DEFERRED_WORKER_INTERVAL_MS / 1000}s, max=${DEFERRED_QUEUE_MAX}, ttl=${DEFERRED_TTL_MS / 3600000}h)`);
262
+ _workerHandle = setInterval(async () => {
263
+ try {
264
+ await processDeferredItem(_stats);
265
+ } catch (err) {
266
+ console.error(`[DEFERRED] Worker tick error: ${err.message}`);
267
+ }
268
+ }, DEFERRED_WORKER_INTERVAL_MS);
269
+ return _workerHandle;
270
+ }
271
+
272
+ function stopDeferredWorker() {
273
+ if (_workerHandle) {
274
+ clearInterval(_workerHandle);
275
+ _workerHandle = null;
276
+ console.log('[DEFERRED] Worker stopped');
277
+ }
278
+ }
279
+
280
+ // ── Persistence ──
281
+
282
+ function persistDeferredQueue() {
283
+ try {
284
+ if (_deferredQueue.length === 0) {
285
+ // Remove stale file
286
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
287
+ return;
288
+ }
289
+ // Strip staticResult to reduce file size (can be large)
290
+ // Keep only essential fields for persistence
291
+ const items = _deferredQueue.map(item => ({
292
+ name: item.name,
293
+ version: item.version,
294
+ ecosystem: item.ecosystem,
295
+ tier: item.tier,
296
+ riskScore: item.riskScore,
297
+ tarballUrl: item.tarballUrl,
298
+ enqueuedAt: item.enqueuedAt,
299
+ retries: item.retries || 0
300
+ // staticResult and npmRegistryMeta are NOT persisted (too large, stale after restart)
301
+ }));
302
+ const payload = JSON.stringify({
303
+ savedAt: new Date().toISOString(),
304
+ count: items.length,
305
+ items
306
+ });
307
+ atomicWriteFileSync(DEFERRED_STATE_FILE, payload);
308
+ } catch (err) {
309
+ console.error(`[DEFERRED] Failed to persist queue: ${err.message}`);
310
+ }
311
+ }
312
+
313
+ function restoreDeferredQueue() {
314
+ try {
315
+ if (!fs.existsSync(DEFERRED_STATE_FILE)) return 0;
316
+ const raw = fs.readFileSync(DEFERRED_STATE_FILE, 'utf8');
317
+ const data = JSON.parse(raw);
318
+
319
+ if (!data || !Array.isArray(data.items) || !data.savedAt) {
320
+ console.log('[DEFERRED] State file invalid \u2014 ignoring');
321
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
322
+ return 0;
323
+ }
324
+
325
+ // Check file age
326
+ const ageMs = Date.now() - new Date(data.savedAt).getTime();
327
+ if (ageMs > DEFERRED_TTL_MS) {
328
+ console.log(`[DEFERRED] State file expired (${Math.round(ageMs / 3600000)}h old) \u2014 ignoring`);
329
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
330
+ return 0;
331
+ }
332
+
333
+ // Restore items, pruning individually expired ones
334
+ const now = Date.now();
335
+ let restored = 0;
336
+ for (const item of data.items) {
337
+ if (now - item.enqueuedAt > DEFERRED_TTL_MS) continue; // expired
338
+ const key = `${item.name}@${item.version}`;
339
+ if (_deferredSeen.has(key)) continue; // dedup
340
+ _deferredQueue.push(item);
341
+ _deferredSeen.add(key);
342
+ restored++;
343
+ }
344
+
345
+ // Sort after bulk insert
346
+ _deferredQueue.sort((a, b) => b.riskScore - a.riskScore);
347
+
348
+ if (restored > 0) {
349
+ console.log(`[DEFERRED] Restored ${restored} items from disk (saved at ${data.savedAt})`);
350
+ }
351
+
352
+ // Delete after successful restore
353
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
354
+ return restored;
355
+ } catch (err) {
356
+ console.log(`[DEFERRED] WARNING: could not restore state: ${err.message}`);
357
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
358
+ return 0;
359
+ }
360
+ }
361
+
362
+ // ── Reset (for testing) ──
363
+
364
+ function _resetDeferredQueue() {
365
+ _deferredQueue.length = 0;
366
+ _deferredSeen.clear();
367
+ _stats = null;
368
+ stopDeferredWorker();
369
+ }
370
+
371
+ module.exports = {
372
+ enqueueDeferred,
373
+ getDeferredQueue,
374
+ getDeferredQueueStats,
375
+ startDeferredWorker,
376
+ stopDeferredWorker,
377
+ processDeferredItem,
378
+ persistDeferredQueue,
379
+ restoreDeferredQueue,
380
+ buildDeferredFollowUpEmbed,
381
+ pruneExpired,
382
+ _resetDeferredQueue,
383
+ DEFERRED_QUEUE_MAX,
384
+ DEFERRED_TTL_MS,
385
+ DEFERRED_MAX_RETRIES,
386
+ DEFERRED_WORKER_INTERVAL_MS,
387
+ DEFERRED_STATE_FILE
388
+ };
@@ -103,6 +103,9 @@ const { getNpmLatestTarball, getPyPITarballUrl, getWeeklyDownloads, checkTrusted
103
103
  // From ./tarball-archive.js
104
104
  const { archiveSuspectTarball } = require('./tarball-archive.js');
105
105
 
106
+ // From ./deferred-sandbox.js
107
+ const { enqueueDeferred } = require('./deferred-sandbox.js');
108
+
106
109
  // --- Constants ---
107
110
 
108
111
  const SCAN_CONCURRENCY = Math.max(1, parseInt(process.env.MUADDIB_SCAN_CONCURRENCY, 10) || 8);
@@ -686,10 +689,30 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
686
689
  } catch (err) {
687
690
  console.error(`[MONITOR] SANDBOX error for ${name}@${version}: ${err.message}`);
688
691
  }
692
+ } else if (tier === '1b' && sandboxAvailable) {
693
+ console.log(`[MONITOR] SANDBOX DEFERRED (T1b, score=${riskScore} < 25, queue ${scanQueue.length} >= 20): ${name}@${version}`);
694
+ enqueueDeferred({
695
+ name, version, ecosystem, tier, riskScore, tarballUrl,
696
+ enqueuedAt: Date.now(),
697
+ staticResult: result,
698
+ npmRegistryMeta,
699
+ retries: 0
700
+ });
701
+ stats.sandboxDeferred = (stats.sandboxDeferred || 0) + 1;
689
702
  } else if (tier === '1b') {
690
- console.log(`[MONITOR] SANDBOX SKIPPED (T1b, score=${riskScore} < 25, queue ${scanQueue.length} >= 20): ${name}@${version}`);
703
+ console.log(`[MONITOR] SANDBOX SKIPPED (T1b, no Docker): ${name}@${version}`);
704
+ } else if (tier === 2 && sandboxAvailable) {
705
+ console.log(`[MONITOR] SANDBOX DEFERRED (T2, queue ${scanQueue.length} >= 50): ${name}@${version}`);
706
+ enqueueDeferred({
707
+ name, version, ecosystem, tier, riskScore, tarballUrl,
708
+ enqueuedAt: Date.now(),
709
+ staticResult: result,
710
+ npmRegistryMeta,
711
+ retries: 0
712
+ });
713
+ stats.sandboxDeferred = (stats.sandboxDeferred || 0) + 1;
691
714
  } else if (tier === 2) {
692
- console.log(`[MONITOR] SANDBOX SKIPPED (T2, queue ${scanQueue.length} >= 50): ${name}@${version}`);
715
+ console.log(`[MONITOR] SANDBOX SKIPPED (T2, no Docker): ${name}@${version}`);
693
716
  }
694
717
 
695
718
  stats.scanned++;
@@ -894,6 +894,9 @@ function buildDailyReportEmbed(stats, dailyAlerts) {
894
894
  { name: 'ML', value: mlText, inline: true },
895
895
  { name: 'LLM Detective', value: llmText, inline: true },
896
896
  { name: 'Top Suspects', value: top3Text, inline: false },
897
+ ...((stats.sandboxDeferred || stats.deferredProcessed || stats.deferredExpired)
898
+ ? [{ name: 'Deferred Sandbox', value: `Enqueued: ${stats.sandboxDeferred || 0} | Processed: ${stats.deferredProcessed || 0} | Expired: ${stats.deferredExpired || 0}`, inline: false }]
899
+ : []),
897
900
  { name: 'System', value: healthText, inline: false }
898
901
  ],
899
902
  footer: {
@@ -939,6 +942,9 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
939
942
  mlFiltered: stats.mlFiltered || 0,
940
943
  llmAnalyzed: stats.llmAnalyzed || 0,
941
944
  llmSuppressed: stats.llmSuppressed || 0,
945
+ sandboxDeferred: stats.sandboxDeferred || 0,
946
+ deferredProcessed: stats.deferredProcessed || 0,
947
+ deferredExpired: stats.deferredExpired || 0,
942
948
  changesStreamPackages: stats.changesStreamPackages || 0,
943
949
  topSuspects: dailyAlerts.slice().sort((a, b) => b.findingsCount - a.findingsCount).slice(0, 10)
944
950
  });
@@ -976,6 +982,9 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
976
982
  stats.mlFiltered = 0;
977
983
  stats.llmAnalyzed = 0;
978
984
  stats.llmSuppressed = 0;
985
+ stats.sandboxDeferred = 0;
986
+ stats.deferredProcessed = 0;
987
+ stats.deferredExpired = 0;
979
988
  // Reset LLM detective internal stats
980
989
  try { require('../ml/llm-detective.js').resetStats(); } catch {}
981
990
  stats.changesStreamPackages = 0;