muaddib-scanner 2.11.160 → 2.11.162

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.160",
3
+ "version": "2.11.162",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "docs:stats": "node scripts/collect-stats.js && node scripts/sync-stats.js",
18
18
  "docs:stats:tests": "node scripts/collect-stats.js --with-tests && node scripts/sync-stats.js",
19
19
  "docs:stats:check": "node scripts/collect-stats.js --check && node scripts/sync-stats.js --check",
20
+ "version": "npm run docs:stats && git diff --name-only | xargs -r git add",
20
21
  "compress-iocs": "node -e \"const fs=require('fs');const zlib=require('zlib');zlib.gzip(fs.readFileSync('src/ioc/data/iocs.json'),(e,b)=>{if(e)throw e;fs.writeFileSync('iocs.json.gz',b);console.log('Compressed: '+b.length+' bytes')})\"",
21
22
  "prepublishOnly": "node -e \"if(!process.env.CI){console.error('ERR: Publish via CI workflow (tag v* push). Local publishes are disabled.');process.exit(1)}\""
22
23
  },
@@ -53,13 +54,13 @@
53
54
  "@inquirer/prompts": "8.5.2",
54
55
  "acorn": "8.17.0",
55
56
  "acorn-walk": "8.3.5",
56
- "adm-zip": "0.5.17",
57
+ "adm-zip": "0.5.18",
57
58
  "js-yaml": "5.1.0",
58
59
  "web-tree-sitter": "^0.26.9"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@eslint/js": "10.0.1",
62
- "eslint": "10.5.0",
63
+ "eslint": "10.6.0",
63
64
  "eslint-plugin-security": "^4.0.0",
64
65
  "globals": "17.7.0"
65
66
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-07-07T14:05:05.690Z",
3
+ "timestamp": "2026-07-10T07:45:05.079Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -201,7 +201,12 @@ function parseCSV(csvContent, hasHeader = true) {
201
201
  }
202
202
 
203
203
  const MAX_REDIRECTS = 5;
204
- const MAX_RESPONSE_SIZE = 200 * 1024 * 1024; // 200MB
204
+ // Env-tunable (idiom: parseInt(env) || default). Default 512MB the OSV npm all.zip
205
+ // crossed the old 200MB cap on 2026-07 (measured 200.04MB), which silently dropped the
206
+ // entire OSV npm dump AND emptied knownIds (→ OSSF re-fetched all ~31k entries one-by-one).
207
+ // Bump gives headroom for growth; lower it via MUADDIB_MAX_RESPONSE_MB on RAM-constrained
208
+ // hosts (the whole response is buffered in memory — streaming-to-disk is the long-term fix).
209
+ const MAX_RESPONSE_SIZE = (parseInt(process.env.MUADDIB_MAX_RESPONSE_MB, 10) || 512) * 1024 * 1024;
205
210
  const MAX_ENTRY_UNCOMPRESSED = 50 * 1024 * 1024; // 50MB per zip entry
206
211
  const MAX_TOTAL_UNCOMPRESSED = 500 * 1024 * 1024; // 500MB total budget per zip
207
212
 
