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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.118",
3
+ "version": "2.11.120",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-15T12:53:45.305Z",
3
+ "timestamp": "2026-06-18T19:56:37.339Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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
- return Number.isFinite(v) && v > 0 ? v : 500;
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 slot (_deferredSlotBusy) that is
10
- * completely independent from the shared semaphore used by T1a/T1b/T2.
11
- * This guarantees the deferred worker can always process, regardless of
12
- * how many main-path sandboxes are running. The VPS supports N+1
13
- * concurrent gVisor containers (3 main + 1 deferred).
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 the dedicated slot
36
- // (_deferredSlotBusy) can never wedge. maxRuns=1 self-bounds at ~SINGLE_RUN_TIMEOUT
37
- // (90s) + the sandbox watchdog grace; this AbortController is belt-and-suspenders.
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 _deferredSlotBusy = false; // Dedicated slot: true while deferred sandbox is running
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. Dedicated slot check — completely independent from main semaphore
208
- if (_deferredSlotBusy) {
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 dedicated slot (bypasses shared semaphore)
221
- _deferredSlotBusy = true;
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 runSandbox(item.name, { canary, skipSemaphore: true, maxRuns, signal: ac.signal });
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
- _deferredSlotBusy = false;
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(async () => {
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
- await processDeferredItem(_stats);
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
- _deferredSlotBusy = false;
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 _deferredSlotBusy;
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
+ };
@@ -1528,6 +1528,7 @@ module.exports = {
1528
1528
  POLL_INTERVAL,
1529
1529
  POLL_MAX_BACKOFF,
1530
1530
  MAX_RESPONSE_BYTES,
1531
+ SOFT_BACKPRESSURE_THRESHOLD,
1531
1532
 
1532
1533
  // Mutable state
1533
1534
  getConsecutivePollErrors,
@@ -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
- extractedDir = extractArchive(tgzPath, tmpDir);
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
- extractedDir = extractArchive(tgzPath, tmpDir);
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
@@ -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 has headroom.
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;
@@ -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
- const lines = [`Scanned ${rollup.scanned} · Alerted ${rollup.alerted} (${pct}%)`];
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
- lines.push(`Dropped ${rollup.dropped} (${vanishedNote} vanished)`);
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
- // Clarify the Ops headline so it isn't read as an overnight drop: it counts
1239
- // COMPLETED scans in the exact ledger window [last report → now], version/
1240
- // dedup-collapsed intentionally lower than the in-memory counter (stats.scanned),
1241
- // which also tallies retries, burst extras and size-cap rejections
1242
- // (cf. queue.js uniqueScanAttempts). Surface the raw counter when it diverges.
1243
- const opsQualifier = headline ? ' (completed, deduped, 24h)' : '';
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
- ? ` · counter ${stats.scanned} (incl. retries/burst)`
1256
+ ? ` · compteur ${stats.scanned} (retries/burst inclus)`
1246
1257
  : '';
1247
1258
  const opsSuffix = catchupSkipped > 0
1248
- ? `\nOps: ${hScanned}${opsQualifier}${rawCounter} | Catch-up skip: ${catchupSkipped}`
1249
- : `\nOps: ${hScanned}${opsQualifier}${rawCounter}`;
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
- coverageText = `${ledger.distinctScanned}/${ledger.distinctPackages} pkgs (${approx}${pct}%)`;
1255
- // Honest 24h coverage loss surfaced next to coverage: `vanished` = distinct names
1256
- // dropped and never re-scanned in the window — the real miss count. The raw
1257
- // `dropped` aggregate (which also folds in recoverable spill + retries, so it
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 = `${attempted}/${published} (${coverageRatio}%)${opsSuffix}`;
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. Its `dropped` folds in recoverable spill + retries,
1367
- // so it OVERSTATES loss `vanished` (in the Coverage field) is the
1368
- // honest miss count, which is why dropped sits here, not the headline.
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 (dropped inclut le spill récupérable — voir « vanished » pour la perte réelle) · Stability = cumulé depuis 08:00 (backlog = instantané) · Degradations/System = instantané',
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): Dynamic import with non-literal arg if it's a variable
30
- // built from URL manipulation, this is remote code loading
31
- const isCritical = node.source.type === 'Identifier' || node.source.type === 'TemplateLiteral' ||
32
- (node.source.type === 'CallExpression' && node.source.callee?.property?.name === 'replace');
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
- const destAllBenign = ctx._content ? networkDestinationsAllBenign(ctx._content) : false;
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',
@@ -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
- if (!_hasExfilSink(threats)) {
1210
- for (const t of threats) {
1211
- if (t.type === 'credential_regex_harvest' && (t.severity === 'HIGH' || t.severity === 'CRITICAL')) {
1212
- t.reductions.push({ rule: 'sink_coupling', from: t.severity, to: 'LOW' });
1213
- t.severity = 'LOW';
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) {
@@ -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,