muaddib-scanner 2.11.132 → 2.11.134

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.132",
3
+ "version": "2.11.134",
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-06-28T11:47:00.100Z",
3
+ "timestamp": "2026-06-28T13:13:44.321Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -606,6 +606,22 @@ const BURST_MIN_VERSIONS_PREFETCH = (() => {
606
606
  return Number.isFinite(n) && n >= 2 ? n : 10;
607
607
  })();
608
608
 
609
+ // A prerelease publish (1.2.0-rc.1, 2.0.0-beta, ...-dev) is EXPECTED to differ
610
+ // from dist-tags.latest: npm semver resolution (^/~/x.y ranges) never selects a
611
+ // prerelease unless it is requested explicitly, so an account-takeover that wants
612
+ // victims to auto-resolve to the malicious version cannot use one. Excluding
613
+ // prereleases from the ATO *prefetch* signal drops the dominant false positive
614
+ // (CI/dev/rc/beta churn — ~17% of npm publishes per the 2026-06-28 backtest, which
615
+ // would otherwise balloon the bounded tarball cache) WITHOUT weakening the
616
+ // release-version ATO catch (Leo Platform: malicious leo-sdk@6.0.19, a plain
617
+ // release, still fires). Prefetch-only: queue.js keeps its own (more inclusive)
618
+ // atoSignal for fast-track / anti-eviction, and this never touches scoring.
619
+ function isPrereleaseVersion(v) {
620
+ // semver: the prerelease lives after '-' in the version core, before any '+'
621
+ // build metadata. Strip build metadata first so '1.0.0+build-7' is NOT flagged.
622
+ return /-/.test(String(v == null ? '' : v).split('+')[0]);
623
+ }
624
+
609
625
  async function preResolveNpmBatch(items, stats, scanQueue) {
610
626
  if (!items || items.length === 0) return;
611
627
  const start = Date.now();
@@ -661,7 +677,8 @@ async function preResolveNpmBatch(items, stats, scanQueue) {
661
677
  // the same signals later for scan-queue protection + extras enqueue (idempotent).
662
678
  if (!item._cacheTrigger) {
663
679
  const atoSignal = !!(npmInfo.latestTagVersion && item.version &&
664
- item.version !== npmInfo.latestTagVersion);
680
+ item.version !== npmInfo.latestTagVersion &&
681
+ !isPrereleaseVersion(item.version));
665
682
  const burstCount = Number.isFinite(npmInfo.recentWindowCount)
666
683
  ? npmInfo.recentWindowCount
667
684
  : ((Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions.length : 0) + 1);
@@ -1586,6 +1603,7 @@ module.exports = {
1586
1603
  preResolveNpmBatch,
1587
1604
  preResolvePyPIBatch,
1588
1605
  preResolveShouldShed,
1606
+ isPrereleaseVersion,
1589
1607
 
1590
1608
  // RSS parsing
1591
1609
  parseNpmRss,
@@ -118,7 +118,7 @@ const MEMORY_SCORE_TOLERANCE = 0.15; // ±15% score tolerance
118
118
 
119
119
  // --- Tarball cache constants ---
120
120
 
121
- const TARBALL_CACHE_DIR = path.join(__dirname, '..', '..', 'data', 'tarball-cache');
121
+ const TARBALL_CACHE_DIR = process.env.MUADDIB_TARBALL_CACHE_DIR || path.join(__dirname, '..', '..', 'data', 'tarball-cache');
122
122
  const TARBALL_CACHE_INDEX_FILE = path.join(TARBALL_CACHE_DIR, 'cache-index.json');
123
123
  const TARBALL_CACHE_DEFAULT_RETENTION_DAYS = 7;
124
124
  const TARBALL_CACHE_HIGH_RISK_RETENTION_DAYS = 30;
@@ -495,6 +495,34 @@ function cacheTarball(name, version, sourcePath, reason, retentionDays) {
495
495
  console.log(`[MONITOR] TARBALL CACHE: cached ${name}@${version} (${reason}, ${retentionDays}d, ${(fileSize / 1024).toFixed(0)}KB)`);
496
496
  }
497
497
 
498
+ /**
499
+ * Reclaim orphaned tarball-cache files: `.tgz` present in `dir` with no entry in
500
+ * `entries` and an mtime older than `cutoffMs`. Returns { files, bytes } reclaimed.
501
+ * Pure w.r.t. the index (orphans are untracked) so callers need not re-save it.
502
+ * Extracted as a seam so it is unit-testable against a temp dir without touching the
503
+ * production cache. See purgeTarballCache Phase 3 for the leak this addresses.
504
+ */
505
+ function gcOrphanTarballFiles(dir, entries, now, cutoffMs) {
506
+ let files = 0;
507
+ let bytes = 0;
508
+ let names;
509
+ try { names = fs.readdirSync(dir); } catch { return { files, bytes }; }
510
+ for (const file of names) {
511
+ if (!file.endsWith('.tgz')) continue;
512
+ const key = file.slice(0, -4);
513
+ if (entries && entries[key]) continue; // tracked -> Phase 1/2 owns it
514
+ const filePath = path.join(dir, file);
515
+ try {
516
+ const st = fs.statSync(filePath);
517
+ if (now - st.mtimeMs <= cutoffMs) continue; // too fresh -> may be in-flight / cache-hittable
518
+ fs.unlinkSync(filePath);
519
+ files++;
520
+ bytes += st.size || 0;
521
+ } catch { /* per-file stat/unlink errors non-fatal */ }
522
+ }
523
+ return { files, bytes };
524
+ }
525
+
498
526
  /**
499
527
  * Purge expired entries and enforce size budget.
500
528
  * Called at startup and hourly.
@@ -538,10 +566,23 @@ function purgeTarballCache() {
538
566
  }
539
567
  }
540
568
 
541
- if (purgedExpired > 0 || purgedBudget > 0) {
542
- saveTarballCacheIndex();
569
+ // Phase 3: orphan-file GC reclaim .tgz on disk with no index entry. The index is a
570
+ // process singleton rebuilt from cache-index.json; a single corrupt/truncated index
571
+ // (crash mid-atomicWriteFileSync, OOM) makes loadTarballCacheIndex fall back to an empty
572
+ // index, then saveTarballCacheIndex persists it — orphaning every .tgz already cached.
573
+ // Phase 1/2 are index-driven and can NEVER reclaim those files -> unbounded disk leak
574
+ // (measured 5.4GB / 8905 files, 2026-06-28). The mtime>retention bound keeps an in-flight
575
+ // cacheTarball (copyFileSync precedes the index save) and any still-cache-hittable recent
576
+ // file safe; only clearly-stale orphans are removed.
577
+ const orphan = gcOrphanTarballFiles(
578
+ TARBALL_CACHE_DIR, index.entries, now,
579
+ TARBALL_CACHE_HIGH_RISK_RETENTION_DAYS * 24 * 60 * 60 * 1000
580
+ );
581
+
582
+ if (purgedExpired > 0 || purgedBudget > 0 || orphan.files > 0) {
583
+ if (purgedExpired > 0 || purgedBudget > 0) saveTarballCacheIndex();
543
584
  const remaining = Object.keys(index.entries).length;
544
- console.log(`[MONITOR] TARBALL CACHE: purged ${purgedExpired} expired + ${purgedBudget} budget entries (${remaining} remaining, ${(totalSize / 1024 / 1024).toFixed(1)}MB)`);
585
+ console.log(`[MONITOR] TARBALL CACHE: purged ${purgedExpired} expired + ${purgedBudget} budget entries + ${orphan.files} orphan files (${(orphan.bytes / 1024 / 1024).toFixed(1)}MB reclaimed, ${remaining} remaining, ${(totalSize / 1024 / 1024).toFixed(1)}MB)`);
545
586
  }
546
587
  }
547
588
 
@@ -1784,6 +1825,7 @@ module.exports = {
1784
1825
  tarballCachePath,
1785
1826
  cacheTarball,
1786
1827
  purgeTarballCache,
1828
+ gcOrphanTarballFiles,
1787
1829
  appendTemporalDetection,
1788
1830
  loadTemporalDetections,
1789
1831
  loadState,