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
package/src/monitor/state.js
CHANGED
|
@@ -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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1130
|
-
const kept =
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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.
|
package/src/sandbox/index.js
CHANGED
|
@@ -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
|
}
|