feat(security): OSV.dev advisory feed replaces the hand-typed list as primary #5513
9 changed files+1394−29
Addeddrizzle/0125_osv_feed.sql+55−0View fileUnifiedSplit
@@ -0,0 +1,55 @@
1-- OSV.dev advisory feed cache (2026-08-22).
2--
3-- The advisory surface previously matched only against the 12-entry
4-- hand-typed seed list in src/lib/advisories.ts. These two tables back a
5-- real feed: per-(ecosystem, package, version) query results from
6-- https://api.osv.dev/v1/querybatch, plus the per-vulnerability detail
7-- documents.
8--
9-- osv_scan_state exists so that a package is re-queried at most once per
10-- TTL window (24h) regardless of how often a page renders — and it stores
11-- MISSES too (vuln_ids = '[]'), because without stored misses every clean
12-- package would hit the network on every render, which is exactly the
13-- unbounded-latency problem the cache is here to prevent.
14--
15-- osv_advisories is keyed by the OSV id itself (GHSA-…, CVE-…, PYSEC-…):
16-- OSV ids are globally unique and stable, and a synthetic uuid would force
17-- every reader through a join just to learn the id it wanted to display.
18--
19-- No FK between the two: vuln_ids is a jsonb array of osv_advisories ids,
20-- and a detail fetch that failed (fail-soft) must not block recording the
21-- querybatch hit — the detail is retried on the next refresh.
22
23--> statement-breakpoint
24CREATE TABLE IF NOT EXISTS "osv_advisories" (
25 "id" text PRIMARY KEY NOT NULL,
26 "ecosystem" text NOT NULL,
27 "package_name" text NOT NULL,
28 "summary" text NOT NULL,
29 "severity" text DEFAULT 'moderate' NOT NULL,
30 "cvss" text,
31 "affected_range" text,
32 "fixed_version" text,
33 "aliases" jsonb,
34 "refs" jsonb,
35 "modified_at" timestamp,
36 "fetched_at" timestamp DEFAULT now() NOT NULL
37);
38--> statement-breakpoint
39CREATE INDEX IF NOT EXISTS "osv_advisories_pkg_idx"
40 ON "osv_advisories" ("ecosystem", "package_name");
41--> statement-breakpoint
42CREATE TABLE IF NOT EXISTS "osv_scan_state" (
43 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
44 "ecosystem" text NOT NULL,
45 "package_name" text NOT NULL,
46 "version" text NOT NULL,
47 "vuln_ids" jsonb NOT NULL,
48 "fetched_at" timestamp DEFAULT now() NOT NULL
49);
50--> statement-breakpoint
51-- Load-bearing for the upsert in refreshOsvForRepo(): two concurrent
52-- refreshes of one repo must collapse to one state row per package, or the
53-- TTL check reads a stale duplicate forever.
54CREATE UNIQUE INDEX IF NOT EXISTS "osv_scan_state_pkg_uq"
55 ON "osv_scan_state" ("ecosystem", "package_name", "version");
Modifiedsrc/__tests__/copy-honesty.test.ts+12−3View fileUnifiedSplit
@@ -180,20 +180,29 @@ describe("copy honesty — auth/DB surfaces (source assertions)", () => {
180180 expect(s).toContain("/api/v2/integrations/slack/events");
181181 });
182182
183 // 2026-08-22: the "not a full CVE feed" disclaimers were retired with the
184 // OSV.dev feed (src/lib/osv.ts) — the capability shipped, so per the header
185 // above the assertion changes with it. What must stay honest now: the copy
186 // names the 24h cache window and the direct-deps-only scope, and no
187 // silence-as-all-clear chips come back.
183188 it("dependencies: no hardcoded '0 CVEs' / 'license: —' chips; advisory count is real", () => {
184189 const s = src("src/routes/deps.tsx");
185190 expect(s).not.toContain("const vulnCount: number = 0;");
186191 expect(s).not.toContain('{license || "license: —"}');
187192 expect(s).not.toContain('"No known vulnerabilities"');
188193 expect(s).toContain("repoAdvisoryAlerts");
189 expect(s).toContain("not a full CVE feed");
194 expect(s).toContain("listOsvAlertsForRepo");
195 expect(s).toContain("cached up");
196 expect(s).toContain("clean bill of health");
190197 });
191198
192 it("advisories: empty state names the built-in list and its size", () => {
199 it("advisories: copy names the OSV feed, its cache window, and the supplemental list size", () => {
193200 const s = src("src/routes/advisories.tsx");
194201 expect(s).not.toContain("No open vulnerabilities right now.");
195202 expect(s).toContain("SEED_ADVISORIES.length");
196 expect(s).toContain("not a full CVE feed");
203 expect(s).toContain("OSV.dev");
204 expect(s).toContain("cached up to 24h");
205 expect(s).toContain("not a full audit");
197206 });
198207
199208 it("org-memory: /:owner/:repo/memory is gated behind FABRICATED_METRICS_PAGES_ENABLED", () => {
Addedsrc/__tests__/osv.test.ts+444−0View fileUnifiedSplit
@@ -0,0 +1,444 @@
1/**
2 * OSV.dev feed client (src/lib/osv.ts).
3 *
4 * Network-free by construction: every fetch is a mock injected through the
5 * lib's fetchImpl seam. The DB-backed end-to-end (refresh twice → second run
6 * makes zero network calls) is gated on DATABASE_URL via the HAS_DB skipIf
7 * pattern used across the suite, and additionally on the 0125 tables
8 * existing — the suite must stay green against a DB that has not run the
9 * migration yet.
10 */
11
12import { describe, it, expect } from "bun:test";
13import {
14 QUERYBATCH_LIMIT,
15 OSV_TTL_MS,
16 depsToPackageKeys,
17 extractAffectedRange,
18 extractFixedVersion,
19 extractSeverity,
20 fetchVulnDetail,
21 isFresh,
22 osvUrl,
23 queryBatch,
24 refreshOsvForRepo,
25 toOsvEcosystem,
26 type OsvVulnDetail,
27} from "../lib/osv";
28
29const HAS_DB = Boolean(process.env.DATABASE_URL);
30
31type FetchCall = { url: string; body: unknown };
32
33/** Mock fetch that answers querybatch with empty results and records calls. */
34function mockFetch(
35 handler?: (url: string, init?: RequestInit) => Response | Promise<Response>
36) {
37 const calls: FetchCall[] = [];
38 const fn = (async (input: any, init?: RequestInit) => {
39 const url = String(input);
40 calls.push({
41 url,
42 body: init?.body ? JSON.parse(String(init.body)) : null,
43 });
44 if (handler) return handler(url, init);
45 if (url.includes("/querybatch")) {
46 const queries = (init?.body ? JSON.parse(String(init.body)) : {})
47 .queries as unknown[];
48 return new Response(
49 JSON.stringify({ results: (queries ?? []).map(() => ({})) }),
50 { status: 200 }
51 );
52 }
53 return new Response("{}", { status: 404 });
54 }) as typeof fetch;
55 return { fn, calls };
56}
57
58// ---------------------------------------------------------------------------
59// Ecosystem mapping
60// ---------------------------------------------------------------------------
61
62describe("osv — ecosystem mapping", () => {
63 it("maps internal names to OSV spellings", () => {
64 expect(toOsvEcosystem("npm")).toBe("npm");
65 expect(toOsvEcosystem("pypi")).toBe("PyPI");
66 expect(toOsvEcosystem("go")).toBe("Go");
67 expect(toOsvEcosystem("cargo")).toBe("crates.io");
68 expect(toOsvEcosystem("rubygems")).toBe("RubyGems");
69 expect(toOsvEcosystem("maven")).toBe("Maven");
70 expect(toOsvEcosystem("composer")).toBe("Packagist");
71 });
72
73 it("passes OSV-native spellings through", () => {
74 expect(toOsvEcosystem("PyPI")).toBe("PyPI");
75 expect(toOsvEcosystem("crates.io")).toBe("crates.io");
76 expect(toOsvEcosystem("RubyGems")).toBe("RubyGems");
77 });
78
79 it("returns null for ecosystems OSV does not index", () => {
80 expect(toOsvEcosystem("homebrew")).toBeNull();
81 expect(toOsvEcosystem("")).toBeNull();
82 });
83});
84
85describe("osv — depsToPackageKeys", () => {
86 it("maps, normalizes versions, dedupes, drops unmappable ecosystems", () => {
87 const keys = depsToPackageKeys([
88 { ecosystem: "npm", name: "lodash", versionSpec: "^4.17.10" },
89 // Same package via a second manifest — must collapse to one query.
90 { ecosystem: "npm", name: "lodash", versionSpec: "^4.17.10" },
91 { ecosystem: "cargo", name: "serde", versionSpec: "1.0.0" },
92 { ecosystem: "homebrew", name: "jq", versionSpec: "1.7" },
93 // Unpinnable spec → "" version (OSV returns all advisories).
94 { ecosystem: "pypi", name: "requests", versionSpec: "*" },
95 ]);
96 expect(keys).toEqual([
97 { ecosystem: "npm", name: "lodash", version: "4.17.10" },
98 { ecosystem: "crates.io", name: "serde", version: "1.0.0" },
99 { ecosystem: "PyPI", name: "requests", version: "" },
100 ]);
101 });
102});
103
104// ---------------------------------------------------------------------------
105// querybatch — chunking + fail-soft
106// ---------------------------------------------------------------------------
107
108describe("osv — queryBatch chunking", () => {
109 it("splits above the 1000-query limit and keeps results positional", async () => {
110 const { fn, calls } = mockFetch();
111 const queries = Array.from({ length: QUERYBATCH_LIMIT + 500 }, (_, i) => ({
112 ecosystem: "npm",
113 name: `pkg-${i}`,
114 version: "1.0.0",
115 }));
116 const results = await queryBatch(queries, fn);
117 expect(calls.length).toBe(2);
118 expect((calls[0].body as any).queries.length).toBe(QUERYBATCH_LIMIT);
119 expect((calls[1].body as any).queries.length).toBe(500);
120 expect(results.length).toBe(queries.length);
121 });
122
123 it("sends package + version in the OSV shape, omitting empty versions", async () => {
124 const { fn, calls } = mockFetch();
125 await queryBatch(
126 [
127 { ecosystem: "PyPI", name: "urllib3", version: "1.24.0" },
128 { ecosystem: "npm", name: "leftpad", version: "" },
129 ],
130 fn
131 );
132 const qs = (calls[0].body as any).queries;
133 expect(qs[0]).toEqual({
134 version: "1.24.0",
135 package: { name: "urllib3", ecosystem: "PyPI" },
136 });
137 expect(qs[1]).toEqual({ package: { name: "leftpad", ecosystem: "npm" } });
138 });
139
140 it("fail-soft: a rejected fetch yields null per query, never a throw", async () => {
141 const fn = (async () => {
142 throw new Error("ECONNREFUSED");
143 }) as unknown as typeof fetch;
144 const results = await queryBatch(
145 [{ ecosystem: "npm", name: "lodash", version: "1.0.0" }],
146 fn
147 );
148 expect(results).toEqual([null]);
149 });
150
151 it("fail-soft: non-200 and shape-mismatch responses also yield nulls", async () => {
152 const bad = mockFetch(() => new Response("nope", { status: 500 }));
153 expect(
154 await queryBatch([{ ecosystem: "npm", name: "a", version: "1" }], bad.fn)
155 ).toEqual([null]);
156
157 const mismatched = mockFetch(
158 () => new Response(JSON.stringify({ results: [] }), { status: 200 })
159 );
160 expect(
161 await queryBatch(
162 [{ ecosystem: "npm", name: "a", version: "1" }],
163 mismatched.fn
164 )
165 ).toEqual([null]);
166 });
167});
168
169describe("osv — fetchVulnDetail", () => {
170 it("returns the document on 200", async () => {
171 const { fn } = mockFetch(
172 () =>
173 new Response(JSON.stringify({ id: "GHSA-p6mc-m468-83gw" }), {
174 status: 200,
175 })
176 );
177 const detail = await fetchVulnDetail("GHSA-p6mc-m468-83gw", fn);
178 expect(detail?.id).toBe("GHSA-p6mc-m468-83gw");
179 });
180
181 it("fail-soft: null on network error or non-200", async () => {
182 const err = (async () => {
183 throw new Error("timeout");
184 }) as unknown as typeof fetch;
185 expect(await fetchVulnDetail("GHSA-x", err)).toBeNull();
186 const notFound = mockFetch(() => new Response("{}", { status: 404 }));
187 expect(await fetchVulnDetail("GHSA-x", notFound.fn)).toBeNull();
188 });
189});
190
191// ---------------------------------------------------------------------------
192// Severity + range extraction
193// ---------------------------------------------------------------------------
194
195describe("osv — extractSeverity", () => {
196 it("classifies numeric CVSS scores by threshold", () => {
197 const at = (score: string) =>
198 extractSeverity({ id: "X", severity: [{ type: "CVSS_V3", score }] })
199 .label;
200 expect(at("9.8")).toBe("critical");
201 expect(at("7.5")).toBe("high");
202 expect(at("5.3")).toBe("moderate");
203 expect(at("2.1")).toBe("low");
204 });
205
206 it("falls back to database_specific labels when the score is a vector", () => {
207 const vuln: OsvVulnDetail = {
208 id: "GHSA-x",
209 severity: [{ type: "CVSS_V3", score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N" }],
210 database_specific: { severity: "HIGH" },
211 };
212 const out = extractSeverity(vuln);
213 expect(out.label).toBe("high");
214 expect(out.cvss).toBe("CVSS:3.1/AV:N/AC:L/PR:N/UI:N");
215 expect(
216 extractSeverity({ id: "X", database_specific: { severity: "MODERATE" } })
217 .label
218 ).toBe("moderate");
219 });
220
221 it("defaults to moderate when OSV gives nothing usable", () => {
222 expect(extractSeverity({ id: "X" }).label).toBe("moderate");
223 expect(extractSeverity({ id: "X" }).cvss).toBeNull();
224 });
225});
226
227describe("osv — affected range / fixed version", () => {
228 const vuln: OsvVulnDetail = {
229 id: "GHSA-jfh8-c2jp-5v3q",
230 affected: [
231 {
232 package: { name: "log4j-core", ecosystem: "Maven" },
233 ranges: [
234 {
235 type: "ECOSYSTEM",
236 events: [{ introduced: "2.0" }, { fixed: "2.15.0" }],
237 },
238 ],
239 },
240 {
241 package: { name: "unrelated", ecosystem: "npm" },
242 ranges: [
243 { type: "ECOSYSTEM", events: [{ introduced: "0" }, { fixed: "9.9" }] },
244 ],
245 },
246 ],
247 };
248
249 it("renders >=introduced <fixed for the matching package only", () => {
250 expect(extractAffectedRange(vuln, "Maven", "log4j-core")).toBe(
251 ">=2.0 <2.15.0"
252 );
253 expect(extractFixedVersion(vuln, "Maven", "log4j-core")).toBe("2.15.0");
254 });
255
256 it("introduced: '0' renders as an unbounded lower edge", () => {
257 expect(extractAffectedRange(vuln, "npm", "unrelated")).toBe("<9.9");
258 });
259
260 it("returns null when OSV lists no ranges for the package", () => {
261 expect(extractAffectedRange({ id: "X" }, "npm", "a")).toBeNull();
262 expect(extractFixedVersion({ id: "X" }, "npm", "a")).toBeNull();
263 });
264
265 it("osvUrl points at the vulnerability page", () => {
266 expect(osvUrl("GHSA-p6mc-m468-83gw")).toBe(
267 "https://osv.dev/vulnerability/GHSA-p6mc-m468-83gw"
268 );
269 });
270});
271
272// ---------------------------------------------------------------------------
273// TTL
274// ---------------------------------------------------------------------------
275
276describe("osv — TTL (isFresh)", () => {
277 const now = new Date("2026-08-22T12:00:00Z");
278
279 it("fresh inside 24h, stale at/after 24h, stale when never fetched", () => {
280 expect(isFresh(new Date(now.getTime() - 60 * 60 * 1000), now)).toBe(true);
281 expect(isFresh(new Date(now.getTime() - OSV_TTL_MS + 1000), now)).toBe(
282 true
283 );
284 expect(isFresh(new Date(now.getTime() - OSV_TTL_MS), now)).toBe(false);
285 expect(isFresh(new Date(now.getTime() - 25 * 60 * 60 * 1000), now)).toBe(
286 false
287 );
288 expect(isFresh(null, now)).toBe(false);
289 expect(isFresh(undefined, now)).toBe(false);
290 });
291});
292
293// ---------------------------------------------------------------------------
294// refreshOsvForRepo — fail-soft without a DB; end-to-end TTL with one
295// ---------------------------------------------------------------------------
296
297describe("osv — refreshOsvForRepo fail-soft", () => {
298 it.skipIf(HAS_DB)("without a DB it resolves (null) instead of throwing", async () => {
299 const { fn, calls } = mockFetch();
300 const result = await refreshOsvForRepo(
301 "00000000-0000-0000-0000-000000000000",
302 { fetchImpl: fn }
303 );
304 expect(result).toBeNull();
305 expect(calls.length).toBe(0);
306 });
307});
308
309describe.skipIf(!HAS_DB)("osv — refresh end-to-end (DB-backed)", () => {
310 it("queries once, records hits and misses, then honors the 24h TTL", async () => {
311 const { db } = await import("../db");
312 const { users, repositories, repoDependencies, osvAdvisories, osvScanState } =
313 await import("../db/schema");
314 const { eq, inArray } = await import("drizzle-orm");
315 const { randomBytes } = await import("crypto");
316
317 // The 0125 tables may not exist on the DB this suite points at yet —
318 // skip rather than fail; the migration ships in the same PR.
319 try {
320 await db.select({ id: osvScanState.id }).from(osvScanState).limit(1);
321 } catch {
322 return;
323 }
324
325 const stamp = randomBytes(4).toString("hex");
326 const vulnId = `GHSA-test-${stamp}`;
327 const pkgVuln = `osv-vuln-${stamp}`;
328 const pkgClean = `osv-clean-${stamp}`;
329
330 const [u] = await db
331 .insert(users)
332 .values({
333 username: `osv-${stamp}`,
334 email: `osv-${stamp}@test.local`,
335 passwordHash: "x",
336 })
337 .returning({ id: users.id });
338 const [repo] = await db
339 .insert(repositories)
340 .values({
341 name: `osv-repo-${stamp}`,
342 ownerId: u!.id,
343 diskPath: `/tmp/osv-test-${stamp}.git`,
344 })
345 .returning({ id: repositories.id });
346
347 try {
348 await db.insert(repoDependencies).values([
349 {
350 repositoryId: repo!.id,
351 ecosystem: "npm",
352 name: pkgVuln,
353 versionSpec: "^1.0.0",
354 manifestPath: "package.json",
355 commitSha: "deadbeef",
356 },
357 {
358 repositoryId: repo!.id,
359 ecosystem: "npm",
360 name: pkgClean,
361 versionSpec: "^2.0.0",
362 manifestPath: "package.json",
363 commitSha: "deadbeef",
364 },
365 ]);
366
367 let batchCalls = 0;
368 const { fn } = mockFetch((url, init) => {
369 if (url.includes("/querybatch")) {
370 batchCalls++;
371 const queries = JSON.parse(String(init?.body)).queries as Array<{
372 package: { name: string };
373 }>;
374 return new Response(
375 JSON.stringify({
376 results: queries.map((q) =>
377 q.package.name === pkgVuln ? { vulns: [{ id: vulnId }] } : {}
378 ),
379 }),
380 { status: 200 }
381 );
382 }
383 if (url.includes("/vulns/")) {
384 return new Response(
385 JSON.stringify({
386 id: vulnId,
387 summary: "Test vulnerability",
388 database_specific: { severity: "HIGH" },
389 affected: [
390 {
391 package: { name: pkgVuln, ecosystem: "npm" },
392 ranges: [
393 { type: "SEMVER", events: [{ introduced: "0" }, { fixed: "1.2.0" }] },
394 ],
395 },
396 ],
397 } satisfies OsvVulnDetail),
398 { status: 200 }
399 );
400 }
401 return new Response("{}", { status: 404 });
402 });
403
404 const first = await refreshOsvForRepo(repo!.id, { fetchImpl: fn });
405 expect(first).not.toBeNull();
406 expect(first!.queried).toBe(2);
407 expect(first!.hits).toBe(1);
408 expect(batchCalls).toBe(1);
409
410 const advisory = await db
411 .select()
412 .from(osvAdvisories)
413 .where(eq(osvAdvisories.id, vulnId));
414 expect(advisory.length).toBe(1);
415 expect(advisory[0].severity).toBe("high");
416 expect(advisory[0].fixedVersion).toBe("1.2.0");
417
418 // Second refresh inside the TTL: both packages fresh (the miss was
419 // stored too), so zero network calls.
420 const second = await refreshOsvForRepo(repo!.id, { fetchImpl: fn });
421 expect(second).not.toBeNull();
422 expect(second!.queried).toBe(0);
423 expect(second!.skippedFresh).toBe(2);
424 expect(batchCalls).toBe(1);
425
426 const { listOsvAlertsForRepo } = await import("../lib/osv");
427 const alerts = await listOsvAlertsForRepo(repo!.id);
428 expect(alerts.length).toBe(1);
429 expect(alerts[0].advisory.id).toBe(vulnId);
430 expect(alerts[0].dependencyName).toBe(pkgVuln);
431 } finally {
432 await db
433 .delete(osvScanState)
434 .where(inArray(osvScanState.packageName, [pkgVuln, pkgClean]))
435 .catch(() => {});
436 await db
437 .delete(osvAdvisories)
438 .where(eq(osvAdvisories.id, vulnId))
439 .catch(() => {});
440 await db.delete(repositories).where(eq(repositories.id, repo!.id));
441 await db.delete(users).where(eq(users.id, u!.id));
442 }
443 });
444});
Modifiedsrc/db/schema.ts+72−0View fileUnifiedSplit
@@ -4979,3 +4979,75 @@ export const platformErrors = pgTable(
49794979
49804980export type PlatformError = typeof platformErrors.$inferSelect;
49814981export type NewPlatformError = typeof platformErrors.$inferInsert;
4982
4983// ---------------------------------------------------------------------------
4984// OSV.dev advisory feed cache (2026-08-22, migration 0125).
4985//
4986// Backs src/lib/osv.ts. The advisory surface used to consult only the
4987// hand-typed SEED_ADVISORIES list in src/lib/advisories.ts; these tables hold
4988// real querybatch results from https://api.osv.dev. `osv_scan_state` stores
4989// one row per (ecosystem, package, version) INCLUDING misses (vulnIds = []),
4990// so a clean package costs one network call per 24h TTL window, not one per
4991// render. `osv_advisories` is keyed by the OSV id itself (GHSA-/CVE-/PYSEC-…)
4992// — globally unique and the exact string the UI displays, so a synthetic
4993// uuid would only add a join. No FK from vulnIds to osv_advisories: a failed
4994// detail fetch (fail-soft) must not block recording the querybatch hit.
4995// ---------------------------------------------------------------------------
4996
4997export const osvAdvisories = pgTable(
4998 "osv_advisories",
4999 {
5000 // OSV vulnerability id: "GHSA-…", "CVE-…", "PYSEC-…", "RUSTSEC-…", "GO-…"
5001 id: text("id").primaryKey(),
5002 // OSV ecosystem string ("npm", "PyPI", "Go", "crates.io", …), not the
5003 // internal lowercase names repo_dependencies uses.
5004 ecosystem: text("ecosystem").notNull(),
5005 packageName: text("package_name").notNull(),
5006 summary: text("summary").notNull(),
5007 // "critical" | "high" | "moderate" | "low" — same vocabulary as
5008 // security_advisories.severity so severityClass() works unchanged.
5009 severity: text("severity").default("moderate").notNull(),
5010 // Raw CVSS score or vector string when OSV supplies one.
5011 cvss: text("cvss"),
5012 // Human-readable range derived from affected[].ranges events,
5013 // e.g. ">=2.0.0 <2.15.0".
5014 affectedRange: text("affected_range"),
5015 fixedVersion: text("fixed_version"),
5016 aliases: jsonb("aliases"),
5017 refs: jsonb("refs"),
5018 modifiedAt: timestamp("modified_at"),
5019 fetchedAt: timestamp("fetched_at").defaultNow().notNull(),
5020 },
5021 (table) => [
5022 index("osv_advisories_pkg_idx").on(table.ecosystem, table.packageName),
5023 ]
5024);
5025
5026export type OsvAdvisory = typeof osvAdvisories.$inferSelect;
5027
5028export const osvScanState = pgTable(
5029 "osv_scan_state",
5030 {
5031 id: uuid("id").primaryKey().defaultRandom(),
5032 ecosystem: text("ecosystem").notNull(),
5033 packageName: text("package_name").notNull(),
5034 // Concrete version queried; "" when the manifest spec had no pinnable
5035 // version (OSV then returns every advisory for the package).
5036 version: text("version").notNull(),
5037 // jsonb array of osv_advisories ids. [] is a stored miss and is what
5038 // keeps clean packages off the network inside the TTL.
5039 vulnIds: jsonb("vuln_ids").notNull(),
5040 fetchedAt: timestamp("fetched_at").defaultNow().notNull(),
5041 },
5042 (table) => [
5043 // Two concurrent refreshes must collapse to one state row per package —
5044 // the refresh path upserts on this key.
5045 uniqueIndex("osv_scan_state_pkg_uq").on(
5046 table.ecosystem,
5047 table.packageName,
5048 table.version
5049 ),
5050 ]
5051);
5052
5053export type OsvScanState = typeof osvScanState.$inferSelect;
Modifiedsrc/lib/advisories.ts+7−0View fileUnifiedSplit
@@ -1,6 +1,13 @@
11/**
22 * Block J2 — Security advisories + per-repo alerts.
33 *
4 * SUPERSEDED AS PRIMARY SOURCE (2026-08-22): the live OSV.dev feed in
5 * ./osv.ts is now the primary advisory source on every surface. This module
6 * and its SEED_ADVISORIES list stay as a supplemental source — the seed
7 * alerts carry the dismiss/reopen lifecycle and cover ecosystems while the
8 * OSV cache warms — and its version matchers are reused by osv.ts. Do not
9 * grow the seed list; add nothing here that OSV already provides.
10 *
411 * This module does three things:
512 * 1. Provides a seeded set of well-known public advisories (log4j, lodash,
613 * minimist, etc.) that get inserted into `security_advisories` on first
Modifiedsrc/lib/autopilot.ts+5−0View fileUnifiedSplit
@@ -39,6 +39,7 @@ import { syncAllDue } from "./mirrors";
3939import { peekHead, requeueStaleRunning } from "./merge-queue";
4040import { sendDigestsToAll } from "./email-digest";
4141import { scanRepositoryForAlerts } from "./advisories";
42import { refreshOsvForRepo } from "./osv";
4243import { releaseExpiredWaitTimers } from "./environments";
4344import { runScheduledWorkflowsTick } from "./scheduled-workflows";
4445import {
@@ -2049,6 +2050,10 @@ async function rescanAdvisoriesBatch(limit: number): Promise<void> {
20492050 }
20502051 for (const id of repoIds) {
20512052 try {
2053 // OSV cache refresh rides the same batch. TTL-guarded inside — a repo
2054 // whose packages were queried in the last 24h costs two SELECTs and no
2055 // network. Never throws (fail-soft in osv.ts).
2056 await refreshOsvForRepo(id);
20522057 await scanRepositoryForAlerts(id);
20532058 } catch (err) {
20542059 console.error(
Addedsrc/lib/osv.ts+634−0View fileUnifiedSplit
@@ -0,0 +1,634 @@
1/**
2 * OSV.dev advisory feed — the real CVE/GHSA source behind the advisory UI.
3 *
4 * Replaces the hand-typed SEED_ADVISORIES list in ./advisories.ts as the
5 * primary advisory source (the seed list stays as a supplemental source and
6 * keeps its own alert lifecycle). Two-layer cache, both in Postgres:
7 *
8 * osv_scan_state — one row per (OSV ecosystem, package, version) holding
9 * the querybatch result (vuln id array — [] is a stored miss). TTL-based:
10 * a package is re-queried at most once per 24h no matter how often a
11 * page renders.
12 * osv_advisories — one row per OSV vulnerability id with the display
13 * fields (severity, affected range, fixed version, refs).
14 *
15 * Contract with callers:
16 * - `listOsvAlertsForRepo` is a pure cache read — never touches the
17 * network, safe to await in a request handler.
18 * - `refreshOsvForRepo` does the network work and is TTL-guarded; routes
19 * call it fire-and-forget (render stale, refresh in background), the
20 * autopilot tick awaits it.
21 * - Every network/parse failure degrades to cached/empty data. Nothing in
22 * this module throws into a caller. A querybatch failure is NOT recorded
23 * as a miss — caching a false all-clear for 24h is worse than retrying.
24 *
25 * `fetchImpl` is injectable on every entry point so tests never hit the
26 * network.
27 */
28
29import { and, eq, inArray } from "drizzle-orm";
30import { db } from "../db";
31import {
32 osvAdvisories,
33 osvScanState,
34 repoDependencies,
35 type OsvAdvisory,
36} from "../db/schema";
37import { normalizeManifestVersion } from "./advisories";
38
39// ---------------------------------------------------------------------------
40// Constants + ecosystem mapping
41// ---------------------------------------------------------------------------
42
43export const OSV_API_BASE = "https://api.osv.dev/v1";
44// OSV documents a 1000-query cap per querybatch call.
45export const QUERYBATCH_LIMIT = 1000;
46export const OSV_TTL_MS = 24 * 60 * 60 * 1000;
47const FETCH_TIMEOUT_MS = 10_000;
48// Detail fetches are one GET per vuln id; cap per refresh so a repo with a
49// huge dependency tree cannot turn one background refresh into hundreds of
50// serial requests. Uncached ids are picked up by the next refresh.
51const MAX_DETAIL_FETCHES = 60;
52
53type FetchLike = typeof fetch;
54
55/**
56 * Internal ecosystem names (src/lib/deps.ts, repo_dependencies.ecosystem)
57 * → OSV ecosystem strings. OSV-native names (as used by
58 * dependency-scanner.ts) pass through unchanged.
59 */
60const ECOSYSTEM_TO_OSV: Record<string, string> = {
61 npm: "npm",
62 pypi: "PyPI",
63 go: "Go",
64 cargo: "crates.io",
65 rubygems: "RubyGems",
66 maven: "Maven",
67 composer: "Packagist",
68 // OSV-native spellings map to themselves.
69 PyPI: "PyPI",
70 "crates.io": "crates.io",
71 RubyGems: "RubyGems",
72 Go: "Go",
73 Maven: "Maven",
74 Packagist: "Packagist",
75};
76
77/** Returns the OSV ecosystem string, or null when OSV has no such ecosystem. */
78export function toOsvEcosystem(ecosystem: string): string | null {
79 return ECOSYSTEM_TO_OSV[ecosystem] ?? ECOSYSTEM_TO_OSV[ecosystem.toLowerCase()] ?? null;
80}
81
82// ---------------------------------------------------------------------------
83// OSV wire types (subset)
84// ---------------------------------------------------------------------------
85
86export interface OsvPackageQuery {
87 name: string;
88 ecosystem: string; // OSV spelling
89 version: string; // "" allowed — OSV returns all advisories for the package
90}
91
92interface OsvEvent {
93 introduced?: string;
94 fixed?: string;
95 last_affected?: string;
96 limit?: string;
97}
98
99interface OsvAffected {
100 package?: { name?: string; ecosystem?: string };
101 ranges?: Array<{ type?: string; events?: OsvEvent[] }>;
102 versions?: string[];
103}
104
105export interface OsvVulnDetail {
106 id: string;
107 summary?: string;
108 details?: string;
109 aliases?: string[];
110 modified?: string;
111 severity?: Array<{ type?: string; score?: string }>;
112 affected?: OsvAffected[];
113 references?: Array<{ type?: string; url?: string }>;
114 database_specific?: { severity?: string };
115}
116
117/** querybatch returns ids (+ modified) only, not full documents. */
118interface OsvBatchResult {
119 vulns?: Array<{ id: string; modified?: string }>;
120}
121
122// ---------------------------------------------------------------------------
123// HTTP layer — chunked querybatch + per-vuln detail GET
124// ---------------------------------------------------------------------------
125
126/**
127 * POST /v1/querybatch, chunked at QUERYBATCH_LIMIT. Result array is
128 * positional (parallel to `queries`). `null` marks a failed chunk — callers
129 * must skip those entries rather than record them as misses.
130 */
131export async function queryBatch(
132 queries: OsvPackageQuery[],
133 fetchImpl: FetchLike = fetch
134): Promise<(OsvBatchResult | null)[]> {
135 const out: (OsvBatchResult | null)[] = [];
136 for (let i = 0; i < queries.length; i += QUERYBATCH_LIMIT) {
137 const chunk = queries.slice(i, i + QUERYBATCH_LIMIT);
138 try {
139 const res = await fetchImpl(`${OSV_API_BASE}/querybatch`, {
140 method: "POST",
141 headers: { "Content-Type": "application/json" },
142 body: JSON.stringify({
143 queries: chunk.map((q) => ({
144 ...(q.version ? { version: q.version } : {}),
145 package: { name: q.name, ecosystem: q.ecosystem },
146 })),
147 }),
148 signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
149 });
150 if (!res.ok) {
151 out.push(...chunk.map(() => null));
152 continue;
153 }
154 const data = (await res.json()) as { results?: OsvBatchResult[] };
155 const results = data.results;
156 if (!Array.isArray(results) || results.length !== chunk.length) {
157 out.push(...chunk.map(() => null));
158 continue;
159 }
160 out.push(...results);
161 } catch {
162 out.push(...chunk.map(() => null));
163 }
164 }
165 return out;
166}
167
168/** GET /v1/vulns/{id}. Null on any failure. */
169export async function fetchVulnDetail(
170 id: string,
171 fetchImpl: FetchLike = fetch
172): Promise<OsvVulnDetail | null> {
173 try {
174 const res = await fetchImpl(
175 `${OSV_API_BASE}/vulns/${encodeURIComponent(id)}`,
176 { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }
177 );
178 if (!res.ok) return null;
179 const data = (await res.json()) as OsvVulnDetail;
180 if (!data || typeof data.id !== "string") return null;
181 return data;
182 } catch {
183 return null;
184 }
185}
186
187// ---------------------------------------------------------------------------
188// Detail-document extraction
189// ---------------------------------------------------------------------------
190
191export type SeverityLabel = "critical" | "high" | "moderate" | "low";
192
193/**
194 * Severity: numeric CVSS score when one parses; otherwise the GHSA-style
195 * database_specific.severity label. The raw score/vector string is preserved
196 * for display. Default "moderate" — same vocabulary as
197 * security_advisories.severity, so the existing severityClass() maps it.
198 */
199export function extractSeverity(vuln: OsvVulnDetail): {
200 label: SeverityLabel;
201 cvss: string | null;
202} {
203 const cvss = vuln.severity?.find((s) => s.score)?.score ?? null;
204 for (const sev of vuln.severity ?? []) {
205 if (!sev.score) continue;
206 const score = parseFloat(sev.score);
207 if (!Number.isNaN(score)) {
208 if (score >= 9.0) return { label: "critical", cvss };
209 if (score >= 7.0) return { label: "high", cvss };
210 if (score >= 4.0) return { label: "moderate", cvss };
211 return { label: "low", cvss };
212 }
213 }
214 const dbSev = vuln.database_specific?.severity?.toUpperCase();
215 if (dbSev === "CRITICAL") return { label: "critical", cvss };
216 if (dbSev === "HIGH") return { label: "high", cvss };
217 if (dbSev === "MODERATE" || dbSev === "MEDIUM")
218 return { label: "moderate", cvss };
219 if (dbSev === "LOW") return { label: "low", cvss };
220 return { label: "moderate", cvss };
221}
222
223/** Affected entries for this (ecosystem, package) only — one OSV document can
224 * span several packages, and rendering another package's range would be a
225 * plausible-looking lie. */
226function affectedForPackage(
227 vuln: OsvVulnDetail,
228 osvEcosystem: string,
229 packageName: string
230): OsvAffected[] {
231 const matches = (vuln.affected ?? []).filter(
232 (a) =>
233 a.package?.ecosystem === osvEcosystem &&
234 (a.package?.name ?? "").toLowerCase() === packageName.toLowerCase()
235 );
236 // A single-package document sometimes omits the package block; keep it.
237 if (matches.length === 0 && (vuln.affected ?? []).length === 1) {
238 return vuln.affected ?? [];
239 }
240 return matches;
241}
242
243/** ">=X <Y" per range, ranges joined with "; ". Null when OSV gives none. */
244export function extractAffectedRange(
245 vuln: OsvVulnDetail,
246 osvEcosystem: string,
247 packageName: string
248): string | null {
249 const clauses: string[] = [];
250 for (const affected of affectedForPackage(vuln, osvEcosystem, packageName)) {
251 for (const range of affected.ranges ?? []) {
252 const parts: string[] = [];
253 for (const ev of range.events ?? []) {
254 if (ev.introduced !== undefined && ev.introduced !== "0") {
255 parts.push(`>=${ev.introduced}`);
256 }
257 if (ev.fixed) parts.push(`<${ev.fixed}`);
258 if (ev.last_affected) parts.push(`<=${ev.last_affected}`);
259 }
260 if (parts.length > 0) clauses.push(parts.join(" "));
261 else if ((range.events ?? []).some((e) => e.introduced === "0"))
262 clauses.push("all versions");
263 }
264 if ((affected.ranges ?? []).length === 0 && affected.versions?.length) {
265 clauses.push(affected.versions.slice(0, 6).join(", "));
266 }
267 }
268 if (clauses.length === 0) return null;
269 return clauses.join("; ");
270}
271
272/** First fixed version OSV reports for this package, if any. */
273export function extractFixedVersion(
274 vuln: OsvVulnDetail,
275 osvEcosystem: string,
276 packageName: string
277): string | null {
278 for (const affected of affectedForPackage(vuln, osvEcosystem, packageName)) {
279 for (const range of affected.ranges ?? []) {
280 for (const ev of range.events ?? []) {
281 if (ev.fixed) return ev.fixed;
282 }
283 }
284 }
285 return null;
286}
287
288export function osvUrl(id: string): string {
289 return `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
290}
291
292// ---------------------------------------------------------------------------
293// Package-key plumbing
294// ---------------------------------------------------------------------------
295
296interface PackageKey extends OsvPackageQuery {}
297
298function keyOf(p: { ecosystem: string; name: string; version: string }): string {
299 return `${p.ecosystem}