From fc3afd0ad66e13177f42ae4435c63364b01e57db Mon Sep 17 00:00:00 2001 From: "Dr. Florian Steiner" <75360256+ProduktEntdecker@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:13:37 +0200 Subject: [PATCH 1/3] feat: supply chain attack prevention with version quarantine, new package detection, low download check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three heuristics that run in parallel with existing OSV/CVE checks to catch supply chain attacks like the Axios compromise (March 2026): - Version Quarantine (72h): flags recently published versions and suggests the previous stable release as alternative - New Package Detection (7d): flags packages created less than a week ago - Low Downloads (<100/week): flags packages with minimal community adoption All three would have caught the Axios attack vector (plain-crypto-js). Registry checks fail-open and use 'ask' (never 'deny') to avoid false-positive blocking. Zero additional latency — runs parallel to OSV. Closes #15 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/decision.ts | 43 +++++ src/index.ts | 43 +++-- src/registry.test.ts | 363 +++++++++++++++++++++++++++++++++++++++++++ src/registry.ts | 263 +++++++++++++++++++++++++++++++ 4 files changed, 700 insertions(+), 12 deletions(-) create mode 100644 src/registry.test.ts create mode 100644 src/registry.ts diff --git a/src/decision.ts b/src/decision.ts index 6296797..411632f 100644 --- a/src/decision.ts +++ b/src/decision.ts @@ -1,3 +1,4 @@ +import type { SupplyChainSignal } from './registry.js'; export interface Vulnerability { name: string; @@ -54,3 +55,45 @@ export function makeDecision(vulnerabilities: Vulnerability[]): DecisionResult { return { decision, reason }; } + +export function makeFullDecision( + vulnerabilities: Vulnerability[], + signals: SupplyChainSignal[] +): DecisionResult { + // CVE-based decision first (always takes priority) + const cveResult = makeDecision(vulnerabilities); + + // If CVE already denies, keep it — supply chain signals don't override + if (cveResult.decision === 'deny') { + // Append supply chain context if present + if (signals.length > 0) { + const scWarnings = signals.map(s => s.detail).join(' '); + return { decision: 'deny', reason: `${cveResult.reason} Additionally: ${scWarnings}` }; + } + return cveResult; + } + + // Build supply chain reason parts + if (signals.length === 0) { + return cveResult; + } + + const parts: string[] = []; + for (const signal of signals) { + let line = `⚠️ ${signal.detail}`; + if (signal.suggestion) { + line += ` ${signal.suggestion}`; + } + parts.push(line); + } + + const scReason = parts.join(' '); + + // Escalate allow → ask if any supply chain signal fires + if (cveResult.decision === 'allow') { + return { decision: 'ask', reason: scReason }; + } + + // CVE was already 'ask' — combine reasons + return { decision: 'ask', reason: `${cveResult.reason} ${scReason}` }; +} diff --git a/src/index.ts b/src/index.ts index 433054d..5b983d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,9 +20,10 @@ * } */ -import { makeDecision, type Vulnerability } from './decision.js'; +import { makeDecision, makeFullDecision, type Vulnerability } from './decision.js'; import { parseInstallCommand } from './parser.js'; import { checkPackageVulnerabilities, type Vulnerability as OSVVulnerability, type CheckResult } from './osv.js'; +import { checkRegistryMetadata, type SupplyChainSignal } from './registry.js'; // Map OSV severity to decision engine severity function mapSeverity(osvSeverity: OSVVulnerability['severity']): Vulnerability['severity'] { @@ -149,15 +150,25 @@ async function main() { return; } - // PARALLEL: Check all packages for vulnerabilities concurrently - const checkResults = await Promise.all( - checkablePackages.map(pkg => - checkPackageVulnerabilities(pkg.name, pkg.version, pkg.ecosystem) - .then(result => ({ pkg, result })) - ) - ); - - // FAIL CLOSED: If any check failed, deny the install + // PARALLEL: Run OSV and registry checks concurrently + const [checkResults, registryResults] = await Promise.all([ + // OSV vulnerability checks (fail-closed) + Promise.all( + checkablePackages.map(pkg => + checkPackageVulnerabilities(pkg.name, pkg.version, pkg.ecosystem) + .then(result => ({ pkg, result })) + ) + ), + // Registry metadata checks (fail-open) + Promise.all( + checkablePackages.map(pkg => + checkRegistryMetadata(pkg.name, pkg.version, pkg.ecosystem) + .then(result => ({ pkg, result })) + ) + ), + ]); + + // FAIL CLOSED: If any OSV check failed, deny the install const errors: string[] = []; const allVulnerabilities: Vulnerability[] = []; @@ -188,8 +199,16 @@ async function main() { return; } - // Make decision based on vulnerabilities - let { decision, reason } = makeDecision(allVulnerabilities); + // Collect supply chain signals (fail-open: errors silently ignored) + const allSignals: SupplyChainSignal[] = []; + for (const { result } of registryResults) { + if (result.status === 'success') { + allSignals.push(...result.signals); + } + } + + // Make decision based on CVE vulnerabilities + supply chain signals + let { decision, reason } = makeFullDecision(allVulnerabilities, allSignals); // Add note about unchecked homebrew packages if any if (homebrewPackages.length > 0) { diff --git a/src/registry.test.ts b/src/registry.test.ts new file mode 100644 index 0000000..d400d97 --- /dev/null +++ b/src/registry.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { checkRegistryMetadata } from './registry.js'; + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +function npmRegistryResponse(overrides: { + created?: string; + latestVersion?: string; + latestPublished?: string; + previousVersion?: string; + previousPublished?: string; + maintainers?: number; +} = {}) { + const now = new Date(); + const created = overrides.created ?? new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(); + const latestVersion = overrides.latestVersion ?? '2.0.0'; + const latestPublished = overrides.latestPublished ?? new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const previousVersion = overrides.previousVersion ?? '1.9.0'; + const previousPublished = overrides.previousPublished ?? new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); + + const maintainers = Array.from({ length: overrides.maintainers ?? 3 }, (_, i) => ({ + name: `maintainer-${i}`, + })); + + return { + time: { + created, + modified: latestPublished, + [previousVersion]: previousPublished, + [latestVersion]: latestPublished, + }, + 'dist-tags': { latest: latestVersion }, + maintainers, + }; +} + +function npmDownloadsResponse(downloads: number) { + return { downloads, start: '2026-03-26', end: '2026-04-01', package: 'test-pkg' }; +} + +describe('checkRegistryMetadata', () => { + describe('homebrew', () => { + it('returns empty signals for homebrew packages', async () => { + const result = await checkRegistryMetadata('wget', undefined, 'homebrew'); + expect(result).toEqual({ status: 'success', signals: [] }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('npm — version quarantine', () => { + it('flags version published < 72h ago', async () => { + const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); + const threeMonthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(50000)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse({ + latestVersion: '1.14.1', + latestPublished: oneHourAgo, + previousVersion: '1.14.0', + previousPublished: threeMonthsAgo, + })), + }); + }); + + const result = await checkRegistryMetadata('axios', '1.14.1', 'npm'); + expect(result.status).toBe('success'); + + const quarantine = result.signals.find(s => s.type === 'version-quarantine'); + expect(quarantine).toBeDefined(); + expect(quarantine!.severity).toBe('HIGH'); + expect(quarantine!.detail).toContain('1.14.1'); + expect(quarantine!.detail).toContain('72h quarantine'); + expect(quarantine!.suggestion).toContain('1.14.0'); + expect(result.previousStableVersion).toBe('1.14.0'); + }); + + it('does not flag version published > 72h ago', async () => { + const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(50000)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse({ + latestPublished: fiveDaysAgo, + })), + }); + }); + + const result = await checkRegistryMetadata('lodash', undefined, 'npm'); + const quarantine = result.signals.find(s => s.type === 'version-quarantine'); + expect(quarantine).toBeUndefined(); + }); + + it('resolves latest version when no version specified', async () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const threeMonthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(50000)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse({ + latestVersion: '3.0.0', + latestPublished: twoHoursAgo, + previousVersion: '2.9.0', + previousPublished: threeMonthsAgo, + })), + }); + }); + + const result = await checkRegistryMetadata('some-pkg', undefined, 'npm'); + const quarantine = result.signals.find(s => s.type === 'version-quarantine'); + expect(quarantine).toBeDefined(); + expect(quarantine!.detail).toContain('3.0.0'); + }); + + it('skips pre-release versions when suggesting previous stable', async () => { + const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const sixMonthsAgo = new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(50000)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + time: { + created: sixMonthsAgo, + modified: oneHourAgo, + '1.0.0': sixMonthsAgo, + '2.0.0-beta.1': twoHoursAgo, + '2.0.0': oneHourAgo, + }, + 'dist-tags': { latest: '2.0.0' }, + maintainers: [{ name: 'dev' }], + }), + }); + }); + + const result = await checkRegistryMetadata('test-pkg', '2.0.0', 'npm'); + expect(result.previousStableVersion).toBe('1.0.0'); + }); + }); + + describe('npm — new package detection', () => { + it('flags package created < 7 days ago', async () => { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(5)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse({ + created: twoDaysAgo, + latestPublished: twoDaysAgo, + })), + }); + }); + + const result = await checkRegistryMetadata('plain-crypto-js', undefined, 'npm'); + const newPkg = result.signals.find(s => s.type === 'new-package'); + expect(newPkg).toBeDefined(); + expect(newPkg!.severity).toBe('HIGH'); + expect(newPkg!.detail).toContain('no established history'); + }); + + it('does not flag package created > 7 days ago', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(50000)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse()), + }); + }); + + const result = await checkRegistryMetadata('lodash', undefined, 'npm'); + const newPkg = result.signals.find(s => s.type === 'new-package'); + expect(newPkg).toBeUndefined(); + }); + }); + + describe('npm — low downloads', () => { + it('flags package with < 100 weekly downloads', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(12)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse()), + }); + }); + + const result = await checkRegistryMetadata('obscure-pkg', undefined, 'npm'); + const lowDl = result.signals.find(s => s.type === 'low-downloads'); + expect(lowDl).toBeDefined(); + expect(lowDl!.severity).toBe('MEDIUM'); + expect(lowDl!.detail).toContain('12 weekly downloads'); + }); + + it('does not flag package with >= 100 weekly downloads', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(50000)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse()), + }); + }); + + const result = await checkRegistryMetadata('popular-pkg', undefined, 'npm'); + const lowDl = result.signals.find(s => s.type === 'low-downloads'); + expect(lowDl).toBeUndefined(); + }); + }); + + describe('npm — fail-open', () => { + it('returns empty signals on registry timeout', async () => { + mockFetch.mockRejectedValue(new Error('AbortError: The operation was aborted')); + + const result = await checkRegistryMetadata('any-pkg', undefined, 'npm'); + expect(result).toEqual({ status: 'success', signals: [] }); + }); + + it('returns empty signals on registry 500', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const result = await checkRegistryMetadata('any-pkg', undefined, 'npm'); + expect(result).toEqual({ status: 'success', signals: [] }); + }); + }); + + describe('npm — scoped packages', () => { + it('encodes scoped package names in URL', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(500000)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(npmRegistryResponse()), + }); + }); + + await checkRegistryMetadata('@types/node', undefined, 'npm'); + + const registryCall = mockFetch.mock.calls.find( + (call: string[]) => call[0].includes('registry.npmjs.org') + ); + expect(registryCall).toBeDefined(); + expect(registryCall![0]).toContain('@types%2Fnode'); + }); + }); + + describe('npm — combined signals (Axios attack scenario)', () => { + it('flags all three signals for a brand-new malicious package', async () => { + const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockImplementation((url: string) => { + if (url.includes('api.npmjs.org')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(npmDownloadsResponse(0)) }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + time: { + created: threeHoursAgo, + modified: threeHoursAgo, + '4.2.1': threeHoursAgo, + }, + 'dist-tags': { latest: '4.2.1' }, + maintainers: [{ name: 'attacker' }], + }), + }); + }); + + const result = await checkRegistryMetadata('plain-crypto-js', '4.2.1', 'npm'); + expect(result.signals).toHaveLength(3); + + const types = result.signals.map(s => s.type); + expect(types).toContain('version-quarantine'); + expect(types).toContain('new-package'); + expect(types).toContain('low-downloads'); + }); + }); + + describe('pypi — version quarantine', () => { + it('flags version published < 72h ago', async () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const sixMonthsAgo = new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + info: { name: 'evil-pkg' }, + releases: { + '1.0.0': [{ upload_time_iso_8601: sixMonthsAgo }], + '1.1.0': [{ upload_time_iso_8601: twoHoursAgo }], + }, + }), + }); + + const result = await checkRegistryMetadata('evil-pkg', '1.1.0', 'pypi'); + const quarantine = result.signals.find(s => s.type === 'version-quarantine'); + expect(quarantine).toBeDefined(); + expect(quarantine!.detail).toContain('1.1.0'); + expect(quarantine!.suggestion).toContain('1.0.0'); + }); + }); + + describe('pypi — new package detection', () => { + it('flags package created < 7 days ago', async () => { + const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + info: { name: 'new-evil' }, + releases: { + '0.1.0': [{ upload_time_iso_8601: oneDayAgo }], + }, + }), + }); + + const result = await checkRegistryMetadata('new-evil', '0.1.0', 'pypi'); + const newPkg = result.signals.find(s => s.type === 'new-package'); + expect(newPkg).toBeDefined(); + expect(newPkg!.detail).toContain('no established history'); + }); + }); + + describe('pypi — fail-open', () => { + it('returns empty signals on fetch error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await checkRegistryMetadata('any-pkg', undefined, 'pypi'); + expect(result).toEqual({ status: 'success', signals: [] }); + }); + }); +}); diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..6b2986f --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,263 @@ +export type Ecosystem = 'npm' | 'pypi' | 'homebrew'; + +// Thresholds — all constants for easy tuning +const VERSION_QUARANTINE_HOURS = 72; +const PACKAGE_FRESHNESS_DAYS = 7; +const LOW_DOWNLOAD_THRESHOLD = 100; +const REGISTRY_TIMEOUT_MS = 3000; + +export interface SupplyChainSignal { + type: 'version-quarantine' | 'new-package' | 'low-downloads'; + severity: 'HIGH' | 'MEDIUM'; + detail: string; + suggestion?: string; +} + +export interface RegistryResult { + status: 'success' | 'error'; + signals: SupplyChainSignal[]; + previousStableVersion?: string; +} + +function hoursAgo(isoDate: string): number { + return (Date.now() - new Date(isoDate).getTime()) / (1000 * 60 * 60); +} + +function daysAgo(isoDate: string): number { + return hoursAgo(isoDate) / 24; +} + +function formatAge(hours: number): string { + if (hours < 1) return 'less than 1 hour ago'; + if (hours < 24) return `${Math.round(hours)} hours ago`; + const days = Math.round(hours / 24); + if (days === 1) return '1 day ago'; + return `${days} days ago`; +} + +const PRE_RELEASE_PATTERN = /-(alpha|beta|rc|canary|dev|preview|next|experimental)/i; + +function findPreviousStableVersion( + timeMap: Record, + currentVersion: string +): string | undefined { + // Get all versions sorted by publish date, newest first + const versions = Object.entries(timeMap) + .filter(([key]) => key !== 'created' && key !== 'modified') + .sort(([, a], [, b]) => new Date(b).getTime() - new Date(a).getTime()); + + let foundCurrent = false; + for (const [version, publishDate] of versions) { + if (version === currentVersion) { + foundCurrent = true; + continue; + } + if (!foundCurrent) continue; + + // Skip pre-release versions + if (PRE_RELEASE_PATTERN.test(version)) continue; + + // Must be older than quarantine window + if (hoursAgo(publishDate) >= VERSION_QUARANTINE_HOURS) { + return version; + } + } + + return undefined; +} + +interface NpmRegistryResponse { + time?: Record; + maintainers?: Array<{ name?: string }>; + 'dist-tags'?: Record; +} + +interface NpmDownloadsResponse { + downloads?: number; +} + +interface PyPIResponse { + info?: { name?: string }; + releases?: Record>; +} + +async function fetchWithTimeout(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + +async function checkNpm( + name: string, + version: string | undefined +): Promise { + const encodedName = name.startsWith('@') ? name.replace('/', '%2F') : name; + + // Fetch registry metadata and downloads in parallel + const [registryResp, downloadsResp] = await Promise.all([ + fetchWithTimeout(`https://registry.npmjs.org/${encodedName}`, REGISTRY_TIMEOUT_MS), + fetchWithTimeout( + `https://api.npmjs.org/downloads/point/last-week/${encodedName}`, + REGISTRY_TIMEOUT_MS + ), + ]); + + if (!registryResp.ok) return { status: 'success', signals: [] }; + + const data = (await registryResp.json()) as NpmRegistryResponse; + const timeMap = data.time ?? {}; + + // Resolve "latest" version if none specified + const resolvedVersion = version ?? data['dist-tags']?.latest; + + const signals: SupplyChainSignal[] = []; + let previousStableVersion: string | undefined; + + // H1: Version Quarantine + if (resolvedVersion && timeMap[resolvedVersion]) { + const versionAge = hoursAgo(timeMap[resolvedVersion]); + if (versionAge < VERSION_QUARANTINE_HOURS) { + previousStableVersion = findPreviousStableVersion(timeMap, resolvedVersion); + const suggestion = previousStableVersion + ? `Consider using ${name}@${previousStableVersion} instead.` + : undefined; + + signals.push({ + type: 'version-quarantine', + severity: 'HIGH', + detail: `${name}@${resolvedVersion} was published ${formatAge(versionAge)} (within ${VERSION_QUARANTINE_HOURS}h quarantine window).`, + suggestion, + }); + } + } + + // H2: New Package Detection + if (timeMap.created) { + const packageAge = daysAgo(timeMap.created); + if (packageAge < PACKAGE_FRESHNESS_DAYS) { + signals.push({ + type: 'new-package', + severity: 'HIGH', + detail: `${name} was created ${formatAge(packageAge * 24)}. This package has no established history.`, + }); + } + } + + // H3: Low Download Count + if (downloadsResp.ok) { + const dlData = (await downloadsResp.json()) as NpmDownloadsResponse; + const downloads = dlData.downloads ?? 0; + if (downloads < LOW_DOWNLOAD_THRESHOLD) { + signals.push({ + type: 'low-downloads', + severity: 'MEDIUM', + detail: `${name} has ${downloads} weekly downloads on npm.`, + }); + } + } + + return { status: 'success', signals, previousStableVersion }; +} + +async function checkPyPI( + name: string, + version: string | undefined +): Promise { + const resp = await fetchWithTimeout( + `https://pypi.org/pypi/${encodeURIComponent(name)}/json`, + REGISTRY_TIMEOUT_MS + ); + + if (!resp.ok) return { status: 'success', signals: [] }; + + const data = (await resp.json()) as PyPIResponse; + const releases = data.releases ?? {}; + const signals: SupplyChainSignal[] = []; + + // Find the earliest release date (package creation) + let earliestDate: string | undefined; + for (const files of Object.values(releases)) { + if (files?.[0]?.upload_time_iso_8601) { + if (!earliestDate || files[0].upload_time_iso_8601 < earliestDate) { + earliestDate = files[0].upload_time_iso_8601; + } + } + } + + // H2: New Package Detection + if (earliestDate) { + const packageAge = daysAgo(earliestDate); + if (packageAge < PACKAGE_FRESHNESS_DAYS) { + signals.push({ + type: 'new-package', + severity: 'HIGH', + detail: `${name} was created ${formatAge(packageAge * 24)}. This package has no established history.`, + }); + } + } + + // H1: Version Quarantine + const resolvedVersion = version ?? Object.keys(releases).pop(); + if (resolvedVersion && releases[resolvedVersion]?.[0]?.upload_time_iso_8601) { + const versionAge = hoursAgo(releases[resolvedVersion][0].upload_time_iso_8601); + if (versionAge < VERSION_QUARANTINE_HOURS) { + // Find previous stable version + const versionKeys = Object.keys(releases); + const currentIdx = versionKeys.indexOf(resolvedVersion); + let previousStableVersion: string | undefined; + + for (let i = currentIdx - 1; i >= 0; i--) { + const v = versionKeys[i]; + if (PRE_RELEASE_PATTERN.test(v)) continue; + const files = releases[v]; + if (files?.[0]?.upload_time_iso_8601 && hoursAgo(files[0].upload_time_iso_8601) >= VERSION_QUARANTINE_HOURS) { + previousStableVersion = v; + break; + } + } + + const suggestion = previousStableVersion + ? `Consider using ${name}==${previousStableVersion} instead.` + : undefined; + + signals.push({ + type: 'version-quarantine', + severity: 'HIGH', + detail: `${name}==${resolvedVersion} was published ${formatAge(versionAge)} (within ${VERSION_QUARANTINE_HOURS}h quarantine window).`, + suggestion, + }); + + return { status: 'success', signals, previousStableVersion }; + } + } + + return { status: 'success', signals }; +} + +export async function checkRegistryMetadata( + name: string, + version: string | undefined, + ecosystem: Ecosystem +): Promise { + // Homebrew has no registry API + if (ecosystem === 'homebrew') { + return { status: 'success', signals: [] }; + } + + // Fail-open: any error returns empty signals + try { + if (ecosystem === 'npm') { + return await checkNpm(name, version); + } + if (ecosystem === 'pypi') { + return await checkPyPI(name, version); + } + return { status: 'success', signals: [] }; + } catch { + return { status: 'success', signals: [] }; + } +} From 6e23ea52d2ed580e17da71a1f0c057af0a56fb74 Mon Sep 17 00:00:00 2001 From: "Dr. Florian Steiner" <75360256+ProduktEntdecker@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:11:14 +0200 Subject: [PATCH 2/3] fix: use timestamp-based version resolution for PyPI (not key order) PyPI's JSON API does not guarantee the order of releases in the releases object. Replace Object.keys().pop() with timestamp-based resolution to find the actual latest version and previous stable version. Addresses CodeRabbit review on PR #16. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/registry.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/registry.ts b/src/registry.ts index 6b2986f..dbed681 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -201,22 +201,32 @@ async function checkPyPI( } // H1: Version Quarantine - const resolvedVersion = version ?? Object.keys(releases).pop(); + // PyPI does not guarantee release order — find latest by upload timestamp + const resolvedVersion = version ?? (() => { + let latestVersion: string | undefined; + let latestTime = ''; + for (const [v, files] of Object.entries(releases)) { + const uploadTime = files?.[0]?.upload_time_iso_8601; + if (uploadTime && uploadTime > latestTime) { + latestTime = uploadTime; + latestVersion = v; + } + } + return latestVersion; + })(); if (resolvedVersion && releases[resolvedVersion]?.[0]?.upload_time_iso_8601) { const versionAge = hoursAgo(releases[resolvedVersion][0].upload_time_iso_8601); if (versionAge < VERSION_QUARANTINE_HOURS) { - // Find previous stable version - const versionKeys = Object.keys(releases); - const currentIdx = versionKeys.indexOf(resolvedVersion); + // Find previous stable version by timestamp (PyPI order not guaranteed) let previousStableVersion: string | undefined; - - for (let i = currentIdx - 1; i >= 0; i--) { - const v = versionKeys[i]; + let bestPreviousTime = ''; + for (const [v, files] of Object.entries(releases)) { + if (v === resolvedVersion) continue; if (PRE_RELEASE_PATTERN.test(v)) continue; - const files = releases[v]; - if (files?.[0]?.upload_time_iso_8601 && hoursAgo(files[0].upload_time_iso_8601) >= VERSION_QUARANTINE_HOURS) { + const uploadTime = files?.[0]?.upload_time_iso_8601; + if (uploadTime && hoursAgo(uploadTime) >= VERSION_QUARANTINE_HOURS && uploadTime > bestPreviousTime) { + bestPreviousTime = uploadTime; previousStableVersion = v; - break; } } From cad8c09e2aac916b0889182c1a473920b521e1fd Mon Sep 17 00:00:00 2001 From: "Dr. Florian Steiner" <75360256+ProduktEntdecker@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:18:57 +0200 Subject: [PATCH 3/3] fix: PEP 440 pre-release pattern + Promise.allSettled for downloads - Extend PRE_RELEASE_PATTERN to match PEP 440 suffixes (1.0a1, 1.0b2, 1.0rc1, 1.0.dev1) alongside npm-style (-alpha, -beta) - Use Promise.allSettled instead of Promise.all so downloads API failure doesn't discard registry signals (quarantine, new-package) Addresses CodeRabbit re-review on PR #16. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/registry.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/registry.ts b/src/registry.ts index dbed681..229d358 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -35,7 +35,8 @@ function formatAge(hours: number): string { return `${days} days ago`; } -const PRE_RELEASE_PATTERN = /-(alpha|beta|rc|canary|dev|preview|next|experimental)/i; +// Matches npm-style (-alpha, -beta) and PEP 440-style (1.0a1, 1.0b2, 1.0rc1, 1.0.dev1) +const PRE_RELEASE_PATTERN = /-(alpha|beta|rc|canary|dev|preview|next|experimental)|[\d](a|b|rc)\d|\.dev\d|\.post\d/i; function findPreviousStableVersion( timeMap: Record, @@ -97,8 +98,8 @@ async function checkNpm( ): Promise { const encodedName = name.startsWith('@') ? name.replace('/', '%2F') : name; - // Fetch registry metadata and downloads in parallel - const [registryResp, downloadsResp] = await Promise.all([ + // Fetch registry metadata and downloads in parallel; downloads failure shouldn't lose registry signals + const [registryResult, downloadsResult] = await Promise.allSettled([ fetchWithTimeout(`https://registry.npmjs.org/${encodedName}`, REGISTRY_TIMEOUT_MS), fetchWithTimeout( `https://api.npmjs.org/downloads/point/last-week/${encodedName}`, @@ -106,7 +107,11 @@ async function checkNpm( ), ]); - if (!registryResp.ok) return { status: 'success', signals: [] }; + if (registryResult.status === 'rejected' || !registryResult.value.ok) { + return { status: 'success', signals: [] }; + } + const registryResp = registryResult.value; + const downloadsResp = downloadsResult.status === 'fulfilled' ? downloadsResult.value : null; const data = (await registryResp.json()) as NpmRegistryResponse; const timeMap = data.time ?? {}; @@ -148,7 +153,7 @@ async function checkNpm( } // H3: Low Download Count - if (downloadsResp.ok) { + if (downloadsResp?.ok) { const dlData = (await downloadsResp.json()) as NpmDownloadsResponse; const downloads = dlData.downloads ?? 0; if (downloads < LOW_DOWNLOAD_THRESHOLD) {