muaddib-scanner 2.11.118 → 2.11.120
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/{self-scan-v2.11.118.json → self-scan-v2.11.120.json} +1 -1
- package/src/monitor/daemon.js +42 -2
- package/src/monitor/deferred-sandbox.js +87 -21
- package/src/monitor/event-loop-monitor.js +186 -0
- package/src/monitor/ingestion.js +1 -0
- package/src/monitor/queue.js +5 -2
- package/src/monitor/spill.js +7 -1
- package/src/monitor/webhook.js +34 -25
- package/src/scanner/ast-detectors/handle-import-expression.js +71 -4
- package/src/scanner/ast-detectors/handle-post-walk.js +6 -1
- package/src/scanner/dataflow.js +34 -0
- package/src/scoring.js +35 -6
- package/src/sdk-destination.js +40 -0
package/package.json
CHANGED
package/src/monitor/daemon.js
CHANGED
|
@@ -9,10 +9,11 @@ const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled
|
|
|
9
9
|
const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, isDailyReportDue, atomicWriteFileSync, saveNpmSeq, ALERTS_FILE, runStateMigrations, loadRecentlyScanned, saveRecentlyScanned } = require('./state.js');
|
|
10
10
|
const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
|
|
11
11
|
const { pendingGrouped, flushScopeGroup, sendDailyReport, redeliverPendingReportOnBoot, alertedPackageRules, ALERTED_PACKAGES_MAX: MAX_ALERTED_PACKAGES } = require('./webhook.js');
|
|
12
|
-
const { poll, getPollBackoffMs } = require('./ingestion.js');
|
|
12
|
+
const { poll, getPollBackoffMs, SOFT_BACKPRESSURE_THRESHOLD } = require('./ingestion.js');
|
|
13
13
|
const { ensureWorkers, drainWorkers, getTargetConcurrency, setTargetConcurrency, getActiveWorkers, terminateAllWorkers, getInFlightItems, computeInterruptDisposition } = require('./queue.js');
|
|
14
14
|
const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY } = require('./adaptive-concurrency.js');
|
|
15
15
|
const { startHealthcheck } = require('./healthcheck.js');
|
|
16
|
+
const { startLagSampler } = require('./event-loop-monitor.js');
|
|
16
17
|
const { startDeferredWorker, stopDeferredWorker, persistDeferredQueue, restoreDeferredQueue, clearDeferredQueue } = require('./deferred-sandbox.js');
|
|
17
18
|
const { evictFromScanQueueBulk, enqueueScan } = require('./scan-queue.js');
|
|
18
19
|
const { isSpillEnabled, shouldDrain, drainBacklog, getBacklogSize } = require('./spill.js');
|
|
@@ -42,9 +43,25 @@ const SHUTDOWN_DRAIN_MAX_MS = (() => {
|
|
|
42
43
|
return Number.isFinite(v) && v > 0 ? v : 20_000;
|
|
43
44
|
})();
|
|
44
45
|
|
|
46
|
+
// Drain ceiling (marge): re-ingest from the spill backlog as long as the live
|
|
47
|
+
// queue stays a safe margin BELOW the ingestion backpressure point. The old
|
|
48
|
+
// default (500) was unreachable in steady state — the live queue structurally
|
|
49
|
+
// sits in the thousands (μ scan ≈ λ ingest in active hours), so the backlog
|
|
50
|
+
// drained ~never and grew toward its cap (a one-way street). Tying the ceiling
|
|
51
|
+
// to SOFT_BACKPRESSURE_THRESHOLD makes the drain a self-throttling trickle: it
|
|
52
|
+
// fires during any non-congested window (pressure NONE + headroom) and stops as
|
|
53
|
+
// the queue approaches the point where ingestion would pause anyway, so the
|
|
54
|
+
// backlog never starves fresh ingestion. Env-tunable for live ops.
|
|
55
|
+
const SPILL_DRAIN_MARGIN = (() => {
|
|
56
|
+
const v = parseInt(process.env.MUADDIB_SPILL_DRAIN_MARGIN, 10);
|
|
57
|
+
return Number.isFinite(v) && v > 0 ? v : 5_000;
|
|
58
|
+
})();
|
|
45
59
|
const SPILL_DRAIN_THRESHOLD = (() => {
|
|
46
60
|
const v = parseInt(process.env.MUADDIB_SPILL_DRAIN_THRESHOLD, 10);
|
|
47
|
-
|
|
61
|
+
if (Number.isFinite(v) && v > 0) return v;
|
|
62
|
+
// Default: a fixed margin below backpressure (30K - 5K = 25K). Clamp to >= 1
|
|
63
|
+
// in case a future backpressure value is smaller than the margin.
|
|
64
|
+
return Math.max(1, SOFT_BACKPRESSURE_THRESHOLD - SPILL_DRAIN_MARGIN);
|
|
48
65
|
})();
|
|
49
66
|
const SPILL_DRAIN_BATCH = (() => {
|
|
50
67
|
const v = parseInt(process.env.MUADDIB_SPILL_DRAIN_BATCH, 10);
|
|
@@ -950,6 +967,7 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
950
967
|
let pollIntervalHandle = null; // Decoupled poll timer — set after initial poll
|
|
951
968
|
let queuePersistHandle = null; // Queue persistence timer
|
|
952
969
|
let concurrencyAdjustHandle = null; // Adaptive concurrency timer
|
|
970
|
+
let loopLagSamplerStop = null; // Event-loop stall attribution (instrumentation)
|
|
953
971
|
|
|
954
972
|
// Restore queue from previous run (if file exists and is < 24h old)
|
|
955
973
|
const restoredCount = restoreQueue(scanQueue);
|
|
@@ -985,6 +1003,10 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
985
1003
|
clearInterval(concurrencyAdjustHandle);
|
|
986
1004
|
concurrencyAdjustHandle = null;
|
|
987
1005
|
}
|
|
1006
|
+
if (loopLagSamplerStop) {
|
|
1007
|
+
loopLagSamplerStop();
|
|
1008
|
+
loopLagSamplerStop = null;
|
|
1009
|
+
}
|
|
988
1010
|
// Bounded drain (phase C, C7). The old unbounded `await drainWorkers()`
|
|
989
1011
|
// could outlive systemd's TimeoutStopSec (scans run up to 300s): SIGKILL
|
|
990
1012
|
// then landed MID-drain, persistQueue never ran, and every in-flight scan
|
|
@@ -1093,6 +1115,24 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
1093
1115
|
}
|
|
1094
1116
|
}, ADJUST_INTERVAL_MS);
|
|
1095
1117
|
|
|
1118
|
+
// ─── Event-loop stall attribution (instrumentation only — see event-loop-monitor.js) ───
|
|
1119
|
+
// The RSS breaker, governor RSS feed and EMERGENCY purge all live on these
|
|
1120
|
+
// main-thread timers; a long SYNC op (extractAllTo) that wedges the loop
|
|
1121
|
+
// disables them silently → unchecked RSS climb → cgroup OOM (4-6 min of zero
|
|
1122
|
+
// completions/logs before every kill, 2026-06-17/18). NAME the culprit op so the
|
|
1123
|
+
// real fix targets it. Off-switch: MUADDIB_LOOP_MONITOR=0.
|
|
1124
|
+
if (process.env.MUADDIB_LOOP_MONITOR !== '0') {
|
|
1125
|
+
loopLagSamplerStop = startLagSampler({
|
|
1126
|
+
onStall: (r) => {
|
|
1127
|
+
const o = r.op;
|
|
1128
|
+
const where = o
|
|
1129
|
+
? ` during op=${o.label}${o.meta && o.meta.name ? ` ${o.meta.name}@${o.meta.version || '?'}${o.meta.unpackedSizeMb != null ? ` (${o.meta.unpackedSizeMb}MB)` : ''}` : ''}${o.running ? ', STILL RUNNING' : ''}`
|
|
1130
|
+
: ' (no op breadcrumb — widen instrumentation)';
|
|
1131
|
+
console.warn(`[MONITOR] LOOP-STALL: event loop blocked ${r.blockedSec}s${where} | rss=${r.rssMb}MB`);
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1096
1136
|
// ─── Decoupled polling ───
|
|
1097
1137
|
// Poll runs on its own interval, independent of processing.
|
|
1098
1138
|
// This ensures new packages are ingested even while a large batch is being scanned.
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* Items are sorted by riskScore DESC (highest-risk first) to defend
|
|
7
7
|
* against queue-poisoning attacks.
|
|
8
8
|
*
|
|
9
|
-
* The worker owns a dedicated sandbox
|
|
10
|
-
* completely independent from the shared semaphore
|
|
11
|
-
* This guarantees the deferred worker can always
|
|
12
|
-
* how many main-path sandboxes are running
|
|
13
|
-
*
|
|
9
|
+
* The worker owns a dedicated POOL of sandbox slots (DEFERRED_SANDBOX_SLOTS,
|
|
10
|
+
* _deferredSlotsActive) that is completely independent from the shared semaphore
|
|
11
|
+
* used by the synchronous path. This guarantees the deferred worker can always
|
|
12
|
+
* process, regardless of how many main-path sandboxes are running, and runs
|
|
13
|
+
* several items concurrently so the queue actually drains (a single slot
|
|
14
|
+
* serialized all T1a deep sandboxes and the queue stayed permanently full).
|
|
14
15
|
*/
|
|
15
16
|
const fs = require('fs');
|
|
16
17
|
const path = require('path');
|
|
@@ -32,10 +33,23 @@ const DEFERRED_STATE_FILE = path.join(__dirname, '..', '..', 'data', 'deferred-q
|
|
|
32
33
|
// slot. HIGH=10 pts is the intended T1b floor — values below 5 are LOW-only
|
|
33
34
|
// aggregates which carry no actionable sandbox signal.
|
|
34
35
|
const DEFERRED_MIN_SCORE = 5;
|
|
35
|
-
// Hard ceiling on a single deferred sandbox run so
|
|
36
|
-
//
|
|
37
|
-
//
|
|
36
|
+
// Hard ceiling on a single deferred sandbox run so a deferred slot can never
|
|
37
|
+
// wedge. maxRuns=1 self-bounds at ~SINGLE_RUN_TIMEOUT (90s) + the sandbox
|
|
38
|
+
// watchdog grace; this AbortController is belt-and-suspenders.
|
|
38
39
|
const DEFERRED_SANDBOX_TIMEOUT_MS = 150_000;
|
|
40
|
+
// Number of CONCURRENT deferred sandbox runs. The old design used a single
|
|
41
|
+
// boolean slot (1 at a time), which serialized ALL deferred T1a deep sandboxes
|
|
42
|
+
// — measured at ~1 run / several minutes, so the queue (cap DEFERRED_QUEUE_MAX)
|
|
43
|
+
// sat permanently full with items aging out at TTL. Phase 3 routed T1a's sandbox
|
|
44
|
+
// here AND bypasses the shared semaphore, so the main pool (MUADDIB_SANDBOX_CONCURRENCY)
|
|
45
|
+
// was sitting idle while everything queued behind one deferred slot. This pool
|
|
46
|
+
// uses that idle capacity. Default 3 (conservative under the typical 4-slot main
|
|
47
|
+
// pool); each gVisor container is ~512 MB, so 3 ≈ 1.5 GB — keep an eye on host
|
|
48
|
+
// RSS if raised. Env-tunable for live ops.
|
|
49
|
+
const DEFERRED_SANDBOX_SLOTS = (() => {
|
|
50
|
+
const v = parseInt(process.env.MUADDIB_DEFERRED_SANDBOX_SLOTS, 10);
|
|
51
|
+
return Number.isFinite(v) && v >= 1 ? v : 3;
|
|
52
|
+
})();
|
|
39
53
|
|
|
40
54
|
// Tier priority for the deferred queue. Phase 3 routes T1a's sandbox here (async)
|
|
41
55
|
// instead of block-waiting a scan worker, so T1a is the highest-confidence tier and
|
|
@@ -61,7 +75,10 @@ const _deferredQueue = [];
|
|
|
61
75
|
const _deferredSeen = new Set(); // name@version dedup
|
|
62
76
|
let _workerHandle = null;
|
|
63
77
|
let _stats = null; // reference to shared stats object
|
|
64
|
-
let
|
|
78
|
+
let _deferredSlotsActive = 0; // Concurrent deferred sandbox runs in flight (0..DEFERRED_SANDBOX_SLOTS)
|
|
79
|
+
// Indirection so tests can inject a controllable async sandbox without Docker
|
|
80
|
+
// (the concurrency contract is verified behaviorally, not by source-grep).
|
|
81
|
+
let _runSandboxFn = runSandbox;
|
|
65
82
|
|
|
66
83
|
// ── Queue management ──
|
|
67
84
|
|
|
@@ -204,8 +221,11 @@ async function processDeferredItem(stats) {
|
|
|
204
221
|
|
|
205
222
|
if (_deferredQueue.length === 0) return null;
|
|
206
223
|
|
|
207
|
-
// 2.
|
|
208
|
-
|
|
224
|
+
// 2. Pool slot check — completely independent from main semaphore. The
|
|
225
|
+
// synchronous prefix below (shift + increment) runs before the first await,
|
|
226
|
+
// so processDeferredBatch can launch several of these in a tight loop without
|
|
227
|
+
// over-subscribing: each increment is visible to the next iteration.
|
|
228
|
+
if (_deferredSlotsActive >= DEFERRED_SANDBOX_SLOTS) {
|
|
209
229
|
if (stats) stats.deferredSkipped = (stats.deferredSkipped || 0) + 1;
|
|
210
230
|
return null;
|
|
211
231
|
}
|
|
@@ -215,10 +235,10 @@ async function processDeferredItem(stats) {
|
|
|
215
235
|
const key = `${item.name}@${item.version}`;
|
|
216
236
|
_deferredSeen.delete(key);
|
|
217
237
|
|
|
218
|
-
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, retries=${item.retries})`);
|
|
238
|
+
console.log(`[DEFERRED] PROCESSING: ${key} (tier=${_tierLabel(item.tier)}, score=${item.riskScore}, retries=${item.retries}, slots=${_deferredSlotsActive + 1}/${DEFERRED_SANDBOX_SLOTS})`);
|
|
219
239
|
|
|
220
|
-
// 4. Run sandbox on
|
|
221
|
-
|
|
240
|
+
// 4. Run sandbox on a pool slot (bypasses shared semaphore)
|
|
241
|
+
_deferredSlotsActive++;
|
|
222
242
|
let sandboxResult;
|
|
223
243
|
const ac = new AbortController();
|
|
224
244
|
const deadline = setTimeout(() => ac.abort(), DEFERRED_SANDBOX_TIMEOUT_MS);
|
|
@@ -230,7 +250,7 @@ async function processDeferredItem(stats) {
|
|
|
230
250
|
// single-run (maxRuns=1, ~90s vs ~270s) for fast deferred-queue drain.
|
|
231
251
|
const maxRuns = item.tier === '1a' ? undefined : 1;
|
|
232
252
|
markSandboxed(item.name); // stamp for sandbox-revalidation cadence (matches the synchronous path)
|
|
233
|
-
sandboxResult = await
|
|
253
|
+
sandboxResult = await _runSandboxFn(item.name, { canary, skipSemaphore: true, maxRuns, signal: ac.signal });
|
|
234
254
|
console.log(`[DEFERRED] SANDBOX COMPLETE: ${key} -> score=${sandboxResult.score}, severity=${sandboxResult.severity}`);
|
|
235
255
|
} catch (err) {
|
|
236
256
|
console.error(`[DEFERRED] SANDBOX ERROR: ${key} — ${err.message}`);
|
|
@@ -247,7 +267,7 @@ async function processDeferredItem(stats) {
|
|
|
247
267
|
return null;
|
|
248
268
|
} finally {
|
|
249
269
|
clearTimeout(deadline);
|
|
250
|
-
|
|
270
|
+
_deferredSlotsActive--;
|
|
251
271
|
}
|
|
252
272
|
|
|
253
273
|
// 5. Follow-up webhook if sandbox found something
|
|
@@ -302,6 +322,31 @@ async function processDeferredItem(stats) {
|
|
|
302
322
|
return sandboxResult;
|
|
303
323
|
}
|
|
304
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Tick dispatcher: launch deferred items CONCURRENTLY up to the free pool slots.
|
|
327
|
+
* processDeferredItem runs its slot-acquire (shift + increment) synchronously
|
|
328
|
+
* before its first await, so each launch is visible to the next loop iteration —
|
|
329
|
+
* no over-subscription past DEFERRED_SANDBOX_SLOTS. Calls are fire-and-forget:
|
|
330
|
+
* processDeferredItem is fully self-contained (its try/catch/finally swallows
|
|
331
|
+
* sandbox errors and always releases the slot), so a launched run never rejects
|
|
332
|
+
* the dispatcher. Returns the number launched this tick (for tests/observability).
|
|
333
|
+
* @returns {number}
|
|
334
|
+
*/
|
|
335
|
+
function processDeferredBatch(stats) {
|
|
336
|
+
let launched = 0;
|
|
337
|
+
// Bound the loop by the free slot count so a transient queue can't spin it.
|
|
338
|
+
while (_deferredSlotsActive < DEFERRED_SANDBOX_SLOTS && _deferredQueue.length > 0) {
|
|
339
|
+
const before = _deferredSlotsActive;
|
|
340
|
+
const p = processDeferredItem(stats);
|
|
341
|
+
// If the slot wasn't acquired (e.g. queue emptied by pruning inside the call),
|
|
342
|
+
// stop — otherwise the guard above could loop without progress.
|
|
343
|
+
if (_deferredSlotsActive === before) break;
|
|
344
|
+
launched++;
|
|
345
|
+
if (p && typeof p.catch === 'function') p.catch(() => { /* self-handled */ });
|
|
346
|
+
}
|
|
347
|
+
return launched;
|
|
348
|
+
}
|
|
349
|
+
|
|
305
350
|
/**
|
|
306
351
|
* Build Discord embed for deferred sandbox follow-up.
|
|
307
352
|
*/
|
|
@@ -348,10 +393,14 @@ function buildDeferredFollowUpEmbed(name, version, ecosystem, sandboxResult, sta
|
|
|
348
393
|
function startDeferredWorker(stats) {
|
|
349
394
|
_stats = stats;
|
|
350
395
|
if (_workerHandle) return _workerHandle;
|
|
351
|
-
console.log(`[DEFERRED] Worker started (interval=${DEFERRED_WORKER_INTERVAL_MS / 1000}s, max=${DEFERRED_QUEUE_MAX}, ttl=${DEFERRED_TTL_MS / 3600000}h)`);
|
|
352
|
-
_workerHandle = setInterval(
|
|
396
|
+
console.log(`[DEFERRED] Worker started (interval=${DEFERRED_WORKER_INTERVAL_MS / 1000}s, max=${DEFERRED_QUEUE_MAX}, slots=${DEFERRED_SANDBOX_SLOTS}, ttl=${DEFERRED_TTL_MS / 3600000}h)`);
|
|
397
|
+
_workerHandle = setInterval(() => {
|
|
353
398
|
try {
|
|
354
|
-
|
|
399
|
+
// Fill free pool slots each tick. The dispatcher launches concurrent runs
|
|
400
|
+
// (fire-and-forget); long-running sandboxes keep their slots across ticks,
|
|
401
|
+
// so steady state is DEFERRED_SANDBOX_SLOTS in flight while the queue drains.
|
|
402
|
+
pruneExpired(_stats);
|
|
403
|
+
processDeferredBatch(_stats);
|
|
355
404
|
} catch (err) {
|
|
356
405
|
console.error(`[DEFERRED] Worker tick error: ${err.message}`);
|
|
357
406
|
}
|
|
@@ -465,12 +514,25 @@ function _resetDeferredQueue() {
|
|
|
465
514
|
_deferredQueue.length = 0;
|
|
466
515
|
_deferredSeen.clear();
|
|
467
516
|
_stats = null;
|
|
468
|
-
|
|
517
|
+
_deferredSlotsActive = 0;
|
|
518
|
+
_runSandboxFn = runSandbox;
|
|
469
519
|
stopDeferredWorker();
|
|
470
520
|
}
|
|
471
521
|
|
|
522
|
+
// Test seam: inject a controllable sandbox runner (restored by _resetDeferredQueue).
|
|
523
|
+
function _setRunSandboxForTest(fn) {
|
|
524
|
+
_runSandboxFn = fn || runSandbox;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// True while at least one deferred sandbox is in flight. Kept for back-compat
|
|
528
|
+
// (callers/tests that only care "is the deferred path active"); use
|
|
529
|
+
// getDeferredSlotsActive() for the concurrent count.
|
|
472
530
|
function isDeferredSlotBusy() {
|
|
473
|
-
return
|
|
531
|
+
return _deferredSlotsActive > 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function getDeferredSlotsActive() {
|
|
535
|
+
return _deferredSlotsActive;
|
|
474
536
|
}
|
|
475
537
|
|
|
476
538
|
/**
|
|
@@ -492,14 +554,18 @@ module.exports = {
|
|
|
492
554
|
startDeferredWorker,
|
|
493
555
|
stopDeferredWorker,
|
|
494
556
|
processDeferredItem,
|
|
557
|
+
processDeferredBatch,
|
|
495
558
|
persistDeferredQueue,
|
|
496
559
|
restoreDeferredQueue,
|
|
497
560
|
buildDeferredFollowUpEmbed,
|
|
498
561
|
pruneExpired,
|
|
499
562
|
isDeferredSlotBusy,
|
|
563
|
+
getDeferredSlotsActive,
|
|
500
564
|
clearDeferredQueue,
|
|
501
565
|
_resetDeferredQueue,
|
|
566
|
+
_setRunSandboxForTest,
|
|
502
567
|
DEFERRED_QUEUE_MAX,
|
|
568
|
+
DEFERRED_SANDBOX_SLOTS,
|
|
503
569
|
DEFERRED_TTL_MS,
|
|
504
570
|
DEFERRED_MAX_RETRIES,
|
|
505
571
|
DEFERRED_WORKER_INTERVAL_MS,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event-loop stall ATTRIBUTION (2026-06-18). Pure observability — NO change to
|
|
5
|
+
* scan behavior, scoring, or detection.
|
|
6
|
+
*
|
|
7
|
+
* Why: every in-process OOM defense — the RSS circuit breaker, the memory
|
|
8
|
+
* governor's RSS feed (`updateGovernorRss`), the EMERGENCY queue purge — runs on
|
|
9
|
+
* the main-thread `setInterval` poll loop (`daemon.js`). A long SYNCHRONOUS op on
|
|
10
|
+
* that loop wedges it: none of those timers fire, so RSS climbs to the cgroup
|
|
11
|
+
* MemoryMax (9.5G) unchecked → kernel SIGKILL (cgroup OOM). Measured signature
|
|
12
|
+
* 2026-06-17/18: 4-6 min of ZERO scan completions AND zero daemon log lines
|
|
13
|
+
* immediately before EVERY OOM kill. Leading suspect for the block: adm-zip
|
|
14
|
+
* `extractAllTo` (synchronous), which `extractArchive` runs on the main thread for
|
|
15
|
+
* the large-package quick-scan (4071 size_skip/24h) and pre-worker extraction.
|
|
16
|
+
*
|
|
17
|
+
* This module does NOT fix the stall — it NAMES it, so the next refactor targets
|
|
18
|
+
* the confirmed culprit instead of a guess:
|
|
19
|
+
* - `beginOp`/`endOp` leave a breadcrumb naming the op (and package) in flight.
|
|
20
|
+
* - `startLagSampler` runs its OWN low-frequency timer; its tardiness measures
|
|
21
|
+
* how long the loop was blocked (the sampler is starved the same way the
|
|
22
|
+
* breaker is, but decoupled so it can't perturb the breaker). On a stall it
|
|
23
|
+
* reports the op whose lifetime OVERLAPS the blocked window — the still-running
|
|
24
|
+
* op, or the one that just ended (the blocking op has usually returned by the
|
|
25
|
+
* time the loop frees and the sampler can finally fire).
|
|
26
|
+
* Resolving stalls share their source with the fatal one, so attributing the
|
|
27
|
+
* resolving precursors pins the culprit.
|
|
28
|
+
*
|
|
29
|
+
* Bounded (CLAUDE.md §2): a single current + single last-ended breadcrumb slot;
|
|
30
|
+
* the stall log is size-capped. Instrumentation NEVER throws.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
const STALL_LOG_FILE = process.env.MUADDIB_LOOP_STALL_FILE
|
|
37
|
+
|| path.join(__dirname, '..', '..', 'data', 'loop-stalls.jsonl');
|
|
38
|
+
const STALL_LOG_MAX_BYTES = 256 * 1024; // bounded: truncate-then-append past this
|
|
39
|
+
|
|
40
|
+
// Threshold 5s: far above normal GC/IO jitter (sub-100ms) and a full scan's
|
|
41
|
+
// main-thread slice, far below the multi-minute fatal stalls — catches the
|
|
42
|
+
// resolving precursors without noise. Sampler cadence 1s.
|
|
43
|
+
const DEFAULT_INTERVAL_MS = 1000;
|
|
44
|
+
const DEFAULT_THRESHOLD_MS = 5000;
|
|
45
|
+
|
|
46
|
+
// Injectable clock (tests drive it via _reset(clockFn)); default real time.
|
|
47
|
+
let _clock = () => Date.now();
|
|
48
|
+
function _now() { return _clock(); }
|
|
49
|
+
|
|
50
|
+
// ─── Breadcrumb: the op currently / most-recently on the main thread ───
|
|
51
|
+
let _current = null; // { label, meta, startedAt }
|
|
52
|
+
let _lastEnded = null; // { label, meta, startedAt, endedAt }
|
|
53
|
+
|
|
54
|
+
/** Mark the start of a (potentially blocking, synchronous) main-thread op. */
|
|
55
|
+
function beginOp(label, meta) {
|
|
56
|
+
_current = { label: String(label || 'op'), meta: meta || null, startedAt: _now() };
|
|
57
|
+
return _current;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Mark it done. Token optional; a mismatched token is ignored (nesting-safe). */
|
|
61
|
+
function endOp(token) {
|
|
62
|
+
if (!_current) return;
|
|
63
|
+
if (token && token !== _current) return;
|
|
64
|
+
_lastEnded = { ..._current, endedAt: _now() };
|
|
65
|
+
_current = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The op whose lifetime overlaps [windowStartMs, windowEndMs] — i.e. the op on
|
|
70
|
+
* the thread during the blocked window. Prefers the still-running op, else the
|
|
71
|
+
* one that ended inside the window. Null when nothing was instrumented (a real
|
|
72
|
+
* signal: widen the breadcrumbs).
|
|
73
|
+
*/
|
|
74
|
+
function opOverlapping(windowStartMs, windowEndMs) {
|
|
75
|
+
if (_current && _current.startedAt <= windowEndMs) {
|
|
76
|
+
return { label: _current.label, meta: _current.meta, running: true, elapsedMs: _now() - _current.startedAt };
|
|
77
|
+
}
|
|
78
|
+
if (_lastEnded && _lastEnded.endedAt >= windowStartMs && _lastEnded.startedAt <= windowEndMs) {
|
|
79
|
+
return { label: _lastEnded.label, meta: _lastEnded.meta, running: false, durationMs: _lastEnded.endedAt - _lastEnded.startedAt };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Lag core (pure, unit-testable: timestamps in, no timers) ───
|
|
85
|
+
const _state = { lastTickMs: null, intervalMs: DEFAULT_INTERVAL_MS, thresholdMs: DEFAULT_THRESHOLD_MS, maxLagMs: 0, stalls: 0 };
|
|
86
|
+
|
|
87
|
+
function configure(opts = {}) {
|
|
88
|
+
if (Number.isFinite(opts.intervalMs)) _state.intervalMs = opts.intervalMs;
|
|
89
|
+
if (Number.isFinite(opts.thresholdMs)) _state.thresholdMs = opts.thresholdMs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Feed one sampler tick. Returns { lagMs, windowStartMs, firstTick }. lagMs = how
|
|
94
|
+
* much LATER than the configured interval this tick fired = the time the loop was
|
|
95
|
+
* blocked since the previous tick. First call seeds (lag 0).
|
|
96
|
+
*/
|
|
97
|
+
function observeTick(nowMs) {
|
|
98
|
+
if (_state.lastTickMs === null) {
|
|
99
|
+
_state.lastTickMs = nowMs;
|
|
100
|
+
return { lagMs: 0, windowStartMs: nowMs, firstTick: true };
|
|
101
|
+
}
|
|
102
|
+
const windowStartMs = _state.lastTickMs;
|
|
103
|
+
const lagMs = Math.max(0, nowMs - _state.lastTickMs - _state.intervalMs);
|
|
104
|
+
_state.lastTickMs = nowMs;
|
|
105
|
+
if (lagMs > _state.maxLagMs) _state.maxLagMs = lagMs;
|
|
106
|
+
return { lagMs, windowStartMs, firstTick: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isStall(lagMs, thresholdMs = _state.thresholdMs) { return lagMs >= thresholdMs; }
|
|
110
|
+
function getMaxLagMs() { return _state.maxLagMs; }
|
|
111
|
+
function getStallCount() { return _state.stalls; }
|
|
112
|
+
|
|
113
|
+
function _appendStall(record) {
|
|
114
|
+
try {
|
|
115
|
+
try {
|
|
116
|
+
const st = fs.statSync(STALL_LOG_FILE);
|
|
117
|
+
if (st.size > STALL_LOG_MAX_BYTES) fs.truncateSync(STALL_LOG_FILE, 0); // bounded
|
|
118
|
+
} catch { /* absent — first write */ }
|
|
119
|
+
fs.appendFileSync(STALL_LOG_FILE, JSON.stringify(record) + '\n');
|
|
120
|
+
} catch { /* instrumentation must never throw */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build the structured stall record for a detected lag (pure; exported for tests). */
|
|
124
|
+
function buildStallRecord(lagMs, windowStartMs, nowMs) {
|
|
125
|
+
const op = opOverlapping(windowStartMs, nowMs);
|
|
126
|
+
return {
|
|
127
|
+
ts: new Date(nowMs).toISOString(),
|
|
128
|
+
lagMs,
|
|
129
|
+
blockedSec: Math.round(lagMs / 100) / 10,
|
|
130
|
+
op: op
|
|
131
|
+
? { label: op.label, meta: op.meta, running: op.running, durationMs: op.running ? op.elapsedMs : op.durationMs }
|
|
132
|
+
: null,
|
|
133
|
+
rssMb: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start the lag sampler. Its OWN tardiness measures loop lag. unref'd: a pure
|
|
139
|
+
* monitor never keeps the process alive on its own. Returns a stop fn.
|
|
140
|
+
* onStall(record) is an optional extra sink (the daemon logs it); every stall is
|
|
141
|
+
* also appended to STALL_LOG_FILE for post-hoc analysis.
|
|
142
|
+
*/
|
|
143
|
+
function startLagSampler(opts = {}) {
|
|
144
|
+
configure(opts);
|
|
145
|
+
_state.lastTickMs = null;
|
|
146
|
+
const onStall = typeof opts.onStall === 'function' ? opts.onStall : null;
|
|
147
|
+
const timer = setInterval(() => {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
const { lagMs, windowStartMs, firstTick } = observeTick(now);
|
|
150
|
+
if (firstTick || !isStall(lagMs)) return;
|
|
151
|
+
_state.stalls += 1;
|
|
152
|
+
const record = buildStallRecord(lagMs, windowStartMs, now);
|
|
153
|
+
_appendStall(record);
|
|
154
|
+
if (onStall) { try { onStall(record); } catch { /* ignore */ } }
|
|
155
|
+
}, _state.intervalMs);
|
|
156
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
157
|
+
return () => clearInterval(timer);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Test helper: clear all state; optionally install a deterministic clock. */
|
|
161
|
+
function _reset(clockFn) {
|
|
162
|
+
_current = null;
|
|
163
|
+
_lastEnded = null;
|
|
164
|
+
_state.lastTickMs = null;
|
|
165
|
+
_state.intervalMs = DEFAULT_INTERVAL_MS;
|
|
166
|
+
_state.thresholdMs = DEFAULT_THRESHOLD_MS;
|
|
167
|
+
_state.maxLagMs = 0;
|
|
168
|
+
_state.stalls = 0;
|
|
169
|
+
_clock = typeof clockFn === 'function' ? clockFn : (() => Date.now());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
beginOp,
|
|
174
|
+
endOp,
|
|
175
|
+
opOverlapping,
|
|
176
|
+
configure,
|
|
177
|
+
observeTick,
|
|
178
|
+
isStall,
|
|
179
|
+
getMaxLagMs,
|
|
180
|
+
getStallCount,
|
|
181
|
+
buildStallRecord,
|
|
182
|
+
startLagSampler,
|
|
183
|
+
_reset,
|
|
184
|
+
_state,
|
|
185
|
+
STALL_LOG_FILE
|
|
186
|
+
};
|
package/src/monitor/ingestion.js
CHANGED
package/src/monitor/queue.js
CHANGED
|
@@ -22,6 +22,7 @@ const { buildTrainingRecord } = require('../ml/feature-extractor.js');
|
|
|
22
22
|
const { appendWorkerMem } = require('./worker-mem.js');
|
|
23
23
|
const { acquireHeavySlot, releaseHeavySlot, isHeavyScan, getHeavyLaneState, heavyWaitMaxMs, HEAVY_REQUEUE_MAX } = require('./heavy-lane.js');
|
|
24
24
|
const { isGovernorEnabled, classifyWeight, acquireMemoryTicket, releaseMemoryTicket, isFrozen: isGovernorFrozen, getGovernorState } = require('./memory-governor.js');
|
|
25
|
+
const { beginOp, endOp } = require('./event-loop-monitor.js');
|
|
25
26
|
const { appendRecord: appendTrainingRecord, relabelRecords } = require('../ml/jsonl-writer.js');
|
|
26
27
|
|
|
27
28
|
// From ./state.js
|
|
@@ -764,7 +765,8 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
764
765
|
// Validates actual tarball contents (not just registry metadata).
|
|
765
766
|
let bypassQuickScan = false;
|
|
766
767
|
try {
|
|
767
|
-
|
|
768
|
+
const _crumb = beginOp('extract:quickscan', { name, version, unpackedSizeMb: Math.round(unpackedSize / 1024 / 1024) });
|
|
769
|
+
try { extractedDir = extractArchive(tgzPath, tmpDir); } finally { endOp(_crumb); }
|
|
768
770
|
|
|
769
771
|
const [pkgThreats, shellThreats] = await Promise.all([
|
|
770
772
|
scanPackageJson(extractedDir),
|
|
@@ -813,7 +815,8 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
813
815
|
}
|
|
814
816
|
|
|
815
817
|
if (!extractedDir) {
|
|
816
|
-
|
|
818
|
+
const _crumb = beginOp('extract:prework', { name, version, unpackedSizeMb: Math.round((meta.unpackedSize || 0) / 1024 / 1024) });
|
|
819
|
+
try { extractedDir = extractArchive(tgzPath, tmpDir); } finally { endOp(_crumb); }
|
|
817
820
|
}
|
|
818
821
|
|
|
819
822
|
// ML Phase 2a: Count JS files and detect test presence for enriched features
|
package/src/monitor/spill.js
CHANGED
|
@@ -182,7 +182,13 @@ function _compactBacklog(file, ledgerFn = null) {
|
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
184
|
* Pure drain predicate (exported for tests + the daemon main loop): drain only
|
|
185
|
-
* when memory pressure is fully cleared AND the live queue
|
|
185
|
+
* when memory pressure is fully cleared AND the live queue is below the drain
|
|
186
|
+
* ceiling. `threshold` is a MARGE ceiling (a margin below the ingestion
|
|
187
|
+
* backpressure point — see daemon.js SPILL_DRAIN_THRESHOLD), NOT a "queue nearly
|
|
188
|
+
* empty" low-water mark: the latter (the old 500/5000) was unreachable in steady
|
|
189
|
+
* state, so the backlog never drained. With the marge ceiling the drain is a
|
|
190
|
+
* self-throttling trickle — it auto-stops the moment pressure rises (≥ ELEVATED)
|
|
191
|
+
* or the queue climbs toward backpressure, so it never starves fresh ingestion.
|
|
186
192
|
*/
|
|
187
193
|
function shouldDrain(pressureLevel, queueLen, threshold) {
|
|
188
194
|
return pressureLevel === 0 && queueLen < threshold;
|
package/src/monitor/webhook.js
CHANGED
|
@@ -1117,10 +1117,17 @@ function safeLedgerRollup() {
|
|
|
1117
1117
|
function formatLedgerField(rollup) {
|
|
1118
1118
|
if (!rollup || rollup.total <= 0) return null;
|
|
1119
1119
|
const pct = rollup.alertRate != null ? (rollup.alertRate * 100).toFixed(2) : '0.00';
|
|
1120
|
-
|
|
1120
|
+
// All counts here are name@version scan EVENTS (NOT distinct package names — that
|
|
1121
|
+
// ratio is the headline's "Noms uniques"). alertRate = suspect+confirmed / scanned
|
|
1122
|
+
// (NOT a TPR — see computeLedgerRollup's HONEST METRIC NOTE).
|
|
1123
|
+
const lines = [`Scans: ${rollup.scanned} events · alertés ${rollup.alerted} (${pct}%)`];
|
|
1121
1124
|
if (rollup.dropped > 0) {
|
|
1122
1125
|
const vanishedNote = rollup.exactVanished ? `${rollup.vanished}` : `≥${rollup.vanished}`;
|
|
1123
|
-
|
|
1126
|
+
// `dropped` folds in recoverable spill (backlog awaiting drain) + queue-cap evictions
|
|
1127
|
+
// + burst-extras; `vanished` is the distinct name@version subset never (re)scanned
|
|
1128
|
+
// in-window — still version-granular (a name with 50 dropped versions = 50 here) and
|
|
1129
|
+
// it too still includes not-yet-drained spill, so it is NOT a registry-removal count.
|
|
1130
|
+
lines.push(`Non scannés: ${rollup.dropped} events (${vanishedNote} name@ver jamais (re)scannés)`);
|
|
1124
1131
|
}
|
|
1125
1132
|
const ecos = Object.keys(rollup.byEcosystem)
|
|
1126
1133
|
.sort((a, b) => rollup.byEcosystem[b].total - rollup.byEcosystem[a].total);
|
|
@@ -1235,36 +1242,35 @@ function buildDailyReportEmbed(stats, dailyAlerts, ledgerRollup) {
|
|
|
1235
1242
|
const pypiPub = stats.pypiChangelogPackages || 0;
|
|
1236
1243
|
const published = npmPub + pypiPub;
|
|
1237
1244
|
const catchupSkipped = (stats.npmCatchupSkippedSeqs || 0) + (stats.pypiCatchupSkippedEvents || 0);
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1245
|
+
// UNIT DISCIPLINE (2026-06-18): the Coverage field stacked three counting units
|
|
1246
|
+
// with no labels, so the headline read as self-contradictory — e.g.
|
|
1247
|
+
// "30K/90K pkgs · 112K vanished" looks like 112K > 90K packages (impossible), but
|
|
1248
|
+
// `vanished` counts name@VERSION events while the ratio counts distinct NAMES.
|
|
1249
|
+
// Each line below now states its unit; the version-granular drop/vanished detail
|
|
1250
|
+
// lives in the Ops embed's Ledger field, NOT next to a name ratio.
|
|
1251
|
+
// • "Noms uniques" → distinct package NAMES (version-collapsed) — the honest headline
|
|
1252
|
+
// • "Scans" → name@version scan events (same unit as Clean/Suspects/Errors)
|
|
1253
|
+
// • "compteur" → in-memory stats.scanned (events + retries + burst + size-cap)
|
|
1254
|
+
const opsQualifier = headline ? ' (events terminés)' : '';
|
|
1244
1255
|
const rawCounter = (headline && typeof stats.scanned === 'number' && stats.scanned > hScanned)
|
|
1245
|
-
? ` ·
|
|
1256
|
+
? ` · compteur ${stats.scanned} (retries/burst inclus)`
|
|
1246
1257
|
: '';
|
|
1247
1258
|
const opsSuffix = catchupSkipped > 0
|
|
1248
|
-
? `\
|
|
1249
|
-
: `\
|
|
1259
|
+
? `\nScans: ${hScanned}${opsQualifier}${rawCounter} | Catch-up skip: ${catchupSkipped}`
|
|
1260
|
+
: `\nScans: ${hScanned}${opsQualifier}${rawCounter}`;
|
|
1250
1261
|
let coverageText;
|
|
1251
1262
|
if (ledger && ledger.distinctPackages > 0 && ledger.distinctCoverage != null) {
|
|
1252
1263
|
const pct = (ledger.distinctCoverage * 100).toFixed(0);
|
|
1253
1264
|
const approx = ledger.exactVanished === false ? '~' : '';
|
|
1254
|
-
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
// OVERSTATES loss) is relegated to the Ops embed's Ledger field, not the headline.
|
|
1259
|
-
if (ledger.vanished > 0) {
|
|
1260
|
-
coverageText += ` · ${ledger.exactVanished ? '' : '≥'}${ledger.vanished} vanished`;
|
|
1261
|
-
}
|
|
1262
|
-
if (published > 0) coverageText += `\nRaw events: ${attempted}/${published}`;
|
|
1265
|
+
// Headline = distinct package NAMES scanned vs seen (version-collapsed, immune to
|
|
1266
|
+
// version-spam). The name@version drop/vanished detail is in the Ledger field below.
|
|
1267
|
+
coverageText = `Noms uniques: ${ledger.distinctScanned}/${ledger.distinctPackages} (${approx}${pct}%)`;
|
|
1268
|
+
if (published > 0) coverageText += `\nPubliés: ${attempted}/${published}`;
|
|
1263
1269
|
coverageText += opsSuffix;
|
|
1264
1270
|
} else if (published > 0) {
|
|
1265
1271
|
// Fallback: ledger unavailable (first boot / empty ledger) → legacy event ratio.
|
|
1266
1272
|
const coverageRatio = (attempted / published * 100).toFixed(0);
|
|
1267
|
-
coverageText =
|
|
1273
|
+
coverageText = `Publiés: ${attempted}/${published} (${coverageRatio}%)${opsSuffix}`;
|
|
1268
1274
|
} else {
|
|
1269
1275
|
coverageText = `${attempted} attempted${opsSuffix}`;
|
|
1270
1276
|
}
|
|
@@ -1363,16 +1369,19 @@ function buildDailyReportEmbed(stats, dailyAlerts, ledgerRollup) {
|
|
|
1363
1369
|
// --- Embed 2: Ops / system state (kept OUT of the daily headline) ---
|
|
1364
1370
|
// Operator feedback: a daily that mixes 24h outcome with multi-day system state
|
|
1365
1371
|
// reads as failure when it isn't. Each line here carries its own clock:
|
|
1366
|
-
// • Ledger → 24h window
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1372
|
+
// • Ledger → 24h window, in name@version scan EVENTS (NOT package names —
|
|
1373
|
+
// the name ratio is the headline's "Noms uniques"). `dropped` folds
|
|
1374
|
+
// in recoverable spill (backlog awaiting drain) + queue-cap evictions
|
|
1375
|
+
// + burst-extras; `vanished` is the distinct name@version subset never
|
|
1376
|
+
// (re)scanned in-window — also version-granular, and it too still
|
|
1377
|
+
// includes not-yet-drained spill, so neither is a registry-removal count.
|
|
1369
1378
|
// • Stability → cumulative since the 08:00 reset (backlog = point-in-time depth
|
|
1370
1379
|
// of the persistent spill file, the one snapshot in this field).
|
|
1371
1380
|
// • Degradations / System → instantaneous snapshot (degradations have no TTL: if
|
|
1372
1381
|
// shown, the condition is active right now, not earlier in the window).
|
|
1373
1382
|
title: '⚙️ Ops / état système',
|
|
1374
1383
|
color: 0x95a5a6,
|
|
1375
|
-
description: 'Ledger = fenêtre 24h (
|
|
1384
|
+
description: 'Ledger = fenêtre 24h en events name@version (pas des noms de paquets — voir « Noms uniques » dans le headline) · « Non scannés » inclut le spill récupérable en attente de drain · Stability = cumulé depuis 08:00 (backlog = instantané) · Degradations/System = instantané',
|
|
1376
1385
|
fields: [
|
|
1377
1386
|
...((stats.sandboxDeferred || stats.deferredProcessed || stats.deferredExpired)
|
|
1378
1387
|
? [{ name: 'Deferred Sandbox', value: `Enqueued: ${stats.sandboxDeferred || 0} | Processed: ${stats.deferredProcessed || 0} | Expired: ${stats.deferredExpired || 0}`, inline: false }]
|
|
@@ -3,6 +3,55 @@
|
|
|
3
3
|
const {
|
|
4
4
|
SOLANA_PACKAGES
|
|
5
5
|
} = require('./constants.js');
|
|
6
|
+
const { containsDecodePattern } = require('./helpers.js');
|
|
7
|
+
|
|
8
|
+
// Gate #2 (FPR 2026-06-15 — Étape 0 adjudication): a computed dynamic import() is only
|
|
9
|
+
// remote-code-loading when there is positive evidence of a remote/decoded/env-driven target
|
|
10
|
+
// (URL literal, .replace() URL manipulation, atob/Buffer decode, or a process.env-sourced
|
|
11
|
+
// specifier). Bounded-local imports — CLI subcommand dispatchers (import(MAP[cmd])), layout/i18n
|
|
12
|
+
// loaders (import(`../x/${name}.js`)), dep-resolve / own-dist shims (import(join(dir,'dist/main.js')))
|
|
13
|
+
// — were ~19% of the band-20-49 false positives with 0 TP. Without evidence, computed imports
|
|
14
|
+
// stay HIGH (still fires, but ~25→10 pts: sub-threshold alone) instead of CRITICAL. Flag-gated;
|
|
15
|
+
// when the flag is off the legacy CRITICAL-on-Identifier/TemplateLiteral behavior is preserved.
|
|
16
|
+
function _importStaticText(node) {
|
|
17
|
+
if (!node) return '';
|
|
18
|
+
if (node.type === 'Literal') return typeof node.value === 'string' ? node.value : '';
|
|
19
|
+
if (node.type === 'TemplateLiteral') {
|
|
20
|
+
return (node.quasis || [])
|
|
21
|
+
.map(q => (q.value && (q.value.cooked != null ? q.value.cooked : q.value.raw)) || '')
|
|
22
|
+
.join(' ');
|
|
23
|
+
}
|
|
24
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
25
|
+
return _importStaticText(node.left) + ' ' + _importStaticText(node.right);
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _isProcessEnvMember(node) {
|
|
31
|
+
return !!node && node.type === 'MemberExpression' &&
|
|
32
|
+
node.object && node.object.type === 'MemberExpression' &&
|
|
33
|
+
node.object.object && node.object.object.type === 'Identifier' && node.object.object.name === 'process' &&
|
|
34
|
+
node.object.property && node.object.property.type === 'Identifier' && node.object.property.name === 'env';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _importRemoteEvidence(src, ctx) {
|
|
38
|
+
// URL manipulation (GlassWorm): import(x.replace(...))
|
|
39
|
+
if (src.type === 'CallExpression' && src.callee && src.callee.type === 'MemberExpression' &&
|
|
40
|
+
src.callee.property && src.callee.property.name === 'replace') return true;
|
|
41
|
+
// env-driven specifier: import(process.env.X), or import(v) where v was assigned from process.env.X
|
|
42
|
+
if (_isProcessEnvMember(src)) return true;
|
|
43
|
+
if (src.type === 'Identifier' && ctx.varSource && ctx.varSource.get(src.name) === 'env_var') return true;
|
|
44
|
+
// identifier resolving to a URL string literal: const u = 'https://evil/x.js'; import(u)
|
|
45
|
+
if (src.type === 'Identifier' && ctx.stringVarValues) {
|
|
46
|
+
const resolved = ctx.stringVarValues.get(src.name);
|
|
47
|
+
if (resolved && /https?:|:\/\//i.test(resolved)) return true;
|
|
48
|
+
}
|
|
49
|
+
// runtime decode: import(atob(...)) / import(Buffer.from(...).toString())
|
|
50
|
+
if (containsDecodePattern(src)) return true;
|
|
51
|
+
// explicit URL scheme in the static parts of the specifier
|
|
52
|
+
if (/https?:|:\/\//i.test(_importStaticText(src))) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
6
55
|
|
|
7
56
|
function handleImportExpression(node, ctx) {
|
|
8
57
|
if (node.source) {
|
|
@@ -25,11 +74,29 @@ function handleImportExpression(node, ctx) {
|
|
|
25
74
|
if (SOLANA_PACKAGES.some(pkg => src.value === pkg)) {
|
|
26
75
|
ctx.hasSolanaImport = true;
|
|
27
76
|
}
|
|
77
|
+
} else if (process.env.MUADDIB_DYNIMPORT_BOUNDED === '1') {
|
|
78
|
+
// Gate #2 (downgrade-only — never escalates above legacy severity, so it cannot raise FPR):
|
|
79
|
+
// a legacy-CRITICAL computed import (Identifier / TemplateLiteral / .replace URL) drops to HIGH
|
|
80
|
+
// when there is NO remote/decode/env evidence (bounded/local: CLI dispatchers, layout/i18n
|
|
81
|
+
// loaders, dep-resolve shims). With evidence it stays CRITICAL; a legacy-HIGH argument stays HIGH.
|
|
82
|
+
const legacyCritical = src.type === 'Identifier' || src.type === 'TemplateLiteral' ||
|
|
83
|
+
(src.type === 'CallExpression' && src.callee?.property?.name === 'replace');
|
|
84
|
+
const bounded = legacyCritical && !_importRemoteEvidence(src, ctx);
|
|
85
|
+
ctx.threats.push({
|
|
86
|
+
type: 'dynamic_import',
|
|
87
|
+
severity: bounded ? 'HIGH' : (legacyCritical ? 'CRITICAL' : 'HIGH'),
|
|
88
|
+
message: bounded
|
|
89
|
+
? 'Dynamic import() with computed (bounded/local) argument — possible obfuscation.'
|
|
90
|
+
: (legacyCritical
|
|
91
|
+
? 'Dynamic import() with computed URL argument — remote code loading from dynamically constructed URL.'
|
|
92
|
+
: 'Dynamic import() with computed argument (possible obfuscation).'),
|
|
93
|
+
file: ctx.relFile
|
|
94
|
+
});
|
|
28
95
|
} else {
|
|
29
|
-
// Blue Team v8b (C6)
|
|
30
|
-
//
|
|
31
|
-
const isCritical =
|
|
32
|
-
(
|
|
96
|
+
// Legacy behavior (gate off): Blue Team v8b (C6) — non-literal arg is CRITICAL when it
|
|
97
|
+
// looks like a constructed URL (Identifier / TemplateLiteral / .replace()).
|
|
98
|
+
const isCritical = src.type === 'Identifier' || src.type === 'TemplateLiteral' ||
|
|
99
|
+
(src.type === 'CallExpression' && src.callee?.property?.name === 'replace');
|
|
33
100
|
ctx.threats.push({
|
|
34
101
|
type: 'dynamic_import',
|
|
35
102
|
severity: isCritical ? 'CRITICAL' : 'HIGH',
|
|
@@ -216,6 +216,11 @@ function handlePostWalk(ctx) {
|
|
|
216
216
|
});
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// Per-file network-destination verdict (decoy-safe): true iff every literal host is
|
|
220
|
+
// local/reserved or a curated provider; any public-IP/suspicious/unknown host — or no host —
|
|
221
|
+
// ⇒ false. Reused by the detached/uncaught-exfil compounds below.
|
|
222
|
+
const destAllBenign = ctx._content ? networkDestinationsAllBenign(ctx._content) : false;
|
|
223
|
+
|
|
219
224
|
// Credential regex harvesting: credential-matching regex + network call in same file
|
|
220
225
|
// Real-world pattern: Transform/stream that scans data for tokens/passwords and exfiltrates
|
|
221
226
|
if (ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
|
|
@@ -328,7 +333,7 @@ function handlePostWalk(ctx) {
|
|
|
328
333
|
// destination in the file is first-party/local/provider (e.g. an otel collector on
|
|
329
334
|
// localhost, an SDK POST to its own API). A suspicious/unknown/public-IP host — or no
|
|
330
335
|
// literal host at all — leaves it firing (conservative: confirmed-benign only).
|
|
331
|
-
|
|
336
|
+
// (destAllBenign is computed once above, at the credential_regex_harvest emission site.)
|
|
332
337
|
if (hasDetachedInFile && hasSensitiveEnvInFile && ctx.hasNetworkCallInFile && !destAllBenign) {
|
|
333
338
|
ctx.threats.push({
|
|
334
339
|
type: 'detached_credential_exfil',
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -1043,6 +1043,40 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
1043
1043
|
}
|
|
1044
1044
|
}
|
|
1045
1045
|
|
|
1046
|
+
// Gate #1 (FPR 2026-06-15 — Étape 0 adjudication): the C7 block above only covers pure
|
|
1047
|
+
// env_read sources; the dominant live FP cluster (~25% of band 20-49, 0 TP) is a
|
|
1048
|
+
// credential_env_read API key (OPENAI_API_KEY, YINGDAO_ACCESS_TOKEN, …) flowing to the
|
|
1049
|
+
// package's OWN first-party API or a curated provider. The decoy-safe discriminant is
|
|
1050
|
+
// brand coherence (env-var brand ↔ host label) + curated providers + local hosts, applied
|
|
1051
|
+
// to EVERY destination. Limited to env-like sources (a credential_read FILE, command_output,
|
|
1052
|
+
// or fingerprint_read source stays CRITICAL — those are genuinely higher-risk). Downgrade to
|
|
1053
|
+
// MEDIUM so the signal survives; residual = compromised first-party domain, the same risk the
|
|
1054
|
+
// mature/MT-1 cap already accepts. Flag-gated (default off) for measure-then-flip rollout.
|
|
1055
|
+
if (process.env.MUADDIB_DF_SDK_GATE === '1' &&
|
|
1056
|
+
(severity === 'CRITICAL' || severity === 'HIGH')) {
|
|
1057
|
+
const envLike = sources.filter(s => s.type === 'env_read' || s.type === 'credential_env_read');
|
|
1058
|
+
const onlyEnvLike = sources.every(s =>
|
|
1059
|
+
s.type === 'env_read' || s.type === 'credential_env_read' || s.type === 'telemetry_read');
|
|
1060
|
+
if (envLike.length > 0 && onlyEnvLike) {
|
|
1061
|
+
try {
|
|
1062
|
+
const { extractBrandFromEnvVar, networkDestinationsAllBenignOrBrand } = require('../sdk-destination.js');
|
|
1063
|
+
const gateContent = fs.readFileSync(filePath, 'utf8');
|
|
1064
|
+
const brands = envLike.map(s => {
|
|
1065
|
+
const envVar = s.name
|
|
1066
|
+
.replace(/^process\.env\./, '')
|
|
1067
|
+
.replace(/^process\.env\[['"]/, '')
|
|
1068
|
+
.replace(/['"]\]$/, '');
|
|
1069
|
+
return extractBrandFromEnvVar(envVar);
|
|
1070
|
+
}).filter(Boolean);
|
|
1071
|
+
if (networkDestinationsAllBenignOrBrand(gateContent, brands)) {
|
|
1072
|
+
severity = 'MEDIUM';
|
|
1073
|
+
}
|
|
1074
|
+
} catch {
|
|
1075
|
+
// sdk-destination / file read unavailable — keep severity
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1046
1080
|
const sourceDesc = hasCommandOutput ? 'command output' : 'credentials read';
|
|
1047
1081
|
threats.push({
|
|
1048
1082
|
type: 'suspicious_dataflow',
|
package/src/scoring.js
CHANGED
|
@@ -1051,6 +1051,25 @@ function _hasExfilSink(threats) {
|
|
|
1051
1051
|
return threats.some(t => EXFIL_SINK_TYPES.has(t.type) && t.severity !== 'LOW');
|
|
1052
1052
|
}
|
|
1053
1053
|
|
|
1054
|
+
// Sink-coupling (chantier 2026-06-15): the subset of EXFIL_SINK_TYPES that PROVES taint or
|
|
1055
|
+
// unambiguous structural malice — NOT mere host-reputation string presence. When one of these
|
|
1056
|
+
// co-occurs with credential_regex_harvest it stays HIGH (anti-FN floor: protects cross-file
|
|
1057
|
+
// read→exfil and the intent/detached/staged compounds). The complement (suspicious_domain,
|
|
1058
|
+
// direct_ip_exfil, ioc_string_match, ioc_match) is host-reputation-only.
|
|
1059
|
+
const PROVEN_EXFIL_SINK_TYPES = new Set([
|
|
1060
|
+
'known_malicious_package', 'pypi_malicious_package', 'shai_hulud_marker',
|
|
1061
|
+
'detached_credential_exfil', 'silent_stealth_process',
|
|
1062
|
+
'curl_pipe_shell', 'curl_env_exfil', 'reverse_shell', 'dns_exfil', 'oast_callback',
|
|
1063
|
+
'function_constructor_require', 'staged_remote_loader', 'staged_eval_decode',
|
|
1064
|
+
'fetch_decrypt_exec', 'download_exec_binary', 'self_destruct_eval',
|
|
1065
|
+
'newsletter_auto_follow', 'cross_file_dataflow', 'intent_credential_exfil',
|
|
1066
|
+
'intent_command_exfil', 'sandbox_known_exfil_domain', 'sandbox_network_after_sensitive_read'
|
|
1067
|
+
]);
|
|
1068
|
+
function _hasProvenExfilSink(threats) {
|
|
1069
|
+
if (!Array.isArray(threats)) return false;
|
|
1070
|
+
return threats.some(t => PROVEN_EXFIL_SINK_TYPES.has(t.type) && t.severity !== 'LOW');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1054
1073
|
function applyFPReductions(threats, reachableFiles, packageName, packageDeps, reachableFunctions) {
|
|
1055
1074
|
// Initialize reductions audit trail on each threat
|
|
1056
1075
|
// Store original severity before any FP reductions, so compound
|
|
@@ -1206,13 +1225,23 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
|
|
|
1206
1225
|
// taint ...). When no such sink is present, downgrade HIGH/CRITICAL → LOW. Runs after the dilution
|
|
1207
1226
|
// floor so the floor's restored instance is also gated (the floor protects real exfil; with no sink
|
|
1208
1227
|
// there is nothing to protect). No GT sample relies on credential_regex_harvest (verified).
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1228
|
+
// Sink-coupling for credential_regex_harvest (per-instance, two-way): a proven taint /
|
|
1229
|
+
// structural-malice sink ⇒ keep HIGH (anti-FN floor); no exfil sink at all ⇒ LOW.
|
|
1230
|
+
const _crhProvenSink = _hasProvenExfilSink(threats);
|
|
1231
|
+
const _crhAnySink = _hasExfilSink(threats);
|
|
1232
|
+
for (const t of threats) {
|
|
1233
|
+
if (t.type !== 'credential_regex_harvest') continue;
|
|
1234
|
+
if (t.severity !== 'HIGH' && t.severity !== 'CRITICAL') continue;
|
|
1235
|
+
// (1) anti-FN floor: a proven taint / structural-malice sink ⇒ keep HIGH (host/flag irrelevant).
|
|
1236
|
+
if (_crhProvenSink) continue;
|
|
1237
|
+
// (2) no exfil sink at all ⇒ LOW (legacy behavior, flag-independent).
|
|
1238
|
+
if (!_crhAnySink) {
|
|
1239
|
+
t.reductions.push({ rule: 'sink_coupling', from: t.severity, to: 'LOW' });
|
|
1240
|
+
t.severity = 'LOW';
|
|
1241
|
+
continue;
|
|
1215
1242
|
}
|
|
1243
|
+
// (3) only host-reputation sink(s) co-occur ⇒ keep HIGH (fall-through). A host-coupling
|
|
1244
|
+
// downgrade here (gate #3, MUADDIB_CRH_HOST_GATE) was measured inert and removed 2026-06-15.
|
|
1216
1245
|
}
|
|
1217
1246
|
|
|
1218
1247
|
for (const t of threats) {
|
package/src/sdk-destination.js
CHANGED
|
@@ -308,6 +308,45 @@ function networkDestinationsAllBenign(fileContent) {
|
|
|
308
308
|
return true;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Gate #1 variant of networkDestinationsAllBenign: a host ALSO passes if one of its labels
|
|
313
|
+
* matches a credential env-var BRAND (e.g. YINGDAO_ACCESS_TOKEN → api.yingdao.com). This covers
|
|
314
|
+
* the dominant credential→own-API FP cluster (Étape 0 2026-06-15: ~25% of band 20-49, 0 TP) that
|
|
315
|
+
* networkDestinationsAllBenign rejects because a package's own domain is not a curated provider.
|
|
316
|
+
* Decoy-safe by construction: EVERY host must be local/reserved OR a curated provider OR
|
|
317
|
+
* brand-coherent; any unknown / public-IP / suspicious-tunnel host ⇒ false. No hosts ⇒ false.
|
|
318
|
+
* Brand coherence is not attacker-spoofable for the credential-theft case: stealing a VICTIM's
|
|
319
|
+
* OTHER-service key (OPENAI_API_KEY) and sending it to attacker.com yields brand "openai" vs label
|
|
320
|
+
* "attacker" ⇒ mismatch ⇒ keeps firing.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} fileContent - source of the file containing the network sink
|
|
323
|
+
* @param {string[]} brands - brand tokens extracted from the credential env-var names
|
|
324
|
+
* @returns {boolean}
|
|
325
|
+
*/
|
|
326
|
+
function networkDestinationsAllBenignOrBrand(fileContent, brands) {
|
|
327
|
+
const hosts = extractHostsFromContent(fileContent);
|
|
328
|
+
if (hosts.length === 0) return false;
|
|
329
|
+
// RFC 2606 / 6761 documentation & test placeholders (example.com/.net/.org, *.test, *.invalid)
|
|
330
|
+
// are NOT real SDK destinations — no benign SDK ships a live credential flow to example.com.
|
|
331
|
+
// A credential→placeholder flow is either a synthetic exfil sample or an evasion stand-in, so it
|
|
332
|
+
// must keep firing (it is deliberately NOT in the local-IPC benign class, unlike loopback/RFC1918).
|
|
333
|
+
const DOC_DOMAIN_RE = /(^|\.)example\.(?:com|net|org)$|\.(?:test|example|invalid)$/i;
|
|
334
|
+
const brandSet = (brands || [])
|
|
335
|
+
.map(b => String(b || '').toLowerCase())
|
|
336
|
+
.filter(b => b.length >= 3);
|
|
337
|
+
for (const h of hosts) {
|
|
338
|
+
if (SUSPICIOUS_DOMAIN_PATTERNS.test(h)) return false;
|
|
339
|
+
if (isPublicIpHost(h)) return false;
|
|
340
|
+
if (DOC_DOMAIN_RE.test(h)) return false;
|
|
341
|
+
if (isLocalOrReservedHost(h)) continue;
|
|
342
|
+
if (PROVIDER_DOMAIN_SUFFIXES.some(s => domainMatchesSuffix(h, [s]))) continue;
|
|
343
|
+
const labels = String(h).toLowerCase().split('.');
|
|
344
|
+
if (brandSet.length && labels.some(l => brandSet.includes(l))) continue;
|
|
345
|
+
return false; // unknown / unrecognised destination → keep firing
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
311
350
|
module.exports = {
|
|
312
351
|
SDK_ENV_DOMAIN_MAP,
|
|
313
352
|
ENV_NOISE_TOKENS,
|
|
@@ -320,6 +359,7 @@ module.exports = {
|
|
|
320
359
|
extractDomain,
|
|
321
360
|
domainMatchesSuffix,
|
|
322
361
|
isSDKPattern,
|
|
362
|
+
networkDestinationsAllBenignOrBrand,
|
|
323
363
|
stripPort,
|
|
324
364
|
isLocalOrReservedHost,
|
|
325
365
|
isPublicIpHost,
|