muaddib-scanner 2.11.162 → 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.162",
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-10T07:45:05.079Z",
3
+ "timestamp": "2026-07-10T09:30:28.827Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -24,9 +24,22 @@
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
 
31
+ // Event-loop stall attribution (event-loop-monitor.js): the periodic JSONL
32
+ // compactions below are O(file-size) SYNCHRONOUS rewrites that run on the monitor
33
+ // main thread. At the 500K-entry scan-ledger steady state this is the leading
34
+ // suspect for the multi-minute loop stalls that blind the RSS breaker — yet it was
35
+ // uninstrumented, so a stall in it mis-attributed to a stale prior breadcrumb
36
+ // (docker:rm with durationMs << lagMs). Wrapping each compaction NAMES it: a stall
37
+ // now logs op.label='compact:*' with durationMs ~ lagMs. Guarded require so state.js
38
+ // stays usable from the CLI / scan-worker where the sampler isn't running
39
+ // (runInstrumented is then a transparent passthrough). Never throws.
40
+ let _runInstrumented = (label, meta, fn) => fn();
41
+ try { ({ runInstrumented: _runInstrumented } = require('./event-loop-monitor.js')); } catch { /* sampler unavailable — passthrough */ }
42
+
30
43
  // --- File path constants ---
31
44
 
32
45
  const STATE_FILE = path.join(__dirname, '..', '..', 'data', 'monitor-state.json');
