CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(security): OSV.dev advisory feed replaces the hand-typed list as primary #5513

Merged⚡ AI-generatedXSccantynz wants to mergefeat/osv-advisoriesmainopened 11d ago
9 changed files+1394−29
Addeddrizzle/0125_osv_feed.sql+55−0View fileUnifiedSplit
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
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
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
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
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
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 {
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
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}${p.name}${p.version}`;
300}
301
302/**
303 * repo_dependencies rows → distinct OSV package queries. Ecosystems OSV
304 * doesn't index are dropped; manifest specs collapse to a concrete version
305 * via the same normalizer the seed-list matcher uses ("" when unpinnable —
306 * OSV then reports every advisory for the package, which errs on the side
307 * of surfacing risk, matching rangeMatches()'s conservative choice).
308 */
309export function depsToPackageKeys(
310 deps: Array<{ ecosystem: string; name: string; versionSpec: string | null }>
311): PackageKey[] {
312 const seen = new Set<string>();
313 const out: PackageKey[] = [];
314 for (const dep of deps) {
315 const osvEco = toOsvEcosystem(dep.ecosystem);
316 if (!osvEco) continue;
317 const version = normalizeManifestVersion(dep.versionSpec) ?? "";
318 const pkg = { ecosystem: osvEco, name: dep.name, version };
319 const key = keyOf(pkg);
320 if (seen.has(key)) continue;
321 seen.add(key);
322 out.push(pkg);
323 }
324 return out;
325}
326
327/** TTL check, pure so tests don't need a database. */
328export function isFresh(
329 fetchedAt: Date | null | undefined,
330 now: Date,
331 ttlMs: number = OSV_TTL_MS
332): boolean {
333 if (!fetchedAt) return false;
334 return now.getTime() - fetchedAt.getTime() < ttlMs;
335}
336
337// ---------------------------------------------------------------------------
338// Refresh — the only network path
339// ---------------------------------------------------------------------------
340
341export interface RefreshResult {
342 queried: number; // packages sent to querybatch
343 skippedFresh: number; // packages inside the TTL window
344 hits: number; // packages with >=1 vuln among those queried
345 detailsFetched: number;
346}
347
348export interface RefreshOpts {
349 fetchImpl?: FetchLike;
350 now?: () => Date;
351 ttlMs?: number;
352 maxDetailFetches?: number;
353}
354
355/**
356 * TTL-guarded refresh of the OSV cache for one repository. Steady state
357 * (everything fresh) costs two SELECTs and zero network calls. Never throws.
358 */
359export async function refreshOsvForRepo(
360 repositoryId: string,
361 opts: RefreshOpts = {}
362): Promise<RefreshResult | null> {
363 const fetchImpl = opts.fetchImpl ?? fetch;
364 const now = (opts.now ?? (() => new Date()))();
365 const ttlMs = opts.ttlMs ?? OSV_TTL_MS;
366 const maxDetails = opts.maxDetailFetches ?? MAX_DETAIL_FETCHES;
367 try {
368 const deps = await db
369 .select({
370 ecosystem: repoDependencies.ecosystem,
371 name: repoDependencies.name,
372 versionSpec: repoDependencies.versionSpec,
373 })
374 .from(repoDependencies)
375 .where(eq(repoDependencies.repositoryId, repositoryId));
376 const pkgs = depsToPackageKeys(deps);
377 if (pkgs.length === 0) {
378 return { queried: 0, skippedFresh: 0, hits: 0, detailsFetched: 0 };
379 }
380
381 const stateRows = await loadStateRows(pkgs);
382 // Two views of state, kept current as the stale loop writes: fetchedAt
383 // drives the TTL, vulnIdsByKey attributes each vuln id to the package
384 // that reported it (detail rows store that attribution).
385 const fetchedAtByKey = new Map<string, Date>();
386 const vulnIdsByKey = new Map<string, string[]>();
387 for (const r of stateRows) {
388 const k = keyOf({
389 ecosystem: r.ecosystem,
390 name: r.packageName,
391 version: r.version,
392 });
393 fetchedAtByKey.set(k, r.fetchedAt);
394 vulnIdsByKey.set(k, asStringArray(r.vulnIds));
395 }
396
397 const stale = pkgs.filter(
398 (p) => !isFresh(fetchedAtByKey.get(keyOf(p)), now, ttlMs)
399 );
400 const skippedFresh = pkgs.length - stale.length;
401
402 let hits = 0;
403 if (stale.length > 0) {
404 const results = await queryBatch(stale, fetchImpl);
405 for (let i = 0; i < stale.length; i++) {
406 const result = results[i];
407 // null = that chunk failed; recording it as a miss would cache a
408 // false all-clear for the whole TTL window.
409 if (result === null || result === undefined) continue;
410 const ids = (result.vulns ?? []).map((v) => v.id).filter(Boolean);
411 if (ids.length > 0) hits++;
412 const pkg = stale[i];
413 vulnIdsByKey.set(keyOf(pkg), ids);
414 await db
415 .insert(osvScanState)
416 .values({
417 ecosystem: pkg.ecosystem,
418 packageName: pkg.name,
419 version: pkg.version,
420 vulnIds: ids,
421 fetchedAt: now,
422 })
423 .onConflictDoUpdate({
424 target: [
425 osvScanState.ecosystem,
426 osvScanState.packageName,
427 osvScanState.version,
428 ],
429 set: { vulnIds: ids, fetchedAt: now },
430 });
431 }
432 }
433
434 // Detail documents for any known id not yet cached — including ids whose
435 // detail fetch failed on a previous refresh (self-healing).
436 const knownIds = new Set<string>();
437 for (const ids of vulnIdsByKey.values()) for (const id of ids) knownIds.add(id);
438 let detailsFetched = 0;
439 if (knownIds.size > 0) {
440 const cached = await db
441 .select({ id: osvAdvisories.id })
442 .from(osvAdvisories)
443 .where(inArray(osvAdvisories.id, [...knownIds]));
444 const cachedIds = new Set(cached.map((r) => r.id));
445 const idToPkg = buildIdToPackageIndex(pkgs, vulnIdsByKey);
446 for (const id of knownIds) {
447 if (cachedIds.has(id)) continue;
448 if (detailsFetched >= maxDetails) break;
449 const detail = await fetchVulnDetail(id, fetchImpl);
450 detailsFetched++;
451 if (!detail) continue;
452 const pkg = idToPkg.get(id) ?? { ecosystem: "", name: "", version: "" };
453 const { label, cvss } = extractSeverity(detail);
454 await db
455 .insert(osvAdvisories)
456 .values({
457 id: detail.id,
458 ecosystem: pkg.ecosystem,
459 packageName: pkg.name,
460 summary:
461 detail.summary ||
462 (detail.details ?? "").slice(0, 300) ||
463 "No summary provided by OSV.",
464 severity: label,
465 cvss,
466 affectedRange: extractAffectedRange(detail, pkg.ecosystem, pkg.name),
467 fixedVersion: extractFixedVersion(detail, pkg.ecosystem, pkg.name),
468 aliases: detail.aliases ?? [],
469 refs: (detail.references ?? [])
470 .map((r) => r.url)
471 .filter((u): u is string => Boolean(u))
472 .slice(0, 20),
473 modifiedAt: detail.modified ? new Date(detail.modified) : null,
474 fetchedAt: now,
475 })
476 .onConflictDoUpdate({
477 target: osvAdvisories.id,
478 set: { fetchedAt: now },
479 });
480 }
481 }
482
483 return { queried: stale.length, skippedFresh, hits, detailsFetched };
484 } catch (err) {
485 console.error("[osv] refreshOsvForRepo:", err);
486 return null;
487 }
488}
489
490/** State rows for a set of package keys. Filtered in memory after a
491 * name-based fetch — the key is composite and the set is repo-sized. */
492async function loadStateRows(pkgs: PackageKey[]) {
493 const names = [...new Set(pkgs.map((p) => p.name))];
494 if (names.length === 0) return [];
495 const wanted = new Set(pkgs.map((p) => keyOf(p)));
496 const rows = await db
497 .select()
498 .from(osvScanState)
499 .where(inArray(osvScanState.packageName, names));
500 return rows.filter((r) =>
501 wanted.has(keyOf({ ecosystem: r.ecosystem, name: r.packageName, version: r.version }))
502 );
503}
504
505function asStringArray(v: unknown): string[] {
506 return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
507}
508
509/** vuln id → the package whose state row reported it (for detail storage). */
510function buildIdToPackageIndex(
511 pkgs: PackageKey[],
512 vulnIdsByKey: Map<string, string[]>
513): Map<string, PackageKey> {
514 const out = new Map<string, PackageKey>();
515 for (const pkg of pkgs) {
516 for (const id of vulnIdsByKey.get(keyOf(pkg)) ?? []) {
517 if (!out.has(id)) out.set(id, pkg);
518 }
519 }
520 return out;
521}
522
523// ---------------------------------------------------------------------------
524// Read path — pure cache, no network
525// ---------------------------------------------------------------------------
526
527export interface OsvAlert {
528 dependencyName: string;
529 dependencyVersion: string | null;
530 manifestPath: string;
531 ecosystem: string; // OSV spelling, for display
532 advisory: OsvAdvisory;
533}
534
535/**
536 * Current OSV matches for a repo, from cache only. A vuln id whose detail
537 * document is not cached yet still surfaces (stub summary) — a known hit
538 * must never render as clean just because the detail GET hasn't landed.
539 */
540export async function listOsvAlertsForRepo(
541 repositoryId: string
542): Promise<OsvAlert[]> {
543 try {
544 const deps = await db
545 .select()
546 .from(repoDependencies)
547 .where(eq(repoDependencies.repositoryId, repositoryId));
548 if (deps.length === 0) return [];
549 const pkgs = depsToPackageKeys(deps);
550 const stateRows = await loadStateRows(pkgs);
551 const stateByKey = new Map(
552 stateRows.map((r) => [
553 keyOf({ ecosystem: r.ecosystem, name: r.packageName, version: r.version }),
554 r,
555 ])
556 );
557
558 const allIds = new Set<string>();
559 for (const r of stateRows) for (const id of asStringArray(r.vulnIds)) allIds.add(id);
560 const details =
561 allIds.size === 0
562 ? []
563 : await db
564 .select()
565 .from(osvAdvisories)
566 .where(inArray(osvAdvisories.id, [...allIds]));
567 const detailById = new Map(details.map((d) => [d.id, d]));
568
569 const out: OsvAlert[] = [];
570 const emitted = new Set<string>();
571 for (const dep of deps) {
572 const osvEco = toOsvEcosystem(dep.ecosystem);
573 if (!osvEco) continue;
574 const version = normalizeManifestVersion(dep.versionSpec) ?? "";
575 const state = stateByKey.get(
576 keyOf({ ecosystem: osvEco, name: dep.name, version })
577 );
578 if (!state) continue;
579 for (const id of asStringArray(state.vulnIds)) {
580 const dedupe = `${id}${dep.name}${dep.manifestPath}`;
581 if (emitted.has(dedupe)) continue;
582 emitted.add(dedupe);
583 const advisory: OsvAdvisory =
584 detailById.get(id) ??
585 ({
586 id,
587 ecosystem: osvEco,
588 packageName: dep.name,
589 summary:
590 "Advisory details not cached yet — follow the OSV link for the full record.",
591 severity: "moderate",
592 cvss: null,
593 affectedRange: null,
594 fixedVersion: null,
595 aliases: [],
596 refs: [],
597 modifiedAt: null,
598 fetchedAt: state.fetchedAt,
599 } as OsvAdvisory);
600 out.push({
601 dependencyName: dep.name,
602 dependencyVersion: dep.versionSpec ?? null,
603 manifestPath: dep.manifestPath,
604 ecosystem: osvEco,
605 advisory,
606 });
607 }
608 }
609 const rank: Record<string, number> = { critical: 0, high: 1, moderate: 2, low: 3 };
610 out.sort(
611 (a, b) =>
612 (rank[a.advisory.severity] ?? 4) - (rank[b.advisory.severity] ?? 4) ||
613 a.dependencyName.localeCompare(b.dependencyName)
614 );
615 return out;
616 } catch (err) {
617 console.error("[osv] listOsvAlertsForRepo:", err);
618 return [];
619 }
620}
621
622// ---------------------------------------------------------------------------
623// Test seam
624// ---------------------------------------------------------------------------
625
626export const __internal = {
627 keyOf,
628 asStringArray,
629 affectedForPackage,
630 buildIdToPackageIndex,
631 loadStateRows,
632 FETCH_TIMEOUT_MS,
633 MAX_DETAIL_FETCHES,
634};
Modifiedsrc/routes/advisories.tsx+140−16View fileUnifiedSplit
2929 scanRepositoryForAlerts,
3030 seedAdvisories,
3131} from "../lib/advisories";
32
33// The advisory "database" is the hand-maintained SEED_ADVISORIES list in
34// src/lib/advisories.ts — not a live CVE / GHSA feed. Every empty state and
35// intro below says so, with the real entry count, so "no advisories" is
36// never read as "no vulnerabilities".
32import {
33 listOsvAlertsForRepo,
34 osvUrl,
35 refreshOsvForRepo,
36 type OsvAlert,
37} from "../lib/osv";
38
39// Primary source: the OSV.dev feed (src/lib/osv.ts) — cached in Postgres,
40// refreshed at most every 24h per package, rendered from cache only.
41// Supplemental: the hand-maintained SEED_ADVISORIES list, whose alerts keep
42// the dismiss/reopen lifecycle. The copy still names both so a clean result
43// is read as "no known OSV match for the indexed direct deps", not "audited".
3744const ADVISORY_LIST_SIZE = SEED_ADVISORIES.length;
3845
3946const advisories = new Hono<AuthEnv>();
459466 .adv-status.is-dismissed { color: #cbd5e1; }
460467 .adv-status.is-fixed { color: var(--green); border-color: rgba(52,211,153,0.32); background: rgba(52,211,153,0.10); }
461468
469 /* Section label — separates the OSV feed from the supplemental list */
470 .adv-section-label {
471 margin: var(--space-4) 0 var(--space-2);
472 font-family: var(--font-mono);
473 font-size: 11px;
474 font-weight: 700;
475 letter-spacing: 0.14em;
476 text-transform: uppercase;
477 color: var(--text-muted);
478 }
479
462480 /* Empty state — dashed orb card */
463481 .adv-empty {
464482 position: relative;
509527
510528 const isOwner = !!user && user.id === repo.ownerId;
511529 const alerts = await listAlertsForRepo(repo.id, status);
530 // OSV feed: render from cache, refresh in the background. The TTL inside
531 // refreshOsvForRepo makes the steady-state background call two SELECTs and
532 // no network; it must never block this response.
533 const osvAlerts = await listOsvAlertsForRepo(repo.id);
534 void refreshOsvForRepo(repo.id);
512535 const message = c.req.query("message");
513536 const error = c.req.query("error");
514537
535558 </h2>
536559 <p class="adv-sub">
537560 Cross-references this repo's parsed dependency graph against
538 Gluecron's built-in advisory list ({ADVISORY_LIST_SIZE}{" "}
539 hand-maintained entries — not a full CVE / GHSA feed, so a
540 clean result is not proof of a clean dependency tree). Run{" "}
541 <em>Reindex</em> on{" "}
561 the <a href="https://osv.dev" rel="noreferrer" target="_blank">OSV.dev</a>{" "}
562 vulnerability feed (results cached up to 24h per package),
563 plus Gluecron's built-in supplemental list ({ADVISORY_LIST_SIZE}{" "}
564 hand-maintained entries). Only indexed direct dependencies
565 are checked — run <em>Reindex</em> on{" "}
542566 <a href={`/${ownerName}/${repoName}/dependencies`}>
543567 Dependencies
544568 </a>{" "}
571595 <div class="adv-banner is-error">{decodeURIComponent(error)}</div>
572596 )}
573597
574 {status === "open" && alerts.length === 0 && (
598 {status === "open" && alerts.length === 0 && osvAlerts.length === 0 && (
575599 <div class="adv-healthy" role="status">
576600 <span class="adv-healthy-icon" aria-hidden="true">
577601 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
603627 </a>
604628 </nav>
605629
606 {alerts.length === 0 ? (
630 {osvAlerts.length > 0 && (
631 <>
632 <h3 class="adv-section-label">
633 OSV.dev feed · {osvAlerts.length} match
634 {osvAlerts.length === 1 ? "" : "es"}
635 </h3>
636 <div class="adv-list">
637 {osvAlerts.map((a: OsvAlert) => {
638 const sevClass = severityClass(a.advisory.severity);
639 const aliases = Array.isArray(a.advisory.aliases)
640 ? (a.advisory.aliases as unknown[]).filter(
641 (x): x is string => typeof x === "string"
642 )
643 : [];
644 const cve = a.advisory.id.startsWith("CVE-")
645 ? null
646 : aliases.find((x) => x.startsWith("CVE-"));
647 return (
648 <article class={"adv-card " + sevClass}>
649 <div class="adv-card-head">
650 <div class="adv-card-id">
651 <span class={"adv-pill " + sevClass}>
652 <span class="dot" aria-hidden="true" />
653 {a.advisory.severity}
654 </span>
655 <span class="adv-card-cve">{a.advisory.id}</span>
656 {cve && <span class="adv-card-cve">{cve}</span>}
657 </div>
658 <span class="adv-status">osv</span>
659 </div>
660 <h3 class="adv-card-title">{a.advisory.summary}</h3>
661 <div class="adv-card-meta">
662 <div class="adv-card-meta-item">
663 <span class="adv-card-meta-label">Component</span>
664 <span class="adv-card-meta-value">
665 {a.ecosystem} · {a.dependencyName}
666 {a.dependencyVersion ? ` ${a.dependencyVersion}` : ""}
667 </span>
668 </div>
669 {a.advisory.affectedRange && (
670 <div class="adv-card-meta-item">
671 <span class="adv-card-meta-label">Affected</span>
672 <span class="adv-card-meta-value">
673 {a.advisory.affectedRange}
674 </span>
675 </div>
676 )}
677 {a.advisory.fixedVersion && (
678 <div class="adv-card-meta-item">
679 <span class="adv-card-meta-label">Fixed in</span>
680 <span class="adv-card-fixed">
681 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
682 <polyline points="20 6 9 17 4 12" />
683 </svg>
684 ≥ {a.advisory.fixedVersion}
685 </span>
686 </div>
687 )}
688 <div class="adv-card-meta-item">
689 <span class="adv-card-meta-label">Manifest</span>
690 <span class="adv-card-meta-value">
691 <a
692 href={`/${ownerName}/${repoName}/blob/HEAD/${a.manifestPath}`}
693 >
694 {a.manifestPath}
695 </a>
696 </span>
697 </div>
698 <div class="adv-card-meta-item">
699 <span class="adv-card-meta-label">Reference</span>
700 <span class="adv-card-meta-value">
701 <a
702 href={osvUrl(a.advisory.id)}
703 rel="noreferrer"
704 target="_blank"
705 >
706 osv.dev ↗
707 </a>
708 </span>
709 </div>
710 </div>
711 </article>
712 );
713 })}
714 </div>
715 </>
716 )}
717
718 {alerts.length === 0 && osvAlerts.length === 0 ? (
607719 <div class="adv-empty">
608720 <div class="adv-empty-inner">
609721 <strong>No matches</strong>
610722 <span>
611723 {status === "open"
612 ? `No matches against Gluecron's built-in advisory list (${ADVISORY_LIST_SIZE} entries) — this is not a full CVE feed, so it does not mean your dependencies are vulnerability-free.`
724 ? `No match in the OSV.dev feed (cached up to 24h) or Gluecron's built-in supplemental list (${ADVISORY_LIST_SIZE} entries) for the indexed direct dependencies — transitive dependencies are not checked, so this is not a full audit.`
613725 : "Nothing in the advisory history."}
614726 {isOwner &&
615727 status === "open" &&
616 " Click Re-scan to check again against the built-in list."}
728 " Click Re-scan to refresh from OSV.dev now."}
617729 </span>
618730 </div>
619731 </div>
620 ) : (
732 ) : alerts.length === 0 ? null : (
621733 <div class="adv-list">
734 {osvAlerts.length > 0 && (
735 <h3 class="adv-section-label">
736 Built-in list (supplemental) · {alerts.length}
737 </h3>
738 )}
622739 {alerts.map((a) => {
623740 const sevClass = severityClass(a.advisory.severity);
624741 const idText =
767884 err instanceof Error ? err.message : err
768885 );
769886 });
887 // Explicit re-scan is the one place a user is waiting on OSV freshness,
888 // so this await is intentional (still TTL-bounded; fresh packages cost
889 // no network). refreshOsvForRepo never throws.
890 const osv = await refreshOsvForRepo(repo.id);
770891 const result = await scanRepositoryForAlerts(repo.id);
771892 await audit({
772893 userId: user.id,
773894 repositoryId: repo.id,
774895 action: "advisories.scan",
775 metadata: result || {},
896 metadata: { ...(result || {}), osv: osv || null },
776897 });
777898 const to = `/${ownerName}/${repoName}/security/advisories`;
778899 if (!result) {
780901 `${to}?error=${encodeURIComponent("Scan failed")}`
781902 );
782903 }
783 const msg = `Scan complete — ${result.opened} new, ${result.closed} closed, ${result.matched} total matches.`;
904 const osvPart = osv
905 ? ` OSV: ${osv.queried} package${osv.queried === 1 ? "" : "s"} queried, ${osv.skippedFresh} fresh in cache.`
906 : " OSV refresh unavailable — showing cached results.";
907 const msg = `Scan complete — ${result.opened} new, ${result.closed} closed, ${result.matched} total matches from the built-in list.${osvPart}`;
784908 return c.redirect(`${to}?message=${encodeURIComponent(msg)}`);
785909 }
786910);
Modifiedsrc/routes/deps.tsx+25−10View fileUnifiedSplit
3030 listDependenciesForRepo,
3131 summarizeDependencies,
3232} from "../lib/deps";
33import { listOsvAlertsForRepo, refreshOsvForRepo } from "../lib/osv";
3334
3435const deps = new Hono<AuthEnv>();
3536deps.use("*", softAuth);
479480 const message = c.req.query("message");
480481 const error = c.req.query("error");
481482
482 // Real per-dependency advisory counts from repo_advisory_alerts (open
483 // rows only). Cards used to render a hardcoded "0 CVEs — No known
484 // vulnerabilities" chip for every dependency regardless of the data,
485 // which is a false all-clear. Now the chip only appears when the
486 // built-in advisory scan actually matched something; silence means "no
487 // match in Gluecron's built-in list", never "clean".
483 // Real per-dependency advisory counts: the OSV.dev cache (primary) plus
484 // open repo_advisory_alerts rows from the built-in supplemental list.
485 // Cards used to render a hardcoded "0 CVEs — No known vulnerabilities"
486 // chip for every dependency regardless of the data, which is a false
487 // all-clear. The chip only appears on an actual match; silence means "no
488 // known match for this indexed direct dep", never "clean".
488489 const openAlertsByDep = new Map<string, number>();
489490 try {
490491 const rows = await db
503504 } catch {
504505 // Advisory table unavailable — render no chips rather than a fake zero.
505506 }
507 // OSV matches render from cache only; the refresh runs in the background
508 // (TTL-guarded — steady state is two SELECTs, no network) so this page
509 // never blocks on api.osv.dev.
510 const seenOsv = new Set<string>();
511 for (const a of await listOsvAlertsForRepo(repo.id)) {
512 const dedupe = `${a.advisory.id} ${a.dependencyName.toLowerCase()}`;
513 if (seenOsv.has(dedupe)) continue;
514 seenOsv.add(dedupe);
515 const key = a.dependencyName.toLowerCase();
516 openAlertsByDep.set(key, (openAlertsByDep.get(key) || 0) + 1);
517 }
518 void refreshOsvForRepo(repo.id);
506519
507520 // Group by ecosystem
508521 const grouped = new Map<string, typeof all>();
531544 </h1>
532545 <p class="deps-sub">
533546 Parsed from your manifests on the default branch — direct
534 dependencies only. A red chip means Gluecron's built-in advisory
535 list matched the package; no chip means no match in that list,
536 not a clean bill of health (this is not a full CVE feed).
547 dependencies only. A red chip means the OSV.dev feed (cached up
548 to 24h) or Gluecron's built-in supplemental list matched the
549 package; no chip means no known match for that direct dep, not
550 a clean bill of health — transitive dependencies are not
551 checked.
537552 </p>
538553 </div>
539554 {isOwner && (
632647 <a
633648 class="deps-chip is-vuln"
634649 href={`/${ownerName}/${repoName}/security/advisories`}
635 title={`${vulnCount} open advisor${vulnCount === 1 ? "y" : "ies"} matched by Gluecron's built-in advisory list`}
650 title={`${vulnCount} open advisor${vulnCount === 1 ? "y" : "ies"} matched by the OSV.dev feed or Gluecron's built-in supplemental list`}
636651 >
637652 <span class="vuln-dot" aria-hidden="true" />
638653 {vulnCount} advisor{vulnCount === 1 ? "y" : "ies"}
639654
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts