Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand Down
178 changes: 178 additions & 0 deletions src/osv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { checkPackageVulnerabilities } from './osv.js';

const mockFetch = vi.fn();

beforeEach(() => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});

afterEach(() => {
vi.unstubAllGlobals();
});

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([]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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');
});
});
50 changes: 47 additions & 3 deletions src/osv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string | undefined> {
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,
Expand All @@ -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();
Expand Down Expand Up @@ -139,6 +182,7 @@ export async function checkPackageVulnerabilities(
summary: v.summary ?? '',
severity: coerceSeverity(v),
})),
resolvedVersion,
};
} catch (err) {
// FAIL CLOSED: Network error = deny, not allow
Expand Down
Loading