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

security.tsx

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

security.tsxBlame617 lines · 1 contributor
da3fc18Claude1/**
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;