146 lines
5.2 KiB
TypeScript
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;
|
|
}
|