import { logger } from "../lib/logger"; import type { NewAdvisory } from "../db/schema"; import { firstFixedVersion, type OsvAffectedLike } from "./semver"; // Thin client for OSV.dev (https://osv.dev). `querybatch` does authoritative, // server-side version matching and returns the vuln ids affecting each queried // {package, version}; we then fetch full records to enrich + cache them. const OSV_BASE = "https://api.osv.dev/v1"; export interface OsvPackageQuery { ecosystem: string; name: string; version: string; } interface OsvSeverity { type?: string; score?: string; } export interface OsvAffected extends OsvAffectedLike { package?: { ecosystem?: string; name?: string }; database_specific?: { severity?: string }; } export interface OsvRecord { id: string; modified?: string; summary?: string; details?: string; aliases?: string[]; severity?: OsvSeverity[]; affected?: OsvAffected[]; database_specific?: { severity?: string }; } // Maps OSV's ecosystem casing. Manifests give us lowercase-ish keys; OSV uses // canonical names (npm, PyPI, Go, crates.io, Maven, RubyGems, Packagist, ...). const ECOSYSTEM_CANON: Record = { npm: "npm", pypi: "PyPI", go: "Go", "crates.io": "crates.io", cargo: "crates.io", maven: "Maven", rubygems: "RubyGems", gem: "RubyGems", packagist: "Packagist", composer: "Packagist", }; export function canonicalEcosystem(ecosystem: string): string { return ECOSYSTEM_CANON[ecosystem.toLowerCase()] ?? ecosystem; } // POST /querybatch — returns, per query (index-aligned), the vuln ids affecting it. export async function queryBatch(queries: OsvPackageQuery[]): Promise { if (queries.length === 0) return []; const body = { queries: queries.map((q) => ({ package: { ecosystem: canonicalEcosystem(q.ecosystem), name: q.name }, version: q.version, })), }; const res = await fetch(`${OSV_BASE}/querybatch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`OSV querybatch failed (${res.status}): ${await res.text()}`); const data = (await res.json()) as { results?: { vulns?: { id: string }[] }[] }; return (data.results ?? []).map((r) => (r.vulns ?? []).map((v) => v.id)); } // GET /vulns/{id} — full record for enrichment/caching. export async function fetchVuln(id: string): Promise { const res = await fetch(`${OSV_BASE}/vulns/${encodeURIComponent(id)}`); if (!res.ok) throw new Error(`OSV vuln fetch failed for ${id} (${res.status})`); return (await res.json()) as OsvRecord; } // Best-effort severity label from an OSV record (GHSA-style database_specific first, // else a coarse bucket from a CVSS base score if present). function deriveSeverity(record: OsvRecord, affected?: OsvAffected): string | null { const raw = affected?.database_specific?.severity ?? record.database_specific?.severity; if (raw) return raw.toLowerCase(); const cvss = record.severity?.find((s) => s.type?.startsWith("CVSS"))?.score; const score = cvss ? Number.parseFloat(cvss) : NaN; if (!Number.isNaN(score)) { if (score >= 9) return "critical"; if (score >= 7) return "high"; if (score >= 4) return "medium"; return "low"; } return null; } function pickAffected(record: OsvRecord, ecosystem: string, name: string): OsvAffected | undefined { const canon = canonicalEcosystem(ecosystem); return record.affected?.find( (a) => a.package?.name === name && (a.package?.ecosystem ?? "").toLowerCase() === canon.toLowerCase(), ); } export function cveIdFromAliases(record: OsvRecord): string | null { return record.aliases?.find((a) => a.startsWith("CVE-")) ?? record.id; } // Maps a full OSV record to an `advisories` row for a specific queried package. export function toAdvisoryRow(record: OsvRecord, ecosystem: string, name: string): NewAdvisory { const affected = pickAffected(record, ecosystem, name); return { osvId: record.id, ecosystem: canonicalEcosystem(ecosystem), packageName: name, severity: deriveSeverity(record, affected), summary: record.summary ?? null, details: record.details ?? null, aliases: record.aliases ?? [], rangesRaw: { ranges: affected?.ranges ?? [], versions: affected?.versions ?? [] }, modified: record.modified ? new Date(record.modified) : null, }; } export function fixedVersionFor(record: OsvRecord, ecosystem: string, name: string): string | null { const affected = pickAffected(record, ecosystem, name); return affected ? firstFixedVersion(affected) : null; } // Fetches many vuln records with bounded concurrency; failures are logged and skipped. export async function fetchVulns(ids: string[]): Promise> { const out = new Map(); const unique = [...new Set(ids)]; const limit = 6; for (let i = 0; i < unique.length; i += limit) { const batch = unique.slice(i, i + limit); const records = await Promise.all( batch.map((id) => fetchVuln(id).catch((err) => { logger.warn({ err, id }, "OSV vuln fetch failed"); return null; }), ), ); for (const rec of records) if (rec) out.set(rec.id, rec); } return out; }