muaddib-scanner 2.11.163 → 2.11.164

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.163",
3
+ "version": "2.11.164",
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-07-10T08:26:38.815Z",
3
+ "timestamp": "2026-07-10T09:30:28.827Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -24,6 +24,7 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
+ const readline = require('readline');
27
28
  const { isMainThread, threadId } = require('worker_threads');
28
29
  const { sanitizePackageName } = require('../shared/download.js');
29
30
 
@@ -1054,6 +1055,12 @@ const MAX_SCAN_LEDGER = (() => {
1054
1055
  })();
1055
1056
  const SCAN_LEDGER_COMPACT_INTERVAL = 2000;
1056
1057
  let _scanLedgerAppendedSinceCompact = 0;
1058
+ // Async-compaction coordination. `_scanLedgerCompacting` guards re-entrancy (a second
1059
+ // trigger while a compaction is in flight is skipped — the cap is soft, the next
1060
+ // interval retries). The promise lets tests + graceful shutdown await an in-flight
1061
+ // compaction settling. See _compactScanLedgerJsonl (async, streaming, race-safe).
1062
+ let _scanLedgerCompacting = false;
1063
+ let _scanLedgerCompactionPromise = Promise.resolve();
1057
1064
 
1058
1065
  // Terminal outcomes a dequeued package can reach. Unknown values normalize to 'clean'
1059
1066
  // so a typo at a call site can never crash the pipeline.
