Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

advisories.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

advisories.tsBlame546 lines · 1 contributor
f60ccdeClaude1/**
2 * Block J2 — Security advisories + per-repo alerts.
3 *
4 * This module does three things:
5 * 1. Provides a seeded set of well-known public advisories (log4j, lodash,
6 * minimist, etc.) that get inserted into `security_advisories` on first
7 * run.
8 * 2. Exposes a minimal version-range matcher (`rangeMatches`) that can tell
9 * whether a declared manifest spec like `"^1.2.0"` or `">=0.5"` falls
10 * inside an advisory's `affected_range` such as `"<1.2.3"`. This is a
11 * heuristic — we never claim to replace npm's full semver resolver, but
12 * for unpinned or tightly-pinned dev manifests (the common case on
13 * GitHub), it's accurate in the common cases.
14 * 3. `scanRepositoryForAlerts(repositoryId)` iterates the repo's
15 * `repo_dependencies` rows, finds matching advisories, and upserts
16 * `repo_advisory_alerts` rows. Existing alerts whose underlying dep
17 * went away are auto-closed (status=fixed).
18 */
19
20import { and, desc, eq, inArray } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repoAdvisoryAlerts,
24 repoDependencies,
25 securityAdvisories,
26 type RepoAdvisoryAlert,
27 type SecurityAdvisory,
28} from "../db/schema";
29
30// ----------------------------------------------------------------------------
31// Seed: a small but real set of widely-cited advisories
32// ----------------------------------------------------------------------------
33
34export interface SeedAdvisory {
35 ghsaId: string;
36 cveId?: string;
37 summary: string;
38 severity: "low" | "moderate" | "high" | "critical";
39 ecosystem: string;
40 packageName: string;
41 affectedRange: string;
42 fixedVersion?: string;
43 referenceUrl?: string;
44}
45
46export const SEED_ADVISORIES: SeedAdvisory[] = [
47 {
48 ghsaId: "GHSA-jfh8-c2jp-5v3q",
49 cveId: "CVE-2021-44228",
50 summary:
51 "Log4Shell: Apache Log4j2 JNDI features do not protect against attacker-controlled LDAP and other JNDI related endpoints",
52 severity: "critical",
53 ecosystem: "composer",
54 packageName: "apache/log4j",
55 affectedRange: ">=2.0 <2.15.0",
56 fixedVersion: "2.15.0",
57 referenceUrl: "https://nvd.nist.gov/vuln/detail/CVE-2021-44228",
58 },
59 {
60 ghsaId: "GHSA-p6mc-m468-83gw",
61 cveId: "CVE-2019-10744",
62 summary: "Prototype pollution in lodash",
63 severity: "high",
64 ecosystem: "npm",
65 packageName: "lodash",
66 affectedRange: "<4.17.12",
67 fixedVersion: "4.17.12",
68 referenceUrl: "https://github.com/advisories/GHSA-jf85-cpcp-j695",
69 },
70 {
71 ghsaId: "GHSA-vh95-rmgr-6w4m",
72 cveId: "CVE-2020-7598",
73 summary: "Prototype pollution in minimist",
74 severity: "moderate",
75 ecosystem: "npm",
76 packageName: "minimist",
77 affectedRange: "<1.2.2",
78 fixedVersion: "1.2.2",
79 referenceUrl: "https://github.com/advisories/GHSA-vh95-rmgr-6w4m",
80 },
81 {
82 ghsaId: "GHSA-hpx4-r86g-5jrg",
83 cveId: "CVE-2020-28469",
84 summary: "Regex DoS in glob-parent",
85 severity: "high",
86 ecosystem: "npm",
87 packageName: "glob-parent",
88 affectedRange: "<5.1.2",
89 fixedVersion: "5.1.2",
90 referenceUrl: "https://github.com/advisories/GHSA-ww39-953v-wcq6",
91 },
92 {
93 ghsaId: "GHSA-rxrx-xcr5-2f33",
94 cveId: "CVE-2022-25883",
95 summary: "Regex DoS in node-semver",
96 severity: "moderate",
97 ecosystem: "npm",
98 packageName: "semver",
99 affectedRange: "<5.7.2",
100 fixedVersion: "5.7.2",
101 referenceUrl: "https://github.com/advisories/GHSA-c2qf-rxjj-qqgw",
102 },
103 {
104 ghsaId: "GHSA-j8xg-fqg3-53r7",
105 cveId: "CVE-2022-24999",
106 summary: "Express qs prototype pollution",
107 severity: "high",
108 ecosystem: "npm",
109 packageName: "qs",
110 affectedRange: "<6.9.7",
111 fixedVersion: "6.9.7",
112 referenceUrl: "https://github.com/advisories/GHSA-hrpp-h998-j3pp",
113 },
114 {
115 ghsaId: "GHSA-6v2p-pv7g-wrx2",
116 cveId: "CVE-2022-36067",
117 summary: "vm2 sandbox escape",
118 severity: "critical",
119 ecosystem: "npm",
120 packageName: "vm2",
121 affectedRange: "<3.9.11",
122 fixedVersion: "3.9.11",
123 referenceUrl: "https://github.com/advisories/GHSA-6v2p-pv7g-wrx2",
124 },
125 {
126 ghsaId: "GHSA-mh63-6h87-95cp",
127 cveId: "CVE-2022-21222",
128 summary: "Prototype pollution in jquery.extend",
129 severity: "moderate",
130 ecosystem: "npm",
131 packageName: "jquery",
132 affectedRange: "<3.5.0",
133 fixedVersion: "3.5.0",
134 referenceUrl: "https://github.com/advisories/GHSA-gxr4-xjj5-5px2",
135 },
136 {
137 ghsaId: "GHSA-h52j-qf5x-j8ch",
138 cveId: "CVE-2021-33503",
139 summary: "urllib3 catastrophic backtracking regex",
140 severity: "high",
141 ecosystem: "pypi",
142 packageName: "urllib3",
143 affectedRange: "<1.26.5",
144 fixedVersion: "1.26.5",
145 referenceUrl: "https://github.com/advisories/GHSA-q2q7-5pp4-w6pg",
146 },
147 {
148 ghsaId: "GHSA-56pw-mpj4-fxww",
149 cveId: "CVE-2019-11236",
150 summary: "CRLF injection in urllib3",
151 severity: "moderate",
152 ecosystem: "pypi",
153 packageName: "urllib3",
154 affectedRange: "<1.24.2",
155 fixedVersion: "1.24.2",
156 referenceUrl: "https://github.com/advisories/GHSA-wqvq-5m8c-6g24",
157 },
158 {
159 ghsaId: "GHSA-w596-4wvx-j9j6",
160 cveId: "CVE-2021-33026",
161 summary: "Flask-Caching pickle deserialisation RCE",
162 severity: "high",
163 ecosystem: "pypi",
164 packageName: "flask-caching",
165 affectedRange: "<1.10.1",
166 fixedVersion: "1.10.1",
167 referenceUrl: "https://github.com/advisories/GHSA-xx7p-3c2j-8c7w",
168 },
169 {
170 ghsaId: "GHSA-5545-jx63-4mgx",
171 cveId: "CVE-2020-26160",
172 summary: "JWT-go incorrect type assertion allows auth bypass",
173 severity: "high",
174 ecosystem: "go",
175 packageName: "github.com/dgrijalva/jwt-go",
176 affectedRange: "<4.0.0",
177 fixedVersion: "4.0.0",
178 referenceUrl: "https://github.com/advisories/GHSA-w73w-5m7g-f7qc",
179 },
180];
181
182// ----------------------------------------------------------------------------
183// Version range matcher
184// ----------------------------------------------------------------------------
185
186/**
187 * Parse a dotted version string → numeric components. Non-numeric segments
188 * become 0 so "1.2.3-beta" < "1.2.3". Good enough for advisory comparisons.
189 */
190export function parseVersion(v: string): number[] {
191 const cleaned = (v || "").trim().replace(/^[\^~=v\s]+/, "");
192 if (!cleaned) return [0, 0, 0];
193 const stem = cleaned.split(/[+\-\s]/, 1)[0]; // strip prerelease / build
194 return stem
195 .split(".")
196 .map((p) => parseInt(p, 10))
197 .map((n) => (Number.isFinite(n) ? n : 0));
198}
199
200export function compareVersions(a: string, b: string): number {
201 const av = parseVersion(a);
202 const bv = parseVersion(b);
203 const len = Math.max(av.length, bv.length);
204 for (let i = 0; i < len; i++) {
205 const x = av[i] ?? 0;
206 const y = bv[i] ?? 0;
207 if (x !== y) return x - y;
208 }
209 return 0;
210}
211
212/** Satisfies "<1.2.3", ">=1.0.0", ">=1.0 <2.0", "=1.2.3", or bare "1.2.3". */
213export function satisfiesRange(version: string, range: string): boolean {
214 const clauses = range
215 .split(/\s+/)
216 .map((c) => c.trim())
217 .filter(Boolean);
218 if (clauses.length === 0) return false;
219 for (const clause of clauses) {
220 const m = clause.match(/^(<=|>=|<|>|=)?\s*(\S+)$/);
221 if (!m) return false;
222 const op = m[1] || "=";
223 const target = m[2];
224 const cmp = compareVersions(version, target);
225 let ok = false;
226 switch (op) {
227 case "<":
228 ok = cmp < 0;
229 break;
230 case "<=":
231 ok = cmp <= 0;
232 break;
233 case ">":
234 ok = cmp > 0;
235 break;
236 case ">=":
237 ok = cmp >= 0;
238 break;
239 case "=":
240 ok = cmp === 0;
241 break;
242 }
243 if (!ok) return false;
244 }
245 return true;
246}
247
248/**
249 * Extract a concrete version from a manifest spec:
250 * "^1.2.3" → "1.2.3"
251 * "~2.0.0" → "2.0.0"
252 * "v1.8.0" → "1.8.0"
253 * ">=1.0 <2.0" → "1.0" (lower bound — conservative)
254 * "1.2.3" → "1.2.3"
255 *
256 * Returns null if the spec has no concrete version (e.g. "*" or git URL).
257 */
258export function normalizeManifestVersion(spec: string | null): string | null {
259 if (!spec) return null;
260 const trimmed = spec.trim();
261 if (!trimmed || trimmed === "*" || trimmed === "latest") return null;
262 // Grab the first number-like token
263 const m = trimmed.match(/(\d+(?:\.\d+)*(?:\.\d+)?)/);
264 if (!m) return null;
265 return m[1];
266}
267
268/**
269 * True when a declared manifest spec overlaps with an advisory range.
270 * When the spec can't be pinned to a version, we still return true to
271 * surface the potential risk (false positives are safer than false
272 * negatives in a dependabot-style feature).
273 */
274export function rangeMatches(
275 manifestSpec: string | null,
276 affectedRange: string
277): boolean {
278 const normalized = normalizeManifestVersion(manifestSpec);
279 if (!normalized) {
280 // Can't resolve spec → conservatively match (no concrete version means
281 // the floating spec could resolve to anything).
282 return true;
283 }
284 return satisfiesRange(normalized, affectedRange);
285}
286
287// ----------------------------------------------------------------------------
288// Seeding
289// ----------------------------------------------------------------------------
290
291/**
292 * Inserts the hardcoded seed list if not already present, matched by
293 * `ghsa_id`. Safe to call on every boot — idempotent.
294 */
295export async function seedAdvisories(): Promise<{ inserted: number }> {
296 let inserted = 0;
297 for (const a of SEED_ADVISORIES) {
298 try {
299 const [existing] = await db
300 .select({ id: securityAdvisories.id })
301 .from(securityAdvisories)
302 .where(eq(securityAdvisories.ghsaId, a.ghsaId))
303 .limit(1);
304 if (existing) continue;
305 await db.insert(securityAdvisories).values({
306 ghsaId: a.ghsaId,
307 cveId: a.cveId ?? null,
308 summary: a.summary,
309 severity: a.severity,
310 ecosystem: a.ecosystem,
311 packageName: a.packageName,
312 affectedRange: a.affectedRange,
313 fixedVersion: a.fixedVersion ?? null,
314 referenceUrl: a.referenceUrl ?? null,
315 });
316 inserted++;
317 } catch (err) {
318 console.error("[advisories] seed:", err);
319 }
320 }
321 return { inserted };
322}
323
324// ----------------------------------------------------------------------------
325// Scan + alert upsert
326// ----------------------------------------------------------------------------
327
328export async function scanRepositoryForAlerts(
329 repositoryId: string
330): Promise<{ opened: number; matched: number; closed: number } | null> {
331 try {
332 const deps = await db
333 .select()
334 .from(repoDependencies)
335 .where(eq(repoDependencies.repositoryId, repositoryId));
336
337 if (deps.length === 0) {
338 // All prior alerts become fixed
339 const closed = await closeAllAlerts(repositoryId);
340 return { opened: 0, matched: 0, closed };
341 }
342
343 const ecosystems = Array.from(new Set(deps.map((d) => d.ecosystem)));
344 const advisories = await db
345 .select()
346 .from(securityAdvisories)
347 .where(inArray(securityAdvisories.ecosystem, ecosystems));
348
349 const existing = await db
350 .select()
351 .from(repoAdvisoryAlerts)
352 .where(eq(repoAdvisoryAlerts.repositoryId, repositoryId));
353 const existingByKey = new Map<string, RepoAdvisoryAlert>();
354 for (const e of existing) {
355 existingByKey.set(`${e.advisoryId}::${e.manifestPath}`, e);
356 }
357
358 let opened = 0;
359 let matched = 0;
360 const keepKeys = new Set<string>();
361
362 for (const dep of deps) {
363 for (const adv of advisories) {
364 if (adv.ecosystem !== dep.ecosystem) continue;
365 if (adv.packageName !== dep.name) continue;
366 if (!rangeMatches(dep.versionSpec, adv.affectedRange)) continue;
367 matched++;
368 const key = `${adv.id}::${dep.manifestPath}`;
369 keepKeys.add(key);
370 const prior = existingByKey.get(key);
371 if (prior) {
372 // Reopen if previously fixed; keep dismissed as dismissed.
373 if (prior.status === "fixed") {
374 await db
375 .update(repoAdvisoryAlerts)
376 .set({
377 status: "open",
378 dependencyVersion: dep.versionSpec ?? null,
379 updatedAt: new Date(),
380 })
381 .where(eq(repoAdvisoryAlerts.id, prior.id));
382 } else {
383 await db
384 .update(repoAdvisoryAlerts)
385 .set({
386 dependencyVersion: dep.versionSpec ?? null,
387 updatedAt: new Date(),
388 })
389 .where(eq(repoAdvisoryAlerts.id, prior.id));
390 }
391 } else {
392 await db.insert(repoAdvisoryAlerts).values({
393 repositoryId,
394 advisoryId: adv.id,
395 dependencyName: dep.name,
396 dependencyVersion: dep.versionSpec ?? null,
397 manifestPath: dep.manifestPath,
398 });
399 opened++;
400 }
401 }
402 }
403
404 // Close alerts whose dep + advisory combo is no longer present
405 let closed = 0;
406 for (const [key, prior] of existingByKey) {
407 if (keepKeys.has(key)) continue;
408 if (prior.status === "dismissed") continue;
409 if (prior.status === "fixed") continue;
410 await db
411 .update(repoAdvisoryAlerts)
412 .set({ status: "fixed", updatedAt: new Date() })
413 .where(eq(repoAdvisoryAlerts.id, prior.id));
414 closed++;
415 }
416
417 return { opened, matched, closed };
418 } catch (err) {
419 console.error("[advisories] scanRepositoryForAlerts:", err);
420 return null;
421 }
422}
423
424async function closeAllAlerts(repositoryId: string): Promise<number> {
425 try {
426 const existing = await db
427 .select()
428 .from(repoAdvisoryAlerts)
429 .where(
430 and(
431 eq(repoAdvisoryAlerts.repositoryId, repositoryId),
432 eq(repoAdvisoryAlerts.status, "open")
433 )
434 );
435 if (existing.length === 0) return 0;
436 await db
437 .update(repoAdvisoryAlerts)
438 .set({ status: "fixed", updatedAt: new Date() })
439 .where(
440 and(
441 eq(repoAdvisoryAlerts.repositoryId, repositoryId),
442 eq(repoAdvisoryAlerts.status, "open")
443 )
444 );
445 return existing.length;
446 } catch {
447 return 0;
448 }
449}
450
451export interface AlertWithAdvisory extends RepoAdvisoryAlert {
452 advisory: SecurityAdvisory;
453}
454
455export async function listAlertsForRepo(
456 repositoryId: string,
457 status: "open" | "dismissed" | "fixed" | "all" = "open"
458): Promise<AlertWithAdvisory[]> {
459 try {
460 const whereClauses =
461 status === "all"
462 ? eq(repoAdvisoryAlerts.repositoryId, repositoryId)
463 : and(
464 eq(repoAdvisoryAlerts.repositoryId, repositoryId),
465 eq(repoAdvisoryAlerts.status, status)
466 )!;
467 const rows = await db
468 .select({
469 alert: repoAdvisoryAlerts,
470 advisory: securityAdvisories,
471 })
472 .from(repoAdvisoryAlerts)
473 .innerJoin(
474 securityAdvisories,
475 eq(securityAdvisories.id, repoAdvisoryAlerts.advisoryId)
476 )
477 .where(whereClauses)
478 .orderBy(desc(repoAdvisoryAlerts.createdAt));
479 return rows.map((r) => ({ ...r.alert, advisory: r.advisory }));
480 } catch {
481 return [];
482 }
483}
484
485export async function dismissAlert(
486 alertId: string,
487 repositoryId: string,
488 reason: string
489): Promise<boolean> {
490 try {
491 const result = await db
492 .update(repoAdvisoryAlerts)
493 .set({
494 status: "dismissed",
495 dismissedReason: reason.slice(0, 280),
496 updatedAt: new Date(),
497 })
498 .where(
499 and(
500 eq(repoAdvisoryAlerts.id, alertId),
501 eq(repoAdvisoryAlerts.repositoryId, repositoryId)
502 )
503 )
504 .returning({ id: repoAdvisoryAlerts.id });
505 return result.length > 0;
506 } catch {
507 return false;
508 }
509}
510
511export async function reopenAlert(
512 alertId: string,
513 repositoryId: string
514): Promise<boolean> {
515 try {
516 const result = await db
517 .update(repoAdvisoryAlerts)
518 .set({
519 status: "open",
520 dismissedReason: null,
521 updatedAt: new Date(),
522 })
523 .where(
524 and(
525 eq(repoAdvisoryAlerts.id, alertId),
526 eq(repoAdvisoryAlerts.repositoryId, repositoryId)
527 )
528 )
529 .returning({ id: repoAdvisoryAlerts.id });
530 return result.length > 0;
531 } catch {
532 return false;
533 }
534}
535
536// ----------------------------------------------------------------------------
537// Test-only exports
538// ----------------------------------------------------------------------------
539
540export const __internal = {
541 parseVersion,
542 compareVersions,
543 satisfiesRange,
544 normalizeManifestVersion,
545 rangeMatches,
546};