muaddib-scanner 2.10.45 → 2.10.47

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.47",
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
@@ -57,6 +58,15 @@ function persistQueue(scanQueue, state) {
57
58
  * Skips if file is missing, corrupt, or older than 24h.
58
59
  */
59
60
  function restoreQueue(scanQueue) {
61
+ // Cleanup orphan .tmp from previous crash / disk-full (ENOSPC)
62
+ const tmpFile = QUEUE_STATE_FILE + '.tmp';
63
+ try {
64
+ if (fs.existsSync(tmpFile)) {
65
+ console.log(`[MONITOR] Cleaning up orphan ${path.basename(tmpFile)}`);
66
+ fs.unlinkSync(tmpFile);
67
+ }
68
+ } catch { /* best-effort */ }
69
+
60
70
  try {
61
71
  if (!fs.existsSync(QUEUE_STATE_FILE)) return 0;
62
72
  const raw = fs.readFileSync(QUEUE_STATE_FILE, 'utf8');
@@ -133,6 +143,94 @@ function cleanupOrphanContainers() {
133
143
  }
134
144
  }
135
145
 
146
+ /**
147
+ * Clean up orphan gVisor runtime directories in /tmp/runsc.
148
+ * runsc creates per-container state dirs that are NOT cleaned up when gVisor or
149
+ * Docker crashes. In production this reached 61GB and filled the disk (ENOSPC),
150
+ * cascading into 0-byte .tmp files and total persistence failure.
151
+ * Removes directories older than maxAgeMs (default: 1h).
152
+ */
153
+ function cleanupRunscOrphans(maxAgeMs = 3600_000) {
154
+ const runscDir = process.env.MUADDIB_GVISOR_LOG_DIR || '/tmp/runsc';
155
+ try {
156
+ if (!fs.existsSync(runscDir)) return 0;
157
+ const entries = fs.readdirSync(runscDir);
158
+ const now = Date.now();
159
+ let cleaned = 0;
160
+ for (const entry of entries) {
161
+ const fullPath = path.join(runscDir, entry);
162
+ try {
163
+ const stat = fs.statSync(fullPath);
164
+ if (now - stat.mtimeMs > maxAgeMs) {
165
+ fs.rmSync(fullPath, { recursive: true, force: true });
166
+ cleaned++;
167
+ }
168
+ } catch { /* skip unreadable entries */ }
169
+ }
170
+ if (cleaned > 0) {
171
+ console.log(`[MONITOR] Cleaned up ${cleaned} orphan runsc dir(s) in ${runscDir}`);
172
+ }
173
+ return cleaned;
174
+ } catch {
175
+ return 0;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Check disk usage at boot. Warns if root partition > 90% full and logs
181
+ * the largest consumers in /tmp/ and data/ to aid diagnosis.
182
+ * Uses df + du — Linux-only, silently skips on other platforms.
183
+ */
184
+ function checkDiskSpace() {
185
+ try {
186
+ // df --output=pcent / → "Use%\n 42%\n"
187
+ const dfOutput = execFileSync('df', ['--output=pcent', '/'], {
188
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000
189
+ });
190
+ const match = dfOutput.match(/(\d+)%/);
191
+ if (!match) return;
192
+ const usagePercent = parseInt(match[1], 10);
193
+ if (usagePercent < 90) return;
194
+
195
+ console.warn(`[MONITOR] WARNING: disk usage at ${usagePercent}% — persistence may fail (ENOSPC)`);
196
+
197
+ // Top consumers in /tmp/
198
+ try {
199
+ const tmpDu = execFileSync('du', ['-sh', '--max-depth=1', '/tmp/'], {
200
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
201
+ });
202
+ const lines = tmpDu.trim().split('\n')
203
+ .map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
204
+ .filter(Boolean)
205
+ .sort((a, b) => b.size.localeCompare(a.size))
206
+ .slice(0, 5);
207
+ if (lines.length > 0) {
208
+ console.warn('[MONITOR] Top /tmp/ consumers:');
209
+ for (const l of lines) console.warn(`[MONITOR] ${l.size}\t${l.path}`);
210
+ }
211
+ } catch { /* du failed */ }
212
+
213
+ // Top consumers in data/
214
+ const dataDir = path.join(__dirname, '..', '..', 'data');
215
+ try {
216
+ const dataDu = execFileSync('du', ['-sh', '--max-depth=1', dataDir], {
217
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
218
+ });
219
+ const lines = dataDu.trim().split('\n')
220
+ .map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
221
+ .filter(Boolean)
222
+ .sort((a, b) => b.size.localeCompare(a.size))
223
+ .slice(0, 5);
224
+ if (lines.length > 0) {
225
+ console.warn('[MONITOR] Top data/ consumers:');
226
+ for (const l of lines) console.warn(`[MONITOR] ${l.size}\t${l.path}`);
227
+ }
228
+ } catch { /* du failed */ }
229
+ } catch {
230
+ // df not available (non-Linux) — skip silently
231
+ }
232
+ }
233
+
136
234
  function reportStats(stats) {
137
235
  const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
138
236
  const { t1, t1a, t1b, t2, t3 } = stats.suspectByTier;
@@ -149,6 +247,11 @@ function reportStats(stats) {
149
247
  if (stats.tarballCacheHits) {
150
248
  console.log(`[MONITOR] Tarball cache hits: ${stats.tarballCacheHits}`);
151
249
  }
250
+ if (stats.sandboxDeferred || stats.deferredProcessed) {
251
+ const { getDeferredQueueStats } = require('./deferred-sandbox.js');
252
+ const dq = getDeferredQueueStats();
253
+ console.log(`[MONITOR] Deferred sandbox: ${stats.sandboxDeferred || 0} enqueued, ${stats.deferredProcessed || 0} processed, ${stats.deferredExpired || 0} expired, ${stats.deferredSkipped || 0} skipped, ${dq.size} pending`);
254
+ }
152
255
  stats.lastReportTime = Date.now();
153
256
  }
154
257
 
@@ -165,10 +268,14 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
165
268
  setVerboseMode(true);
166
269
  }
167
270
 
271
+ // Disk space check — early warning before ENOSPC cascading failure
272
+ checkDiskSpace();
168
273
  // Cleanup temp dirs from previous runs (SIGTERM/crash may leave orphans)
169
274
  cleanupOrphanTmpDirs();
170
275
  // Kill orphan sandbox containers from previous crash (npm-audit-* prefix)
171
276
  cleanupOrphanContainers();
277
+ // Clean up stale gVisor runtime dirs (runsc leak — caused 61GB disk fill in prod)
278
+ cleanupRunscOrphans();
172
279
  // Layer 3: Purge expired cached tarballs on startup
173
280
  purgeTarballCache();
174
281
 
@@ -262,6 +369,12 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
262
369
  console.log(`[MONITOR] ${restoredCount} packages pre-loaded from previous session`);
263
370
  }
264
371
 
372
+ // Restore deferred sandbox queue from previous run
373
+ const deferredRestored = restoreDeferredQueue();
374
+ if (deferredRestored > 0) {
375
+ console.log(`[MONITOR] ${deferredRestored} deferred sandbox items restored from previous session`);
376
+ }
377
+
265
378
  // Graceful shutdown handler (shared by SIGINT and SIGTERM)
266
379
  // Daily report is NEVER sent on shutdown — it only fires at 08:00 Paris time.
267
380
  // Counters are persisted to disk so they survive the restart.
@@ -278,6 +391,9 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
278
391
  }
279
392
  // Persist remaining queue items so they survive the restart
280
393
  persistQueue(scanQueue, state);
394
+ // Stop deferred sandbox worker and persist its queue
395
+ stopDeferredWorker();
396
+ persistDeferredQueue();
281
397
  healthcheck.stop();
282
398
  // Flush all pending scope groups before exit
283
399
  for (const [scope, group] of pendingGrouped) {
@@ -329,8 +445,17 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
329
445
  queuePersistHandle = setInterval(() => {
330
446
  if (!running) return;
331
447
  persistQueue(scanQueue, state);
448
+ persistDeferredQueue(); // Piggyback: persist deferred sandbox queue on same interval
332
449
  }, QUEUE_PERSIST_INTERVAL);
333
450
 
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
+
334
459
  // ─── Continuous processing loop ───
335
460
  // Consumes scanQueue independently of polling. Workers inside processQueue
336
461
  // check scanQueue.length > 0 after each item, so items added by a concurrent
@@ -340,10 +465,11 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
340
465
  await processQueue(scanQueue, stats, dailyAlerts, recentlyScanned, downloadsCache, sandboxAvailableRef.value);
341
466
  }
342
467
 
343
- // Hourly stats report + cache purge
468
+ // Hourly stats report + cache purge + runsc cleanup
344
469
  if (Date.now() - stats.lastReportTime >= 3600_000) {
345
470
  reportStats(stats);
346
471
  purgeTarballCache();
472
+ cleanupRunscOrphans();
347
473
  }
348
474
 
349
475
  // Daily webhook report at 08:00 Paris time
@@ -360,6 +486,8 @@ module.exports = {
360
486
  startMonitor,
361
487
  cleanupOrphanTmpDirs,
362
488
  cleanupOrphanContainers,
489
+ cleanupRunscOrphans,
490
+ checkDiskSpace,
363
491
  reportStats,
364
492
  isDailyReportDue,
365
493
  sleep,
@@ -0,0 +1,417 @@
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
+ 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
+
28
+ // ── Mutable state ──
29
+ const _deferredQueue = [];
30
+ const _deferredSeen = new Set(); // name@version dedup
31
+ let _workerHandle = null;
32
+ let _stats = null; // reference to shared stats object
33
+ let _consecutiveSkips = 0; // Tracks consecutive yield-skips for anti-starvation
34
+
35
+ // ── Queue management ──
36
+
37
+ /**
38
+ * Enqueue a T1b/T2 package for deferred sandbox analysis.
39
+ * Items are sorted by riskScore DESC (highest risk first).
40
+ * When the queue is full, the lowest-score item is evicted if the new item scores higher.
41
+ *
42
+ * @param {object} item - Package to defer
43
+ * @returns {boolean} true if enqueued, false if rejected
44
+ */
45
+ function enqueueDeferred(item) {
46
+ // Guard: only T1b and T2 are allowed
47
+ if (item.tier !== '1b' && item.tier !== 2) {
48
+ console.error(`[DEFERRED] REJECTED: ${item.name}@${item.version} — tier ${item.tier} not eligible`);
49
+ return false;
50
+ }
51
+
52
+ const key = `${item.name}@${item.version}`;
53
+
54
+ // Dedup
55
+ if (_deferredSeen.has(key)) {
56
+ console.log(`[DEFERRED] DEDUP: ${key} already in deferred queue`);
57
+ return false;
58
+ }
59
+
60
+ // Queue full — evict lowest or reject
61
+ if (_deferredQueue.length >= DEFERRED_QUEUE_MAX) {
62
+ const lowest = _deferredQueue[_deferredQueue.length - 1];
63
+ if (item.riskScore > lowest.riskScore) {
64
+ const evictKey = `${lowest.name}@${lowest.version}`;
65
+ _deferredQueue.pop();
66
+ _deferredSeen.delete(evictKey);
67
+ console.log(`[DEFERRED] EVICTED: ${evictKey} (score=${lowest.riskScore}) to make room for ${key} (score=${item.riskScore})`);
68
+ } else {
69
+ console.log(`[DEFERRED] QUEUE FULL: ${key} (score=${item.riskScore}) rejected — all ${DEFERRED_QUEUE_MAX} items have higher scores`);
70
+ return false;
71
+ }
72
+ }
73
+
74
+ _deferredQueue.push(item);
75
+ _deferredSeen.add(key);
76
+ // Sort by riskScore DESC (highest first)
77
+ _deferredQueue.sort((a, b) => b.riskScore - a.riskScore);
78
+ console.log(`[DEFERRED] ENQUEUED: ${key} (tier=${item.tier === 2 ? 'T2' : 'T1b'}, score=${item.riskScore}, queue=${_deferredQueue.length})`);
79
+ return true;
80
+ }
81
+
82
+ function getDeferredQueue() {
83
+ return _deferredQueue;
84
+ }
85
+
86
+ function getDeferredQueueStats() {
87
+ const tierBreakdown = { t1b: 0, t2: 0 };
88
+ for (const item of _deferredQueue) {
89
+ if (item.tier === '1b') tierBreakdown.t1b++;
90
+ else if (item.tier === 2) tierBreakdown.t2++;
91
+ }
92
+ return {
93
+ size: _deferredQueue.length,
94
+ oldest: _deferredQueue.length > 0
95
+ ? _deferredQueue[_deferredQueue.length - 1].enqueuedAt
96
+ : null,
97
+ tierBreakdown
98
+ };
99
+ }
100
+
101
+ // ── TTL pruning ──
102
+
103
+ function pruneExpired(stats) {
104
+ const now = Date.now();
105
+ let pruned = 0;
106
+ for (let i = _deferredQueue.length - 1; i >= 0; i--) {
107
+ if (now - _deferredQueue[i].enqueuedAt > DEFERRED_TTL_MS) {
108
+ const item = _deferredQueue[i];
109
+ const key = `${item.name}@${item.version}`;
110
+ _deferredQueue.splice(i, 1);
111
+ _deferredSeen.delete(key);
112
+ if (stats) stats.deferredExpired = (stats.deferredExpired || 0) + 1;
113
+ console.log(`[DEFERRED] EXPIRED: ${key} (age=${((now - item.enqueuedAt) / 3600000).toFixed(1)}h)`);
114
+ pruned++;
115
+ }
116
+ }
117
+ return pruned;
118
+ }
119
+
120
+ // ── Worker ──
121
+
122
+ /**
123
+ * Process one deferred item. Exported for testing.
124
+ * @returns {object|null} sandboxResult or null if nothing processed
125
+ */
126
+ async function processDeferredItem(stats) {
127
+ // 1. Prune expired items
128
+ pruneExpired(stats);
129
+
130
+ if (_deferredQueue.length === 0) return null;
131
+
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++;
139
+ 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
+ return null;
144
+ }
145
+
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
+ // 3. Pick highest-score item
152
+ const item = _deferredQueue.shift();
153
+ const key = `${item.name}@${item.version}`;
154
+ _deferredSeen.delete(key);
155
+
156
+ console.log(`[DEFERRED] PROCESSING: ${key} (tier=${item.tier === 2 ? 'T2' : 'T1b'}, score=${item.riskScore}, retries=${item.retries})`);
157
+
158
+ // 4. Run sandbox
159
+ let sandboxResult;
160
+ try {
161
+ const canary = isCanaryEnabled();
162
+ sandboxResult = await runSandbox(item.name, { canary });
163
+ console.log(`[DEFERRED] SANDBOX COMPLETE: ${key} -> score=${sandboxResult.score}, severity=${sandboxResult.severity}`);
164
+ } catch (err) {
165
+ console.error(`[DEFERRED] SANDBOX ERROR: ${key} — ${err.message}`);
166
+ item.retries = (item.retries || 0) + 1;
167
+ if (item.retries >= DEFERRED_MAX_RETRIES) {
168
+ console.log(`[DEFERRED] DROPPED: ${key} after ${item.retries} failed attempts`);
169
+ } else {
170
+ // Re-enqueue for retry
171
+ _deferredQueue.push(item);
172
+ _deferredSeen.add(key);
173
+ _deferredQueue.sort((a, b) => b.riskScore - a.riskScore);
174
+ console.log(`[DEFERRED] RE-ENQUEUED: ${key} for retry (attempt ${item.retries + 1}/${DEFERRED_MAX_RETRIES})`);
175
+ }
176
+ return null;
177
+ }
178
+
179
+ // 5. Follow-up webhook if sandbox found something
180
+ if (stats) stats.deferredProcessed = (stats.deferredProcessed || 0) + 1;
181
+
182
+ if (sandboxResult && sandboxResult.score > 0) {
183
+ const deferredDedupKey = 'deferred_sandbox';
184
+ const previousRules = alertedPackageRules.get(item.name);
185
+ const alreadySentFollowUp = previousRules && previousRules.has(deferredDedupKey);
186
+
187
+ if (!alreadySentFollowUp) {
188
+ const url = getWebhookUrl();
189
+ if (url) {
190
+ try {
191
+ const embed = buildDeferredFollowUpEmbed(
192
+ item.name, item.version, item.ecosystem,
193
+ sandboxResult,
194
+ item.riskScore
195
+ );
196
+ await sendWebhook(url, embed, { rawPayload: true });
197
+ console.log(`[DEFERRED] FOLLOW-UP WEBHOOK: ${key} (sandbox score=${sandboxResult.score})`);
198
+
199
+ // Track in dedup map
200
+ if (previousRules) {
201
+ previousRules.add(deferredDedupKey);
202
+ } else {
203
+ alertedPackageRules.set(item.name, new Set([deferredDedupKey]));
204
+ }
205
+ } catch (webhookErr) {
206
+ console.error(`[DEFERRED] FOLLOW-UP WEBHOOK FAILED: ${key} — ${webhookErr.message}`);
207
+ }
208
+ }
209
+
210
+ // Persist updated alert with sandbox data
211
+ try {
212
+ const alertData = buildAlertData(
213
+ item.name, item.version, item.ecosystem,
214
+ item.staticResult, sandboxResult
215
+ );
216
+ persistAlert(item.name, item.version, item.ecosystem, alertData);
217
+ console.log(`[DEFERRED] ALERT PERSISTED: ${key} (with sandbox data)`);
218
+ } catch (persistErr) {
219
+ console.error(`[DEFERRED] ALERT PERSIST FAILED: ${key} — ${persistErr.message}`);
220
+ }
221
+ } else {
222
+ console.log(`[DEFERRED] DEDUP: follow-up already sent for ${item.name}`);
223
+ }
224
+ } else {
225
+ console.log(`[DEFERRED] CLEAN: ${key} (sandbox score=0, static score=${item.riskScore})`);
226
+ }
227
+
228
+ return sandboxResult;
229
+ }
230
+
231
+ /**
232
+ * Build Discord embed for deferred sandbox follow-up.
233
+ */
234
+ function buildDeferredFollowUpEmbed(name, version, ecosystem, sandboxResult, staticScore) {
235
+ const npmLink = ecosystem === 'npm'
236
+ ? `https://www.npmjs.com/package/${encodeURIComponent(name)}`
237
+ : `https://pypi.org/project/${encodeURIComponent(name)}/`;
238
+
239
+ const color = sandboxResult.score >= 80 ? 0xe74c3c // red: critical
240
+ : sandboxResult.score >= 30 ? 0xe67e22 // orange: high
241
+ : 0xf1c40f; // yellow: moderate
242
+
243
+ const fields = [
244
+ { name: 'Package', value: `[${name}@${version}](${npmLink})`, inline: true },
245
+ { name: 'Ecosystem', value: ecosystem.toUpperCase(), inline: true },
246
+ { name: 'Sandbox Score', value: `**${sandboxResult.score}/100** (${sandboxResult.severity})`, inline: true },
247
+ { name: 'Static Score', value: String(staticScore), inline: true },
248
+ { name: 'Status', value: 'Deferred sandbox completed after initial static-only alert', inline: false }
249
+ ];
250
+
251
+ // Top sandbox findings (max 5)
252
+ if (sandboxResult.findings && sandboxResult.findings.length > 0) {
253
+ const findingLines = sandboxResult.findings.slice(0, 5)
254
+ .map(f => `- [${f.severity || 'UNKNOWN'}] ${f.type}: ${(f.detail || '').slice(0, 100)}`)
255
+ .join('\n');
256
+ fields.push({ name: 'Sandbox Findings', value: findingLines.slice(0, 1024), inline: false });
257
+ }
258
+
259
+ return {
260
+ embeds: [{
261
+ title: `SANDBOX FOLLOW-UP \u2014 ${name}@${version}`,
262
+ color,
263
+ fields,
264
+ footer: {
265
+ text: `MUAD'DIB Deferred Sandbox | ${new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`
266
+ },
267
+ timestamp: new Date().toISOString()
268
+ }]
269
+ };
270
+ }
271
+
272
+ // ── Worker lifecycle ──
273
+
274
+ function startDeferredWorker(stats) {
275
+ _stats = stats;
276
+ if (_workerHandle) return _workerHandle;
277
+ console.log(`[DEFERRED] Worker started (interval=${DEFERRED_WORKER_INTERVAL_MS / 1000}s, max=${DEFERRED_QUEUE_MAX}, ttl=${DEFERRED_TTL_MS / 3600000}h)`);
278
+ _workerHandle = setInterval(async () => {
279
+ try {
280
+ await processDeferredItem(_stats);
281
+ } catch (err) {
282
+ console.error(`[DEFERRED] Worker tick error: ${err.message}`);
283
+ }
284
+ }, DEFERRED_WORKER_INTERVAL_MS);
285
+ return _workerHandle;
286
+ }
287
+
288
+ function stopDeferredWorker() {
289
+ if (_workerHandle) {
290
+ clearInterval(_workerHandle);
291
+ _workerHandle = null;
292
+ console.log('[DEFERRED] Worker stopped');
293
+ }
294
+ }
295
+
296
+ // ── Persistence ──
297
+
298
+ function persistDeferredQueue() {
299
+ try {
300
+ if (_deferredQueue.length === 0) {
301
+ // Remove stale file
302
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
303
+ return;
304
+ }
305
+ // Strip staticResult to reduce file size (can be large)
306
+ // Keep only essential fields for persistence
307
+ const items = _deferredQueue.map(item => ({
308
+ name: item.name,
309
+ version: item.version,
310
+ ecosystem: item.ecosystem,
311
+ tier: item.tier,
312
+ riskScore: item.riskScore,
313
+ tarballUrl: item.tarballUrl,
314
+ enqueuedAt: item.enqueuedAt,
315
+ retries: item.retries || 0
316
+ // staticResult and npmRegistryMeta are NOT persisted (too large, stale after restart)
317
+ }));
318
+ const payload = JSON.stringify({
319
+ savedAt: new Date().toISOString(),
320
+ count: items.length,
321
+ items
322
+ });
323
+ atomicWriteFileSync(DEFERRED_STATE_FILE, payload);
324
+ } catch (err) {
325
+ console.error(`[DEFERRED] Failed to persist queue: ${err.message}`);
326
+ }
327
+ }
328
+
329
+ function restoreDeferredQueue() {
330
+ // Cleanup orphan .tmp from previous crash / disk-full (ENOSPC)
331
+ const tmpFile = DEFERRED_STATE_FILE + '.tmp';
332
+ try {
333
+ if (fs.existsSync(tmpFile)) {
334
+ const stat = fs.statSync(tmpFile);
335
+ console.log(`[DEFERRED] Cleaning up orphan ${path.basename(tmpFile)} (${stat.size} bytes)`);
336
+ fs.unlinkSync(tmpFile);
337
+ }
338
+ } catch { /* best-effort */ }
339
+
340
+ try {
341
+ if (!fs.existsSync(DEFERRED_STATE_FILE)) return 0;
342
+ const raw = fs.readFileSync(DEFERRED_STATE_FILE, 'utf8');
343
+ const data = JSON.parse(raw);
344
+
345
+ if (!data || !Array.isArray(data.items) || !data.savedAt) {
346
+ console.log('[DEFERRED] State file invalid \u2014 ignoring');
347
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
348
+ return 0;
349
+ }
350
+
351
+ // Check file age
352
+ const ageMs = Date.now() - new Date(data.savedAt).getTime();
353
+ if (ageMs > DEFERRED_TTL_MS) {
354
+ console.log(`[DEFERRED] State file expired (${Math.round(ageMs / 3600000)}h old) \u2014 ignoring`);
355
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
356
+ return 0;
357
+ }
358
+
359
+ // Restore items, pruning individually expired ones
360
+ const now = Date.now();
361
+ let restored = 0;
362
+ for (const item of data.items) {
363
+ if (now - item.enqueuedAt > DEFERRED_TTL_MS) continue; // expired
364
+ const key = `${item.name}@${item.version}`;
365
+ if (_deferredSeen.has(key)) continue; // dedup
366
+ _deferredQueue.push(item);
367
+ _deferredSeen.add(key);
368
+ restored++;
369
+ }
370
+
371
+ // Sort after bulk insert
372
+ _deferredQueue.sort((a, b) => b.riskScore - a.riskScore);
373
+
374
+ if (restored > 0) {
375
+ console.log(`[DEFERRED] Restored ${restored} items from disk (saved at ${data.savedAt})`);
376
+ }
377
+
378
+ // Delete after successful restore
379
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
380
+ return restored;
381
+ } catch (err) {
382
+ console.log(`[DEFERRED] WARNING: could not restore state: ${err.message}`);
383
+ try { fs.unlinkSync(DEFERRED_STATE_FILE); } catch { /* ignore missing */ }
384
+ return 0;
385
+ }
386
+ }
387
+
388
+ // ── Reset (for testing) ──
389
+
390
+ function _resetDeferredQueue() {
391
+ _deferredQueue.length = 0;
392
+ _deferredSeen.clear();
393
+ _stats = null;
394
+ _consecutiveSkips = 0;
395
+ stopDeferredWorker();
396
+ }
397
+
398
+ module.exports = {
399
+ enqueueDeferred,
400
+ getDeferredQueue,
401
+ getDeferredQueueStats,
402
+ startDeferredWorker,
403
+ stopDeferredWorker,
404
+ processDeferredItem,
405
+ persistDeferredQueue,
406
+ restoreDeferredQueue,
407
+ buildDeferredFollowUpEmbed,
408
+ pruneExpired,
409
+ _resetDeferredQueue,
410
+ DEFERRED_QUEUE_MAX,
411
+ DEFERRED_TTL_MS,
412
+ DEFERRED_MAX_RETRIES,
413
+ DEFERRED_WORKER_INTERVAL_MS,
414
+ DEFERRED_STATE_FILE,
415
+ SKIP_LOG_INTERVAL,
416
+ ANTI_STARVATION_TICKS
417
+ };
@@ -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++;
@@ -116,6 +116,10 @@ function atomicWriteFileSync(filePath, data) {
116
116
  console.warn(`[MONITOR] Cannot create directory ${dir} (${err.code}) — skipping write to ${path.basename(filePath)}`);
117
117
  return;
118
118
  }
119
+ if (err.code === 'ENOSPC') {
120
+ console.warn(`[MONITOR] WARNING: disk full (ENOSPC) — cannot create directory ${dir}. Free space immediately.`);
121
+ return;
122
+ }
119
123
  throw err;
120
124
  }
121
125
  const tmpFile = filePath + '.tmp';
@@ -125,7 +129,11 @@ function atomicWriteFileSync(filePath, data) {
125
129
  } catch (err) {
126
130
  if (err.code === 'EROFS' || err.code === 'EACCES' || err.code === 'EPERM') {
127
131
  console.warn(`[MONITOR] Cannot write ${path.basename(filePath)} (${err.code}) — skipping`);
128
- // Clean up .tmp if it was partially written
132
+ try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
133
+ return;
134
+ }
135
+ if (err.code === 'ENOSPC') {
136
+ console.warn(`[MONITOR] WARNING: disk full (ENOSPC) — cannot write ${path.basename(filePath)}. Free space in /tmp and data/ immediately.`);
129
137
  try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
130
138
  return;
131
139
  }
@@ -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;