muaddib-scanner 2.10.44 → 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 +1 -1
- package/src/monitor/daemon.js +24 -0
- package/src/monitor/deferred-sandbox.js +388 -0
- package/src/monitor/queue.js +27 -4
- package/src/monitor/webhook.js +9 -0
- package/src/sandbox/index.js +1 -1
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
|
|
@@ -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
|
+
};
|
package/src/monitor/queue.js
CHANGED
|
@@ -103,10 +103,13 @@ 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
|
-
const SCAN_CONCURRENCY = Math.max(1, parseInt(process.env.MUADDIB_SCAN_CONCURRENCY, 10) ||
|
|
109
|
-
const SCAN_TIMEOUT_MS =
|
|
111
|
+
const SCAN_CONCURRENCY = Math.max(1, parseInt(process.env.MUADDIB_SCAN_CONCURRENCY, 10) || 8);
|
|
112
|
+
const SCAN_TIMEOUT_MS = 300_000; // 5 minutes per package (3 sandbox runs × 90s + static scan headroom)
|
|
110
113
|
const STATIC_SCAN_TIMEOUT_MS = 45_000; // 45s for static analysis only
|
|
111
114
|
const LARGE_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
112
115
|
|
|
@@ -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/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;
|
package/src/sandbox/index.js
CHANGED
|
@@ -20,7 +20,7 @@ const { parseGvisorLogs, cleanupGvisorLogs } = require('./gvisor-parser.js');
|
|
|
20
20
|
|
|
21
21
|
const DOCKER_IMAGE = 'muaddib-sandbox';
|
|
22
22
|
const CONTAINER_TIMEOUT = 120000; // 120 seconds
|
|
23
|
-
const SINGLE_RUN_TIMEOUT =
|
|
23
|
+
const SINGLE_RUN_TIMEOUT = 90000; // 90 seconds per run in multi-run mode (gVisor ~30% I/O overhead)
|
|
24
24
|
|
|
25
25
|
// ── Sandbox concurrency limiter ──
|
|
26
26
|
// Prevents Docker container saturation under load (16 workers × 3 runs = 48 containers).
|