@@ -1124,7 +1131,10 @@ function appendScanLedger(e) {
1124
1131
  _scanLedgerAppendedSinceCompact++;
1125
1132
  if (_scanLedgerAppendedSinceCompact >= SCAN_LEDGER_COMPACT_INTERVAL) {
1126
1133
  _scanLedgerAppendedSinceCompact = 0;
1127
- _compactScanLedgerJsonl();
1134
+ // Fire the async compaction (non-blocking — keeps the RSS-breaker poll loop
1135
+ // reactive). Guarded + fully caught internally so it never rejects; stored so
1136
+ // tests and graceful shutdown can await an in-flight compaction settling.
1137
+ _scanLedgerCompactionPromise = _compactScanLedgerJsonl();
1128
1138
  }
1129
1139
  } catch (err) {
1130
1140
  if (err.code === 'EROFS' || err.code === 'EACCES' || err.code === 'EPERM') return;
@@ -1140,31 +1150,99 @@ function appendScanLedger(e) {
1140
1150
  * Compact the scan-ledger JSONL: keep only the most recent MAX_SCAN_LEDGER entries.
1141
1151
  * No-op when already under cap. Streams (never loads the whole file at once).
1142
1152
  */
1143
- function _compactScanLedgerJsonl() {
1144
- // Leading loop-stall suspect (see require note above): count + parse + re-stringify
1145
- // + join + 127MB write + rename, all synchronous over ~500K entries. Instrument the
1146
- // whole body (the count pass can block too) so a stall NAMES 'compact:scan-ledger'.
1147
- _runInstrumented('compact:scan-ledger', { cap: MAX_SCAN_LEDGER }, () => {
1148
- try {
1149
- const total = _countJsonlLines(SCAN_LEDGER_FILE);
1150
- if (total <= MAX_SCAN_LEDGER) return;
1151
- const toDrop = total - MAX_SCAN_LEDGER;
1152
- let skipped = 0;
1153
- const kept = [];
1154
- _iterateJsonlSync(SCAN_LEDGER_FILE, (entry) => {
1155
- if (skipped < toDrop) { skipped++; return; }
1156
- kept.push(JSON.stringify(entry));
1157
- });
1158
- const tmpFile = SCAN_LEDGER_FILE + '.tmp';
1159
- fs.writeFileSync(tmpFile, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
1160
- fs.renameSync(tmpFile, SCAN_LEDGER_FILE);
1161
- console.log(`[MONITOR] COMPACT scan-ledger: ${total} -> ${kept.length} entries`);
1162
- } catch (err) {
1163
- console.error(`[MONITOR] Scan-ledger compaction failed: ${err.message}`);
1164
- }
1153
+ // Stream-count newline-terminated lines in [0, endByte) without loading the file or
1154
+ // parsing JSON. Async yields between 64KB chunks so it never wedges the poll loop.
1155
+ function _streamCountLines(filePath, endByte) {
1156
+ return new Promise((resolve, reject) => {
1157
+ if (!endByte || endByte <= 0) { resolve(0); return; }
1158
+ let count = 0;
1159
+ const rs = fs.createReadStream(filePath, { start: 0, end: endByte - 1 });
1160
+ rs.on('error', (e) => ((e && e.code === 'ENOENT') ? resolve(0) : reject(e)));
1161
+ rs.on('data', (buf) => { for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) count++; });
1162
+ rs.on('end', () => resolve(count));
1163
+ });
1164
+ }
1165
+
1166
+ // Stream [0, endByte): skip the first `skip` lines, copy the rest VERBATIM (no parse,
1167
+ // no re-stringify) to tmp. Bounded memory: one line at a time + write backpressure
1168
+ // (pause the reader until the write buffer drains). Returns the kept line count.
1169
+ function _streamKeepTailToTmp(filePath, skip, endByte, tmpFile) {
1170
+ return new Promise((resolve, reject) => {
1171
+ let seen = 0, kept = 0, failed = false, draining = false;
1172
+ const rs = fs.createReadStream(filePath, { encoding: 'utf8', start: 0, end: endByte - 1 });
1173
+ const ws = fs.createWriteStream(tmpFile, { encoding: 'utf8' }); // truncates any orphan tmp
1174
+ const rl = readline.createInterface({ input: rs, crlfDelay: Infinity });
1175
+ const fail = (e) => { if (failed) return; failed = true; try { rl.close(); } catch { /* */ } try { rs.destroy(); } catch { /* */ } try { ws.destroy(); } catch { /* */ } reject(e); };
1176
+ rs.on('error', fail); ws.on('error', fail);
1177
+ rl.on('line', (line) => {
1178
+ if (seen++ < skip) return;
1179
+ if (!line) return;
1180
+ const ok = ws.write(line + '\n');
1181
+ kept++;
1182
+ // Backpressure: pause the reader until the write buffer drains. The `draining`
1183
+ // guard is required — readline emits lines ALREADY buffered from the current
1184
+ // chunk synchronously even after pause(), so without it each such line would
1185
+ // stack another 'drain' listener (MaxListenersExceededWarning + redundant resumes).
1186
+ if (!ok && !draining) {
1187
+ draining = true;
1188
+ rl.pause();
1189
+ ws.once('drain', () => { draining = false; rl.resume(); });
1190
+ }
1191
+ });
1192
+ rl.on('close', () => { if (!failed) ws.end(() => resolve(kept)); });
1165
1193
  });
1166
1194
  }
1167
1195
 
1196
+ // Async, streaming, race-safe compaction of the scan-ledger. Replaces the old
1197
+ // SYNCHRONOUS 127MB rewrite that wedged the main-thread poll loop for minutes — and
1198
+ // because the RSS circuit breaker runs on that same loop, a wedged loop is a BLIND
1199
+ // breaker → RSS climbs unchecked → cgroup OOM. Design (why it can't lose appends):
1200
+ // • two async streaming passes over the immutable snapshot [0, preSize): count, then
1201
+ // copy the last MAX lines to a .tmp. Bounded memory (no 500K-entry array / 127MB
1202
+ // join). Non-blocking: appends keep flowing to the LIVE file during these passes.
1203
+ // • the ONLY destructive step — fold the live tail [preSize, nowSize) onto .tmp, then
1204
+ // rename .tmp over the ledger — runs with NO `await` between the tail read and the
1205
+ // rename, so it is ATOMIC w.r.t. the single-threaded event loop: no concurrent
1206
+ // appendScanLedger can interleave, hence the rename can never clobber an un-folded
1207
+ // append. (appendFileSync writes whole lines, so preSize is always line-aligned.)
1208
+ // • crash-safe: the live ledger is untouched until the atomic rename; a crash leaves
1209
+ // it intact plus a stale .tmp (overwritten next run). No sidecar, no recovery hook.
1210
+ // Never throws (fire-and-forget from appendScanLedger).
1211
+ async function _compactScanLedgerJsonl() {
1212
+ if (_scanLedgerCompacting) return; // re-entrancy guard
1213
+ _scanLedgerCompacting = true;
1214
+ const tmpFile = SCAN_LEDGER_FILE + '.tmp';
1215
+ try {
1216
+ if (!fs.existsSync(SCAN_LEDGER_FILE)) return;
1217
+ const preSize = fs.statSync(SCAN_LEDGER_FILE).size; // immutable snapshot boundary
1218
+ if (preSize === 0) return;
1219
+ const total = await _streamCountLines(SCAN_LEDGER_FILE, preSize);
1220
+ if (total <= MAX_SCAN_LEDGER) return;
1221
+ const toDrop = total - MAX_SCAN_LEDGER;
1222
+ const t0 = Date.now();
1223
+ const kept = await _streamKeepTailToTmp(SCAN_LEDGER_FILE, toDrop, preSize, tmpFile);
1224
+ // ── SYNCHRONOUS finalize (atomic w.r.t. the event loop — no await below) ──
1225
+ const nowSize = fs.statSync(SCAN_LEDGER_FILE).size;
1226
+ let foldedBytes = 0;
1227
+ if (nowSize > preSize) { // appends landed during the async passes
1228
+ const fd = fs.openSync(SCAN_LEDGER_FILE, 'r');
1229
+ try {
1230
+ foldedBytes = nowSize - preSize;
1231
+ const tail = Buffer.allocUnsafe(foldedBytes);
1232
+ fs.readSync(fd, tail, 0, foldedBytes, preSize);
1233
+ fs.appendFileSync(tmpFile, tail); // preserve concurrent appends (whole lines)
1234
+ } finally { fs.closeSync(fd); }
1235
+ }
1236
+ fs.renameSync(tmpFile, SCAN_LEDGER_FILE); // atomic swap
1237
+ console.log(`[MONITOR] COMPACT scan-ledger: ${total} -> ${kept} entries (async, ${Date.now() - t0}ms, +${foldedBytes}B live tail)`);
1238
+ } catch (err) {
1239
+ console.error(`[MONITOR] Scan-ledger compaction failed: ${err.message}`);
1240
+ try { fs.unlinkSync(tmpFile); } catch { /* nothing to clean */ }
1241
+ } finally {
1242
+ _scanLedgerCompacting = false;
1243
+ }
1244
+ }
1245
+
1168
1246
  /** Stream the scan-ledger into an array (tests + Phase 0b rollup). */
1169
1247
  function loadScanLedger() {
1170
1248
  const entries = [];
@@ -1880,6 +1958,7 @@ module.exports = {
1880
1958
  loadScanLedger,
1881
1959
  computeLedgerRollup,
1882
1960
  _compactScanLedgerJsonl,
1961
+ _whenScanLedgerCompactionSettles: () => _scanLedgerCompactionPromise,
1883
1962
  getDetectionStats,
1884
1963
  runStateMigrations,
1885
1964
  // Internal — exported for tests and for the daemon hourly housekeeping.
package/stats.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "GENERATED by scripts/collect-stats.js — do not edit by hand. Run `npm run docs:stats`.",
3
- "version": "2.11.163",
3
+ "version": "2.11.164",
4
4
  "scanners": 21,
5
5
  "rulesTotal": 275,
6
6
  "rulesCore": 270,