muaddib-scanner 2.11.134 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.134",
3
+ "version": "2.11.135",
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-28T13:13:44.321Z",
3
+ "timestamp": "2026-06-28T13:42:09.849Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -622,6 +622,100 @@ function isPrereleaseVersion(v) {
622
622
  return /-/.test(String(v == null ? '' : v).split('+')[0]);
623
623
  }
624
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
+
625
719
  async function preResolveNpmBatch(items, stats, scanQueue) {
626
720
  if (!items || items.length === 0) return;
627
721
  const start = Date.now();
@@ -676,17 +770,8 @@ async function preResolveNpmBatch(items, stats, scanQueue) {
676
770
  // higher-priority (ioc/typosquat) trigger already fired. queue.js still computes
677
771
  // the same signals later for scan-queue protection + extras enqueue (idempotent).
678
772
  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
- }
773
+ const trig = evaluateBurstAtoTrigger(item.name, npmInfo, item.version);
774
+ if (trig) item._cacheTrigger = trig;
690
775
  }
691
776
  resolved++;
692
777
  } else {
@@ -962,6 +1047,12 @@ async function pollNpmChanges(state, scanQueue, stats) {
962
1047
  // resolveTarballAndScan() picks up the slack — zero scan loss.
963
1048
  await preResolveNpmBatch(newItems, stats, scanQueue);
964
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
+
965
1056
  // Capture-at-publish (Miasma / Leo, June 2026): prefetch the high-value
966
1057
  // subset's tarballs into the local cache NOW — before the (often backlogged)
967
1058
  // scan downloads them — so we win the race against fast-takedown unpublishes.
@@ -1604,6 +1695,8 @@ module.exports = {
1604
1695
  preResolvePyPIBatch,
1605
1696
  preResolveShouldShed,
1606
1697
  isPrereleaseVersion,
1698
+ evaluateBurstAtoTrigger,
1699
+ realtimePrefetchHighRisk,
1607
1700
 
1608
1701
  // RSS parsing
1609
1702
  parseNpmRss,