From c6ac16164d8ea44e9b9ed15c4cb1fa468d3d7d42 Mon Sep 17 00:00:00 2001 From: "Dr. Florian Steiner" <75360256+ProduktEntdecker@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:57:53 +0200 Subject: [PATCH 1/2] fix(osv): resolve latest version before querying to eliminate false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a package is referenced without a version (e.g. `npx vite`, `npx playwright install chromium`), the OSV query was sent without a version field. OSV then returns vulnerabilities across ALL versions ever published, surfacing CVEs that have long since been patched. Real-world impact: `vite@latest` was reported with 5 HIGH vulnerabilities when the actual current release (`vite@8.0.8`) has zero. Same for `playwright@latest` (1 HIGH reported, 0 in current `1.59.1`). Fix: when no version is specified, resolve `latest` from the npm or PyPI registry first, then query OSV with that concrete version. On registry failure (404, timeout, network error), fall back to the previous unversioned query — keeps prior fail-closed behavior intact. The resolved version is also surfaced through `CheckResult.resolvedVersion` so the hook output shows `vite@8.0.8` instead of the misleading `vite@latest`. Closes #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 5 +- src/osv.test.ts | 174 ++++++++++++++++++++++++++++++++++++++++++++++++ src/osv.ts | 50 +++++++++++++- 3 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 src/osv.test.ts diff --git a/src/index.ts b/src/index.ts index 5b983d3..cac7d40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,10 +177,13 @@ async function main() { errors.push(`${pkg.name}: ${result.error}`); } else { // Convert OSV vulnerabilities to decision engine format + // Use resolvedVersion (from registry lookup) when no version was specified, + // so messages show the real version instead of misleading "latest". + const displayVersion = pkg.version || result.resolvedVersion || 'latest'; for (const v of result.vulnerabilities) { allVulnerabilities.push({ name: pkg.name, - version: pkg.version || 'latest', + version: displayVersion, severity: mapSeverity(v.severity), }); } diff --git a/src/osv.test.ts b/src/osv.test.ts new file mode 100644 index 0000000..d3a47d7 --- /dev/null +++ b/src/osv.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { checkPackageVulnerabilities } from './osv.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +function osvResponse(vulns: Array<{ id: string; severity?: string }> = []) { + return { + ok: true, + status: 200, + json: () => + Promise.resolve({ + vulns: vulns.map(v => ({ + id: v.id, + summary: '', + database_specific: { severity: v.severity ?? 'HIGH' }, + })), + }), + }; +} + +function npmLatestResponse(version: string) { + return { ok: true, status: 200, json: () => Promise.resolve({ version }) }; +} + +function pypiResponse(version: string) { + return { ok: true, status: 200, json: () => Promise.resolve({ info: { version } }) }; +} + +describe('checkPackageVulnerabilities — version resolution', () => { + it('resolves npm latest from registry when no version given, then queries OSV with that version', async () => { + const calls: Array<{ url: string; body?: string }> = []; + mockFetch.mockImplementation((url: string, opts?: { body?: string }) => { + calls.push({ url, body: opts?.body }); + if (url === 'https://registry.npmjs.org/vite/latest') { + return Promise.resolve(npmLatestResponse('8.0.8')); + } + if (url === 'https://api.osv.dev/v1/query') { + return Promise.resolve(osvResponse([])); // no vulns at 8.0.8 + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const result = await checkPackageVulnerabilities('vite', undefined, 'npm'); + + expect(result.status).toBe('success'); + if (result.status !== 'success') return; + expect(result.vulnerabilities).toEqual([]); + expect(result.resolvedVersion).toBe('8.0.8'); + + // OSV must have been called WITH the resolved version (not unversioned) + const osvCall = calls.find(c => c.url === 'https://api.osv.dev/v1/query'); + expect(osvCall).toBeDefined(); + const body = JSON.parse(osvCall!.body!); + expect(body.version).toBe('8.0.8'); + }); + + it('encodes scoped npm packages correctly when resolving latest', async () => { + const seenUrls: string[] = []; + mockFetch.mockImplementation((url: string) => { + seenUrls.push(url); + if (url.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve(npmLatestResponse('20.0.0')); + } + return Promise.resolve(osvResponse([])); + }); + + await checkPackageVulnerabilities('@types/node', undefined, 'npm'); + + expect(seenUrls).toContain('https://registry.npmjs.org/@types%2Fnode/latest'); + }); + + it('resolves PyPI latest when no version given', async () => { + const calls: Array<{ url: string; body?: string }> = []; + mockFetch.mockImplementation((url: string, opts?: { body?: string }) => { + calls.push({ url, body: opts?.body }); + if (url.startsWith('https://pypi.org/')) { + return Promise.resolve(pypiResponse('2.31.0')); + } + return Promise.resolve(osvResponse([])); + }); + + const result = await checkPackageVulnerabilities('requests', undefined, 'pypi'); + + expect(result.status).toBe('success'); + if (result.status !== 'success') return; + expect(result.resolvedVersion).toBe('2.31.0'); + + const osvCall = calls.find(c => c.url === 'https://api.osv.dev/v1/query'); + expect(JSON.parse(osvCall!.body!).version).toBe('2.31.0'); + }); + + it('skips registry resolution when version is already specified', async () => { + const seenUrls: string[] = []; + mockFetch.mockImplementation((url: string) => { + seenUrls.push(url); + return Promise.resolve(osvResponse([])); + }); + + await checkPackageVulnerabilities('vite', '5.0.0', 'npm'); + + expect(seenUrls).not.toContain('https://registry.npmjs.org/vite/latest'); + expect(seenUrls).toContain('https://api.osv.dev/v1/query'); + }); + + it('falls back to unversioned OSV query when registry resolution fails (404)', async () => { + const calls: Array<{ url: string; body?: string }> = []; + mockFetch.mockImplementation((url: string, opts?: { body?: string }) => { + calls.push({ url, body: opts?.body }); + if (url.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + } + return Promise.resolve(osvResponse([])); + }); + + const result = await checkPackageVulnerabilities('nonexistent', undefined, 'npm'); + + expect(result.status).toBe('success'); + if (result.status !== 'success') return; + expect(result.resolvedVersion).toBeUndefined(); + + // OSV called WITHOUT version field (preserves prior behavior) + const osvCall = calls.find(c => c.url === 'https://api.osv.dev/v1/query'); + const body = JSON.parse(osvCall!.body!); + expect(body.version).toBeUndefined(); + }); + + it('falls back gracefully when registry resolution throws (network error)', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.startsWith('https://registry.npmjs.org/')) { + return Promise.reject(new Error('network down')); + } + return Promise.resolve(osvResponse([])); + }); + + const result = await checkPackageVulnerabilities('vite', undefined, 'npm'); + + expect(result.status).toBe('success'); + if (result.status !== 'success') return; + expect(result.resolvedVersion).toBeUndefined(); + expect(result.vulnerabilities).toEqual([]); + }); + + it('reproduces the vite false positive: unversioned query would return 19 vulns, versioned returns 0', async () => { + // This test demonstrates the bug being fixed. + mockFetch.mockImplementation((url: string, opts?: { body?: string }) => { + if (url === 'https://registry.npmjs.org/vite/latest') { + return Promise.resolve(npmLatestResponse('8.0.8')); + } + // Simulate OSV: returns 19 historical vulns when no version, 0 for current latest + const body = JSON.parse(opts!.body!); + if (body.version === '8.0.8') { + return Promise.resolve(osvResponse([])); // patched + } + return Promise.resolve( + osvResponse( + Array.from({ length: 19 }, (_, i) => ({ id: `GHSA-vite-${i}`, severity: 'HIGH' })) + ) + ); + }); + + const result = await checkPackageVulnerabilities('vite', undefined, 'npm'); + + expect(result.status).toBe('success'); + if (result.status !== 'success') return; + // Without the fix this would be 19. With the fix, it's 0. + expect(result.vulnerabilities).toHaveLength(0); + expect(result.resolvedVersion).toBe('8.0.8'); + }); +}); diff --git a/src/osv.ts b/src/osv.ts index 01e1959..78565c4 100644 --- a/src/osv.ts +++ b/src/osv.ts @@ -8,7 +8,7 @@ export type Vulnerability = { // Result type that distinguishes between "no vulnerabilities" and "check failed" export type CheckResult = - | { status: 'success'; vulnerabilities: Vulnerability[] } + | { status: 'success'; vulnerabilities: Vulnerability[]; resolvedVersion?: string } | { status: 'error'; error: string }; type OSVVulnerability = { @@ -93,6 +93,41 @@ interface OSVQueryRequest { version?: string; } +// Short timeout for latest-version resolution; falls back gracefully on failure. +const LATEST_RESOLVE_TIMEOUT_MS = 2000; + +// Resolve the actual version string when none was specified by the user. +// Without this, OSV returns vulnerabilities across ALL versions ever published, +// flagging packages whose current release is patched (false positive). +async function resolveLatestVersion( + name: string, + ecosystem: Ecosystem +): Promise { + let url: string; + if (ecosystem === 'npm') { + // /latest endpoint returns just the latest manifest (smaller than full registry doc) + const encoded = name.startsWith('@') ? name.replace('/', '%2F') : encodeURIComponent(name); + url = `https://registry.npmjs.org/${encoded}/latest`; + } else if (ecosystem === 'pypi') { + url = `https://pypi.org/pypi/${encodeURIComponent(name)}/json`; + } else { + return undefined; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), LATEST_RESOLVE_TIMEOUT_MS); + try { + const resp = await fetch(url, { signal: controller.signal }); + if (!resp.ok) return undefined; + const data = (await resp.json()) as { version?: string; info?: { version?: string } }; + return ecosystem === 'pypi' ? data.info?.version : data.version; + } catch { + return undefined; + } finally { + clearTimeout(timeoutId); + } +} + export async function checkPackageVulnerabilities( name: string, version: string | undefined, @@ -105,11 +140,19 @@ export async function checkPackageVulnerabilities( const osvEcosystem = mapEcosystem(ecosystem); const pkgName = name.trim(); + // If no version was specified, resolve current latest from the registry. + // Falls back to unversioned OSV query on resolve failure (preserves prior behavior). + let resolvedVersion: string | undefined; + if (!version || !version.trim()) { + resolvedVersion = await resolveLatestVersion(pkgName, ecosystem); + } + const effectiveVersion = (version && version.trim()) || resolvedVersion; + const body: OSVQueryRequest = { package: { name: pkgName, ecosystem: osvEcosystem }, }; - if (version && version.trim()) { - body.version = version.trim(); + if (effectiveVersion) { + body.version = effectiveVersion; } const controller = new AbortController(); @@ -139,6 +182,7 @@ export async function checkPackageVulnerabilities( summary: v.summary ?? '', severity: coerceSeverity(v), })), + resolvedVersion, }; } catch (err) { // FAIL CLOSED: Network error = deny, not allow From 62e7b6e431b6e02e8619df3d280bdb9feabdeeb0 Mon Sep 17 00:00:00 2001 From: "Dr. Florian Steiner" <75360256+ProduktEntdecker@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:33:45 +0200 Subject: [PATCH 2/2] test(osv): stub/unstub fetch per-test to prevent cross-test interference CodeRabbit review on PR #20: the module-scoped vi.stubGlobal('fetch') persisted across the test file. Move stubbing into beforeEach and add afterEach with vi.unstubAllGlobals() so each test gets a fresh stub and the global is restored cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/osv.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/osv.test.ts b/src/osv.test.ts index d3a47d7..8e5a7a6 100644 --- a/src/osv.test.ts +++ b/src/osv.test.ts @@ -1,11 +1,15 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { checkPackageVulnerabilities } from './osv.js'; const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); beforeEach(() => { mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); function osvResponse(vulns: Array<{ id: string; severity?: string }> = []) {