muaddib-scanner 2.11.68 → 2.11.69
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
|
@@ -37,6 +37,25 @@ const DEFERRED_MIN_SCORE = 5;
|
|
|
37
37
|
// (90s) + the sandbox watchdog grace; this AbortController is belt-and-suspenders.
|
|
38
38
|
const DEFERRED_SANDBOX_TIMEOUT_MS = 150_000;
|
|
39
39
|
|
|
40
|
+
// Tier priority for the deferred queue. Phase 3 routes T1a's sandbox here (async)
|
|
41
|
+
// instead of block-waiting a scan worker, so T1a is the highest-confidence tier and
|
|
42
|
+
// must be processed first and evicted last — it must never sit behind a high-score
|
|
43
|
+
// T1b/T2. Minimal blast radius: ONLY T1a is elevated (rank 0); T1b and T2 keep the
|
|
44
|
+
// same rank (1) so their existing riskScore-DESC ordering between them is unchanged.
|
|
45
|
+
// Map (not a plain object) keeps numeric tier 2 and string tiers distinct and avoids
|
|
46
|
+
// an object-injection sink.
|
|
47
|
+
const _TIER_RANK = new Map([['1a', 0], ['1b', 1], [2, 1]]);
|
|
48
|
+
function _tierRank(tier) {
|
|
49
|
+
return _TIER_RANK.has(tier) ? _TIER_RANK.get(tier) : 1;
|
|
50
|
+
}
|
|
51
|
+
function _deferredCompare(a, b) {
|
|
52
|
+
const r = _tierRank(a.tier) - _tierRank(b.tier);
|
|
53
|
+
return r !== 0 ? r : (b.riskScore - a.riskScore);
|
|
54
|
+
}
|
|
55
|
+
function _tierLabel(tier) {
|
|
56
|
+
return tier === '1a' ? 'T1a' : tier === 2 ? 'T2' : 'T1b';
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
// ── Mutable state ──
|
|
41
60
|
const _deferredQueue = [];
|
|
42
61
|
const _deferredSeen = new Set(); // name@version dedup
|
|
@@ -55,8 +74,10 @@ let _deferredSlotBusy = false; // Dedicated slot: true while deferred sandbox
|
|
|
55
74
|
* @returns {boolean} true if enqueued, false if rejected
|
|
56
75
|
*/
|
|
57
76
|
function enqueueDeferred(item) {
|
|
58
|
-
// Guard:
|
|
59
|
-
|
|
77
|
+
// Guard: T1a (Phase 3 async-routed high-confidence tier), T1b and T2 are eligible.
|
|
78
|
+
// T1a was previously block-waited in the scan worker; it now runs on the dedicated
|
|
79
|
+
// deferred slot at top priority (see _deferredCompare).
|
|
80
|
+
if (item.tier !== '1a' && item.tier !== '1b' && item.tier !== 2) {
|
|
60
81
|
console.error(`[DEFERRED] REJECTED: ${item.name}@${item.version} — tier ${item.tier} not eligible`);
|
|
61
82
|
return false;
|
|
62
83
|
}
|
|
@@ -74,9 +95,11 @@ function enqueueDeferred(item) {
|
|
|
74
95
|
// still warrant sandbox verification — an adversary could otherwise
|
|
75
96
|
// tune their malware to fire only LOW-severity TIER1 patterns to
|
|
76
97
|
// bypass sandbox entirely.
|
|
98
|
+
// T1a is high-confidence malice by classification — it always bypasses the
|
|
99
|
+
// min-score floor (it must never be dropped before its sandbox runs).
|
|
77
100
|
const itemThreats = (item.staticResult && item.staticResult.threats) || [];
|
|
78
101
|
const hasTier1Signal = itemThreats.some(t => TIER1_TYPES.has(t.type));
|
|
79
|
-
if ((item.riskScore || 0) < DEFERRED_MIN_SCORE && !hasTier1Signal) {
|
|
102
|
+
if (item.tier !== '1a' && (item.riskScore || 0) < DEFERRED_MIN_SCORE && !hasTier1Signal) {
|
|
80
103
|
console.error(`[DEFERRED] REJECTED: ${item.name}@${item.version} — score=${item.riskScore || 0} below minimum ${DEFERRED_MIN_SCORE}, no TIER1 signal (possible classification regression)`);
|
|
81
104
|
return false;
|
|
82
105
|
}
|
|
@@ -89,16 +112,18 @@ function enqueueDeferred(item) {
|
|
|
89
112
|
return false;
|
|
90
113
|
}
|
|
91
114
|
|
|
92
|
-
// Queue full — evict lowest
|
|
115
|
+
// Queue full — evict the lowest-priority item (by tier then score) if the new
|
|
116
|
+
// item outranks it, else reject. Tier-aware so a T1a can always displace a
|
|
117
|
+
// lower-tier item even when its score is lower.
|
|
93
118
|
if (_deferredQueue.length >= DEFERRED_QUEUE_MAX) {
|
|
94
119
|
const lowest = _deferredQueue[_deferredQueue.length - 1];
|
|
95
|
-
if (item
|
|
120
|
+
if (_deferredCompare(item, lowest) < 0) {
|
|
96
121
|
const evictKey = `${lowest.name}@${lowest.version}`;
|
|
97
122
|
_deferredQueue.pop();
|
|
98
123
|
_deferredSeen.delete(evictKey);
|
|
99
|
-
console.log(`[DEFERRED] EVICTED: ${evictKey} (score=${lowest.riskScore}) to make room for ${key} (score=${item.riskScore})`);
|
|
124
|
+
console.log(`[DEFERRED] EVICTED: ${evictKey} (${_tierLabel(lowest.tier)}, score=${lowest.riskScore}) to make room for ${key} (${_tierLabel(item.tier)}, score=${item.riskScore})`);
|
|
100
125
|
} else {
|
|
101
|
-
console.log(`[DEFERRED] QUEUE FULL: ${key} (score=${item.riskScore}) rejected — all ${DEFERRED_QUEUE_MAX} items
|
|
126
|
+
console.log(`[DEFERRED] QUEUE FULL: ${key} (${_tierLabel(item.tier)}, score=${item.riskScore}) rejected — all ${DEFERRED_QUEUE_MAX} items rank higher`);
|
|
102
127
|
return false;
|
|
103
128
|
}
|
|
104
129
|
}
|
|
@@ -122,9 +147,9 @@ function enqueueDeferred(item) {
|
|
|
122
147
|
};
|
|
123
148
|
}
|
|
124
149
|
delete item.npmRegistryMeta;
|
|
125
|
-
// Sort by riskScore DESC (highest
|
|
126
|
-
_deferredQueue.sort(
|
|
127
|
-
console.log(`[DEFERRED] ENQUEUED: ${key} (tier=${item.tier
|
|
150
|
+
// Sort by tier priority then riskScore DESC (T1a first, then highest score)
|
|
151
|
+
_deferredQueue.sort(_deferredCompare);
|
|
152
|
+
console.log(`[DEFERRED] ENQUEUED: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, queue=${_deferredQueue.length})`);
|
|
128
153
|
return true;
|
|
129
154
|
}
|
|
130
155
|
|
|
@@ -133,9 +158,10 @@ function getDeferredQueue() {
|
|
|
133
158
|
}
|
|
134
159
|
|
|
135
160
|
function getDeferredQueueStats() {
|
|
136
|
-
const tierBreakdown = { t1b: 0, t2: 0 };
|
|
161
|
+
const tierBreakdown = { t1a: 0, t1b: 0, t2: 0 };
|
|
137
162
|
for (const item of _deferredQueue) {
|
|
138
|
-
if (item.tier === '
|
|
163
|
+
if (item.tier === '1a') tierBreakdown.t1a++;
|
|
164
|
+
else if (item.tier === '1b') tierBreakdown.t1b++;
|
|
139
165
|
else if (item.tier === 2) tierBreakdown.t2++;
|
|
140
166
|
}
|
|
141
167
|
return {
|
|
@@ -189,7 +215,7 @@ async function processDeferredItem(stats) {
|
|
|
189
215
|
const key = `${item.name}@${item.version}`;
|
|
190
216
|
_deferredSeen.delete(key);
|
|
191
217
|
|
|
192
|
-
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${item.tier
|
|
218
|
+
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, retries=${item.retries})`);
|
|
193
219
|
|
|
194
220
|
// 4. Run sandbox on dedicated slot (bypasses shared semaphore)
|
|
195
221
|
_deferredSlotBusy = true;
|
|
@@ -198,10 +224,13 @@ async function processDeferredItem(stats) {
|
|
|
198
224
|
const deadline = setTimeout(() => ac.abort(), DEFERRED_SANDBOX_TIMEOUT_MS);
|
|
199
225
|
try {
|
|
200
226
|
const canary = isCanaryEnabled();
|
|
201
|
-
//
|
|
202
|
-
//
|
|
227
|
+
// T1a keeps multi-run time-bomb detection (maxRuns=undefined) — that was its
|
|
228
|
+
// behavior on the old blocking in-worker path, preserved here for detection
|
|
229
|
+
// parity (Phase 3 only moves WHERE it runs, not how thoroughly). T1b/T2 stay
|
|
230
|
+
// single-run (maxRuns=1, ~90s vs ~270s) for fast deferred-queue drain.
|
|
231
|
+
const maxRuns = item.tier === '1a' ? undefined : 1;
|
|
203
232
|
markSandboxed(item.name); // stamp for sandbox-revalidation cadence (matches the synchronous path)
|
|
204
|
-
sandboxResult = await runSandbox(item.name, { canary, skipSemaphore: true, maxRuns
|
|
233
|
+
sandboxResult = await runSandbox(item.name, { canary, skipSemaphore: true, maxRuns, signal: ac.signal });
|
|
205
234
|
console.log(`[DEFERRED] SANDBOX COMPLETE: ${key} -> score=${sandboxResult.score}, severity=${sandboxResult.severity}`);
|
|
206
235
|
} catch (err) {
|
|
207
236
|
console.error(`[DEFERRED] SANDBOX ERROR: ${key} — ${err.message}`);
|
|
@@ -212,7 +241,7 @@ async function processDeferredItem(stats) {
|
|
|
212
241
|
// Re-enqueue for retry
|
|
213
242
|
_deferredQueue.push(item);
|
|
214
243
|
_deferredSeen.add(key);
|
|
215
|
-
_deferredQueue.sort(
|
|
244
|
+
_deferredQueue.sort(_deferredCompare);
|
|
216
245
|
console.log(`[DEFERRED] RE-ENQUEUED: ${key} for retry (attempt ${item.retries + 1}/${DEFERRED_MAX_RETRIES})`);
|
|
217
246
|
}
|
|
218
247
|
return null;
|
|
@@ -414,7 +443,7 @@ function restoreDeferredQueue() {
|
|
|
414
443
|
}
|
|
415
444
|
|
|
416
445
|
// Sort after bulk insert
|
|
417
|
-
_deferredQueue.sort(
|
|
446
|
+
_deferredQueue.sort(_deferredCompare);
|
|
418
447
|
|
|
419
448
|
if (restored > 0) {
|
|
420
449
|
console.log(`[DEFERRED] Restored ${restored} items from disk (saved at ${data.savedAt})`);
|
package/src/monitor/queue.js
CHANGED
|
@@ -950,10 +950,23 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
950
950
|
const maxRuns = tier === '1a' ? undefined : 1;
|
|
951
951
|
|
|
952
952
|
if (tier === '1a') {
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
953
|
+
// Phase 3 (throughput decoupling): T1a no longer block-waits a scan
|
|
954
|
+
// worker. The high-confidence STATIC alert still fires synchronously
|
|
955
|
+
// below (trySendWebhook, with sandboxResult=null — same as the T1b/T2
|
|
956
|
+
// defer paths today); the sandbox runs ASYNC on the dedicated deferred
|
|
957
|
+
// slot at top priority (processed first, never evicted, keeps multi-run
|
|
958
|
+
// time-bomb detection) and sends a follow-up webhook if it confirms.
|
|
959
|
+
// Crash-safe: the deferred queue is persisted across restarts, unlike
|
|
960
|
+
// the old in-worker await which lost the sandbox on an OOM restart.
|
|
961
|
+
console.log(`[MONITOR] SANDBOX DEFER (T1a, async high-priority): ${name}@${version} (score=${riskScore})`);
|
|
962
|
+
enqueueDeferred({
|
|
963
|
+
name, version, ecosystem, tier, riskScore, tarballUrl,
|
|
964
|
+
enqueuedAt: Date.now(),
|
|
965
|
+
staticResult: result,
|
|
966
|
+
npmRegistryMeta,
|
|
967
|
+
retries: 0
|
|
968
|
+
});
|
|
969
|
+
stats.sandboxDeferred = (stats.sandboxDeferred || 0) + 1;
|
|
957
970
|
} else if (tryAcquireSandboxSlot()) {
|
|
958
971
|
// T1b/T2: non-blocking — slot acquired atomically, run with skipSemaphore
|
|
959
972
|
const reason = tier === 2 ? ' (T2, queue low)' : ' (T1b, conditional)';
|
|
@@ -1148,8 +1161,13 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
1148
1161
|
// Safety: never suppress packages with high-confidence threats or positive sandbox
|
|
1149
1162
|
const hasHC = hasHighConfidenceThreat(result);
|
|
1150
1163
|
const hasSandboxEvidence = sandboxResult && sandboxResult.score > 0;
|
|
1164
|
+
// Phase 3: T1a sandboxes are now deferred (async), so sandboxResult is
|
|
1165
|
+
// null here — the sandbox evidence that previously guarded a T1a from
|
|
1166
|
+
// LLM suppression hasn't run yet. Never let the LLM clear a T1a before
|
|
1167
|
+
// its deferred sandbox confirms; T1a is the highest-confidence tier and
|
|
1168
|
+
// MUST get sandbox verification (it can come via the follow-up webhook).
|
|
1151
1169
|
if (llmMode === 'active' && llmResult.verdict === 'benign' && llmResult.confidence > 0.85
|
|
1152
|
-
&& !hasHC && !hasSandboxEvidence) {
|
|
1170
|
+
&& !hasHC && !hasSandboxEvidence && tier !== '1a') {
|
|
1153
1171
|
console.log(`[LLM] SUPPRESS: ${name}@${version} cleared (benign, confidence=${llmResult.confidence})`);
|
|
1154
1172
|
stats.llmSuppressed = (stats.llmSuppressed || 0) + 1;
|
|
1155
1173
|
stats.scanned++;
|