muaddib-scanner 2.11.132 → 2.11.135
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/ingestion.js
CHANGED
|
@@ -606,6 +606,116 @@ 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
|
+
|
|
625
|
+
// Derive the capture-at-publish trigger for the fast-takedown class (Leo/Miasma)
|
|
626
|
+
// from a registry packument. Shared by preResolveNpmBatch (Stage 2.1) and the
|
|
627
|
+
// real-time capture lane so both compute the SAME signal: ATO = published version
|
|
628
|
+
// != dist-tags.latest, excluding prereleases (isPrereleaseVersion); burst =
|
|
629
|
+
// >= BURST_MIN_VERSIONS_PREFETCH versions in the recent window. Returns the cache
|
|
630
|
+
// trigger object (shouldCache:true) or null. `name` is passed through so a package
|
|
631
|
+
// that is ALSO an ioc/typosquat match still gets the right (higher-priority) reason.
|
|
632
|
+
function evaluateBurstAtoTrigger(name, npmInfo, version) {
|
|
633
|
+
if (!npmInfo) return null;
|
|
634
|
+
const atoSignal = !!(npmInfo.latestTagVersion && version &&
|
|
635
|
+
version !== npmInfo.latestTagVersion && !isPrereleaseVersion(version));
|
|
636
|
+
const burstCount = Number.isFinite(npmInfo.recentWindowCount)
|
|
637
|
+
? npmInfo.recentWindowCount
|
|
638
|
+
: ((Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions.length : 0) + 1);
|
|
639
|
+
const isBurst = burstCount >= BURST_MIN_VERSIONS_PREFETCH;
|
|
640
|
+
if (!atoSignal && !isBurst) return null;
|
|
641
|
+
const trig = evaluateCacheTrigger(name, null, null, { atoSignal, isBurst });
|
|
642
|
+
return trig.shouldCache ? trig : null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Fix C — real-time capture lane. preResolveNpmBatch sheds the packument prefetch
|
|
646
|
+
// when the scan queue is deep (queue > PRE_RESOLVE_SHED_QUEUE — the chronic backlog
|
|
647
|
+
// state). Under shed nothing is prefetched AT ALL: schedulePrefetch needs a resolved
|
|
648
|
+
// tarballUrl, which the shed never fetched — so even ioc/typosquat/first_publish items
|
|
649
|
+
// (flagged name-only at ingestion) are not captured, and burst/ATO is never even
|
|
650
|
+
// computed. The fast-takedown tarballs then 404 before the backlogged scan reaches
|
|
651
|
+
// them (the Leo/Miasma gap Fix A meant to close). This lane resolves a BOUNDED subset
|
|
652
|
+
// of the still-unresolved items REGARDLESS of the shed — prioritizing the already
|
|
653
|
+
// name-flagged (known high-risk, FP-free), then filling the budget to DISCOVER
|
|
654
|
+
// burst/ATO — and enriches each item (version/tarballUrl/_npmInfo/_cacheTrigger) IN
|
|
655
|
+
// PLACE so (a) the schedulePrefetch pass that immediately follows captures the tarball
|
|
656
|
+
// at publish, and (b) the already-enqueued item (enqueueScan pushes by reference)
|
|
657
|
+
// carries the real version so scanPackage's cache-hit (keyed by name@version) finds it.
|
|
658
|
+
//
|
|
659
|
+
// Bounded + rate-limited + flag-gated so it can be turned on only once throughput /
|
|
660
|
+
// OOM headroom allows: OFF unless MUADDIB_REALTIME_PREFETCH=1; at most
|
|
661
|
+
// MUADDIB_REALTIME_PREFETCH_MAX items/cycle (default 30); registry calls go through
|
|
662
|
+
// getNpmLatestTarball's shared semaphore (honors the 429 brain). npm-only for now.
|
|
663
|
+
const REALTIME_PREFETCH_CONCURRENCY = 4;
|
|
664
|
+
function realtimePrefetchEnabled() {
|
|
665
|
+
return process.env.MUADDIB_REALTIME_PREFETCH === '1';
|
|
666
|
+
}
|
|
667
|
+
function realtimePrefetchMax() {
|
|
668
|
+
const n = parseInt(process.env.MUADDIB_REALTIME_PREFETCH_MAX, 10);
|
|
669
|
+
return Number.isFinite(n) && n >= 0 ? n : 30;
|
|
670
|
+
}
|
|
671
|
+
async function realtimePrefetchHighRisk(items, stats) {
|
|
672
|
+
if (!realtimePrefetchEnabled()) return;
|
|
673
|
+
const cap = realtimePrefetchMax();
|
|
674
|
+
if (!cap || !Array.isArray(items) || items.length === 0) return;
|
|
675
|
+
// Act only on items the shed (or a resolve failure) left UNRESOLVED (no _npmInfo).
|
|
676
|
+
// When preResolveNpmBatch did NOT shed, every item already has _npmInfo and this
|
|
677
|
+
// selects nothing (no duplicate fetch). Flagged first (ioc/typo/first_publish —
|
|
678
|
+
// known high-risk, just needs its tarballUrl resolved to be prefetched), then
|
|
679
|
+
// unflagged (candidates for burst/ATO discovery).
|
|
680
|
+
const flagged = [];
|
|
681
|
+
const unflagged = [];
|
|
682
|
+
for (const it of items) {
|
|
683
|
+
if (!it || it.ecosystem !== 'npm' || !it.name || it._npmInfo) continue;
|
|
684
|
+
(it._cacheTrigger ? flagged : unflagged).push(it);
|
|
685
|
+
}
|
|
686
|
+
const selected = flagged.concat(unflagged).slice(0, cap);
|
|
687
|
+
if (selected.length === 0) return;
|
|
688
|
+
const overflow = (flagged.length + unflagged.length) - selected.length;
|
|
689
|
+
if (overflow > 0) {
|
|
690
|
+
// No silent caps (CLAUDE.md): a burst wider than the budget is only partially captured.
|
|
691
|
+
console.warn(`[MONITOR] REALTIME PREFETCH: ${flagged.length + unflagged.length} unresolved high-risk candidates, capped at ${cap} this cycle (${overflow} deferred to lazy scan)`);
|
|
692
|
+
if (stats) stats.realtimePrefetchCapped = (stats.realtimePrefetchCapped || 0) + overflow;
|
|
693
|
+
}
|
|
694
|
+
let idx = 0;
|
|
695
|
+
const worker = async () => {
|
|
696
|
+
while (idx < selected.length) {
|
|
697
|
+
const it = selected[idx++];
|
|
698
|
+
try {
|
|
699
|
+
const npmInfo = await getNpmLatestTarball(it.name);
|
|
700
|
+
if (!npmInfo || !npmInfo.tarball) continue;
|
|
701
|
+
// Enrich in place — the item is already enqueued by reference (shed path), so
|
|
702
|
+
// this also lets the eventual scan reuse the URL + hit the prefetch cache.
|
|
703
|
+
it.tarballUrl = it.tarballUrl || npmInfo.tarball;
|
|
704
|
+
if (!it.version) it.version = npmInfo.version || '';
|
|
705
|
+
it._npmInfo = npmInfo;
|
|
706
|
+
if (!it._cacheTrigger) {
|
|
707
|
+
const trig = evaluateBurstAtoTrigger(it.name, npmInfo, it.version);
|
|
708
|
+
if (trig) it._cacheTrigger = trig;
|
|
709
|
+
}
|
|
710
|
+
if (it._cacheTrigger && stats) stats.realtimePrefetchFlagged = (stats.realtimePrefetchFlagged || 0) + 1;
|
|
711
|
+
} catch {
|
|
712
|
+
if (stats) stats.realtimePrefetchFailed = (stats.realtimePrefetchFailed || 0) + 1;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
await Promise.all(Array.from({ length: Math.min(REALTIME_PREFETCH_CONCURRENCY, selected.length) }, worker));
|
|
717
|
+
}
|
|
718
|
+
|
|
609
719
|
async function preResolveNpmBatch(items, stats, scanQueue) {
|
|
610
720
|
if (!items || items.length === 0) return;
|
|
611
721
|
const start = Date.now();
|
|
@@ -660,16 +770,8 @@ async function preResolveNpmBatch(items, stats, scanQueue) {
|
|
|
660
770
|
// higher-priority (ioc/typosquat) trigger already fired. queue.js still computes
|
|
661
771
|
// the same signals later for scan-queue protection + extras enqueue (idempotent).
|
|
662
772
|
if (!item._cacheTrigger) {
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
const burstCount = Number.isFinite(npmInfo.recentWindowCount)
|
|
666
|
-
? npmInfo.recentWindowCount
|
|
667
|
-
: ((Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions.length : 0) + 1);
|
|
668
|
-
const isBurst = burstCount >= BURST_MIN_VERSIONS_PREFETCH;
|
|
669
|
-
if (atoSignal || isBurst) {
|
|
670
|
-
const trig = evaluateCacheTrigger(item.name, null, null, { atoSignal, isBurst });
|
|
671
|
-
if (trig.shouldCache) item._cacheTrigger = trig;
|
|
672
|
-
}
|
|
773
|
+
const trig = evaluateBurstAtoTrigger(item.name, npmInfo, item.version);
|
|
774
|
+
if (trig) item._cacheTrigger = trig;
|
|
673
775
|
}
|
|
674
776
|
resolved++;
|
|
675
777
|
} else {
|
|
@@ -945,6 +1047,12 @@ async function pollNpmChanges(state, scanQueue, stats) {
|
|
|
945
1047
|
// resolveTarballAndScan() picks up the slack — zero scan loss.
|
|
946
1048
|
await preResolveNpmBatch(newItems, stats, scanQueue);
|
|
947
1049
|
|
|
1050
|
+
// Fix C — real-time capture lane: when preResolveNpmBatch shed under backlog,
|
|
1051
|
+
// resolve+flag a bounded high-risk subset anyway so the schedulePrefetch below
|
|
1052
|
+
// still captures them. No-op when not shedding or when MUADDIB_REALTIME_PREFETCH
|
|
1053
|
+
// is off (default). See realtimePrefetchHighRisk.
|
|
1054
|
+
await realtimePrefetchHighRisk(newItems, stats);
|
|
1055
|
+
|
|
948
1056
|
// Capture-at-publish (Miasma / Leo, June 2026): prefetch the high-value
|
|
949
1057
|
// subset's tarballs into the local cache NOW — before the (often backlogged)
|
|
950
1058
|
// scan downloads them — so we win the race against fast-takedown unpublishes.
|
|
@@ -1586,6 +1694,9 @@ module.exports = {
|
|
|
1586
1694
|
preResolveNpmBatch,
|
|
1587
1695
|
preResolvePyPIBatch,
|
|
1588
1696
|
preResolveShouldShed,
|
|
1697
|
+
isPrereleaseVersion,
|
|
1698
|
+
evaluateBurstAtoTrigger,
|
|
1699
|
+
realtimePrefetchHighRisk,
|
|
1589
1700
|
|
|
1590
1701
|
// RSS parsing
|
|
1591
1702
|
parseNpmRss,
|
package/src/monitor/state.js
CHANGED
|
@@ -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
|
-
|
|
542
|
-
|
|
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,
|