@@ -761,23 +774,26 @@ function loadTemporalDetections() {
761
774
  * runStateMigrations to enforce caps after migration.
762
775
  */
763
776
  function _compactTemporalDetectionsJsonl() {
764
- try {
765
- const total = _countJsonlLines(TEMPORAL_DETECTIONS_FILE);
766
- if (total <= MAX_TEMPORAL_DETECTIONS) return;
767
- const toDrop = total - MAX_TEMPORAL_DETECTIONS;
768
- let skipped = 0;
769
- const kept = [];
770
- _iterateJsonlSync(TEMPORAL_DETECTIONS_FILE, (entry) => {
771
- if (skipped < toDrop) { skipped++; return; }
772
- kept.push(JSON.stringify(entry));
773
- });
774
- const tmpFile = TEMPORAL_DETECTIONS_FILE + '.tmp';
775
- fs.writeFileSync(tmpFile, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
776
- fs.renameSync(tmpFile, TEMPORAL_DETECTIONS_FILE);
777
- console.log(`[MONITOR] COMPACT temporal-detections: ${total} -> ${kept.length} entries`);
778
- } catch (err) {
779
- console.error(`[MONITOR] Temporal detections compaction failed: ${err.message}`);
780
- }
777
+ // Same O(file) synchronous rewrite shape as the scan-ledger/detections compactions.
778
+ _runInstrumented('compact:temporal-detections', { cap: MAX_TEMPORAL_DETECTIONS }, () => {
779
+ try {
780
+ const total = _countJsonlLines(TEMPORAL_DETECTIONS_FILE);
781
+ if (total <= MAX_TEMPORAL_DETECTIONS) return;
782
+ const toDrop = total - MAX_TEMPORAL_DETECTIONS;
783
+ let skipped = 0;
784
+ const kept = [];
785
+ _iterateJsonlSync(TEMPORAL_DETECTIONS_FILE, (entry) => {
786
+ if (skipped < toDrop) { skipped++; return; }
787
+ kept.push(JSON.stringify(entry));
788
+ });
789
+ const tmpFile = TEMPORAL_DETECTIONS_FILE + '.tmp';
790
+ fs.writeFileSync(tmpFile, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
791
+ fs.renameSync(tmpFile, TEMPORAL_DETECTIONS_FILE);
792
+ console.log(`[MONITOR] COMPACT temporal-detections: ${total} -> ${kept.length} entries`);
793
+ } catch (err) {
794
+ console.error(`[MONITOR] Temporal detections compaction failed: ${err.message}`);
795
+ }
796
+ });
781
797
  }
782
798
 
783
799
  // --- State persistence ---
@@ -996,28 +1012,32 @@ function getDetectionStats() {
996
1012
  * stays consistent. No-op when the file is already under cap.
997
1013
  */
998
1014
  function _compactDetectionsJsonl() {
999
- try {
1000
- const total = _countJsonlLines(DETECTIONS_FILE);
1001
- if (total <= MAX_DETECTIONS) return;
1002
- const toDrop = total - MAX_DETECTIONS;
1003
- let skipped = 0;
1004
- const kept = [];
1005
- const newDedup = new Set();
1006
- _iterateJsonlSync(DETECTIONS_FILE, (entry) => {
1007
- if (skipped < toDrop) { skipped++; return; }
1008
- kept.push(JSON.stringify(entry));
1009
- if (entry && entry.package && entry.version) {
1010
- newDedup.add(`${entry.package}@${entry.version}`);
1011
- }
1012
- });
1013
- const tmpFile = DETECTIONS_FILE + '.tmp';
1014
- fs.writeFileSync(tmpFile, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
1015
- fs.renameSync(tmpFile, DETECTIONS_FILE);
1016
- _detectionDedupSet = newDedup;
1017
- console.log(`[MONITOR] COMPACT detections: ${total} -> ${kept.length} entries`);
1018
- } catch (err) {
1019
- console.error(`[MONITOR] Detections compaction failed: ${err.message}`);
1020
- }
1015
+ // Same O(file) synchronous rewrite as the scan-ledger (smaller cap, but same
1016
+ // main-thread blocking shape). Named so a stall here can't hide behind a stale crumb.
1017
+ _runInstrumented('compact:detections', { cap: MAX_DETECTIONS }, () => {
1018
+ try {
1019
+ const total = _countJsonlLines(DETECTIONS_FILE);
1020
+ if (total <= MAX_DETECTIONS) return;
1021
+ const toDrop = total - MAX_DETECTIONS;
1022
+ let skipped = 0;
1023
+ const kept = [];
1024
+ const newDedup = new Set();
1025
+ _iterateJsonlSync(DETECTIONS_FILE, (entry) => {
1026
+ if (skipped < toDrop) { skipped++; return; }
1027
+ kept.push(JSON.stringify(entry));
1028
+ if (entry && entry.package && entry.version) {
1029
+ newDedup.add(`${entry.package}@${entry.version}`);
1030
+ }
1031
+ });
1032
+ const tmpFile = DETECTIONS_FILE + '.tmp';
1033
+ fs.writeFileSync(tmpFile, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
1034
+ fs.renameSync(tmpFile, DETECTIONS_FILE);
1035
+ _detectionDedupSet = newDedup;
1036
+ console.log(`[MONITOR] COMPACT detections: ${total} -> ${kept.length} entries`);
1037
+ } catch (err) {
1038
+ console.error(`[MONITOR] Detections compaction failed: ${err.message}`);
1039
+ }
1040
+ });
1021
1041
  }
1022
1042
 
1023
1043
  // --- Per-scan ledger (Phase 0a: operational coverage observability) ---
@@ -1035,6 +1055,12 @@ const MAX_SCAN_LEDGER = (() => {
1035
1055
  })();
1036
1056
  const SCAN_LEDGER_COMPACT_INTERVAL = 2000;
1037
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();
1038
1064
 
1039
1065
  // Terminal outcomes a dequeued package can reach. Unknown values normalize to 'clean'
1040
1066
  // so a typo at a call site can never crash the pipeline.
@@ -1105,7 +1131,10 @@ function appendScanLedger(e) {
1105
1131
  _scanLedgerAppendedSinceCompact++;
1106
1132
  if (_scanLedgerAppendedSinceCompact >= SCAN_LEDGER_COMPACT_INTERVAL) {
1107
1133
  _scanLedgerAppendedSinceCompact = 0;
1108
- _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();
1109
1138
  }
1110
1139
  } catch (err) {
1111
1140
  if (err.code === 'EROFS' || err.code === 'EACCES' || err.code === 'EPERM') return;
@@ -1121,23 +1150,96 @@ function appendScanLedger(e) {
1121
1150
  * Compact the scan-ledger JSONL: keep only the most recent MAX_SCAN_LEDGER entries.
1122
1151
  * No-op when already under cap. Streams (never loads the whole file at once).
1123
1152
  */
1124
- function _compactScanLedgerJsonl() {
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)); });
1193
+ });
1194
+ }
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';
1125
1215
  try {
1126
- const total = _countJsonlLines(SCAN_LEDGER_FILE);
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);
1127
1220
  if (total <= MAX_SCAN_LEDGER) return;
1128
1221
  const toDrop = total - MAX_SCAN_LEDGER;
1129
- let skipped = 0;
1130
- const kept = [];
1131
- _iterateJsonlSync(SCAN_LEDGER_FILE, (entry) => {
1132
- if (skipped < toDrop) { skipped++; return; }
1133
- kept.push(JSON.stringify(entry));
1134
- });
1135
- const tmpFile = SCAN_LEDGER_FILE + '.tmp';
1136
- fs.writeFileSync(tmpFile, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
1137
- fs.renameSync(tmpFile, SCAN_LEDGER_FILE);
1138
- console.log(`[MONITOR] COMPACT scan-ledger: ${total} -> ${kept.length} entries`);
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)`);
1139
1238
  } catch (err) {
1140
1239
  console.error(`[MONITOR] Scan-ledger compaction failed: ${err.message}`);
1240
+ try { fs.unlinkSync(tmpFile); } catch { /* nothing to clean */ }
1241
+ } finally {
1242
+ _scanLedgerCompacting = false;
1141
1243
  }
1142
1244
  }
1143
1245
 
@@ -1856,6 +1958,7 @@ module.exports = {
1856
1958
  loadScanLedger,
1857
1959
  computeLedgerRollup,
1858
1960
  _compactScanLedgerJsonl,
1961
+ _whenScanLedgerCompactionSettles: () => _scanLedgerCompactionPromise,
1859
1962
  getDetectionStats,
1860
1963
  runStateMigrations,
1861
1964
  // Internal — exported for tests and for the daemon hourly housekeeping.
@@ -59,6 +59,18 @@ function dockerSync(label, meta, fn) {
59
59
  try { return fn(); } finally { _endOp(_crumb); }
60
60
  }
61
61
 
62
+ // Same breadcrumb mechanism as dockerSync, for the SYNCHRONOUS post-container
63
+ // result-handling that also runs on the main thread (the deferred sandbox worker is a
64
+ // setInterval on the main loop — deferred-sandbox.js): JSON.parse of container stdout,
65
+ // gVisor kernel-log parse/cleanup, preload-log analysis. Uninstrumented today, so a
66
+ // multi-minute stall in any of them mis-attributes to the last docker:* crumb (the
67
+ // docker:rm-leurre seen in data/loop-stalls.jsonl with durationMs << lagMs). Leaf-only:
68
+ // the wrapped fn must NOT nest another beginOp/dockerSync (single-slot model).
69
+ function syncStep(label, meta, fn) {
70
+ const _crumb = _beginOp(label, meta || null);
71
+ try { return fn(); } finally { _endOp(_crumb); }
72
+ }
73
+
62
74
  const DOCKER_IMAGE = 'muaddib-sandbox';
63
75
  const CONTAINER_TIMEOUT = 120000; // 120 seconds
64
76
  const SINGLE_RUN_TIMEOUT = 90000; // 90 seconds per run in multi-run mode (gVisor ~30% I/O overhead)
@@ -595,7 +607,7 @@ async function runSingleSandbox(packageName, options = {}) {
595
607
  }
596
608
  jsonStr = stdout.substring(jsonStart, jsonEnd + 1);
597
609
  }
598
- report = JSON.parse(jsonStr);
610
+ report = syncStep('sandbox:parse-report', { bytes: jsonStr.length }, () => JSON.parse(jsonStr));
599
611
  if (local && report) {
600
612
  report.package = displayName;
601
613
  }
@@ -636,7 +648,7 @@ async function runSingleSandbox(packageName, options = {}) {
636
648
  // connections, and process data come from gVisor's kernel-level tracing.
637
649
  if (gvisorMode && gvisorContainerId) {
638
650
  const gvisorLogDir = process.env.MUADDIB_GVISOR_LOG_DIR || '/tmp/runsc';
639
- const gvisorData = parseGvisorLogs(gvisorContainerId, gvisorLogDir);
651
+ const gvisorData = syncStep('sandbox:gvisor-parse', { container: gvisorContainerId }, () => parseGvisorLogs(gvisorContainerId, gvisorLogDir));
640
652
 
641
653
  // Merge gVisor findings into report without duplicating
642
654
  if (!report.sensitive_files) report.sensitive_files = { read: [], written: [] };
@@ -665,14 +677,14 @@ async function runSingleSandbox(packageName, options = {}) {
665
677
  }
666
678
 
667
679
  // Cleanup gVisor logs to prevent disk fill
668
- cleanupGvisorLogs(gvisorContainerId, gvisorLogDir);
680
+ syncStep('sandbox:gvisor-cleanup', { container: gvisorContainerId }, () => cleanupGvisorLogs(gvisorContainerId, gvisorLogDir));
669
681
  }
670
682
 
671
683
  const { score, findings } = scoreFindings(report);
672
684
 
673
685
  // Analyze preload log for behavioral findings
674
686
  if (report.preload_log) {
675
- const preloadResult = analyzePreloadLog(report.preload_log);
687
+ const preloadResult = syncStep('sandbox:preload-analyze', null, () => analyzePreloadLog(report.preload_log));
676
688
  for (const finding of preloadResult.findings) {
677
689
  findings.push(finding);
678
690
  }
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.162",
3
+ "version": "2.11.164",
4
4
  "scanners": 21,
5
5
  "rulesTotal": 275,
6
6
  "rulesCore": 270,