muaddib-scanner 2.10.46 → 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.46",
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": {
@@ -58,6 +58,15 @@ function persistQueue(scanQueue, state) {
58
58
  * Skips if file is missing, corrupt, or older than 24h.
59
59
  */
60
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
+
61
70
  try {
62
71
  if (!fs.existsSync(QUEUE_STATE_FILE)) return 0;
63
72
  const raw = fs.readFileSync(QUEUE_STATE_FILE, 'utf8');
@@ -134,6 +143,94 @@ function cleanupOrphanContainers() {
134
143
  }
135
144
  }
136
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
+
137
234
  function reportStats(stats) {
138
235
  const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
139
236
  const { t1, t1a, t1b, t2, t3 } = stats.suspectByTier;
@@ -153,7 +250,7 @@ function reportStats(stats) {
153
250
  if (stats.sandboxDeferred || stats.deferredProcessed) {
154
251
  const { getDeferredQueueStats } = require('./deferred-sandbox.js');
155
252
  const dq = getDeferredQueueStats();
156
- console.log(`[MONITOR] Deferred sandbox: ${stats.sandboxDeferred || 0} enqueued, ${stats.deferredProcessed || 0} processed, ${stats.deferredExpired || 0} expired, ${dq.size} pending`);
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`);
157
254
  }
158
255
  stats.lastReportTime = Date.now();
159
256
  }
@@ -171,10 +268,14 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
171
268
  setVerboseMode(true);
172
269
  }
173
270
 
271
+ // Disk space check — early warning before ENOSPC cascading failure
272
+ checkDiskSpace();
174
273
  // Cleanup temp dirs from previous runs (SIGTERM/crash may leave orphans)
175
274
  cleanupOrphanTmpDirs();
176
275
  // Kill orphan sandbox containers from previous crash (npm-audit-* prefix)
177
276
  cleanupOrphanContainers();
277
+ // Clean up stale gVisor runtime dirs (runsc leak — caused 61GB disk fill in prod)
278
+ cleanupRunscOrphans();
178
279
  // Layer 3: Purge expired cached tarballs on startup
179
280
  purgeTarballCache();
180
281
 
@@ -364,10 +465,11 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
364
465
  await processQueue(scanQueue, stats, dailyAlerts, recentlyScanned, downloadsCache, sandboxAvailableRef.value);
365
466
  }
366
467
 
367
- // Hourly stats report + cache purge
468
+ // Hourly stats report + cache purge + runsc cleanup
368
469
  if (Date.now() - stats.lastReportTime >= 3600_000) {
369
470
  reportStats(stats);
370
471
  purgeTarballCache();
472
+ cleanupRunscOrphans();
371
473
  }
372
474
 
373
475
  // Daily webhook report at 08:00 Paris time
@@ -384,6 +486,8 @@ module.exports = {
384
486
  startMonitor,
385
487
  cleanupOrphanTmpDirs,
386
488
  cleanupOrphanContainers,
489
+ cleanupRunscOrphans,
490
+ checkDiskSpace,
387
491
  reportStats,
388
492
  isDailyReportDue,
389
493
  sleep,
@@ -22,12 +22,15 @@ const DEFERRED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
22
22
  const DEFERRED_MAX_RETRIES = 2;
23
23
  const DEFERRED_WORKER_INTERVAL_MS = 30_000; // 30s
24
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)
25
27
 
26
28
  // ── Mutable state ──
27
29
  const _deferredQueue = [];
28
30
  const _deferredSeen = new Set(); // name@version dedup
29
31
  let _workerHandle = null;
30
32
  let _stats = null; // reference to shared stats object
33
+ let _consecutiveSkips = 0; // Tracks consecutive yield-skips for anti-starvation
31
34
 
32
35
  // ── Queue management ──
33
36
 
@@ -126,11 +129,24 @@ async function processDeferredItem(stats) {
126
129
 
127
130
  if (_deferredQueue.length === 0) return null;
128
131
 
129
- // 2. Yield check: reserve 1 slot for T1a
132
+ // 2. Yield check: reserve 1 slot for T1a (unless anti-starvation kicks in)
130
133
  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
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})`);
133
148
  }
149
+ _consecutiveSkips = 0;
134
150
 
135
151
  // 3. Pick highest-score item
136
152
  const item = _deferredQueue.shift();
@@ -311,6 +327,16 @@ function persistDeferredQueue() {
311
327
  }
312
328
 
313
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
+
314
340
  try {
315
341
  if (!fs.existsSync(DEFERRED_STATE_FILE)) return 0;
316
342
  const raw = fs.readFileSync(DEFERRED_STATE_FILE, 'utf8');
@@ -365,6 +391,7 @@ function _resetDeferredQueue() {
365
391
  _deferredQueue.length = 0;
366
392
  _deferredSeen.clear();
367
393
  _stats = null;
394
+ _consecutiveSkips = 0;
368
395
  stopDeferredWorker();
369
396
  }
370
397
 
@@ -384,5 +411,7 @@ module.exports = {
384
411
  DEFERRED_TTL_MS,
385
412
  DEFERRED_MAX_RETRIES,
386
413
  DEFERRED_WORKER_INTERVAL_MS,
387
- DEFERRED_STATE_FILE
414
+ DEFERRED_STATE_FILE,
415
+ SKIP_LOG_INTERVAL,
416
+ ANTI_STARVATION_TICKS
388
417
  };
@@ -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
  }