Commitf60ccdeunknown_key
feat(BLOCK-J): J2 security advisories (Dependabot-style alerts)
feat(BLOCK-J): J2 security advisories (Dependabot-style alerts) Cross-references J1 dependency graph rows against a curated advisory database to surface vulnerable packages per repo. Migration 0029_security_advisories.sql adds: - security_advisories (ghsa_id unique, cve_id, severity, ecosystem, package_name, affected_range, fixed_version, reference_url) - repo_advisory_alerts (repository_id + advisory_id + manifest_path unique, status open/dismissed/fixed, dismissed_reason, timestamps) src/lib/advisories.ts ships: - 12-entry SEED_ADVISORIES (log4shell, lodash, minimist, glob-parent, semver, qs, vm2, jquery, urllib3 x2, flask-caching, jwt-go) - Minimal version-range matcher (parseVersion, compareVersions, satisfiesRange) handling <, <=, >, >=, = and compound ranges like ">=2.0 <2.15.0"; normalizeManifestVersion + rangeMatches conservatively flag unpinnable specs (*, latest, null) as matching - scanRepositoryForAlerts(repoId) \u2014 inserts new alerts, reopens fixed-then-regressed ones, auto-closes alerts whose dep disappeared - listAlertsForRepo, dismissAlert, reopenAlert, closeAllAlerts, seedAdvisories (idempotent) src/routes/advisories.tsx serves: - GET /:owner/:repo/security/advisories (open alerts) - GET /:owner/:repo/security/advisories/all (dismissed + fixed too) - POST /scan (owner-only, seeds then scans, audit-logged) - POST /:id/dismiss (reason in body, 280 char cap, audit-logged) - POST /:id/reopen (audit-logged) 27 new tests covering version parser, range matcher, seed shape, and route auth smokes. https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
7 files changed+1231−1f60ccde334cd1e001193075408406c4c0347e141
7 changed files+1231−1
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
@@ -88,6 +88,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
8888| Semantic / embedding search | ✅ | D1 — `code_chunks` table + lexical fallback, optional Voyage `voyage-code-3`; `src/lib/semantic-search.ts`, `src/routes/semantic-search.tsx` |
8989| Symbol / xref navigation | ✅ | I8 — `src/lib/symbols.ts` regex-based extractor for ts/js/py/rs/go/rb/java/kt/swift; on-demand indexer persists top-level definitions into `code_symbols` (0025). `src/routes/symbols.tsx` serves `/:owner/:repo/symbols` overview + A–Z list, `/:owner/:repo/symbols/search?q=` prefix search, `/:owner/:repo/symbols/:name` definition detail. Owner-only reindex. |
9090| Dependency graph | ✅ | J1 — `src/lib/deps.ts` parses package.json / requirements.txt / pyproject.toml / go.mod / Cargo.toml / Gemfile / composer.json without a TOML lib. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` grouped by ecosystem with per-ecosystem counts; owner-only reindex walks the default-branch tree (max 200 manifests, 1MB each). `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies`. |
91| Security advisories / Dependabot alerts | ✅ | J2 — curated 12-entry seed list + minimal semver range matcher cross-referenced against J1 dep rows. `src/lib/advisories.ts` + `src/routes/advisories.tsx` serve `/:owner/:repo/security/advisories` (open) + `/all`, owner-only `POST /scan`, and per-alert dismiss/reopen. `drizzle/0029_security_advisories.sql` adds `security_advisories` + `repo_advisory_alerts`. |
9192
9293### 2.3 Collaboration
9394| Feature | Status | Notes |
@@ -281,6 +282,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
281282
282283### BLOCK J — Beyond-parity advanced features
283284- **J1** — Dependency graph → ✅ shipped. `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies` (ecosystem + name + version_spec + manifest_path + is_dev + commit_sha) with indexes on `(repository_id, ecosystem)` + `(name)`. `src/lib/deps.ts` parses seven manifest formats (package.json, requirements.txt, pyproject.toml, go.mod, Cargo.toml, Gemfile, composer.json) without a TOML library — each parser is defensive and returns `[]` on malformed input. Walks default-branch tree (max 200 manifests, 1MB each), replaces the prior set on reindex. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` (grouped by ecosystem with per-ecosystem counts) + owner-only `POST /dependencies/reindex`. Reverse-dep lookup via `repositoriesDependingOn(ecosystem, name)` for future "who depends on me" network-graph UI. 21 new tests.
285- **J2** — Security advisories (Dependabot-style) → ✅ shipped. `drizzle/0029_security_advisories.sql` adds `security_advisories` (GHSA + CVE IDs, severity, affected range, fixed version) + `repo_advisory_alerts` (per-repo match state with open/dismissed/fixed, unique on `(repo, advisory, manifest_path)`). `src/lib/advisories.ts` ships a 12-entry seed list (log4shell, lodash, minimist, vm2, urllib3, jwt-go, etc.), a minimal version-range matcher (`satisfiesRange` + `rangeMatches`) that handles `<`/`<=`/`>`/`>=`/`=` clauses including compound ranges, and `scanRepositoryForAlerts(repoId)` which cross-references J1 dep rows against the advisory list — inserts new alerts, reopens fixed-then-regressed ones, auto-closes alerts whose dep went away. `src/routes/advisories.tsx` serves `/:owner/:repo/security/advisories` (open), `/all` (everything), owner-only `POST /scan`, and per-alert `POST /:id/dismiss` + `POST /:id/reopen` with audit-log entries. 27 new tests (version parser, range matcher, seed shape, route auth).
284286
285287### BLOCK H — Marketplace
286288- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
@@ -296,7 +298,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
296298- `src/app.tsx` — route composition, middleware order, error handlers
297299- `src/index.ts` — Bun server entry
298300- `src/lib/config.ts` — env getters (late-binding)
299- `src/db/schema.ts` — 87 tables. New tables only via new migration.
301- `src/db/schema.ts` — 89 tables. New tables only via new migration.
300302- `src/db/index.ts` — lazy proxy DB connection
301303- `src/db/migrate.ts` — migration runner
302304- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -325,6 +327,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
325327- `drizzle/0026_repo_mirrors.sql` (Block I9) — migration, never edited in place. Adds `repo_mirrors` (unique on `repository_id`) + `repo_mirror_runs`.
326328- `drizzle/0027_sso_oidc.sql` (Block I10) — migration, never edited in place. Adds `sso_config` singleton (`id='default'`) + `sso_user_links` (`subject` unique, FK to `users` with ON DELETE CASCADE).
327329- `drizzle/0028_repo_dependencies.sql` (Block J1) — migration, never edited in place. Adds `repo_dependencies` with indexes on `(repository_id, ecosystem)` + `(name)`.
330- `drizzle/0029_security_advisories.sql` (Block J2) — migration, never edited in place. Adds `security_advisories` (`ghsa_id` unique) + `repo_advisory_alerts` (unique on `(repository_id, advisory_id, manifest_path)`, status index).
328331
329332### 4.2 Git layer (locked)
330333- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
Addeddrizzle/0029_security_advisories.sql+52−0View fileUnifiedSplit
@@ -0,0 +1,52 @@
1-- Gluecron migration 0029: security advisories + dependency alerts.
2--
3-- J2 — Per-package CVE-style advisories mapped against J1's `repo_dependencies`.
4-- Builds the "Dependabot alerts" / "GitHub security advisory" feature.
5--
6-- `security_advisories` is the master rule list — populated initially from a
7-- seeded set in `src/lib/advisories.ts`, extensible via admin import later.
8-- `repo_advisory_alerts` is the per-repo match state: one row per (repo,
9-- advisory) when we detect a dependency matches the advisory's affected range.
10-- An alert can be dismissed (ignored), auto-closed when the dep goes away, or
11-- marked fixed when the dep's version moves past the fixed_version.
12
13--> statement-breakpoint
14CREATE TABLE IF NOT EXISTS "security_advisories" (
15 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
16 "ghsa_id" text UNIQUE, -- GitHub Security Advisory ID, e.g. GHSA-xxxx
17 "cve_id" text, -- CVE-YYYY-NNNN when assigned
18 "summary" text NOT NULL,
19 "severity" text NOT NULL DEFAULT 'moderate', -- low | moderate | high | critical
20 "ecosystem" text NOT NULL, -- npm | pypi | go | rubygems | cargo | composer
21 "package_name" text NOT NULL,
22 "affected_range" text NOT NULL, -- "<1.2.3" | ">=2.0.0 <2.5.1"
23 "fixed_version" text, -- "1.2.3" — suggestion to upgrade to
24 "reference_url" text, -- link to upstream advisory / CVE
25 "published_at" timestamp NOT NULL DEFAULT now()
26);
27
28--> statement-breakpoint
29CREATE INDEX IF NOT EXISTS "security_advisories_pkg_idx"
30 ON "security_advisories" ("ecosystem", "package_name");
31
32--> statement-breakpoint
33CREATE TABLE IF NOT EXISTS "repo_advisory_alerts" (
34 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
35 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
36 "advisory_id" uuid NOT NULL REFERENCES "security_advisories"("id") ON DELETE CASCADE,
37 "dependency_name" text NOT NULL,
38 "dependency_version" text,
39 "manifest_path" text NOT NULL,
40 "status" text NOT NULL DEFAULT 'open', -- open | dismissed | fixed
41 "dismissed_reason" text,
42 "created_at" timestamp NOT NULL DEFAULT now(),
43 "updated_at" timestamp NOT NULL DEFAULT now()
44);
45
46--> statement-breakpoint
47CREATE UNIQUE INDEX IF NOT EXISTS "repo_advisory_alerts_unique_idx"
48 ON "repo_advisory_alerts" ("repository_id", "advisory_id", "manifest_path");
49
50--> statement-breakpoint
51CREATE INDEX IF NOT EXISTS "repo_advisory_alerts_status_idx"
52 ON "repo_advisory_alerts" ("repository_id", "status");
Addedsrc/__tests__/advisories.test.ts+195−0View fileUnifiedSplit
@@ -0,0 +1,195 @@
1/**
2 * Block J2 — Security advisory tests.
3 *
4 * Pure matcher tests + route auth smokes. Live scanning requires the DB
5 * and the J1 dep graph; those paths are exercised via integration.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import {
11 SEED_ADVISORIES,
12 parseVersion,
13 compareVersions,
14 satisfiesRange,
15 normalizeManifestVersion,
16 rangeMatches,
17 __internal,
18} from "../lib/advisories";
19
20describe("advisories — parseVersion", () => {
21 it("parses dotted versions", () => {
22 expect(parseVersion("1.2.3")).toEqual([1, 2, 3]);
23 expect(parseVersion("0.5")).toEqual([0, 5]);
24 });
25
26 it("strips ^~ prefixes", () => {
27 expect(parseVersion("^1.2.3")).toEqual([1, 2, 3]);
28 expect(parseVersion("~2.0.0")).toEqual([2, 0, 0]);
29 expect(parseVersion("v1.8.0")).toEqual([1, 8, 0]);
30 });
31
32 it("strips prerelease suffixes", () => {
33 expect(parseVersion("1.2.3-beta.1")).toEqual([1, 2, 3]);
34 expect(parseVersion("1.2.3+build.99")).toEqual([1, 2, 3]);
35 });
36
37 it("treats non-numeric as 0", () => {
38 expect(parseVersion("garbage")).toEqual([0]);
39 expect(parseVersion("")).toEqual([0, 0, 0]);
40 });
41});
42
43describe("advisories — compareVersions", () => {
44 it("orders versions correctly", () => {
45 expect(compareVersions("1.2.3", "1.2.4")).toBeLessThan(0);
46 expect(compareVersions("1.2.4", "1.2.3")).toBeGreaterThan(0);
47 expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
48 });
49
50 it("handles different length arrays", () => {
51 expect(compareVersions("1.2", "1.2.0")).toBe(0);
52 expect(compareVersions("1.2", "1.2.1")).toBeLessThan(0);
53 });
54
55 it("handles major bump across tens", () => {
56 expect(compareVersions("2.0.0", "10.0.0")).toBeLessThan(0);
57 expect(compareVersions("1.9.0", "1.10.0")).toBeLessThan(0);
58 });
59});
60
61describe("advisories — satisfiesRange", () => {
62 it("handles simple comparisons", () => {
63 expect(satisfiesRange("1.0.0", "<1.2.3")).toBe(true);
64 expect(satisfiesRange("1.2.3", "<1.2.3")).toBe(false);
65 expect(satisfiesRange("1.0.0", ">=1.0.0")).toBe(true);
66 expect(satisfiesRange("0.9.9", ">=1.0.0")).toBe(false);
67 });
68
69 it("handles compound ranges", () => {
70 expect(satisfiesRange("1.5.0", ">=1.0.0 <2.0.0")).toBe(true);
71 expect(satisfiesRange("0.9.0", ">=1.0.0 <2.0.0")).toBe(false);
72 expect(satisfiesRange("2.0.0", ">=1.0.0 <2.0.0")).toBe(false);
73 });
74
75 it("handles bare version as equality", () => {
76 expect(satisfiesRange("1.2.3", "1.2.3")).toBe(true);
77 expect(satisfiesRange("1.2.4", "1.2.3")).toBe(false);
78 });
79
80 it("handles <=, >=", () => {
81 expect(satisfiesRange("1.0.0", "<=1.0.0")).toBe(true);
82 expect(satisfiesRange("1.0.1", "<=1.0.0")).toBe(false);
83 expect(satisfiesRange("1.0.0", ">=1.0.0")).toBe(true);
84 });
85});
86
87describe("advisories — normalizeManifestVersion", () => {
88 it("strips semver prefixes", () => {
89 expect(normalizeManifestVersion("^1.2.3")).toBe("1.2.3");
90 expect(normalizeManifestVersion("~2.0.0")).toBe("2.0.0");
91 expect(normalizeManifestVersion("v1.8.0")).toBe("1.8.0");
92 });
93
94 it("plucks lower bound from compound spec", () => {
95 expect(normalizeManifestVersion(">=1.0 <2.0")).toBe("1.0");
96 });
97
98 it("returns null for unpinned / wildcard / null", () => {
99 expect(normalizeManifestVersion(null)).toBeNull();
100 expect(normalizeManifestVersion("*")).toBeNull();
101 expect(normalizeManifestVersion("latest")).toBeNull();
102 expect(normalizeManifestVersion("")).toBeNull();
103 });
104});
105
106describe("advisories — rangeMatches", () => {
107 it("matches unpatched version against <fixed range", () => {
108 expect(rangeMatches("4.17.10", "<4.17.12")).toBe(true);
109 expect(rangeMatches("^4.17.10", "<4.17.12")).toBe(true);
110 });
111
112 it("rejects patched version", () => {
113 expect(rangeMatches("4.17.21", "<4.17.12")).toBe(false);
114 expect(rangeMatches("^4.17.21", "<4.17.12")).toBe(false);
115 });
116
117 it("conservatively matches when spec can't be pinned", () => {
118 expect(rangeMatches("*", "<1.0.0")).toBe(true);
119 expect(rangeMatches(null, "<1.0.0")).toBe(true);
120 });
121
122 it("handles compound ranges from the seed list", () => {
123 // log4j CVE range
124 expect(rangeMatches("2.14.1", ">=2.0 <2.15.0")).toBe(true);
125 expect(rangeMatches("2.15.0", ">=2.0 <2.15.0")).toBe(false);
126 expect(rangeMatches("1.9.0", ">=2.0 <2.15.0")).toBe(false);
127 });
128});
129
130describe("advisories — seed data shape", () => {
131 it("has at least a dozen entries", () => {
132 expect(SEED_ADVISORIES.length).toBeGreaterThanOrEqual(10);
133 });
134
135 it("every entry has required fields", () => {
136 for (const a of SEED_ADVISORIES) {
137 expect(typeof a.ghsaId).toBe("string");
138 expect(a.ghsaId.startsWith("GHSA-")).toBe(true);
139 expect(["low", "moderate", "high", "critical"]).toContain(a.severity);
140 expect(typeof a.ecosystem).toBe("string");
141 expect(typeof a.packageName).toBe("string");
142 expect(typeof a.affectedRange).toBe("string");
143 }
144 });
145
146 it("log4j advisory is present", () => {
147 const log4j = SEED_ADVISORIES.find((a) => a.cveId === "CVE-2021-44228");
148 expect(log4j).toBeDefined();
149 expect(log4j!.severity).toBe("critical");
150 });
151
152 it("seed ghsa_ids are unique", () => {
153 const ids = SEED_ADVISORIES.map((a) => a.ghsaId);
154 expect(new Set(ids).size).toBe(ids.length);
155 });
156});
157
158describe("advisories — route auth", () => {
159 it("GET /:o/:r/security/advisories for unknown repo → 404 or 500", async () => {
160 const res = await app.request("/nobody/missing/security/advisories");
161 expect([404, 500]).toContain(res.status);
162 });
163
164 it("GET /all variant also mounted", async () => {
165 const res = await app.request("/nobody/missing/security/advisories/all");
166 expect([404, 500]).toContain(res.status);
167 });
168
169 it("POST scan without auth → 302 /login", async () => {
170 const res = await app.request(
171 "/alice/repo/security/advisories/scan",
172 { method: "POST" }
173 );
174 expect(res.status).toBe(302);
175 expect(res.headers.get("location") || "").toContain("/login");
176 });
177
178 it("POST dismiss without auth → 302 /login", async () => {
179 const res = await app.request(
180 "/alice/repo/security/advisories/00000000-0000-0000-0000-000000000000/dismiss",
181 { method: "POST" }
182 );
183 expect(res.status).toBe(302);
184 expect(res.headers.get("location") || "").toContain("/login");
185 });
186
187 it("POST reopen without auth → 302 /login", async () => {
188 const res = await app.request(
189 "/alice/repo/security/advisories/00000000-0000-0000-0000-000000000000/reopen",
190 { method: "POST" }
191 );
192 expect(res.status).toBe(302);
193 expect(res.headers.get("location") || "").toContain("/login");
194 });
195});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -70,6 +70,7 @@ import symbolsRoutes from "./routes/symbols";
7070import mirrorsRoutes from "./routes/mirrors";
7171import ssoRoutes from "./routes/sso";
7272import depsRoutes from "./routes/deps";
73import advisoriesRoutes from "./routes/advisories";
7374import webRoutes from "./routes/web";
7475
7576const app = new Hono();
@@ -242,6 +243,9 @@ app.route("/", ssoRoutes);
242243// Dependency graph — /:owner/:repo/dependencies (Block J1)
243244app.route("/", depsRoutes);
244245
246// Security advisories / dependabot alerts — /:owner/:repo/security/advisories (Block J2)
247app.route("/", advisoriesRoutes);
248
245249// Insights + milestones
246250app.route("/", insightsRoutes);
247251
Modifiedsrc/db/schema.ts+59−0View fileUnifiedSplit
@@ -2110,3 +2110,62 @@ export const repoDependencies = pgTable(
21102110);
21112111
21122112export type RepoDependency = typeof repoDependencies.$inferSelect;
2113
2114// ----------------------------------------------------------------------------
2115// Block J2 — Security advisories + alerts
2116// ----------------------------------------------------------------------------
2117
2118/** CVE-style package advisories. Populated via seed + admin import. */
2119export const securityAdvisories = pgTable(
2120 "security_advisories",
2121 {
2122 id: uuid("id").primaryKey().defaultRandom(),
2123 ghsaId: text("ghsa_id").unique(),
2124 cveId: text("cve_id"),
2125 summary: text("summary").notNull(),
2126 severity: text("severity").default("moderate").notNull(),
2127 ecosystem: text("ecosystem").notNull(),
2128 packageName: text("package_name").notNull(),
2129 affectedRange: text("affected_range").notNull(),
2130 fixedVersion: text("fixed_version"),
2131 referenceUrl: text("reference_url"),
2132 publishedAt: timestamp("published_at").defaultNow().notNull(),
2133 },
2134 (table) => [
2135 index("security_advisories_pkg_idx").on(
2136 table.ecosystem,
2137 table.packageName
2138 ),
2139 ]
2140);
2141
2142export type SecurityAdvisory = typeof securityAdvisories.$inferSelect;
2143
2144/** Per-repo match state. One row per (repo, advisory, manifest_path). */
2145export const repoAdvisoryAlerts = pgTable(
2146 "repo_advisory_alerts",
2147 {
2148 id: uuid("id").primaryKey().defaultRandom(),
2149 repositoryId: uuid("repository_id")
2150 .notNull()
2151 .references(() => repositories.id, { onDelete: "cascade" }),
2152 advisoryId: uuid("advisory_id")
2153 .notNull()
2154 .references(() => securityAdvisories.id, { onDelete: "cascade" }),
2155 dependencyName: text("dependency_name").notNull(),
2156 dependencyVersion: text("dependency_version"),
2157 manifestPath: text("manifest_path").notNull(),
2158 status: text("status").default("open").notNull(),
2159 dismissedReason: text("dismissed_reason"),
2160 createdAt: timestamp("created_at").defaultNow().notNull(),
2161 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2162 },
2163 (table) => [
2164 index("repo_advisory_alerts_status_idx").on(
2165 table.repositoryId,
2166 table.status
2167 ),
2168 ]
2169);
2170
2171export type RepoAdvisoryAlert = typeof repoAdvisoryAlerts.$inferSelect;
Addedsrc/lib/advisories.ts+546−0View fileUnifiedSplit
@@ -0,0 +1,546 @@
1/**
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};
Addedsrc/routes/advisories.tsx+371−0View fileUnifiedSplit
@@ -0,0 +1,371 @@
1/**
2 * Block J2 — Security advisory / dependabot-style alert routes.
3 *
4 * GET /:owner/:repo/security/advisories — list open alerts
5 * GET /:owner/:repo/security/advisories/all — dismissed + fixed too
6 * POST /:owner/:repo/security/advisories/scan — owner-only; re-scan
7 * POST /:owner/:repo/security/advisories/:id/dismiss
8 * POST /:owner/:repo/security/advisories/:id/reopen
9 */
10
11import { Hono } from "hono";
12import { and, eq } from "drizzle-orm";
13import { db } from "../db";
14import { repositories, users } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader, RepoNav } from "../views/components";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import { audit } from "../lib/notify";
20import {
21 dismissAlert,
22 listAlertsForRepo,
23 reopenAlert,
24 scanRepositoryForAlerts,
25 seedAdvisories,
26} from "../lib/advisories";
27
28const advisories = new Hono<AuthEnv>();
29advisories.use("*", softAuth);
30
31async function loadRepo(ownerName: string, repoName: string) {
32 const [owner] = await db
33 .select()
34 .from(users)
35 .where(eq(users.username, ownerName))
36 .limit(1);
37 if (!owner) return null;
38 const [repo] = await db
39 .select()
40 .from(repositories)
41 .where(
42 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
43 )
44 .limit(1);
45 if (!repo) return null;
46 return { owner, repo };
47}
48
49function severityColor(sev: string): string {
50 switch (sev) {
51 case "critical":
52 return "var(--red)";
53 case "high":
54 return "var(--red)";
55 case "moderate":
56 return "var(--yellow)";
57 default:
58 return "var(--text-muted)";
59 }
60}
61
62// ---------- List ----------
63
64async function renderList(
65 c: any,
66 ownerName: string,
67 repoName: string,
68 status: "open" | "all"
69) {
70 const ctx = await loadRepo(ownerName, repoName);
71 if (!ctx) return c.notFound();
72 const { repo } = ctx;
73 const user = c.get("user");
74 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
75 return c.notFound();
76 }
77
78 const isOwner = !!user && user.id === repo.ownerId;
79 const alerts = await listAlertsForRepo(repo.id, status);
80 const message = c.req.query("message");
81 const error = c.req.query("error");
82
83 return c.html(
84 <Layout
85 title={`Security advisories — ${ownerName}/${repoName}`}
86 user={user}
87 >
88 <RepoHeader owner={ownerName} repo={repoName} />
89 <RepoNav owner={ownerName} repo={repoName} active="code" />
90 <div class="settings-container">
91 <div style="display:flex;justify-content:space-between;align-items:center">
92 <h2 style="margin:0">Security advisories</h2>
93 {isOwner && (
94 <form
95 method="POST"
96 action={`/${ownerName}/${repoName}/security/advisories/scan`}
97 >
98 <button type="submit" class="btn btn-primary btn-sm">
99 Re-scan
100 </button>
101 </form>
102 )}
103 </div>
104 <p style="color:var(--text-muted);margin-top:8px">
105 Cross-references this repo's parsed dependency graph against a
106 curated advisory database. Run <em>Reindex</em> on{" "}
107 <a href={`/${ownerName}/${repoName}/dependencies`}>Dependencies</a>{" "}
108 first if no alerts show up.
109 </p>
110 {message && (
111 <div class="auth-success" style="margin-top:12px">
112 {decodeURIComponent(message)}
113 </div>
114 )}
115 {error && (
116 <div class="auth-error" style="margin-top:12px">
117 {decodeURIComponent(error)}
118 </div>
119 )}
120
121 <div style="display:flex;gap:8px;margin:16px 0">
122 <a
123 href={`/${ownerName}/${repoName}/security/advisories`}
124 class={`btn ${status === "open" ? "btn-primary" : ""}`}
125 >
126 Open
127 </a>
128 <a
129 href={`/${ownerName}/${repoName}/security/advisories/all`}
130 class={`btn ${status === "all" ? "btn-primary" : ""}`}
131 >
132 All
133 </a>
134 </div>
135
136 {alerts.length === 0 ? (
137 <div class="panel-empty" style="padding:24px">
138 No {status === "open" ? "open " : ""}advisories.
139 {isOwner &&
140 status === "open" &&
141 " Click Re-scan to check against the advisory database."}
142 </div>
143 ) : (
144 <div class="panel">
145 {alerts.map((a) => (
146 <div
147 class="panel-item"
148 style="flex-direction:column;align-items:stretch;gap:6px"
149 >
150 <div
151 style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap"
152 >
153 <div style="min-width:0">
154 <span
155 style={`font-size:10px;padding:2px 6px;border-radius:3px;background:${severityColor(
156 a.advisory.severity
157 )};color:#fff;text-transform:uppercase;margin-right:6px`}
158 >
159 {a.advisory.severity}
160 </span>
161 <span style="font-weight:600">
162 {a.advisory.summary}
163 </span>
164 </div>
165 <div
166 style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase"
167 >
168 {a.status}
169 </div>
170 </div>
171 <div
172 style="font-size:12px;color:var(--text-muted);display:flex;gap:12px;flex-wrap:wrap"
173 >
174 <span style="font-family:var(--font-mono)">
175 {a.advisory.ecosystem} · {a.dependencyName}
176 {a.dependencyVersion
177 ? ` ${a.dependencyVersion}`
178 : ""}
179 </span>
180 <span>affected: {a.advisory.affectedRange}</span>
181 {a.advisory.fixedVersion && (
182 <span>fixed in ≥ {a.advisory.fixedVersion}</span>
183 )}
184 <a
185 href={`/${ownerName}/${repoName}/blob/HEAD/${a.manifestPath}`}
186 >
187 {a.manifestPath}
188 </a>
189 {a.advisory.referenceUrl && (
190 <a
191 href={a.advisory.referenceUrl}
192 rel="noreferrer"
193 target="_blank"
194 >
195 {a.advisory.ghsaId || a.advisory.cveId || "ref"}
196 </a>
197 )}
198 </div>
199 {a.status === "dismissed" && a.dismissedReason && (
200 <div
201 style="font-size:12px;color:var(--text-muted);font-style:italic"
202 >
203 Dismissed: {a.dismissedReason}
204 </div>
205 )}
206 {isOwner && (
207 <div style="display:flex;gap:6px">
208 {a.status === "open" && (
209 <form
210 method="POST"
211 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/dismiss`}
212 style="display:flex;gap:4px;align-items:center"
213 >
214 <input
215 type="text"
216 name="reason"
217 placeholder="reason (optional)"
218 maxLength={280}
219 style="font-size:12px;padding:4px 6px"
220 />
221 <button
222 type="submit"
223 class="btn btn-sm"
224 style="font-size:11px"
225 >
226 Dismiss
227 </button>
228 </form>
229 )}
230 {a.status === "dismissed" && (
231 <form
232 method="POST"
233 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/reopen`}
234 >
235 <button
236 type="submit"
237 class="btn btn-sm"
238 style="font-size:11px"
239 >
240 Reopen
241 </button>
242 </form>
243 )}
244 </div>
245 )}
246 </div>
247 ))}
248 </div>
249 )}
250 </div>
251 </Layout>
252 );
253}
254
255advisories.get("/:owner/:repo/security/advisories", async (c) => {
256 const { owner: ownerName, repo: repoName } = c.req.param();
257 return renderList(c, ownerName, repoName, "open");
258});
259
260advisories.get("/:owner/:repo/security/advisories/all", async (c) => {
261 const { owner: ownerName, repo: repoName } = c.req.param();
262 return renderList(c, ownerName, repoName, "all");
263});
264
265// ---------- Re-scan (owner-only) ----------
266
267advisories.post(
268 "/:owner/:repo/security/advisories/scan",
269 requireAuth,
270 async (c) => {
271 const user = c.get("user")!;
272 const { owner: ownerName, repo: repoName } = c.req.param();
273 const ctx = await loadRepo(ownerName, repoName);
274 if (!ctx) return c.notFound();
275 const { repo } = ctx;
276 if (user.id !== repo.ownerId) {
277 return c.redirect(
278 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
279 "Only the repo owner can scan"
280 )}`
281 );
282 }
283 await seedAdvisories().catch(() => {});
284 const result = await scanRepositoryForAlerts(repo.id);
285 await audit({
286 userId: user.id,
287 repositoryId: repo.id,
288 action: "advisories.scan",
289 metadata: result || {},
290 });
291 const to = `/${ownerName}/${repoName}/security/advisories`;
292 if (!result) {
293 return c.redirect(
294 `${to}?error=${encodeURIComponent("Scan failed")}`
295 );
296 }
297 const msg = `Scan complete — ${result.opened} new, ${result.closed} closed, ${result.matched} total matches.`;
298 return c.redirect(`${to}?message=${encodeURIComponent(msg)}`);
299 }
300);
301
302// ---------- Dismiss / reopen (owner-only) ----------
303
304advisories.post(
305 "/:owner/:repo/security/advisories/:id/dismiss",
306 requireAuth,
307 async (c) => {
308 const user = c.get("user")!;
309 const { owner: ownerName, repo: repoName, id } = c.req.param();
310 const ctx = await loadRepo(ownerName, repoName);
311 if (!ctx) return c.notFound();
312 const { repo } = ctx;
313 if (user.id !== repo.ownerId) {
314 return c.redirect(
315 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
316 "Only the repo owner can dismiss"
317 )}`
318 );
319 }
320 const body = await c.req.parseBody();
321 const reason = String(body.reason || "").trim();
322 const ok = await dismissAlert(id, repo.id, reason);
323 await audit({
324 userId: user.id,
325 repositoryId: repo.id,
326 action: "advisories.dismiss",
327 targetId: id,
328 metadata: { reason: reason || null },
329 });
330 const to = `/${ownerName}/${repoName}/security/advisories`;
331 return c.redirect(
332 `${to}?${ok ? "message" : "error"}=${encodeURIComponent(
333 ok ? "Alert dismissed." : "Dismiss failed"
334 )}`
335 );
336 }
337);
338
339advisories.post(
340 "/:owner/:repo/security/advisories/:id/reopen",
341 requireAuth,
342 async (c) => {
343 const user = c.get("user")!;
344 const { owner: ownerName, repo: repoName, id } = c.req.param();
345 const ctx = await loadRepo(ownerName, repoName);
346 if (!ctx) return c.notFound();
347 const { repo } = ctx;
348 if (user.id !== repo.ownerId) {
349 return c.redirect(
350 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
351 "Only the repo owner can reopen"
352 )}`
353 );
354 }
355 const ok = await reopenAlert(id, repo.id);
356 await audit({
357 userId: user.id,
358 repositoryId: repo.id,
359 action: "advisories.reopen",
360 targetId: id,
361 });
362 const to = `/${ownerName}/${repoName}/security/advisories`;
363 return c.redirect(
364 `${to}?${ok ? "message" : "error"}=${encodeURIComponent(
365 ok ? "Alert reopened." : "Reopen failed"
366 )}`
367 );
368 }
369);
370
371export default advisories;
0372