Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitda3fc18unknown_key

feat: dependency CVE scanner — auto-open security issues on push via OSV API

feat: dependency CVE scanner — auto-open security issues on push via OSV API

- src/lib/dependency-scanner.ts: parse package.json/requirements.txt/Cargo.toml/go.mod/Gemfile from git show, batch-query OSV API, open per-CVE issues for critical/high findings and upsert a weekly digest issue for medium/low findings
- src/routes/security.tsx: /:owner/:repo/security/vulnerabilities page showing all open dep-scan issues grouped by severity with count badges and all-clear panel
- src/views/components.tsx: added "security" to RepoNav active union + Security tab linking to /security/vulnerabilities
- src/lib/config.ts: DEPENDENCY_SCAN_ENABLED getter
- src/hooks/post-receive.ts: fire-and-forget fireDependencyScan() call gated on config.dependencyScanEnabled
- src/app.tsx: mount securityRoutes
- src/__tests__/dependency-scanner.test.ts: 41 tests covering parsers, OSV mapper, severity mapping, fix extraction, and route auth

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 0a69faa
7 files changed+19000da3fc1810a61df289520ecb0d650d69077f31466
7 changed files+1900−0
Addedsrc/__tests__/dependency-scanner.test.ts+401−0View fileUnifiedSplit
1/**
2 * Dependency CVE Scanner — unit tests.
3 *
4 * Tests the pure parsing helpers, the OSV result mapper, and issue-body
5 * renderers without touching the DB or network.
6 */
7
8import { describe, it, expect } from "bun:test";
9import { __internal } from "../lib/dependency-scanner";
10import app from "../app";
11
12const {
13 parsePackageJson,
14 parseRequirementsTxt,
15 parseCargoToml,
16 parseGoMod,
17 parseGemfile,
18 mapSeverity,
19 extractFixVersion,
20 osvResultsToFindings,
21 renderVulnIssueBody,
22 renderDigestBody,
23 WEEKLY_DIGEST_MARKER,
24 FILE_ECOSYSTEM,
25 DEPENDENCY_FILES,
26} = __internal;
27
28// ---------------------------------------------------------------------------
29// parsePackageJson
30// ---------------------------------------------------------------------------
31
32describe("parsePackageJson", () => {
33 it("parses dependencies and devDependencies", () => {
34 const json = JSON.stringify({
35 dependencies: { lodash: "^4.17.11", express: "^4.18.0" },
36 devDependencies: { jest: "^29.0.0" },
37 });
38 const result = parsePackageJson(json, "npm");
39 expect(result.length).toBe(3);
40 expect(result.map((p) => p.name)).toContain("lodash");
41 expect(result.map((p) => p.name)).toContain("express");
42 expect(result.map((p) => p.name)).toContain("jest");
43 });
44
45 it("strips leading ^ ~ from version specs", () => {
46 const json = JSON.stringify({ dependencies: { react: "^18.2.0" } });
47 const result = parsePackageJson(json, "npm");
48 expect(result[0].version).toBe("18.2.0");
49 });
50
51 it("returns [] on invalid JSON", () => {
52 expect(parsePackageJson("not json", "npm")).toEqual([]);
53 });
54
55 it("sets correct ecosystem", () => {
56 const json = JSON.stringify({ dependencies: { foo: "1.0.0" } });
57 const result = parsePackageJson(json, "npm");
58 expect(result[0].ecosystem).toBe("npm");
59 });
60
61 it("handles empty dependency sections", () => {
62 const json = JSON.stringify({ name: "my-app" });
63 expect(parsePackageJson(json, "npm")).toEqual([]);
64 });
65});
66
67// ---------------------------------------------------------------------------
68// parseRequirementsTxt
69// ---------------------------------------------------------------------------
70
71describe("parseRequirementsTxt", () => {
72 it("parses pinned versions", () => {
73 const content = "requests==2.28.0\nDjango>=4.1\nflask\n";
74 const result = parseRequirementsTxt(content, "PyPI");
75 expect(result.map((p) => p.name)).toContain("requests");
76 expect(result.find((p) => p.name === "requests")?.version).toBe("2.28.0");
77 expect(result.map((p) => p.name)).toContain("Django");
78 expect(result.map((p) => p.name)).toContain("flask");
79 });
80
81 it("skips comments and -r flags", () => {
82 const content = "# comment\n-r base.txt\nnumpy==1.24.0\n";
83 const result = parseRequirementsTxt(content, "PyPI");
84 expect(result.length).toBe(1);
85 expect(result[0].name).toBe("numpy");
86 });
87
88 it("returns [] for empty content", () => {
89 expect(parseRequirementsTxt("", "PyPI")).toEqual([]);
90 });
91});
92
93// ---------------------------------------------------------------------------
94// parseCargoToml
95// ---------------------------------------------------------------------------
96
97describe("parseCargoToml", () => {
98 it("parses simple string versions", () => {
99 const content = `[dependencies]\nserde = "1.0.163"\ntokio = "1.28.0"\n`;
100 const result = parseCargoToml(content, "crates.io");
101 expect(result.map((p) => p.name)).toContain("serde");
102 expect(result.find((p) => p.name === "serde")?.version).toBe("1.0.163");
103 expect(result.find((p) => p.name === "tokio")?.version).toBe("1.28.0");
104 });
105
106 it("parses table-style versions", () => {
107 const content = `[dependencies]\nactix-web = { version = "4.3.0", features = ["macros"] }\n`;
108 const result = parseCargoToml(content, "crates.io");
109 expect(result.find((p) => p.name === "actix-web")?.version).toBe("4.3.0");
110 });
111
112 it("skips [package] and other sections", () => {
113 const content = `[package]\nname = "my-crate"\n\n[dependencies]\nhyper = "0.14.27"\n`;
114 const result = parseCargoToml(content, "crates.io");
115 expect(result.length).toBe(1);
116 expect(result[0].name).toBe("hyper");
117 });
118
119 it("returns [] for empty content", () => {
120 expect(parseCargoToml("", "crates.io")).toEqual([]);
121 });
122});
123
124// ---------------------------------------------------------------------------
125// parseGoMod
126// ---------------------------------------------------------------------------
127
128describe("parseGoMod", () => {
129 it("parses require lines", () => {
130 const content = `module example.com/myapp\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.0\n\tgolang.org/x/net v0.10.0\n)\n`;
131 const result = parseGoMod(content, "Go");
132 expect(result.map((p) => p.name)).toContain("github.com/gin-gonic/gin");
133 expect(result.find((p) => p.name === "github.com/gin-gonic/gin")?.version).toBe("1.9.0");
134 });
135
136 it("strips leading v from version", () => {
137 const content = `require github.com/foo/bar v2.1.0\n`;
138 const result = parseGoMod(content, "Go");
139 expect(result[0].version).toBe("2.1.0");
140 });
141
142 it("returns [] for content with no require lines", () => {
143 expect(parseGoMod("module foo\n\ngo 1.21\n", "Go")).toEqual([]);
144 });
145});
146
147// ---------------------------------------------------------------------------
148// parseGemfile
149// ---------------------------------------------------------------------------
150
151describe("parseGemfile", () => {
152 it("parses gem declarations", () => {
153 const content = `source 'https://rubygems.org'\ngem 'rails', '~> 7.0'\ngem 'pg'\n`;
154 const result = parseGemfile(content, "RubyGems");
155 expect(result.map((p) => p.name)).toContain("rails");
156 expect(result.map((p) => p.name)).toContain("pg");
157 });
158
159 it("returns [] for non-gem lines", () => {
160 expect(parseGemfile("source 'https://rubygems.org'\n", "RubyGems")).toEqual([]);
161 });
162});
163
164// ---------------------------------------------------------------------------
165// mapSeverity
166// ---------------------------------------------------------------------------
167
168describe("mapSeverity", () => {
169 it("returns 'critical' for CVSS score >= 9.0", () => {
170 expect(
171 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "9.5" }] })
172 ).toBe("critical");
173 });
174
175 it("returns 'high' for CVSS score >= 7.0", () => {
176 expect(
177 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "8.1" }] })
178 ).toBe("high");
179 });
180
181 it("returns 'medium' for CVSS score >= 4.0", () => {
182 expect(
183 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "5.3" }] })
184 ).toBe("medium");
185 });
186
187 it("returns 'low' for CVSS score < 4.0", () => {
188 expect(
189 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "2.1" }] })
190 ).toBe("low");
191 });
192
193 it("handles string CRITICAL severity", () => {
194 expect(
195 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "CRITICAL" }] })
196 ).toBe("critical");
197 });
198
199 it("handles string HIGH severity", () => {
200 expect(
201 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "HIGH" }] })
202 ).toBe("high");
203 });
204
205 it("returns 'medium' when no severity data", () => {
206 expect(mapSeverity({ id: "x" })).toBe("medium");
207 expect(mapSeverity({ id: "x", severity: [] })).toBe("medium");
208 });
209});
210
211// ---------------------------------------------------------------------------
212// extractFixVersion
213// ---------------------------------------------------------------------------
214
215describe("extractFixVersion", () => {
216 it("extracts fix version from affected ranges", () => {
217 const vuln = {
218 id: "CVE-2021-44228",
219 affected: [
220 {
221 ranges: [
222 {
223 type: "SEMVER",
224 events: [{ introduced: "2.0.0" }, { fixed: "2.15.0" }],
225 },
226 ],
227 },
228 ],
229 };
230 expect(extractFixVersion(vuln)).toBe("2.15.0");
231 });
232
233 it("returns undefined when no fix is available", () => {
234 const vuln = {
235 id: "CVE-XXXX",
236 affected: [{ ranges: [{ type: "SEMVER", events: [{ introduced: "0" }] }] }],
237 };
238 expect(extractFixVersion(vuln)).toBeUndefined();
239 });
240
241 it("returns undefined for vulns with no affected data", () => {
242 expect(extractFixVersion({ id: "x" })).toBeUndefined();
243 });
244});
245
246// ---------------------------------------------------------------------------
247// osvResultsToFindings
248// ---------------------------------------------------------------------------
249
250describe("osvResultsToFindings", () => {
251 it("converts OSV results to VulnFindings", () => {
252 const packages = [{ name: "lodash", version: "4.17.11", ecosystem: "npm" }];
253 const results = [
254 {
255 vulns: [
256 {
257 id: "GHSA-p6mc-m468-83gw",
258 summary: "Prototype pollution in lodash",
259 severity: [{ type: "CVSS_V3", score: "9.8" }],
260 affected: [
261 {
262 ranges: [
263 { type: "SEMVER", events: [{ introduced: "0" }, { fixed: "4.17.12" }] },
264 ],
265 },
266 ],
267 },
268 ],
269 },
270 ];
271 const findings = osvResultsToFindings(packages, results);
272 expect(findings).toHaveLength(1);
273 expect(findings[0].packageName).toBe("lodash");
274 expect(findings[0].installedVersion).toBe("4.17.11");
275 expect(findings[0].severity).toBe("critical");
276 expect(findings[0].cveId).toBe("GHSA-p6mc-m468-83gw");
277 expect(findings[0].fixVersion).toBe("4.17.12");
278 });
279
280 it("returns [] when no vulns in results", () => {
281 const packages = [{ name: "safe-pkg", version: "1.0.0", ecosystem: "npm" }];
282 const results = [{ vulns: [] }];
283 expect(osvResultsToFindings(packages, results)).toEqual([]);
284 });
285
286 it("handles mismatched results length gracefully", () => {
287 const packages = [{ name: "pkg-a", version: "1.0.0", ecosystem: "npm" }];
288 const findings = osvResultsToFindings(packages, []);
289 expect(findings).toEqual([]);
290 });
291});
292
293// ---------------------------------------------------------------------------
294// renderVulnIssueBody
295// ---------------------------------------------------------------------------
296
297describe("renderVulnIssueBody", () => {
298 const finding = {
299 packageName: "lodash",
300 installedVersion: "4.17.11",
301 severity: "critical" as const,
302 cveId: "GHSA-p6mc-m468-83gw",
303 description: "Prototype pollution.",
304 fixVersion: "4.17.12",
305 };
306
307 it("includes package name and version", () => {
308 const body = renderVulnIssueBody(finding);
309 expect(body).toContain("lodash");
310 expect(body).toContain("4.17.11");
311 });
312
313 it("includes CVE ID with OSV link", () => {
314 const body = renderVulnIssueBody(finding);
315 expect(body).toContain("GHSA-p6mc-m468-83gw");
316 expect(body).toContain("https://osv.dev/vulnerability/GHSA-p6mc-m468-83gw");
317 });
318
319 it("includes fix version in remediation", () => {
320 const body = renderVulnIssueBody(finding);
321 expect(body).toContain("4.17.12");
322 });
323
324 it("mentions 'No fix' when fixVersion is absent", () => {
325 const body = renderVulnIssueBody({ ...finding, fixVersion: undefined });
326 expect(body).toContain("No fix version");
327 });
328
329 it("includes CRITICAL urgency text for critical severity", () => {
330 const body = renderVulnIssueBody(finding);
331 expect(body).toContain("CRITICAL");
332 });
333});
334
335// ---------------------------------------------------------------------------
336// renderDigestBody
337// ---------------------------------------------------------------------------
338
339describe("renderDigestBody", () => {
340 const findings = [
341 {
342 packageName: "minimist",
343 installedVersion: "1.2.0",
344 severity: "medium" as const,
345 cveId: "CVE-2020-7598",
346 description: "Prototype pollution.",
347 fixVersion: "1.2.2",
348 },
349 ];
350
351 it("includes the weekly digest marker", () => {
352 expect(renderDigestBody(findings)).toContain(WEEKLY_DIGEST_MARKER);
353 });
354
355 it("includes package name and CVE in the table", () => {
356 const body = renderDigestBody(findings);
357 expect(body).toContain("minimist");
358 expect(body).toContain("CVE-2020-7598");
359 });
360
361 it("returns valid markdown table", () => {
362 const body = renderDigestBody(findings);
363 expect(body).toContain("| Package |");
364 expect(body).toContain("|---|");
365 });
366});
367
368// ---------------------------------------------------------------------------
369// Constants
370// ---------------------------------------------------------------------------
371
372describe("constants", () => {
373 it("DEPENDENCY_FILES contains the five expected files", () => {
374 expect(DEPENDENCY_FILES).toContain("package.json");
375 expect(DEPENDENCY_FILES).toContain("requirements.txt");
376 expect(DEPENDENCY_FILES).toContain("Cargo.toml");
377 expect(DEPENDENCY_FILES).toContain("go.mod");
378 expect(DEPENDENCY_FILES).toContain("Gemfile");
379 });
380
381 it("FILE_ECOSYSTEM maps each file to an ecosystem", () => {
382 expect(FILE_ECOSYSTEM["package.json"]).toBe("npm");
383 expect(FILE_ECOSYSTEM["requirements.txt"]).toBe("PyPI");
384 expect(FILE_ECOSYSTEM["Cargo.toml"]).toBe("crates.io");
385 expect(FILE_ECOSYSTEM["go.mod"]).toBe("Go");
386 expect(FILE_ECOSYSTEM["Gemfile"]).toBe("RubyGems");
387 });
388});
389
390// ---------------------------------------------------------------------------
391// Route auth smoke tests
392// ---------------------------------------------------------------------------
393
394describe("GET /:owner/:repo/security/vulnerabilities — route auth", () => {
395 it("returns 404 for non-existent owner", async () => {
396 const res = await app.request(
397 "/nonexistent-owner-99999/my-repo/security/vulnerabilities"
398 );
399 expect(res.status).toBe(404);
400 });
401});
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
119119import billingUsageRoutes from "./routes/billing-usage";
120120import stripeWebhookRoutes from "./routes/stripe-webhook";
121121import codeScanningRoutes from "./routes/code-scanning";
122import securityRoutes from "./routes/security";
122123import commitStatusesRoutes from "./routes/commit-statuses";
123124import copilotRoutes from "./routes/copilot";
124125import depUpdaterRoutes from "./routes/dep-updater";
657658app.route("/", billingUsageRoutes);
658659app.route("/", stripeWebhookRoutes);
659660app.route("/", codeScanningRoutes);
661// Dependency CVE scanner findings page — /:owner/:repo/security/vulnerabilities
662app.route("/", securityRoutes);
660663app.route("/", commitStatusesRoutes);
661664app.route("/", copilotRoutes);
662665app.route("/", depUpdaterRoutes);
Modifiedsrc/hooks/post-receive.ts+66−0View fileUnifiedSplit
2727import { indexChangedFiles } from "../lib/semantic-index";
2828import { scanDiffForIssues } from "../lib/ai-auto-issues";
2929import { enqueuePreviewBuild } from "../lib/branch-previews";
30import { scanDependencies } from "../lib/dependency-scanner";
3031import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
3132import {
3233 findTargetsForPush,
138139 );
139140 }
140141
142 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
143 // every push that touches a recognized manifest file (package.json,
144 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
145 // for critical/high findings and updates a weekly digest for
146 // medium/low. Fire-and-forget; never blocks the push path.
147 if (config.dependencyScanEnabled) {
148 void fireDependencyScan(owner, repo, refs).catch((err) =>
149 console.warn("[dependency-scanner] dispatch error:", err)
150 );
151 }
152
141153 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
142154 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
143155 // regions, hashes the referenced source, and opens a PR labelled
715727 );
716728}
717729
730/**
731 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
732 * Resolves the repo DB row once, then runs the scanner on each live push.
733 * Swallows all errors so a scanner failure never affects the push path.
734 */
735async function fireDependencyScan(
736 owner: string,
737 repo: string,
738 refs: PushRef[]
739): Promise<void> {
740 const liveRefs = refs.filter(
741 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
742 );
743 if (liveRefs.length === 0) return;
744
745 let repoRow: { id: string; ownerId: string } | null = null;
746 try {
747 const [row] = await db
748 .select({ id: repositories.id, ownerId: repositories.ownerId })
749 .from(repositories)
750 .innerJoin(users, eq(repositories.ownerId, users.id))
751 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
752 .limit(1);
753 repoRow = row || null;
754 } catch {
755 return;
756 }
757 if (!repoRow) return;
758
759 for (const ref of liveRefs) {
760 try {
761 const findings = await scanDependencies(
762 repoRow.id,
763 owner,
764 repo,
765 ref.newSha,
766 ref.oldSha,
767 repoRow.ownerId
768 );
769 if (findings.length > 0) {
770 console.log(
771 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
772 );
773 }
774 } catch (err) {
775 console.warn(
776 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
777 err instanceof Error ? err.message : err
778 );
779 }
780 }
781}
782
718783/** Test-only access to internal helpers. */
719784export const __test = {
720785 triggerCrontechDeploy,
726791 firePreviewBuilds,
727792 fireDocDriftCheck,
728793 fireServerTargetDeploys,
794 fireDependencyScan,
729795};
Modifiedsrc/lib/config.ts+11−0View fileUnifiedSplit
113113 return process.env.WEBAUTHN_RP_NAME || "gluecron";
114114 },
115115 /**
116<<<<<<< HEAD
116117 * Redis / Valkey connection URL for cross-instance SSE fan-out.
117118 * When set, `src/lib/sse.ts` uses Redis pub/sub so SSE events reach all
118119 * server instances behind the load balancer. Falls back to in-process
130131 get aiAutoIssues() {
131132 return process.env.AI_AUTO_ISSUES === "1";
132133 },
134 /**
135 * Dependency CVE scanner — when set to "1", the post-receive hook fires
136 * `scanDependencies()` on every push that touches a recognized manifest
137 * file (package.json, requirements.txt, Cargo.toml, go.mod, Gemfile).
138 * Results open security issues automatically. Fire-and-forget; never
139 * blocks a push.
140 */
141 get dependencyScanEnabled() {
142 return process.env.DEPENDENCY_SCAN_ENABLED === "1";
143 },
133144};
Addedsrc/lib/dependency-scanner.ts+795−0View fileUnifiedSplit
1/**
2 * Dependency CVE Scanner — auto-opens security issues on push.
3 *
4 * When a push touches package.json, requirements.txt, Cargo.toml, go.mod,
5 * or Gemfile, this module:
6 * 1. Reads the dependency file from the pushed commit via `git show`.
7 * 2. Queries the OSV (Open Source Vulnerabilities) API for each package.
8 * 3. For critical/high findings: opens an issue per CVE (deduplicates via
9 * title ILIKE check so the same CVE never gets a second open issue).
10 * 4. For medium/low findings: batches them into a weekly digest issue.
11 *
12 * Integrates into post-receive.ts as a fire-and-forget call guarded by
13 * `DEPENDENCY_SCAN_ENABLED=1`. Never throws — every external call is wrapped.
14 */
15
16import { and, eq, ilike, or } from "drizzle-orm";
17import { db } from "../db";
18import {
19 issueLabels,
20 issues,
21 labels,
22 repositories,
23 users,
24} from "../db/schema";
25import { getRepoPath } from "../git/repository";
26
27// ---------------------------------------------------------------------------
28// Public types
29// ---------------------------------------------------------------------------
30
31export interface VulnFinding {
32 packageName: string;
33 installedVersion: string;
34 severity: "critical" | "high" | "medium" | "low";
35 cveId: string;
36 description: string;
37 fixVersion?: string;
38}
39
40// ---------------------------------------------------------------------------
41// Ecosystem map — maps manifest filename to OSV ecosystem string
42// ---------------------------------------------------------------------------
43
44const DEPENDENCY_FILES = [
45 "package.json",
46 "requirements.txt",
47 "Cargo.toml",
48 "go.mod",
49 "Gemfile",
50] as const;
51
52type DependencyFile = (typeof DEPENDENCY_FILES)[number];
53
54const FILE_ECOSYSTEM: Record<DependencyFile, string> = {
55 "package.json": "npm",
56 "requirements.txt": "PyPI",
57 "Cargo.toml": "crates.io",
58 "go.mod": "Go",
59 "Gemfile": "RubyGems",
60};
61
62// ---------------------------------------------------------------------------
63// Git helpers
64// ---------------------------------------------------------------------------
65
66/** Reads a file at a specific commit SHA via `git show`. Returns null on failure. */
67async function gitShow(
68 owner: string,
69 repoName: string,
70 sha: string,
71 filePath: string
72): Promise<string | null> {
73 try {
74 const cwd = getRepoPath(owner, repoName);
75 const proc = Bun.spawn(
76 ["git", "show", `${sha}:${filePath}`],
77 { cwd, stdout: "pipe", stderr: "pipe" }
78 );
79 const text = await new Response(proc.stdout).text();
80 const code = await proc.exited;
81 if (code !== 0) return null;
82 return text;
83 } catch {
84 return null;
85 }
86}
87
88/** Returns the list of files changed between oldSha and newSha. */
89async function gitDiffNames(
90 owner: string,
91 repoName: string,
92 oldSha: string,
93 newSha: string
94): Promise<string[]> {
95 try {
96 const cwd = getRepoPath(owner, repoName);
97 const allZero = /^0+$/.test(oldSha);
98 const cmd = allZero
99 ? ["git", "ls-tree", "-r", "--name-only", newSha]
100 : ["git", "diff", "--name-only", oldSha, newSha];
101 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
102 const text = await new Response(proc.stdout).text();
103 await proc.exited;
104 return text.split("\n").map((s) => s.trim()).filter(Boolean);
105 } catch {
106 return [];
107 }
108}
109
110// ---------------------------------------------------------------------------
111// Manifest parsers
112// ---------------------------------------------------------------------------
113
114interface ParsedPackage {
115 name: string;
116 version: string;
117 ecosystem: string;
118}
119
120function parsePackageJson(content: string, ecosystem: string): ParsedPackage[] {
121 try {
122 const json = JSON.parse(content) as Record<string, unknown>;
123 const deps: Record<string, string> = {};
124 const raw = json["dependencies"];
125 const rawDev = json["devDependencies"];
126 if (raw && typeof raw === "object") {
127 Object.assign(deps, raw as Record<string, string>);
128 }
129 if (rawDev && typeof rawDev === "object") {
130 Object.assign(deps, rawDev as Record<string, string>);
131 }
132 return Object.entries(deps)
133 .filter(([, v]) => typeof v === "string")
134 .map(([name, versionSpec]) => ({
135 name,
136 // Strip leading ^~>< to get a bare version for the OSV query.
137 // If the spec is something like "workspace:*" we'll still query
138 // with the empty string; OSV returns all advisories for the package.
139 version: versionSpec.replace(/^[^0-9]*/, "").split(" ")[0] || "",
140 ecosystem,
141 }));
142 } catch {
143 return [];
144 }
145}
146
147function parseRequirementsTxt(content: string, ecosystem: string): ParsedPackage[] {
148 const result: ParsedPackage[] = [];
149 for (const rawLine of content.split("\n")) {
150 const line = rawLine.trim();
151 if (!line || line.startsWith("#") || line.startsWith("-")) continue;
152 // Lines like: requests==2.28.0, Django>=4.1, flask
153 const match = line.match(/^([A-Za-z0-9_.\-]+)\s*(?:[=<>!]+\s*([0-9][^\s,;#]*))?/);
154 if (!match) continue;
155 const name = match[1];
156 const version = match[2] || "";
157 result.push({ name, version, ecosystem });
158 }
159 return result;
160}
161
162function parseCargoToml(content: string, ecosystem: string): ParsedPackage[] {
163 const result: ParsedPackage[] = [];
164 let inDeps = false;
165 for (const rawLine of content.split("\n")) {
166 const line = rawLine.trim();
167 if (line.startsWith("[")) {
168 inDeps =
169 line === "[dependencies]" ||
170 line === "[dev-dependencies]" ||
171 line === "[build-dependencies]";
172 continue;
173 }
174 if (!inDeps) continue;
175 if (!line || line.startsWith("#")) continue;
176 // name = "1.2.3"
177 const simpleMatch = line.match(/^([A-Za-z0-9_\-]+)\s*=\s*"([0-9][^"]*)"$/);
178 if (simpleMatch) {
179 result.push({ name: simpleMatch[1], version: simpleMatch[2], ecosystem });
180 continue;
181 }
182 // name = { version = "1.2.3", ... }
183 const tableMatch = line.match(/^([A-Za-z0-9_\-]+)\s*=\s*\{.*version\s*=\s*"([0-9][^"]*)".*\}$/);
184 if (tableMatch) {
185 result.push({ name: tableMatch[1], version: tableMatch[2], ecosystem });
186 }
187 }
188 return result;
189}
190
191function parseGoMod(content: string, ecosystem: string): ParsedPackage[] {
192 const result: ParsedPackage[] = [];
193 for (const rawLine of content.split("\n")) {
194 const line = rawLine.trim();
195 // Matches both block form: github.com/foo/bar v1.2.3
196 // and standalone require: require github.com/foo/bar v1.2.3
197 // Strip a leading "require " if present.
198 const stripped = line.startsWith("require ") ? line.slice("require ".length).trim() : line;
199 const match = stripped.match(/^([A-Za-z0-9_.\-\/]+)\s+(v[0-9][^\s]*)/);
200 if (match && match[1] !== "go" && match[1] !== "module" && match[1] !== "toolchain") {
201 result.push({ name: match[1], version: match[2].replace(/^v/, ""), ecosystem });
202 }
203 }
204 return result;
205}
206
207function parseGemfile(content: string, ecosystem: string): ParsedPackage[] {
208 const result: ParsedPackage[] = [];
209 for (const rawLine of content.split("\n")) {
210 const line = rawLine.trim();
211 if (!line.startsWith("gem ")) continue;
212 // gem 'rails', '~> 7.0'
213 const nameMatch = line.match(/gem\s+['"]([A-Za-z0-9_\-]+)['"]/);
214 if (!nameMatch) continue;
215 const versionMatch = line.match(/,\s*['"]([~><=!^]?\s*[0-9][^\s,'"]*)['"]/);
216 const version = versionMatch
217 ? versionMatch[1].replace(/[^0-9.].*/, "").trim()
218 : "";
219 result.push({ name: nameMatch[1], version, ecosystem });
220 }
221 return result;
222}
223
224function parseManifest(
225 file: DependencyFile,
226 content: string
227): ParsedPackage[] {
228 const ecosystem = FILE_ECOSYSTEM[file];
229 switch (file) {
230 case "package.json":
231 return parsePackageJson(content, ecosystem);
232 case "requirements.txt":
233 return parseRequirementsTxt(content, ecosystem);
234 case "Cargo.toml":
235 return parseCargoToml(content, ecosystem);
236 case "go.mod":
237 return parseGoMod(content, ecosystem);
238 case "Gemfile":
239 return parseGemfile(content, ecosystem);
240 }
241}
242
243// ---------------------------------------------------------------------------
244// OSV API
245// ---------------------------------------------------------------------------
246
247interface OsvVuln {
248 id: string;
249 summary?: string;
250 severity?: Array<{ type: string; score: string }>;
251 affected?: Array<{
252 ranges?: Array<{ type: string; events?: Array<{ introduced?: string; fixed?: string }> }>;
253 versions?: string[];
254 }>;
255}
256
257interface OsvResult {
258 vulns?: OsvVuln[];
259}
260
261/** Batch-query OSV for a list of packages. Returns per-package vulnerability arrays. */
262async function queryOsv(
263 packages: ParsedPackage[]
264): Promise<OsvResult[]> {
265 if (packages.length === 0) return [];
266 try {
267 const response = await fetch("https://api.osv.dev/v1/querybatch", {
268 method: "POST",
269 headers: { "Content-Type": "application/json" },
270 body: JSON.stringify({
271 queries: packages.map((p) => ({
272 version: p.version,
273 package: { name: p.name, ecosystem: p.ecosystem },
274 })),
275 }),
276 signal: AbortSignal.timeout(15_000),
277 });
278 if (!response.ok) return packages.map(() => ({}));
279 const data = await response.json() as { results?: OsvResult[] };
280 return data.results || packages.map(() => ({}));
281 } catch {
282 return packages.map(() => ({}));
283 }
284}
285
286/** Map OSV severity (CVSS score or type) to our severity enum. */
287function mapSeverity(
288 vuln: OsvVuln
289): "critical" | "high" | "medium" | "low" {
290 if (!vuln.severity || vuln.severity.length === 0) return "medium";
291 // Try CVSS score first
292 for (const sev of vuln.severity) {
293 if (sev.score) {
294 const score = parseFloat(sev.score);
295 if (!isNaN(score)) {
296 if (score >= 9.0) return "critical";
297 if (score >= 7.0) return "high";
298 if (score >= 4.0) return "medium";
299 return "low";
300 }
301 // Some OSV entries use CRITICAL/HIGH/MEDIUM/LOW strings
302 const upper = sev.score.toUpperCase();
303 if (upper === "CRITICAL") return "critical";
304 if (upper === "HIGH") return "high";
305 if (upper === "MEDIUM" || upper === "MODERATE") return "medium";
306 if (upper === "LOW") return "low";
307 }
308 }
309 return "medium";
310}
311
312/** Extract the fix version from the OSV affected ranges. */
313function extractFixVersion(vuln: OsvVuln): string | undefined {
314 for (const affected of vuln.affected || []) {
315 for (const range of affected.ranges || []) {
316 for (const event of range.events || []) {
317 if (event.fixed) return event.fixed;
318 }
319 }
320 }
321 return undefined;
322}
323
324/** Convert OSV results into VulnFindings. */
325function osvResultsToFindings(
326 packages: ParsedPackage[],
327 results: OsvResult[]
328): VulnFinding[] {
329 const findings: VulnFinding[] = [];
330 for (let i = 0; i < packages.length; i++) {
331 const pkg = packages[i];
332 const result = results[i] || {};
333 for (const vuln of result.vulns || []) {
334 const cveId = vuln.id || "UNKNOWN";
335 const severity = mapSeverity(vuln);
336 findings.push({
337 packageName: pkg.name,
338 installedVersion: pkg.version || "unknown",
339 severity,
340 cveId,
341 description: vuln.summary || "No description available.",
342 fixVersion: extractFixVersion(vuln),
343 });
344 }
345 }
346 return findings;
347}
348
349// ---------------------------------------------------------------------------
350// Issue creation helpers
351// ---------------------------------------------------------------------------
352
353const WEEKLY_DIGEST_MARKER = "<!-- gluecron:dep-scan-digest:v1 -->";
354const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
355
356/** Returns the repo owner's user ID for issue attribution. */
357async function getRepoOwnerId(repoId: number | string): Promise<string | null> {
358 try {
359 const [row] = await db
360 .select({ ownerId: repositories.ownerId })
361 .from(repositories)
362 .where(eq(repositories.id, repoId as string))
363 .limit(1);
364 return row?.ownerId || null;
365 } catch {
366 return null;
367 }
368}
369
370/**
371 * Find or create a `security` label for the repo.
372 * Returns the label ID or null on failure.
373 */
374async function getOrCreateSecurityLabel(
375 repoId: string
376): Promise<string | null> {
377 try {
378 const [existing] = await db
379 .select({ id: labels.id })
380 .from(labels)
381 .where(
382 and(eq(labels.repositoryId, repoId), eq(labels.name, "security"))
383 )
384 .limit(1);
385 if (existing) return existing.id;
386
387 const [inserted] = await db
388 .insert(labels)
389 .values({
390 repositoryId: repoId,
391 name: "security",
392 color: "#d73a4a",
393 description: "Security vulnerability",
394 })
395 .onConflictDoNothing()
396 .returning();
397 return inserted?.id || null;
398 } catch {
399 return null;
400 }
401}
402
403/** Attach a label to an issue. Best-effort; swallows errors. */
404async function attachLabel(issueId: string, labelId: string): Promise<void> {
405 try {
406 await db
407 .insert(issueLabels)
408 .values({ issueId, labelId })
409 .onConflictDoNothing();
410 } catch {
411 /* best-effort */
412 }
413}
414
415/** Bump the repo's issue count. Best-effort. */
416async function bumpIssueCount(repoId: string): Promise<void> {
417 try {
418 await db
419 .update(repositories)
420 .set({
421 issueCount: db.$with("cte").as(
422 db
423 .select({ v: repositories.issueCount })
424 .from(repositories)
425 .where(eq(repositories.id, repoId))
426 ) as any,
427 })
428 .where(eq(repositories.id, repoId));
429 } catch {
430 /* best-effort */
431 }
432}
433
434/**
435 * Check whether an issue for this CVE already exists (open or recently closed).
436 * Avoids spamming duplicate issues on every push.
437 */
438async function cveIssueExists(
439 repoId: string,
440 cveId: string
441): Promise<boolean> {
442 try {
443 const rows = await db
444 .select({ id: issues.id })
445 .from(issues)
446 .where(
447 and(
448 eq(issues.repositoryId, repoId),
449 or(
450 ilike(issues.title, `%${cveId}%`),
451 ilike(issues.body, `%${cveId}%`)
452 )
453 )
454 )
455 .limit(1);
456 return rows.length > 0;
457 } catch {
458 return false;
459 }
460}
461
462/** Render an issue body for a critical/high finding. */
463function renderVulnIssueBody(f: VulnFinding): string {
464 const fix = f.fixVersion
465 ? `Upgrade to **${f.fixVersion}** or later.`
466 : "No fix version is currently available. Monitor the advisory for updates.";
467
468 const impact = severityImpactText(f.severity);
469
470 return [
471 `> **Automated security scan.** This issue was opened by GlueCron's dependency scanner after detecting a vulnerable dependency in a recent push.`,
472 "",
473 `## Vulnerability details`,
474 "",
475 `| Field | Value |`,
476 `|---|---|`,
477 `| Package | \`${f.packageName}\` |`,
478 `| Installed version | \`${f.installedVersion}\` |`,
479 `| Severity | **${f.severity.toUpperCase()}** |`,
480 `| CVE / Advisory | [${f.cveId}](https://osv.dev/vulnerability/${f.cveId}) |`,
481 "",
482 `## Description`,
483 "",
484 f.description,
485 "",
486 `## Remediation`,
487 "",
488 fix,
489 "",
490 `## Impact & urgency`,
491 "",
492 impact,
493 "",
494 `---`,
495 `_This issue was auto-generated by the GlueCron dependency scanner. Close it once the vulnerability is resolved or confirmed not applicable._`,
496 ].join("\n");
497}
498
499function severityImpactText(severity: VulnFinding["severity"]): string {
500 switch (severity) {
501 case "critical":
502 return (
503 "This is a **CRITICAL** vulnerability. It should be addressed immediately — " +
504 "critical CVEs are commonly exploited in the wild and can lead to full system compromise, " +
505 "data exfiltration, or remote code execution. Treat this as an emergency patch."
506 );
507 case "high":
508 return (
509 "This is a **HIGH** severity vulnerability. It should be patched in the next sprint at the latest. " +
510 "High severity issues often allow privilege escalation, authentication bypass, or significant data leakage."
511 );
512 case "medium":
513 return (
514 "This is a **MEDIUM** severity vulnerability. It should be addressed in the next maintenance window. " +
515 "Medium issues typically require specific conditions to exploit but should not be ignored."
516 );
517 case "low":
518 return (
519 "This is a **LOW** severity vulnerability. While lower risk, it should be tracked " +
520 "and resolved as part of routine dependency maintenance."
521 );
522 }
523}
524
525/**
526 * Open a single issue for a critical/high CVE finding.
527 * Returns the issue number, or null if skipped/failed.
528 */
529async function openVulnIssue(
530 repoId: string,
531 authorId: string,
532 finding: VulnFinding,
533 labelId: string | null
534): Promise<number | null> {
535 // Dedup: skip if an issue for this CVE already exists.
536 const exists = await cveIssueExists(repoId, finding.cveId);
537 if (exists) return null;
538
539 const title = `[CVE] ${finding.packageName} ${finding.installedVersion}${finding.severity.toUpperCase()} vulnerability (${finding.cveId})`;
540 const body = renderVulnIssueBody(finding);
541
542 try {
543 const [inserted] = await db
544 .insert(issues)
545 .values({ repositoryId: repoId, authorId, title, body, state: "open" })
546 .returning();
547 if (!inserted) return null;
548
549 if (labelId) await attachLabel(inserted.id, labelId);
550
551 // Bump issue count.
552 try {
553 const [repo] = await db
554 .select({ issueCount: repositories.issueCount })
555 .from(repositories)
556 .where(eq(repositories.id, repoId))
557 .limit(1);
558 if (repo) {
559 await db
560 .update(repositories)
561 .set({ issueCount: (repo.issueCount || 0) + 1 })
562 .where(eq(repositories.id, repoId));
563 }
564 } catch {
565 /* best-effort */
566 }
567
568 return inserted.number;
569 } catch {
570 return null;
571 }
572}
573
574/**
575 * Find the weekly digest issue for this repo, if one was already opened this week.
576 * Returns the issue ID or null.
577 */
578async function findThisWeeksDigestIssue(
579 repoId: string
580): Promise<{ id: string; number: number } | null> {
581 try {
582 const weekAgo = new Date(Date.now() - MS_PER_WEEK);
583 const rows = await db
584 .select({ id: issues.id, number: issues.number, body: issues.body, createdAt: issues.createdAt })
585 .from(issues)
586 .where(
587 and(
588 eq(issues.repositoryId, repoId),
589 eq(issues.state, "open"),
590 ilike(issues.title, "%Dependency scan digest%")
591 )
592 )
593 .limit(5);
594
595 for (const row of rows) {
596 if (
597 row.body?.includes(WEEKLY_DIGEST_MARKER) &&
598 row.createdAt >= weekAgo
599 ) {
600 return { id: row.id, number: row.number };
601 }
602 }
603 return null;
604 } catch {
605 return null;
606 }
607}
608
609/** Render the digest body for medium/low findings. */
610function renderDigestBody(findings: VulnFinding[]): string {
611 const rows = findings
612 .map(
613 (f) =>
614 `| \`${f.packageName}\` | \`${f.installedVersion}\` | ${f.severity.toUpperCase()} | [${f.cveId}](https://osv.dev/vulnerability/${f.cveId}) | ${f.fixVersion ? `\`${f.fixVersion}\`` : "None available"} |`
615 )
616 .join("\n");
617
618 return [
619 WEEKLY_DIGEST_MARKER,
620 "",
621 `> Automated weekly digest of medium/low severity dependency vulnerabilities detected by GlueCron.`,
622 "",
623 `## Findings`,
624 "",
625 `| Package | Version | Severity | Advisory | Fix |`,
626 `|---|---|---|---|---|`,
627 rows,
628 "",
629 `---`,
630 `_This digest is updated by GlueCron on each push that touches dependency files. Close once all findings are resolved or accepted._`,
631 ].join("\n");
632}
633
634/**
635 * Open or update the weekly digest issue for medium/low findings.
636 */
637async function upsertDigestIssue(
638 repoId: string,
639 authorId: string,
640 findings: VulnFinding[],
641 labelId: string | null
642): Promise<void> {
643 if (findings.length === 0) return;
644
645 const existing = await findThisWeeksDigestIssue(repoId);
646 const title = `Dependency scan digest — ${new Date().toISOString().slice(0, 10)}`;
647 const body = renderDigestBody(findings);
648
649 try {
650 if (existing) {
651 // Update the existing digest issue body.
652 await db
653 .update(issues)
654 .set({ body, updatedAt: new Date() })
655 .where(eq(issues.id, existing.id));
656 } else {
657 // Open a new digest issue.
658 const [inserted] = await db
659 .insert(issues)
660 .values({ repositoryId: repoId, authorId, title, body, state: "open" })
661 .returning();
662 if (inserted && labelId) await attachLabel(inserted.id, labelId);
663 if (inserted) {
664 try {
665 const [repo] = await db
666 .select({ issueCount: repositories.issueCount })
667 .from(repositories)
668 .where(eq(repositories.id, repoId))
669 .limit(1);
670 if (repo) {
671 await db
672 .update(repositories)
673 .set({ issueCount: (repo.issueCount || 0) + 1 })
674 .where(eq(repositories.id, repoId));
675 }
676 } catch {
677 /* best-effort */
678 }
679 }
680 }
681 } catch {
682 /* best-effort */
683 }
684}
685
686// ---------------------------------------------------------------------------
687// Main entry point
688// ---------------------------------------------------------------------------
689
690/**
691 * Scans the dependency files changed in a push and opens/updates security issues.
692 *
693 * @param repoId - DB repository UUID
694 * @param owner - Repository owner username
695 * @param repoName - Repository name
696 * @param headSha - The new commit SHA (used to read dependency files)
697 * @param oldSha - The previous commit SHA (used to detect changed files)
698 * @param pusherUserId - The user who pushed (used as issue author)
699 * @returns Array of VulnFindings found (may be empty)
700 */
701export async function scanDependencies(
702 repoId: string,
703 owner: string,
704 repoName: string,
705 headSha: string,
706 oldSha: string,
707 pusherUserId: string
708): Promise<VulnFinding[]> {
709 try {
710 // 1. Detect which dependency files changed in this push.
711 const changedFiles = await gitDiffNames(owner, repoName, oldSha, headSha);
712 const relevantFiles = DEPENDENCY_FILES.filter((f) =>
713 changedFiles.includes(f)
714 );
715 if (relevantFiles.length === 0) return [];
716
717 // 2. Parse all changed dependency files into a package list.
718 const allPackages: ParsedPackage[] = [];
719 for (const file of relevantFiles) {
720 const content = await gitShow(owner, repoName, headSha, file);
721 if (!content) continue;
722 const parsed = parseManifest(file, content);
723 allPackages.push(...parsed);
724 }
725 if (allPackages.length === 0) return [];
726
727 // 3. Deduplicate by (name, version, ecosystem) to avoid redundant OSV queries.
728 const seen = new Set<string>();
729 const uniquePackages = allPackages.filter((p) => {
730 const key = `${p.ecosystem}:${p.name}@${p.version}`;
731 if (seen.has(key)) return false;
732 seen.add(key);
733 return true;
734 });
735
736 // 4. Query OSV in batches of 200 (API limit).
737 const BATCH = 200;
738 const findings: VulnFinding[] = [];
739 for (let i = 0; i < uniquePackages.length; i += BATCH) {
740 const batch = uniquePackages.slice(i, i + BATCH);
741 const results = await queryOsv(batch);
742 findings.push(...osvResultsToFindings(batch, results));
743 }
744
745 if (findings.length === 0) return [];
746
747 // 5. Ensure the security label exists.
748 const labelId = await getOrCreateSecurityLabel(repoId);
749
750 // 6. Use pusherUserId as author; fall back to repo owner if not available.
751 const authorId = pusherUserId || (await getRepoOwnerId(repoId)) || "";
752 if (!authorId) return findings;
753
754 // 7. Separate critical/high from medium/low.
755 const urgent = findings.filter(
756 (f) => f.severity === "critical" || f.severity === "high"
757 );
758 const digest = findings.filter(
759 (f) => f.severity === "medium" || f.severity === "low"
760 );
761
762 // 8. Open one issue per critical/high CVE (idempotent via dedup check).
763 for (const f of urgent) {
764 await openVulnIssue(repoId, authorId, f, labelId).catch(() => {});
765 }
766
767 // 9. Upsert the weekly digest for medium/low findings.
768 await upsertDigestIssue(repoId, authorId, digest, labelId).catch(() => {});
769
770 return findings;
771 } catch (err) {
772 console.error("[dependency-scanner] unexpected error:", err);
773 return [];
774 }
775}
776
777// ---------------------------------------------------------------------------
778// Test seam
779// ---------------------------------------------------------------------------
780
781export const __internal = {
782 parsePackageJson,
783 parseRequirementsTxt,
784 parseCargoToml,
785 parseGoMod,
786 parseGemfile,
787 mapSeverity,
788 extractFixVersion,
789 osvResultsToFindings,
790 renderVulnIssueBody,
791 renderDigestBody,
792 WEEKLY_DIGEST_MARKER,
793 FILE_ECOSYSTEM,
794 DEPENDENCY_FILES,
795};
Addedsrc/routes/security.tsx+617−0View fileUnifiedSplit
1/**
2 * Security CVE issues page — dependency vulnerability scanner findings.
3 *
4 * GET /:owner/:repo/security/vulnerabilities
5 *
6 * Shows all open issues that were auto-generated by the dependency CVE scanner
7 * (identified by `[CVE]` prefix in title or the weekly digest marker in body).
8 * Groups by severity (critical → high → medium → low) with count badges.
9 * Shows an "All clear" panel when no security issues are open.
10 *
11 * This is a pure surfacing layer over the existing `issues` table — no new
12 * schema changes required.
13 */
14
15import { Hono } from "hono";
16import { and, desc, eq, ilike, or } from "drizzle-orm";
17import { db } from "../db";
18import { issues, repositories, users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { __internal as scanInternal } from "../lib/dependency-scanner";
24
25const securityRoutes = new Hono<AuthEnv>();
26securityRoutes.use("*", softAuth);
27
28/* ─────────────────────────────────────────────────────────────────────────
29 * Scoped CSS — every class prefixed `.svp-` (Security Vulnerability Page).
30 * Matches the gradient-hairline + radial-orb design language used by
31 * code-scanning.tsx and admin-integrations.tsx.
32 * ───────────────────────────────────────────────────────────────────── */
33const styles = `
34 .svp-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
35
36 .svp-hero {
37 position: relative;
38 margin-bottom: var(--space-5);
39 padding: var(--space-5) var(--space-6);
40 background: var(--bg-elevated);
41 border: 1px solid var(--border);
42 border-radius: 16px;
43 overflow: hidden;
44 }
45 .svp-hero::before {
46 content: '';
47 position: absolute;
48 top: 0; left: 0; right: 0;
49 height: 2px;
50 background: linear-gradient(90deg, transparent 0%, #d73a4a 30%, #f97316 70%, transparent 100%);
51 opacity: 0.75;
52 pointer-events: none;
53 }
54 .svp-hero-orb {
55 position: absolute;
56 inset: -30% -15% auto auto;
57 width: 460px; height: 460px;
58 background: radial-gradient(circle, rgba(215,58,74,0.18), rgba(249,115,22,0.08) 45%, transparent 70%);
59 filter: blur(80px);
60 opacity: 0.7;
61 pointer-events: none;
62 z-index: 0;
63 }
64 .svp-hero-inner { position: relative; z-index: 1; max-width: 760px; }
65 .svp-eyebrow {
66 display: inline-flex;
67 align-items: center;
68 gap: 8px;
69 text-transform: uppercase;
70 font-family: var(--font-mono);
71 font-size: 11px;
72 letter-spacing: 0.18em;
73 color: var(--text-muted);
74 font-weight: 600;
75 margin-bottom: 14px;
76 }
77 .svp-eyebrow-dot {
78 width: 8px; height: 8px;
79 border-radius: 9999px;
80 background: linear-gradient(135deg, #d73a4a, #f97316);
81 box-shadow: 0 0 0 3px rgba(215,58,74,0.18);
82 }
83 .svp-title {
84 font-family: var(--font-display);
85 font-size: clamp(24px, 3.5vw, 36px);
86 font-weight: 800;
87 letter-spacing: -0.025em;
88 line-height: 1.05;
89 margin: 0 0 var(--space-2);
90 color: var(--text-strong);
91 }
92 .svp-title-grad {
93 background-image: linear-gradient(135deg, #f87171 0%, #d73a4a 50%, #f97316 100%);
94 -webkit-background-clip: text;
95 background-clip: text;
96 -webkit-text-fill-color: transparent;
97 color: transparent;
98 }
99 .svp-sub {
100 font-size: 15px;
101 color: var(--text-muted);
102 margin: 0;
103 line-height: 1.55;
104 max-width: 640px;
105 }
106
107 /* Counts grid */
108 .svp-counts {
109 display: grid;
110 grid-template-columns: repeat(4, 1fr);
111 gap: var(--space-3);
112 margin-bottom: var(--space-5);
113 }
114 @media (max-width: 680px) {
115 .svp-counts { grid-template-columns: repeat(2, 1fr); }
116 }
117 .svp-count-card {
118 background: var(--bg-elevated);
119 border: 1px solid var(--border);
120 border-radius: 14px;
121 padding: var(--space-4);
122 transition: border-color 120ms ease, transform 120ms ease;
123 }
124 .svp-count-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
125 .svp-count-card.is-critical { border-color: rgba(239,68,68,0.4); }
126 .svp-count-card.is-high { border-color: rgba(249,115,22,0.4); }
127 .svp-count-card.is-medium { border-color: rgba(234,179,8,0.3); }
128 .svp-count-card.is-low { border-color: rgba(99,102,241,0.25); }
129 .svp-count-label {
130 font-size: 10.5px;
131 letter-spacing: 0.14em;
132 text-transform: uppercase;
133 color: var(--text-muted);
134 font-weight: 700;
135 margin-bottom: 6px;
136 }
137 .svp-count-value {
138 font-family: var(--font-display);
139 font-size: 36px;
140 font-weight: 800;
141 letter-spacing: -0.025em;
142 line-height: 1;
143 color: var(--text-strong);
144 font-variant-numeric: tabular-nums;
145 }
146 .svp-count-card.is-critical .svp-count-value { color: #fca5a5; }
147 .svp-count-card.is-high .svp-count-value { color: #fdba74; }
148 .svp-count-card.is-medium .svp-count-value { color: #fde047; }
149 .svp-count-card.is-low .svp-count-value { color: #a5b4fc; }
150 .svp-count-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
151
152 /* All-clear banner */
153 .svp-clear {
154 display: flex;
155 align-items: center;
156 gap: 14px;
157 margin-bottom: var(--space-4);
158 padding: 16px 20px;
159 border-radius: 14px;
160 background: linear-gradient(135deg, rgba(52,211,153,0.10), rgba(54,197,214,0.06));
161 border: 1px solid rgba(52,211,153,0.32);
162 color: #bbf7d0;
163 }
164 .svp-clear-icon {
165 flex: 0 0 auto;
166 width: 38px; height: 38px;
167 border-radius: 9999px;
168 display: inline-flex;
169 align-items: center;
170 justify-content: center;
171 background: linear-gradient(135deg, #34d399 0%, #36c5d6 100%);
172 color: #04231a;
173 font-size: 18px;
174 box-shadow: 0 0 0 4px rgba(52,211,153,0.16);
175 }
176 .svp-clear-text strong { display: block; color: #d1fae5; font-weight: 700; font-size: 15px; margin-bottom: 2px; }
177 .svp-clear-text span { color: rgba(187,247,208,0.85); font-size: 13px; }
178
179 /* Section heading */
180 .svp-section-heading {
181 display: flex;
182 align-items: center;
183 gap: 10px;
184 margin-bottom: var(--space-3);
185 }
186 .svp-section-title {
187 font-family: var(--font-display);
188 font-size: 17px;
189 font-weight: 700;
190 color: var(--text-strong);
191 letter-spacing: -0.012em;
192 margin: 0;
193 }
194 .svp-section-badge {
195 display: inline-flex;
196 align-items: center;
197 padding: 2px 8px;
198 border-radius: 9999px;
199 font-family: var(--font-mono);
200 font-size: 11px;
201 font-weight: 700;
202 letter-spacing: 0.04em;
203 background: var(--bg-subtle, rgba(255,255,255,0.06));
204 border: 1px solid var(--border);
205 color: var(--text-muted);
206 }
207
208 /* Issue list */
209 .svp-issue-list {
210 display: flex;
211 flex-direction: column;
212 gap: var(--space-2);
213 margin-bottom: var(--space-5);
214 }
215 .svp-issue-card {
216 position: relative;
217 background: var(--bg-elevated);
218 border: 1px solid var(--border);
219 border-radius: 12px;
220 padding: var(--space-3) var(--space-4);
221 transition: border-color 120ms ease, transform 120ms ease;
222 }
223 .svp-issue-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
224 .svp-issue-card.sev-critical { border-left: 3px solid #ef4444; }
225 .svp-issue-card.sev-high { border-left: 3px solid #f97316; }
226 .svp-issue-card.sev-medium { border-left: 3px solid #eab308; }
227 .svp-issue-card.sev-low { border-left: 3px solid #6366f1; }
228 .svp-issue-row {
229 display: flex;
230 justify-content: space-between;
231 align-items: flex-start;
232 gap: 14px;
233 flex-wrap: wrap;
234 }
235 .svp-issue-main { min-width: 0; flex: 1; }
236 .svp-issue-title {
237 font-family: var(--font-display);
238 font-size: 14px;
239 font-weight: 700;
240 color: var(--text-strong);
241 letter-spacing: -0.005em;
242 line-height: 1.35;
243 margin: 0 0 4px;
244 word-break: break-word;
245 }
246 .svp-issue-title a { color: inherit; text-decoration: none; }
247 .svp-issue-title a:hover { text-decoration: underline; }
248 .svp-issue-meta {
249 display: flex;
250 gap: 10px;
251 align-items: center;
252 flex-wrap: wrap;
253 margin-top: 4px;
254 }
255 .svp-issue-num { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); }
256 .svp-issue-date { font-size: 12px; color: var(--text-muted); }
257 .svp-sev-pill {
258 display: inline-flex;
259 align-items: center;
260 padding: 2px 9px;
261 border-radius: 9999px;
262 font-size: 11.5px;
263 font-weight: 700;
264 letter-spacing: 0.04em;
265 white-space: nowrap;
266 }
267 .svp-sev-pill.sev-critical { background: rgba(239,68,68,0.15); color: #fca5a5; border: 1px solid rgba(239,68,68,0.35); }
268 .svp-sev-pill.sev-high { background: rgba(249,115,22,0.15); color: #fdba74; border: 1px solid rgba(249,115,22,0.35); }
269 .svp-sev-pill.sev-medium { background: rgba(234,179,8,0.12); color: #fde047; border: 1px solid rgba(234,179,8,0.30); }
270 .svp-sev-pill.sev-low { background: rgba(99,102,241,0.12); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.28); }
271 .svp-sev-pill.sev-digest { background: rgba(107,114,128,0.12); color: var(--text-muted); border: 1px solid var(--border); }
272
273 /* Last scan info */
274 .svp-scan-info {
275 margin-top: var(--space-5);
276 padding: 14px 18px;
277 background: var(--bg-subtle, rgba(255,255,255,0.03));
278 border: 1px solid var(--border);
279 border-radius: 12px;
280 font-size: 12.5px;
281 color: var(--text-muted);
282 display: flex;
283 align-items: center;
284 gap: 8px;
285 }
286
287 /* How-it-works aside */
288 .svp-how {
289 margin-top: var(--space-4);
290 padding: 14px 18px;
291 background: rgba(140,109,255,0.06);
292 border: 1px solid rgba(140,109,255,0.18);
293 border-radius: 12px;
294 font-size: 13px;
295 color: var(--text-muted);
296 line-height: 1.55;
297 }
298 .svp-how strong { color: var(--text-normal); }
299`;
300
301// ---------------------------------------------------------------------------
302// Route
303// ---------------------------------------------------------------------------
304
305interface SecurityIssueRow {
306 id: string;
307 number: number;
308 title: string;
309 body: string | null;
310 createdAt: Date;
311 updatedAt: Date;
312}
313
314/** Infer severity from issue title (set by the scanner). */
315function inferSeverityFromTitle(
316 title: string
317): "critical" | "high" | "medium" | "low" | "digest" {
318 const upper = title.toUpperCase();
319 if (upper.includes("CRITICAL")) return "critical";
320 if (upper.includes("HIGH")) return "high";
321 if (upper.includes("MEDIUM")) return "medium";
322 if (upper.includes("LOW")) return "low";
323 // Weekly digest issues
324 if (upper.includes("DIGEST")) return "digest";
325 return "medium";
326}
327
328function severityLabel(sev: ReturnType<typeof inferSeverityFromTitle>): string {
329 switch (sev) {
330 case "critical": return "CRITICAL";
331 case "high": return "HIGH";
332 case "medium": return "MEDIUM";
333 case "low": return "LOW";
334 case "digest": return "DIGEST";
335 }
336}
337
338function fmtDate(d: Date): string {
339 try {
340 return d.toLocaleDateString("en-US", {
341 month: "short",
342 day: "numeric",
343 year: "numeric",
344 });
345 } catch {
346 return "";
347 }
348}
349
350securityRoutes.get("/:owner/:repo/security/vulnerabilities", async (c) => {
351 const { owner: ownerName, repo: repoName } = c.req.param();
352 const user = c.get("user");
353
354 // Load repo — swallow DB errors so the route returns 404 gracefully
355 // even when the DB is unavailable in tests or CI.
356 let ownerRow: { id: string; username: string } | null = null;
357 let repoRow: {
358 id: string;
359 ownerId: string;
360 isPrivate: boolean;
361 isArchived: boolean;
362 isTemplate: boolean;
363 } | null = null;
364 try {
365 const [o] = await db
366 .select({ id: users.id, username: users.username })
367 .from(users)
368 .where(eq(users.username, ownerName))
369 .limit(1);
370 ownerRow = o || null;
371 } catch {
372 return c.notFound();
373 }
374 if (!ownerRow) return c.notFound();
375
376 try {
377 const [r] = await db
378 .select({
379 id: repositories.id,
380 ownerId: repositories.ownerId,
381 isPrivate: repositories.isPrivate,
382 isArchived: repositories.isArchived,
383 isTemplate: repositories.isTemplate,
384 })
385 .from(repositories)
386 .where(
387 and(
388 eq(repositories.ownerId, ownerRow.id),
389 eq(repositories.name, repoName)
390 )
391 )
392 .limit(1);
393 repoRow = r || null;
394 } catch {
395 return c.notFound();
396 }
397 if (!repoRow) return c.notFound();
398
399 // Private repo — must be owner or collaborator (soft check: must be logged in as owner)
400 if (repoRow.isPrivate && (!user || user.id !== ownerRow.id)) {
401 return c.notFound();
402 }
403
404 // Fetch open security issues: those with [CVE] in title or "Dependency scan digest" in title
405 let securityIssues: SecurityIssueRow[] = [];
406 try {
407 securityIssues = await db
408 .select({
409 id: issues.id,
410 number: issues.number,
411 title: issues.title,
412 body: issues.body,
413 createdAt: issues.createdAt,
414 updatedAt: issues.updatedAt,
415 })
416 .from(issues)
417 .where(
418 and(
419 eq(issues.repositoryId, repoRow.id),
420 eq(issues.state, "open"),
421 or(
422 ilike(issues.title, "%[CVE]%"),
423 ilike(issues.title, "%Dependency scan digest%")
424 )
425 )
426 )
427 .orderBy(desc(issues.createdAt))
428 .limit(200);
429 } catch {
430 securityIssues = [];
431 }
432
433 // Compute counts per severity
434 const counts = { critical: 0, high: 0, medium: 0, low: 0, digest: 0 };
435 for (const issue of securityIssues) {
436 const sev = inferSeverityFromTitle(issue.title);
437 counts[sev]++;
438 }
439
440 // Find the most recent scan timestamp (most recent security issue created_at)
441 const lastScanAt =
442 securityIssues.length > 0
443 ? securityIssues.reduce((latest, i) =>
444 i.createdAt > latest ? i.createdAt : latest,
445 securityIssues[0].createdAt
446 )
447 : null;
448
449 // Group by severity for display
450 const groups: Array<{
451 sev: ReturnType<typeof inferSeverityFromTitle>;
452 label: string;
453 issues: SecurityIssueRow[];
454 }> = [
455 { sev: "critical", label: "Critical", issues: [] },
456 { sev: "high", label: "High", issues: [] },
457 { sev: "medium", label: "Medium", issues: [] },
458 { sev: "low", label: "Low", issues: [] },
459 { sev: "digest", label: "Digest", issues: [] },
460 ];
461
462 for (const issue of securityIssues) {
463 const sev = inferSeverityFromTitle(issue.title);
464 const group = groups.find((g) => g.sev === sev);
465 if (group) group.issues.push(issue);
466 }
467
468 const hasFindings = securityIssues.length > 0;
469
470 return c.html(
471 <Layout title={`Security vulnerabilities — ${ownerName}/${repoName}`} user={user}>
472 <style dangerouslySetInnerHTML={{ __html: styles }} />
473 <div class="svp-wrap">
474 {/* Repo header + nav */}
475 <RepoHeader
476 owner={ownerName}
477 repo={repoName}
478 currentUser={user?.username}
479 archived={repoRow.isArchived}
480 isTemplate={repoRow.isTemplate}
481 />
482 <RepoNav owner={ownerName} repo={repoName} active="security" />
483
484 {/* Hero */}
485 <div class="svp-hero">
486 <div class="svp-hero-orb" />
487 <div class="svp-hero-inner">
488 <div class="svp-eyebrow">
489 <span class="svp-eyebrow-dot" />
490 Dependency Scanner
491 </div>
492 <h1 class="svp-title">
493 <span class="svp-title-grad">CVE</span> Vulnerabilities
494 </h1>
495 <p class="svp-sub">
496 Auto-detected dependency vulnerabilities from push-time scans via the{" "}
497 <a href="https://osv.dev" target="_blank" rel="noopener noreferrer" style="color:inherit">
498 OSV database
499 </a>
500 . Critical and high findings open issues immediately. Medium/low findings are batched into a weekly digest.
501 </p>
502 </div>
503 </div>
504
505 {/* Severity count grid */}
506 <div class="svp-counts">
507 <div class={`svp-count-card${counts.critical > 0 ? " is-critical" : ""}`}>
508 <div class="svp-count-label">Critical</div>
509 <div class="svp-count-value">{counts.critical}</div>
510 <div class="svp-count-hint">open issues</div>
511 </div>
512 <div class={`svp-count-card${counts.high > 0 ? " is-high" : ""}`}>
513 <div class="svp-count-label">High</div>
514 <div class="svp-count-value">{counts.high}</div>
515 <div class="svp-count-hint">open issues</div>
516 </div>
517 <div class={`svp-count-card${counts.medium > 0 ? " is-medium" : ""}`}>
518 <div class="svp-count-label">Medium</div>
519 <div class="svp-count-value">{counts.medium}</div>
520 <div class="svp-count-hint">open issues</div>
521 </div>
522 <div class={`svp-count-card${counts.low > 0 ? " is-low" : ""}`}>
523 <div class="svp-count-label">Low</div>
524 <div class="svp-count-value">{counts.low}</div>
525 <div class="svp-count-hint">open issues</div>
526 </div>
527 </div>
528
529 {/* All clear or findings */}
530 {!hasFindings ? (
531 <div class="svp-clear">
532 <span class="svp-clear-icon"></span>
533 <div class="svp-clear-text">
534 <strong>All clear</strong>
535 <span>
536 No open dependency vulnerability issues detected.{" "}
537 {lastScanAt
538 ? `Last scanned ${fmtDate(lastScanAt)}.`
539 : "Enable DEPENDENCY_SCAN_ENABLED=1 to start scanning pushes automatically."}
540 </span>
541 </div>
542 </div>
543 ) : (
544 <>
545 {groups
546 .filter((g) => g.issues.length > 0)
547 .map((group) => (
548 <div>
549 <div class="svp-section-heading">
550 <h2 class="svp-section-title">{group.label} severity</h2>
551 <span class="svp-section-badge">{group.issues.length}</span>
552 </div>
553 <div class="svp-issue-list">
554 {group.issues.map((issue) => (
555 <div class={`svp-issue-card sev-${group.sev}`}>
556 <div class="svp-issue-row">
557 <div class="svp-issue-main">
558 <p class="svp-issue-title">
559 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
560 {issue.title}
561 </a>
562 </p>
563 <div class="svp-issue-meta">
564 <span class="svp-issue-num">#{issue.number}</span>
565 <span class="svp-issue-date">
566 Opened {fmtDate(issue.createdAt)}
567 </span>
568 </div>
569 </div>
570 <span
571 class={`svp-sev-pill sev-${group.sev}`}
572 >
573 {severityLabel(group.sev)}
574 </span>
575 </div>
576 </div>
577 ))}
578 </div>
579 </div>
580 ))}
581 </>
582 )}
583
584 {/* Last scan info */}
585 {lastScanAt && (
586 <div class="svp-scan-info">
587 <span>🕐</span>
588 <span>
589 Last scan: <strong>{fmtDate(lastScanAt)}</strong> — scans run automatically on every push that touches{" "}
590 <code>package.json</code>, <code>requirements.txt</code>,{" "}
591 <code>Cargo.toml</code>, <code>go.mod</code>, or <code>Gemfile</code>.
592 </span>
593 </div>
594 )}
595
596 {/* How it works */}
597 <div class="svp-how">
598 <strong>How it works:</strong> GlueCron scans your dependency files on
599 every push using the{" "}
600 <a href="https://osv.dev" target="_blank" rel="noopener noreferrer">
601 OSV (Open Source Vulnerabilities)
602 </a>{" "}
603 database. Critical and high severity CVEs open individual issues
604 immediately. Medium and low findings are batched into a weekly digest
605 issue. Requires <code>DEPENDENCY_SCAN_ENABLED=1</code> on your
606 deployment.
607 {" "}
608 <a href={`/${ownerName}/${repoName}/security`}>
609 View gate scan results →
610 </a>
611 </div>
612 </div>
613 </Layout>
614 );
615});
616
617export default securityRoutes;
Modifiedsrc/views/components.tsx+7−0View fileUnifiedSplit
149149 | "projects"
150150 | "agents"
151151 | "discussions"
152 | "security"
152153 | "settings";
153154}> = ({ owner, repo, active }) => (
154155 <div class="repo-nav">
209210 >
210211 {"\u25CF"} Gates
211212 </a>
213 <a
214 href={`/${owner}/${repo}/security/vulnerabilities`}
215 class={active === "security" ? "active" : ""}
216 >
217 Security
218 </a>
212219 <a
213220 href={`/${owner}/${repo}/insights`}
214221 class={active === "insights" ? "active" : ""}
215222