cefense/apps/backend/src/cve/osv.ts
2026-08-18 19:38:47 +05:30

146 lines
5.2 KiB
TypeScript

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<string, string> = {
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<string[][]> {
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<OsvRecord> {
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<Map<string, OsvRecord>> {
const out = new Map<string, OsvRecord>();
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;
}