muaddib-scanner 2.11.29 → 2.11.30

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.29",
3
+ "version": "2.11.30",
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-05-24T21:46:43.561Z",
3
+ "timestamp": "2026-05-24T21:46:45.927Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -13,7 +13,7 @@ const { processQueue, ensureWorkers, drainWorkers, getTargetConcurrency, setTarg
13
13
  const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY, resetDeltas } = require('./adaptive-concurrency.js');
14
14
  const { startHealthcheck } = require('./healthcheck.js');
15
15
  const { startDeferredWorker, stopDeferredWorker, persistDeferredQueue, restoreDeferredQueue, clearDeferredQueue } = require('./deferred-sandbox.js');
16
- const { cleanupOldArchives, getRetentionDays } = require('./tarball-archive.js');
16
+ const { cleanupOldArchives, getRetentionDays, startPeriodicCleanup } = require('./tarball-archive.js');
17
17
  const { clearMetadataCache } = require('../scanner/temporal-analysis.js');
18
18
  // Caches not previously cleared by handleMemoryPressure (OOM fix). These live
19
19
  // in the main thread and are populated by temporal-ast-diff and the typosquat
@@ -499,11 +499,17 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
499
499
  cleanupRunscOrphans();
500
500
  // Layer 3: Purge expired cached tarballs on startup
501
501
  purgeTarballCache();
502
- // Purge archived tarballs older than MUADDIB_ARCHIVE_RETENTION_DAYS (default 30).
503
- // Runs in-process at startup so no external cron is required.
502
+ // Purge archived tarballs older than MUADDIB_ARCHIVE_RETENTION_DAYS (default 7).
503
+ // Runs in-process at startup AND every 6h via setInterval so no external cron is required.
504
+ // Required to prevent the disk-fill cascade observed on 2026-05-24 (96GB filled,
505
+ // .claude.json corrupted, +89K monitor errors): startup-only cleanup never ran on a
506
+ // long-uptime service, and 30-day default + 4.5GB/day average exceeded the 96GB disk.
504
507
  try { cleanupOldArchives(getRetentionDays()); } catch (err) {
505
508
  console.warn(`[Archive] Startup cleanup failed: ${err.message}`);
506
509
  }
510
+ try { startPeriodicCleanup(); } catch (err) {
511
+ console.warn(`[Archive] Failed to start periodic cleanup: ${err.message}`);
512
+ }
507
513
 
508
514
  console.log(`
509
515
  ╔════════════════════════════════════════════╗
@@ -19,9 +19,10 @@ const { downloadToFile } = require('../shared/download.js');
19
19
  const ARCHIVE_DIR = process.env.MUADDIB_ARCHIVE_DIR || '/opt/muaddib/archive';
20
20
  const ARCHIVE_TIMEOUT_MS = 10_000;
21
21
 
22
- // Retention window for archived tarballs. Anything older is purged on startup.
23
- // Bounded to [1, 365] days; non-numeric or out-of-range values fall back to 30.
24
- const DEFAULT_RETENTION_DAYS = 30;
22
+ // Retention window for archived tarballs. Purged at startup and every 6h thereafter.
23
+ // Bounded to [1, 365] days; non-numeric or out-of-range values fall back to 7.
24
+ // Math: ~4.5GB/day average → 7d ≈ 31GB, fits in 96GB disk with safe margin.
25
+ const DEFAULT_RETENTION_DAYS = 7;
25
26
  function getRetentionDays() {
26
27
  const raw = process.env.MUADDIB_ARCHIVE_RETENTION_DAYS;
27
28
  if (raw === undefined || raw === '') return DEFAULT_RETENTION_DAYS;
@@ -30,6 +31,31 @@ function getRetentionDays() {
30
31
  return n;
31
32
  }
32
33
 
34
+ // Defensive disk-space gate. Skip archiving when free space falls below threshold,
35
+ // so a burst of suspects can't run the volume to 100% between periodic cleanups.
36
+ // Bounded to [1, 100] GB, default 5GB.
37
+ const DEFAULT_MIN_FREE_GB = 5;
38
+ function getMinFreeBytes() {
39
+ const raw = process.env.MUADDIB_ARCHIVE_MIN_FREE_GB;
40
+ let gb = DEFAULT_MIN_FREE_GB;
41
+ if (raw !== undefined && raw !== '') {
42
+ const n = parseInt(raw, 10);
43
+ if (Number.isFinite(n) && n >= 1 && n <= 100) gb = n;
44
+ }
45
+ return gb * 1024 * 1024 * 1024;
46
+ }
47
+
48
+ function hasEnoughSpace(targetDir) {
49
+ try {
50
+ if (typeof fs.statfsSync !== 'function') return true; // Node <18.15 — fail-open
51
+ const dirForStat = fs.existsSync(targetDir) ? targetDir : path.dirname(targetDir);
52
+ const s = fs.statfsSync(dirForStat);
53
+ return s.bavail * s.bsize > getMinFreeBytes();
54
+ } catch {
55
+ return true; // never block archiving on a stat error
56
+ }
57
+ }
58
+
33
59
  /**
34
60
  * Get the date string in YYYY-MM-DD format (Paris timezone, consistent with monitor).
35
61
  * Falls back to UTC if Intl is unavailable.
@@ -103,6 +129,14 @@ async function archiveSuspectTarball(packageName, version, tarballUrl, scanResul
103
129
  return false;
104
130
  }
105
131
 
132
+ // Defense layer 3: skip if disk is nearly full, even if retention is well-configured.
133
+ // Prevents a burst of malicious campaigns from blowing past the 7-day budget
134
+ // before the 6h periodic cleanup tick can catch up.
135
+ if (!hasEnoughSpace(ARCHIVE_DIR)) {
136
+ console.warn(`[Archive] Skip ${packageName}@${version}: free space below ${DEFAULT_MIN_FREE_GB}GB threshold`);
137
+ return false;
138
+ }
139
+
106
140
  // Ensure day directory exists
107
141
  fs.mkdirSync(dayDir, { recursive: true });
108
142
 
@@ -208,14 +242,35 @@ function cleanupOldArchives(retentionDays = getRetentionDays()) {
208
242
  return stats;
209
243
  }
210
244
 
245
+ /**
246
+ * Periodically re-run cleanupOldArchives so a long-running daemon (no restarts for
247
+ * weeks) can't accumulate archives past the retention window. Defaults to every 6h.
248
+ * .unref()'d so the timer never keeps the event loop alive on shutdown.
249
+ */
250
+ const DEFAULT_PERIODIC_INTERVAL_MS = 6 * 60 * 60 * 1000;
251
+ function startPeriodicCleanup(intervalMs = DEFAULT_PERIODIC_INTERVAL_MS) {
252
+ const timer = setInterval(() => {
253
+ try {
254
+ cleanupOldArchives();
255
+ } catch (err) {
256
+ console.warn(`[Archive] Periodic cleanup failed: ${err.message}`);
257
+ }
258
+ }, intervalMs);
259
+ timer.unref();
260
+ return timer;
261
+ }
262
+
211
263
  module.exports = {
212
264
  archiveSuspectTarball,
213
265
  cleanupOldArchives,
266
+ startPeriodicCleanup,
267
+ hasEnoughSpace,
214
268
  ARCHIVE_DIR,
215
269
  // Exported for testing
216
270
  sanitizeForFilename,
217
271
  sha256File,
218
272
  getArchiveDateString,
219
273
  getRetentionDays,
274
+ getMinFreeBytes,
220
275
  parseArchiveDayDir
221
276
  };