muaddib-scanner 2.11.131 → 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
package/src/monitor/classify.js
CHANGED
|
@@ -405,6 +405,17 @@ function evaluateCacheTrigger(name, docMeta, doc, opts = {}) {
|
|
|
405
405
|
return { shouldCache: true, reason: 'first_publish', retentionDays: TARBALL_CACHE_DEFAULT_RETENTION_DAYS };
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
// Trigger 4: account-takeover / burst -- 30-day retention (high-risk fast-takedown class).
|
|
409
|
+
// These are batch/registry-derived (published version vs dist-tags.latest; per-name recent
|
|
410
|
+
// version count), NOT knowable from name+docMeta at ingestion, so the caller
|
|
411
|
+
// (preResolveNpmBatch) computes them from _npmInfo and passes opts.{atoSignal,isBurst}.
|
|
412
|
+
// Closes the Leo Platform gap (June 2026): 20 existing packages re-published at non-latest
|
|
413
|
+
// versions matched neither typosquat nor first_publish -> never prefetched -> the malicious
|
|
414
|
+
// tarballs were 404'd before scan. ATO catches that class; burst catches Miasma-style floods.
|
|
415
|
+
if (opts.atoSignal || opts.isBurst) {
|
|
416
|
+
return { shouldCache: true, reason: opts.atoSignal ? 'ato' : 'burst', retentionDays: TARBALL_CACHE_HIGH_RISK_RETENTION_DAYS };
|
|
417
|
+
}
|
|
418
|
+
|
|
408
419
|
return { shouldCache: false, reason: '', retentionDays: 0 };
|
|
409
420
|
}
|
|
410
421
|
|
package/src/monitor/ingestion.js
CHANGED
|
@@ -598,6 +598,30 @@ function preResolveShouldShed(scanQueue) {
|
|
|
598
598
|
// in-flight work, not all the chunks that already completed. When scanQueue
|
|
599
599
|
// is omitted (unit tests, lib usage), items are only mutated in place and the
|
|
600
600
|
// caller decides when to push.
|
|
601
|
+
// Burst threshold for the capture-at-publish prefetch trigger. Mirrors
|
|
602
|
+
// BURST_PREALERT_MIN_VERSIONS (queue.js:212) and reads the SAME env var so ops tunes
|
|
603
|
+
// one knob. Per-name version count in the recent-publish window.
|
|
604
|
+
const BURST_MIN_VERSIONS_PREFETCH = (() => {
|
|
605
|
+
const n = parseInt(process.env.MUADDIB_BURST_MIN_VERSIONS, 10);
|
|
606
|
+
return Number.isFinite(n) && n >= 2 ? n : 10;
|
|
607
|
+
})();
|
|
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
|
+
|
|
601
625
|
async function preResolveNpmBatch(items, stats, scanQueue) {
|
|
602
626
|
if (!items || items.length === 0) return;
|
|
603
627
|
const start = Date.now();
|
|
@@ -644,6 +668,26 @@ async function preResolveNpmBatch(items, stats, scanQueue) {
|
|
|
644
668
|
// weekly_downloads (from getWeeklyDownloads) so the triage block in
|
|
645
669
|
// queue.js can read meta directly without re-fetching.
|
|
646
670
|
item._npmInfo = npmInfo;
|
|
671
|
+
// Capture-at-publish trigger for the fast-takedown class (Leo Platform / Miasma,
|
|
672
|
+
// June 2026). ATO (published version != dist-tags.latest) and per-name burst are
|
|
673
|
+
// only knowable from the registry data fetched here, so the name-only
|
|
674
|
+
// evaluateCacheTrigger at ingestion (change.doc is null post-2025) cannot set them.
|
|
675
|
+
// Feed them in now so the burst/ATO subset is prefetched too — only when no
|
|
676
|
+
// higher-priority (ioc/typosquat) trigger already fired. queue.js still computes
|
|
677
|
+
// the same signals later for scan-queue protection + extras enqueue (idempotent).
|
|
678
|
+
if (!item._cacheTrigger) {
|
|
679
|
+
const atoSignal = !!(npmInfo.latestTagVersion && item.version &&
|
|
680
|
+
item.version !== npmInfo.latestTagVersion &&
|
|
681
|
+
!isPrereleaseVersion(item.version));
|
|
682
|
+
const burstCount = Number.isFinite(npmInfo.recentWindowCount)
|
|
683
|
+
? npmInfo.recentWindowCount
|
|
684
|
+
: ((Array.isArray(npmInfo.recentVersions) ? npmInfo.recentVersions.length : 0) + 1);
|
|
685
|
+
const isBurst = burstCount >= BURST_MIN_VERSIONS_PREFETCH;
|
|
686
|
+
if (atoSignal || isBurst) {
|
|
687
|
+
const trig = evaluateCacheTrigger(item.name, null, null, { atoSignal, isBurst });
|
|
688
|
+
if (trig.shouldCache) item._cacheTrigger = trig;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
647
691
|
resolved++;
|
|
648
692
|
} else {
|
|
649
693
|
failed++;
|
|
@@ -1559,6 +1603,7 @@ module.exports = {
|
|
|
1559
1603
|
preResolveNpmBatch,
|
|
1560
1604
|
preResolvePyPIBatch,
|
|
1561
1605
|
preResolveShouldShed,
|
|
1606
|
+
isPrereleaseVersion,
|
|
1562
1607
|
|
|
1563
1608
|
// RSS parsing
|
|
1564
1609
|
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,
|