muaddib-scanner 2.11.119 → 2.11.121
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.119.json → self-scan-v2.11.121.json} +1 -1
- package/src/monitor/daemon.js +24 -0
- package/src/monitor/event-loop-monitor.js +186 -0
- package/src/monitor/queue.js +5 -2
- package/src/monitor/webhook.js +34 -25
- package/src/response/playbooks.js +7 -0
- package/src/rules/index.js +13 -0
- package/src/scanner/package.js +10 -3
- package/src/scoring.js +17 -0
package/package.json
CHANGED
package/src/monitor/daemon.js
CHANGED
|
@@ -13,6 +13,7 @@ const { poll, getPollBackoffMs, SOFT_BACKPRESSURE_THRESHOLD } = require('./inges
|
|
|
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');
|
|
@@ -966,6 +967,7 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
966
967
|
let pollIntervalHandle = null; // Decoupled poll timer — set after initial poll
|
|
967
968
|
let queuePersistHandle = null; // Queue persistence timer
|
|
968
969
|
let concurrencyAdjustHandle = null; // Adaptive concurrency timer
|
|
970
|
+
let loopLagSamplerStop = null; // Event-loop stall attribution (instrumentation)
|
|
969
971
|
|
|
970
972
|
// Restore queue from previous run (if file exists and is < 24h old)
|
|
971
973
|
const restoredCount = restoreQueue(scanQueue);
|
|
@@ -1001,6 +1003,10 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
1001
1003
|
clearInterval(concurrencyAdjustHandle);
|
|
1002
1004
|
concurrencyAdjustHandle = null;
|
|
1003
1005
|
}
|
|
1006
|
+
if (loopLagSamplerStop) {
|
|
1007
|
+
loopLagSamplerStop();
|
|
1008
|
+
loopLagSamplerStop = null;
|
|
1009
|
+
}
|
|
1004
1010
|
// Bounded drain (phase C, C7). The old unbounded `await drainWorkers()`
|
|
1005
1011
|
// could outlive systemd's TimeoutStopSec (scans run up to 300s): SIGKILL
|
|
1006
1012
|
// then landed MID-drain, persistQueue never ran, and every in-flight scan
|
|
@@ -1109,6 +1115,24 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
1109
1115
|
}
|
|
1110
1116
|
}, ADJUST_INTERVAL_MS);
|
|
1111
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
|
+
|
|
1112
1136
|
// ─── Decoupled polling ───
|
|
1113
1137
|
// Poll runs on its own interval, independent of processing.
|
|
1114
1138
|
// This ensures new packages are ingested even while a large batch is being scanned.
|
|
@@ -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/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/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 }]
|
|
@@ -807,6 +807,13 @@ const PLAYBOOKS = {
|
|
|
807
807
|
'Vecteur classique de dependency confusion: le code s\'execute a l\'installation. ' +
|
|
808
808
|
'NE PAS installer. Verifier le nom exact du package. Signaler sur npm.',
|
|
809
809
|
|
|
810
|
+
lifecycle_version99:
|
|
811
|
+
'CRITIQUE: Version a major repdigit "win-semver" (99/999/9999) + hook lifecycle = ' +
|
|
812
|
+
'dependency confusion complete. La version elevee force npm a resoudre vers ce package ' +
|
|
813
|
+
'public au lieu du package interne prive, et le hook execute le payload a l\'installation. ' +
|
|
814
|
+
'NE PAS installer. Verifier si un package interne du meme nom existe. Regenerer les secrets ' +
|
|
815
|
+
'exposes. Signaler sur npm.',
|
|
816
|
+
|
|
810
817
|
lifecycle_inline_exec:
|
|
811
818
|
'CRITIQUE: Script lifecycle avec node -e (execution inline). Le code s\'execute automatiquement a npm install. ' +
|
|
812
819
|
'NE PAS installer. Si deja installe: considerer la machine compromise. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -2699,6 +2699,19 @@ const RULES = {
|
|
|
2699
2699
|
],
|
|
2700
2700
|
mitre: 'T1195.002'
|
|
2701
2701
|
},
|
|
2702
|
+
lifecycle_version99: {
|
|
2703
|
+
id: 'MUADDIB-COMPOUND-018',
|
|
2704
|
+
name: 'Lifecycle Hook + Dependency-Confusion Version',
|
|
2705
|
+
severity: 'CRITICAL',
|
|
2706
|
+
confidence: 'high',
|
|
2707
|
+
domain: 'malware',
|
|
2708
|
+
description: 'Version a major repdigit "win-semver" (99/999/9999) AVEC hook lifecycle (preinstall/install/postinstall). Chaine complete de dependency confusion: la version elevee force la resolution npm vers le package public malveillant au lieu du package interne prive, et le hook execute le payload a l\'installation. Compound: version_99_preinstall + lifecycle_script (gate-FPR-test 2026-06-19: 0/3901 FP).',
|
|
2709
|
+
references: [
|
|
2710
|
+
'https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610',
|
|
2711
|
+
'https://attack.mitre.org/techniques/T1195.002/'
|
|
2712
|
+
],
|
|
2713
|
+
mitre: 'T1195.002'
|
|
2714
|
+
},
|
|
2702
2715
|
lifecycle_inline_exec: {
|
|
2703
2716
|
id: 'MUADDIB-COMPOUND-004',
|
|
2704
2717
|
name: 'Lifecycle Hook + Inline Node Execution',
|
package/src/scanner/package.js
CHANGED
|
@@ -165,17 +165,24 @@ async function scanPackageJson(targetPath) {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
// v2.10.89: Dependency confusion indicator —
|
|
168
|
+
// v2.10.89: Dependency confusion indicator — repdigit "win-semver" major with install hooks.
|
|
169
169
|
// Catches: @corpweb-ui/wmkt-library, @toprank/partner, @adac-fahrzeugplattform/ui
|
|
170
|
+
// v2.11.118 (2026-06-19, gate-FPR-test on the GHSA-2026 miss corpus): tightened from a
|
|
171
|
+
// plain `major >= 99` to the repdigit set {99, 999, 9999}. `>= 99` also fired on calendar
|
|
172
|
+
// versions (2026.x — 51 in the FP corpus) and legit high-version packages (chromedriver@148,
|
|
173
|
+
// taskcluster@100, @jetbrains/junie@1966, salt@3008) — masked only because the lone signal
|
|
174
|
+
// stayed HIGH (<20). Restricting to repdigit majors keeps 27/27 corpus dep-conf MALWARE at
|
|
175
|
+
// ZERO benign hits, and unblocks the lifecycle_version99 compound below (which would
|
|
176
|
+
// otherwise inherit the calendar FPs once escalated to CRITICAL).
|
|
170
177
|
const versionStr = pkg.version || '';
|
|
171
178
|
const majorVersion = parseInt(versionStr.split('.')[0], 10);
|
|
172
|
-
if (
|
|
179
|
+
if ([99, 999, 9999].includes(majorVersion)) {
|
|
173
180
|
const hasInstallHook = ['preinstall', 'install', 'postinstall'].some(s => scripts[s]);
|
|
174
181
|
if (hasInstallHook) {
|
|
175
182
|
threats.push({
|
|
176
183
|
type: 'version_99_preinstall',
|
|
177
184
|
severity: 'HIGH',
|
|
178
|
-
message: `Version ${versionStr} (major
|
|
185
|
+
message: `Version ${versionStr} (repdigit win-semver major ${majorVersion}) with lifecycle hook — dependency confusion attack pattern.`,
|
|
179
186
|
file: 'package.json'
|
|
180
187
|
});
|
|
181
188
|
}
|
package/src/scoring.js
CHANGED
|
@@ -536,6 +536,23 @@ const SCORING_COMPOUNDS = [
|
|
|
536
536
|
message: 'Lifecycle hook on typosquat package — dependency confusion attack vector (scoring compound).',
|
|
537
537
|
fileFrom: 'typosquat_detected'
|
|
538
538
|
},
|
|
539
|
+
{
|
|
540
|
+
// 2026-06-19 detection-gap (GHSA-2026 misses): a repdigit "win-semver" version
|
|
541
|
+
// (version_99_preinstall: major 99/999/9999) + an install lifecycle hook is the full
|
|
542
|
+
// dependency-confusion RCE chain. version_99_preinstall alone is HIGH (10), below the
|
|
543
|
+
// 20 alert threshold, so these scored ~13 and were missed (e.g. @doaction/* @99.99.99).
|
|
544
|
+
// Gate-FPR-tested on the confirmed corpus: repdigit-major + lifecycle_script = 0/3901
|
|
545
|
+
// benign FP (the 3 repdigit-version FPs have no install hook), 22/42 GT MALWARE.
|
|
546
|
+
// Both signals are package.json-level (no sameFile / excludeIfBundled needed).
|
|
547
|
+
// requireOriginalSeverityHigh anchors on version_99_preinstall (HIGH) so a lone
|
|
548
|
+
// lifecycle_script (MEDIUM, fires on every install hook) can never trip this alone.
|
|
549
|
+
type: 'lifecycle_version99',
|
|
550
|
+
requires: ['version_99_preinstall', 'lifecycle_script'],
|
|
551
|
+
severity: 'CRITICAL',
|
|
552
|
+
message: 'Dependency-confusion version (repdigit major 99/999/9999) + install lifecycle hook — install-time RCE via dependency confusion (scoring compound).',
|
|
553
|
+
fileFrom: 'version_99_preinstall',
|
|
554
|
+
requireOriginalSeverityHigh: true
|
|
555
|
+
},
|
|
539
556
|
{
|
|
540
557
|
// RT-C1: Boundary-squat dep declared AND require()d in code → CRITICAL.
|
|
541
558
|
// Pattern Axios UNC1069 (March 2026): wrapper looks benign, payload is in the dep.
|