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 +1 -1
- package/src/monitor/daemon.js +129 -1
- package/src/monitor/deferred-sandbox.js +417 -0
- package/src/monitor/queue.js +25 -2
- package/src/monitor/state.js +9 -1
- package/src/monitor/webhook.js +9 -0
package/package.json
CHANGED
package/src/monitor/daemon.js
CHANGED
|
@@ -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
|
+
};
|
package/src/monitor/queue.js
CHANGED
|
@@ -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,
|
|
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,
|
|
715
|
+
console.log(`[MONITOR] SANDBOX SKIPPED (T2, no Docker): ${name}@${version}`);
|
|
693
716
|
}
|
|
694
717
|
|
|
695
718
|
stats.scanned++;
|
package/src/monitor/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/monitor/webhook.js
CHANGED
|
@@ -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;
|