@@ -37,6 +37,20 @@ const DEFAULT_TRAINING_FILE = path.join(__dirname, '..', '..', 'data', 'ml-train
37
37
  let TRAINING_FILE = DEFAULT_TRAINING_FILE;
38
38
  const MAX_JSONL_SIZE = 100 * 1024 * 1024; // 100MB rotation threshold
39
39
 
40
+ // Benign down-sampling: 'clean' records are ~88% of the stream and near-identical
41
+ // (the same popular packages re-scanned on every backlog replay / monitor restart).
42
+ // They balloon the file without adding ML signal — negatives are re-derived from
43
+ // GHSA/GT at retrain time, not from these self-labels. Keep 1 in CLEAN_SAMPLE_RATE
44
+ // clean records; every non-clean outcome (suspect/confirmed/unconfirmed/…) is always
45
+ // written. Gating here (the single write chokepoint) rather than in the monitor's
46
+ // recordTrainingSample keeps the separate scan-ledger (coverage/throughput) complete.
47
+ const CLEAN_SAMPLE_RATE = 100; // 1% of 'clean' records are persisted
48
+ let _cleanCounter = 0;
49
+
50
+ // Rotation retention: keep only the MAX_ROTATED_SNAPSHOTS most recent rotated files
51
+ // on disk; prune the rest at each rotation (see pruneOldSnapshots).
52
+ const MAX_ROTATED_SNAPSHOTS = 7;
53
+
40
54
  // In-memory line counter. null = needs recompute (cold boot, file rewrite, or
41
55
  // path swap). Maintained incrementally by appendRecord and invalidated by
42
56
  // relabelRecords and setTrainingFile. Prior to this cache, getStats read the
@@ -50,6 +64,7 @@ let _cachedLineCount = null;
50
64
  function setTrainingFile(filePath) {
51
65
  TRAINING_FILE = filePath;
52
66
  _cachedLineCount = null; // different file → recompute on next getStats
67
+ _cleanCounter = 0; // reset benign-sampling counter for test isolation
53
68
  }
54
69
 
55
70
  /**
@@ -58,6 +73,7 @@ function setTrainingFile(filePath) {
58
73
  function resetTrainingFile() {
59
74
  TRAINING_FILE = DEFAULT_TRAINING_FILE;
60
75
  _cachedLineCount = null;
76
+ _cleanCounter = 0; // reset benign-sampling counter for test isolation
61
77
  }
62
78
 
63
79
  /**
@@ -66,6 +82,12 @@ function resetTrainingFile() {
66
82
  */
67
83
  function appendRecord(record) {
68
84
  try {
85
+ // Down-sample benign records (see CLEAN_SAMPLE_RATE). Every non-clean outcome
86
+ // is always persisted; only 1 in CLEAN_SAMPLE_RATE 'clean' records is kept.
87
+ if (record && record.label === 'clean' && (_cleanCounter++ % CLEAN_SAMPLE_RATE) !== 0) {
88
+ return;
89
+ }
90
+
69
91
  const dir = path.dirname(TRAINING_FILE);
70
92
  if (!fs.existsSync(dir)) {
71
93
  fs.mkdirSync(dir, { recursive: true });
@@ -103,11 +125,49 @@ function maybeRotate() {
103
125
  fs.renameSync(TRAINING_FILE, rotatedName);
104
126
  _cachedLineCount = 0; // fresh file starts empty
105
127
  console.log(`[ML] Rotated training file → ${path.basename(rotatedName)} (${(stat.size / 1024 / 1024).toFixed(1)}MB)`);
128
+ pruneOldSnapshots();
106
129
  } catch (err) {
107
130
  console.error(`[ML] Rotation failed: ${err.message}`);
108
131
  }
109
132
  }
110
133
 
134
+ /**
135
+ * Delete rotated snapshots beyond the MAX_ROTATED_SNAPSHOTS most recent.
136
+ * Matches ONLY timestamped rotation files (`<base>-<ISO>.jsonl`, e.g.
137
+ * ml-training-2026-07-08T08-28-49-263Z.jsonl) so the live file and sibling data
138
+ * files (ml-training-datadog.jsonl, ml-training-relabeled.jsonl,
139
+ * ml-corpus-consolidated.jsonl, …) are never touched.
140
+ */
141
+ function pruneOldSnapshots() {
142
+ try {
143
+ const dir = path.dirname(TRAINING_FILE);
144
+ const base = path.basename(TRAINING_FILE, '.jsonl'); // e.g. 'ml-training'
145
+ const escBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
146
+ const rotatedRe = new RegExp(`^${escBase}-\\d{4}-\\d{2}-\\d{2}T[0-9-]+Z\\.jsonl$`);
147
+
148
+ const rotated = fs.readdirSync(dir)
149
+ .filter(f => rotatedRe.test(f))
150
+ .map(f => {
151
+ const full = path.join(dir, f);
152
+ try { return { full, mtime: fs.statSync(full).mtimeMs }; }
153
+ catch { return null; }
154
+ })
155
+ .filter(Boolean)
156
+ .sort((a, b) => b.mtime - a.mtime); // most recent first
157
+
158
+ for (const { full } of rotated.slice(MAX_ROTATED_SNAPSHOTS)) {
159
+ try {
160
+ fs.unlinkSync(full);
161
+ console.log(`[ML] Pruned old snapshot → ${path.basename(full)}`);
162
+ } catch (e) {
163
+ console.warn(`[ML] Prune failed for ${path.basename(full)}: ${e.message}`);
164
+ }
165
+ }
166
+ } catch (err) {
167
+ console.error(`[ML] Snapshot prune failed: ${err.message}`);
168
+ }
169
+ }
170
+
111
171
  /**
112
172
  * Read all records from the current JSONL file.
113
173
  * Useful for offline analysis and model training.
@@ -32,7 +32,7 @@ const { poll, getPollBackoffMs, SOFT_BACKPRESSURE_THRESHOLD } = require('./inges
32
32
  const { ensureWorkers, drainWorkers, getTargetConcurrency, setTargetConcurrency, getActiveWorkers, terminateAllWorkers, getInFlightItems, computeInterruptDisposition } = require('./queue.js');
33
33
  const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY } = require('./adaptive-concurrency.js');
34
34
  const { startHealthcheck } = require('./healthcheck.js');
35
- const { startLagSampler } = require('./event-loop-monitor.js');
35
+ const { startLagSampler, runInstrumented } = require('./event-loop-monitor.js');
36
36
  const { startDeferredWorker, stopDeferredWorker, persistDeferredQueue, restoreDeferredQueue, clearDeferredQueue } = require('./deferred-sandbox.js');
37
37
  const { evictFromScanQueueBulk, enqueueScan } = require('./scan-queue.js');
38
38
  const { isSpillEnabled, shouldDrain, drainBacklog, getBacklogSize } = require('./spill.js');
@@ -373,23 +373,28 @@ function cleanupOrphanTmpDirs() {
373
373
  }
374
374
 
375
375
  function cleanupOrphanContainers() {
376
- try {
377
- // List running containers with the sandbox name prefix (npm-audit-*)
378
- const output = execFileSync('docker', ['ps', '-q', '--filter', 'name=npm-audit-'], {
379
- stdio: ['pipe', 'pipe', 'pipe'],
380
- timeout: 10000
381
- }).toString().trim();
382
- if (!output) return;
383
- const ids = output.split(/\s+/).filter(Boolean);
384
- for (const id of ids) {
385
- try {
386
- execFileSync('docker', ['rm', '-f', id], { stdio: 'pipe', timeout: 10000 });
387
- } catch {}
376
+ // Instrumented (2026-07-09): `docker ps` + a sequential `docker rm -f` per orphan
377
+ // (10s timeout EACH, count unbounded) is a synchronous main-thread op. Name it so a
378
+ // concurrent lag stall attributes here instead of op:null (event-loop-monitor.js).
379
+ return runInstrumented('maint:orphan-containers', null, () => {
380
+ try {
381
+ // List running containers with the sandbox name prefix (npm-audit-*)
382
+ const output = execFileSync('docker', ['ps', '-q', '--filter', 'name=npm-audit-'], {
383
+ stdio: ['pipe', 'pipe', 'pipe'],
384
+ timeout: 10000
385
+ }).toString().trim();
386
+ if (!output) return;
387
+ const ids = output.split(/\s+/).filter(Boolean);
388
+ for (const id of ids) {
389
+ try {
390
+ execFileSync('docker', ['rm', '-f', id], { stdio: 'pipe', timeout: 10000 });
391
+ } catch {}
392
+ }
393
+ console.log(`[MONITOR] Cleaned up ${ids.length} orphan sandbox container(s)`);
394
+ } catch {
395
+ // Docker not available or command failed — skip silently
388
396
  }
389
- console.log(`[MONITOR] Cleaned up ${ids.length} orphan sandbox container(s)`);
390
- } catch {
391
- // Docker not available or command failed — skip silently
392
- }
397
+ });
393
398
  }
394
399
 
395
400
  /**
@@ -401,28 +406,34 @@ function cleanupOrphanContainers() {
401
406
  */
402
407
  function cleanupRunscOrphans(maxAgeMs = 3600_000) {
403
408
  const runscDir = process.env.MUADDIB_GVISOR_LOG_DIR || '/tmp/runsc';
404
- try {
405
- if (!fs.existsSync(runscDir)) return 0;
406
- const entries = fs.readdirSync(runscDir);
407
- const now = Date.now();
408
- let cleaned = 0;
409
- for (const entry of entries) {
410
- const fullPath = path.join(runscDir, entry);
411
- try {
412
- const stat = fs.statSync(fullPath);
413
- if (now - stat.mtimeMs > maxAgeMs) {
414
- fs.rmSync(fullPath, { recursive: true, force: true });
415
- cleaned++;
416
- }
417
- } catch { /* skip unreadable entries */ }
418
- }
419
- if (cleaned > 0) {
420
- console.log(`[MONITOR] Cleaned up ${cleaned} orphan runsc dir(s) in ${runscDir}`);
409
+ // Instrumented (2026-07-09): readdir + per-entry statSync + recursive rmSync over
410
+ // /tmp/runsc is a synchronous main-thread op that blocks the poll loop (RSS breaker,
411
+ // governor feed) for as long as it runs — the prime suspect for the op:null
412
+ // multi-minute stalls in loop-stalls.jsonl. Name it (also covers the boot call).
413
+ return runInstrumented('maint:runsc-cleanup', { dir: runscDir }, () => {
414
+ try {
415
+ if (!fs.existsSync(runscDir)) return 0;
416
+ const entries = fs.readdirSync(runscDir);
417
+ const now = Date.now();
418
+ let cleaned = 0;
419
+ for (const entry of entries) {
420
+ const fullPath = path.join(runscDir, entry);
421
+ try {
422
+ const stat = fs.statSync(fullPath);
423
+ if (now - stat.mtimeMs > maxAgeMs) {
424
+ fs.rmSync(fullPath, { recursive: true, force: true });
425
+ cleaned++;
426
+ }
427
+ } catch { /* skip unreadable entries */ }
428
+ }
429
+ if (cleaned > 0) {
430
+ console.log(`[MONITOR] Cleaned up ${cleaned} orphan runsc dir(s) in ${runscDir}`);
431
+ }
432
+ return cleaned;
433
+ } catch {
434
+ return 0;
421
435
  }
422
- return cleaned;
423
- } catch {
424
- return 0;
425
- }
436
+ });
426
437
  }
427
438
 
428
439
  /**
@@ -445,9 +456,9 @@ function checkDiskSpace() {
445
456
 
446
457
  // Top consumers in /tmp/
447
458
  try {
448
- const tmpDu = execFileSync('du', ['-sh', '--max-depth=1', '/tmp/'], {
459
+ const tmpDu = runInstrumented('maint:disk-du-tmp', null, () => execFileSync('du', ['-sh', '--max-depth=1', '/tmp/'], {
449
460
  encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
450
- });
461
+ }));
451
462
  const lines = tmpDu.trim().split('\n')
452
463
  .map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
453
464
  .filter(Boolean)
@@ -462,9 +473,9 @@ function checkDiskSpace() {
462
473
  // Top consumers in data/
463
474
  const dataDir = path.join(__dirname, '..', '..', 'data');
464
475
  try {
465
- const dataDu = execFileSync('du', ['-sh', '--max-depth=1', dataDir], {
476
+ const dataDu = runInstrumented('maint:disk-du-data', null, () => execFileSync('du', ['-sh', '--max-depth=1', dataDir], {
466
477
  encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
467
- });
478
+ }));
468
479
  const lines = dataDu.trim().split('\n')
469
480
  .map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
470
481
  .filter(Boolean)
@@ -1328,10 +1339,13 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
1328
1339
 
1329
1340
  // Hourly stats report + cache purge + runsc cleanup + memory pruning
1330
1341
  if (Date.now() - stats.lastReportTime >= 3600_000) {
1331
- reportStats(stats);
1332
- purgeTarballCache();
1342
+ // Hourly maintenance runs synchronously on the poll loop; instrument each step so
1343
+ // a concurrent lag stall names the culprit (was op:null). cleanupRunscOrphans
1344
+ // self-wraps in its own body (that also covers the boot call at startup).
1345
+ runInstrumented('maint:report-stats', null, () => reportStats(stats));
1346
+ runInstrumented('maint:tarball-purge', null, () => purgeTarballCache());
1333
1347
  cleanupRunscOrphans();
1334
- pruneMemoryCaches(recentlyScanned, downloadsCache, alertedPackageRules);
1348
+ runInstrumented('maint:prune-caches', null, () => pruneMemoryCaches(recentlyScanned, downloadsCache, alertedPackageRules));
1335
1349
  }
1336
1350
 
1337
1351
  // Daily webhook report at 08:00 Paris time
@@ -84,6 +84,23 @@ function endOp(token) {
84
84
  _current = null;
85
85
  }
86
86
 
87
+ /**
88
+ * Run a synchronous, potentially-blocking main-thread op UNDER a breadcrumb so a
89
+ * concurrent lag-sampler stall attributes itself to THIS op instead of reporting
90
+ * `op:null`. Mirrors the sandbox `dockerSync` wrapper (sandbox/index.js). Passes
91
+ * fn's return value through; the breadcrumb is cleared in `finally` even when fn
92
+ * throws (and the error re-thrown). Single-slot model (see file header) — the
93
+ * wrapped fn must NOT itself call beginOp/endOp/runInstrumented (no nesting).
94
+ */
95
+ function runInstrumented(label, meta, fn) {
96
+ const token = beginOp(label, meta);
97
+ try {
98
+ return fn();
99
+ } finally {
100
+ endOp(token);
101
+ }
102
+ }
103
+
87
104
  /**
88
105
  * The op whose lifetime overlaps [windowStartMs, windowEndMs] — i.e. the op on
89
106
  * the thread during the blocked window. Prefers the still-running op, else the
@@ -191,6 +208,7 @@ function _reset(clockFn) {
191
208
  module.exports = {
192
209
  beginOp,
193
210
  endOp,
211
+ runInstrumented,
194
212
  opOverlapping,
195
213
  configure,
196
214
  observeTick,
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.160",
3
+ "version": "2.11.162",
4
4
  "scanners": 21,
5
5
  "rulesTotal": 275,
6
6
  "rulesCore": 270,
package/zizmor.yml ADDED
@@ -0,0 +1,18 @@
1
+ # zizmor configuration — https://docs.zizmor.sh/configuration/
2
+ #
3
+ # Suppress a finding ONLY when it is a confirmed false positive, and keep the
4
+ # rationale on the same line. Schema:
5
+ #
6
+ # rules:
7
+ # <audit-id>: # e.g. template-injection, artipacked, cache-poisoning
8
+ # ignore:
9
+ # - <workflow>.yml # ignore the whole file
10
+ # - <workflow>.yml:LINE # ignore one line
11
+ # - <workflow>.yml:LINE:COL# ignore one exact finding
12
+ #
13
+ # Policy for this repo: prefer FIXING findings over ignoring them. As of the first
14
+ # run every finding is real (template-injection in publish.yml, artipacked on the
15
+ # checkout steps, cache-poisoning, adhoc-packages), so nothing is suppressed here.
16
+ # Add an entry below only after confirming a genuine false positive.
17
+
18
+ rules: {}