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

advisories.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.

advisories.tsxBlame847 lines · 1 contributor
f60ccdeClaude1/**
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
b1f2895Claude9 *
10 * 2026 polish: gradient-hairline hero + radial orb + severity-pill cards.
11 * Every class prefixed `.adv-` so this surface doesn't bleed. All data
12 * fetches, queries, and actions preserved exactly.
f60ccdeClaude13 */
14
15import { Hono } from "hono";
16import { and, eq } from "drizzle-orm";
17import { db } from "../db";
18import { repositories, users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { audit } from "../lib/notify";
24import {
25 dismissAlert,
26 listAlertsForRepo,
27 reopenAlert,
28 scanRepositoryForAlerts,
29 seedAdvisories,
30} from "../lib/advisories";
31
32const advisories = new Hono<AuthEnv>();
33advisories.use("*", softAuth);
34
35async function loadRepo(ownerName: string, repoName: string) {
36 const [owner] = await db
37 .select()
38 .from(users)
39 .where(eq(users.username, ownerName))
40 .limit(1);
41 if (!owner) return null;
42 const [repo] = await db
43 .select()
44 .from(repositories)
45 .where(
46 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
47 )
48 .limit(1);
49 if (!repo) return null;
50 return { owner, repo };
51}
52
b1f2895Claude53/** Map advisory severity to a stable class key. */
54function severityClass(sev: string): string {
f60ccdeClaude55 switch (sev) {
56 case "critical":
b1f2895Claude57 return "is-critical";
f60ccdeClaude58 case "high":
b1f2895Claude59 return "is-high";
f60ccdeClaude60 case "moderate":
b1f2895Claude61 case "medium":
62 return "is-medium";
63 case "low":
64 return "is-low";
f60ccdeClaude65 default:
b1f2895Claude66 return "is-low";
f60ccdeClaude67 }
68}
69
b1f2895Claude70/* ─────────────────────────────────────────────────────────────────────────
71 * Scoped CSS — every class prefixed `.adv-`. Mirrors the gradient-hairline
72 * hero + radial orb + per-card pattern from `admin-integrations` and
73 * `admin-diagnose`. Severity colors:
74 * critical → red, high → amber, medium/moderate → yellow, low → blue
75 * ───────────────────────────────────────────────────────────────────── */
76const styles = `
eed4684Claude77 .adv-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
b1f2895Claude78
79 .adv-hero {
80 position: relative;
81 margin-bottom: var(--space-5);
82 padding: var(--space-5) var(--space-6);
83 background: var(--bg-elevated);
84 border: 1px solid var(--border);
85 border-radius: 16px;
86 overflow: hidden;
87 }
88 .adv-hero::before {
89 content: '';
90 position: absolute;
91 top: 0; left: 0; right: 0;
92 height: 2px;
6fd5915Claude93 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b1f2895Claude94 opacity: 0.75;
95 pointer-events: none;
96 }
97 .adv-hero-orb {
98 position: absolute;
99 inset: -30% -15% auto auto;
100 width: 460px; height: 460px;
6fd5915Claude101 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
b1f2895Claude102 filter: blur(80px);
103 opacity: 0.75;
104 pointer-events: none;
105 z-index: 0;
106 }
107 .adv-hero-inner { position: relative; z-index: 1; }
108 .adv-hero-top {
109 display: flex;
110 align-items: center;
111 justify-content: space-between;
112 gap: var(--space-3);
113 flex-wrap: wrap;
114 }
115 .adv-hero-text { max-width: 720px; }
116 .adv-eyebrow {
117 display: inline-flex;
118 align-items: center;
119 gap: 8px;
120 text-transform: uppercase;
121 font-family: var(--font-mono);
122 font-size: 11px;
123 letter-spacing: 0.18em;
124 color: var(--text-muted);
125 font-weight: 600;
126 margin-bottom: 14px;
127 }
128 .adv-eyebrow-dot {
129 width: 8px; height: 8px;
130 border-radius: 9999px;
6fd5915Claude131 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
132 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
b1f2895Claude133 }
134 .adv-title {
135 font-family: var(--font-display);
136 font-size: clamp(28px, 4vw, 40px);
137 font-weight: 800;
138 letter-spacing: -0.028em;
139 line-height: 1.05;
140 margin: 0 0 var(--space-2);
141 color: var(--text-strong);
142 }
143 .adv-title-grad {
6fd5915Claude144 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
b1f2895Claude145 -webkit-background-clip: text;
146 background-clip: text;
147 -webkit-text-fill-color: transparent;
148 color: transparent;
149 }
150 .adv-sub {
151 font-size: 15px;
152 color: var(--text-muted);
153 margin: 0;
154 line-height: 1.55;
155 }
156 .adv-sub a { color: var(--accent); text-decoration: none; }
157 .adv-sub a:hover { text-decoration: underline; }
158
159 .adv-rescan {
160 position: relative;
161 z-index: 1;
162 display: inline-flex;
163 align-items: center;
164 gap: 8px;
165 padding: 9px 16px;
166 font-size: 13px;
167 font-weight: 600;
168 color: #fff;
6fd5915Claude169 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
b1f2895Claude170 border: none;
171 border-radius: 10px;
172 cursor: pointer;
173 text-decoration: none;
6fd5915Claude174 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
b1f2895Claude175 transition: transform 120ms ease, box-shadow 120ms ease;
176 }
6fd5915Claude177 .adv-rescan:hover { transform: translateY(-1px); box-shadow: 0 8px 22px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.18); }
b1f2895Claude178
179 .adv-banner {
180 margin-bottom: var(--space-4);
181 padding: 10px 14px;
182 border-radius: 10px;
183 font-size: 13.5px;
184 border: 1px solid var(--border);
185 background: rgba(255,255,255,0.025);
186 color: var(--text);
187 }
188 .adv-banner.is-ok {
189 border-color: rgba(52,211,153,0.40);
190 background: rgba(52,211,153,0.08);
191 color: #bbf7d0;
192 }
193 .adv-banner.is-error {
194 border-color: rgba(248,113,113,0.40);
195 background: rgba(248,113,113,0.08);
196 color: #fecaca;
197 }
198
199 /* Healthy banner — green gradient checkmark. Shown when zero open alerts. */
200 .adv-healthy {
201 display: flex;
202 align-items: center;
203 gap: 14px;
204 margin-bottom: var(--space-4);
205 padding: 14px 18px;
206 border-radius: 14px;
6fd5915Claude207 background: linear-gradient(135deg, rgba(52,211,153,0.10), rgba(95,143,160,0.06));
b1f2895Claude208 border: 1px solid rgba(52,211,153,0.32);
209 color: #bbf7d0;
210 }
211 .adv-healthy-icon {
212 flex: 0 0 auto;
213 width: 36px; height: 36px;
214 border-radius: 9999px;
215 display: inline-flex;
216 align-items: center;
217 justify-content: center;
6fd5915Claude218 background: linear-gradient(135deg, #34d399 0%, #5f8fa0 100%);
b1f2895Claude219 color: #04231a;
220 box-shadow: 0 0 0 4px rgba(52,211,153,0.16);
221 }
222 .adv-healthy-text { font-size: 14px; line-height: 1.45; }
223 .adv-healthy-text strong { display: block; color: #d1fae5; font-weight: 700; font-size: 14.5px; margin-bottom: 2px; }
224 .adv-healthy-text span { color: rgba(187,247,208,0.85); font-size: 12.5px; }
225
226 /* Tab pills */
227 .adv-tabs {
228 display: inline-flex;
229 gap: 4px;
230 padding: 4px;
231 background: var(--bg-elevated);
232 border: 1px solid var(--border);
233 border-radius: 12px;
234 margin-bottom: var(--space-4);
235 }
236 .adv-tab {
237 padding: 7px 14px;
238 border-radius: 8px;
239 font-size: 12.5px;
240 font-weight: 600;
241 color: var(--text-muted);
242 text-decoration: none;
243 transition: color 120ms ease, background 120ms ease;
244 }
245 .adv-tab:hover { color: var(--text); text-decoration: none; }
246 .adv-tab.is-active {
6fd5915Claude247 background: rgba(91,110,232,0.14);
b1f2895Claude248 color: #c4b5fd;
6fd5915Claude249 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
b1f2895Claude250 }
251
252 /* Card list */
253 .adv-list { display: flex; flex-direction: column; gap: var(--space-2); }
254 .adv-card {
255 position: relative;
256 background: var(--bg-elevated);
257 border: 1px solid var(--border);
258 border-radius: 14px;
259 padding: var(--space-4);
260 transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
261 }
262 .adv-card:hover {
263 border-color: var(--border-strong, var(--border));
264 transform: translateY(-1px);
265 box-shadow: 0 6px 18px -10px rgba(0,0,0,0.45);
266 }
267 .adv-card.is-critical { border-color: rgba(248,113,113,0.40); }
268 .adv-card.is-high { border-color: rgba(251,146,60,0.34); }
269 .adv-card.is-medium { border-color: rgba(251,191,36,0.30); }
270 .adv-card.is-low { border-color: rgba(96,165,250,0.30); }
271
272 .adv-card-head {
273 display: flex;
274 justify-content: space-between;
275 align-items: flex-start;
276 gap: 12px;
277 margin-bottom: 8px;
278 flex-wrap: wrap;
279 }
280 .adv-card-id {
281 display: flex;
282 align-items: center;
283 gap: 10px;
284 flex-wrap: wrap;
285 min-width: 0;
286 }
287 .adv-card-cve {
288 font-family: var(--font-mono);
289 font-size: 11.5px;
290 color: var(--text-muted);
291 background: rgba(255,255,255,0.04);
292 border: 1px solid var(--border);
293 padding: 2px 7px;
294 border-radius: 5px;
295 letter-spacing: 0.01em;
296 }
297 .adv-card-title {
298 font-family: var(--font-display);
299 font-size: 15.5px;
300 font-weight: 700;
301 color: var(--text-strong);
302 letter-spacing: -0.01em;
303 line-height: 1.35;
304 margin: 0 0 4px;
305 word-break: break-word;
306 }
307 .adv-card-meta {
308 display: flex;
309 flex-wrap: wrap;
310 gap: 14px 18px;
311 margin-top: 10px;
312 padding-top: 10px;
313 border-top: 1px solid var(--border);
314 font-size: 12.5px;
315 }
316 .adv-card-meta-item {
317 display: flex;
318 flex-direction: column;
319 gap: 2px;
320 min-width: 0;
321 }
322 .adv-card-meta-label {
323 font-size: 10px;
324 text-transform: uppercase;
325 letter-spacing: 0.12em;
326 color: var(--text-muted);
327 font-weight: 700;
328 }
329 .adv-card-meta-value {
330 font-family: var(--font-mono);
331 font-size: 12.5px;
332 color: var(--text);
333 word-break: break-word;
334 }
335 .adv-card-meta-value a {
336 color: var(--accent);
337 text-decoration: none;
338 }
339 .adv-card-meta-value a:hover { text-decoration: underline; }
340
341 .adv-card-fixed {
342 display: inline-flex;
343 align-items: center;
344 gap: 6px;
345 padding: 2px 8px;
346 border-radius: 6px;
347 font-family: var(--font-mono);
348 font-size: 12px;
349 color: #6ee7b7;
350 background: rgba(52,211,153,0.10);
351 border: 1px solid rgba(52,211,153,0.28);
352 }
353
354 .adv-card-dismissed {
355 margin-top: 10px;
356 padding: 8px 12px;
357 font-size: 12.5px;
358 color: var(--text-muted);
359 background: rgba(255,255,255,0.025);
360 border: 1px solid var(--border);
361 border-radius: 8px;
362 font-style: italic;
363 }
364
365 .adv-card-actions {
366 margin-top: 12px;
367 padding-top: 12px;
368 border-top: 1px solid var(--border);
369 display: flex;
370 gap: 8px;
371 flex-wrap: wrap;
372 align-items: center;
373 }
374 .adv-card-actions form {
375 display: flex;
376 gap: 6px;
377 align-items: center;
378 margin: 0;
379 flex-wrap: wrap;
380 }
381 .adv-card-actions input[type="text"] {
382 padding: 6px 10px;
383 font-size: 12.5px;
384 color: var(--text);
385 background: var(--bg);
386 border: 1px solid var(--border-strong, var(--border));
387 border-radius: 8px;
388 outline: none;
389 transition: border-color 120ms ease, box-shadow 120ms ease;
390 min-width: 200px;
391 }
392 .adv-card-actions input[type="text"]:focus {
6fd5915Claude393 border-color: var(--border-focus, rgba(91,110,232,0.45));
394 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
b1f2895Claude395 }
396 .adv-btn {
397 display: inline-flex;
398 align-items: center;
399 gap: 6px;
400 padding: 7px 14px;
401 font-size: 12.5px;
402 font-weight: 600;
403 color: var(--text);
404 background: transparent;
405 border: 1px solid var(--border-strong, var(--border));
406 border-radius: 8px;
407 cursor: pointer;
408 text-decoration: none;
409 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
410 }
411 .adv-btn:hover {
412 color: var(--text-strong);
6fd5915Claude413 border-color: rgba(91,110,232,0.45);
414 background: rgba(91,110,232,0.08);
b1f2895Claude415 text-decoration: none;
416 }
417
418 /* Severity pill — strong color signal at first glance. */
419 .adv-pill {
420 display: inline-flex;
421 align-items: center;
422 gap: 6px;
423 padding: 3px 10px;
424 border-radius: 9999px;
425 font-size: 10.5px;
426 font-weight: 700;
427 letter-spacing: 0.06em;
428 text-transform: uppercase;
429 line-height: 1.4;
430 }
431 .adv-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
432 .adv-pill.is-critical { background: rgba(248,113,113,0.16); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.42); }
433 .adv-pill.is-high { background: rgba(251,146,60,0.14); color: #fdba74; box-shadow: inset 0 0 0 1px rgba(251,146,60,0.36); }
434 .adv-pill.is-medium { background: rgba(251,191,36,0.12); color: #fde68a; box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32); }
435 .adv-pill.is-low { background: rgba(96,165,250,0.12); color: #93c5fd; box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32); }
436
437 /* Status pill (open / dismissed / fixed) */
438 .adv-status {
439 display: inline-flex;
440 align-items: center;
441 gap: 6px;
442 padding: 3px 10px;
443 border-radius: 9999px;
444 font-size: 10.5px;
445 font-weight: 600;
446 letter-spacing: 0.05em;
447 text-transform: uppercase;
448 background: rgba(255,255,255,0.04);
449 color: var(--text-muted);
450 border: 1px solid var(--border);
451 }
452 .adv-status.is-dismissed { color: #cbd5e1; }
453 .adv-status.is-fixed { color: #6ee7b7; border-color: rgba(52,211,153,0.32); background: rgba(52,211,153,0.10); }
454
455 /* Empty state — dashed orb card */
456 .adv-empty {
457 position: relative;
458 overflow: hidden;
459 text-align: center;
460 padding: var(--space-6) var(--space-4);
461 border: 1px dashed var(--border-strong, var(--border));
462 border-radius: 16px;
463 background: rgba(255,255,255,0.012);
464 color: var(--text-muted);
465 }
466 .adv-empty::before {
467 content: '';
468 position: absolute;
469 inset: -40% -20% auto auto;
470 width: 320px; height: 320px;
6fd5915Claude471 background: radial-gradient(circle, rgba(91,110,232,0.14), rgba(95,143,160,0.06) 45%, transparent 70%);
b1f2895Claude472 filter: blur(60px);
473 pointer-events: none;
474 }
475 .adv-empty-inner { position: relative; z-index: 1; }
476 .adv-empty strong {
477 display: block;
478 font-family: var(--font-display);
479 font-size: 16px;
480 font-weight: 700;
481 color: var(--text-strong);
482 margin-bottom: 4px;
483 }
484 .adv-empty span { font-size: 13px; }
485`;
486
f60ccdeClaude487// ---------- List ----------
488
489async function renderList(
490 c: any,
491 ownerName: string,
492 repoName: string,
493 status: "open" | "all"
494) {
495 const ctx = await loadRepo(ownerName, repoName);
496 if (!ctx) return c.notFound();
497 const { repo } = ctx;
498 const user = c.get("user");
499 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
500 return c.notFound();
501 }
502
503 const isOwner = !!user && user.id === repo.ownerId;
504 const alerts = await listAlertsForRepo(repo.id, status);
505 const message = c.req.query("message");
506 const error = c.req.query("error");
507
508 return c.html(
509 <Layout
510 title={`Security advisories — ${ownerName}/${repoName}`}
511 user={user}
512 >
513 <RepoHeader owner={ownerName} repo={repoName} />
514 <RepoNav owner={ownerName} repo={repoName} active="code" />
b1f2895Claude515
516 <div class="adv-wrap">
517 <section class="adv-hero">
518 <div class="adv-hero-orb" aria-hidden="true" />
519 <div class="adv-hero-inner">
520 <div class="adv-hero-top">
521 <div class="adv-hero-text">
522 <div class="adv-eyebrow">
523 <span class="adv-eyebrow-dot" aria-hidden="true" />
524 Security advisories · {ownerName}/{repoName}
525 </div>
526 <h2 class="adv-title">
527 <span class="adv-title-grad">Known vulnerabilities.</span>
528 </h2>
529 <p class="adv-sub">
530 Cross-references this repo's parsed dependency graph against a
531 curated advisory database. Run <em>Reindex</em> on{" "}
532 <a href={`/${ownerName}/${repoName}/dependencies`}>
533 Dependencies
534 </a>{" "}
535 first if no alerts show up.
536 </p>
537 </div>
538 {isOwner && (
539 <form
540 method="post"
541 action={`/${ownerName}/${repoName}/security/advisories/scan`}
542 style="margin:0"
543 >
544 <button type="submit" class="adv-rescan">
545 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
546 <polyline points="23 4 23 10 17 10" />
547 <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
548 </svg>
549 Re-scan
550 </button>
551 </form>
552 )}
553 </div>
f60ccdeClaude554 </div>
b1f2895Claude555 </section>
556
557 {message && (
558 <div class="adv-banner is-ok">{decodeURIComponent(message)}</div>
f60ccdeClaude559 )}
560 {error && (
b1f2895Claude561 <div class="adv-banner is-error">{decodeURIComponent(error)}</div>
562 )}
563
564 {status === "open" && alerts.length === 0 && (
565 <div class="adv-healthy" role="status">
566 <span class="adv-healthy-icon" aria-hidden="true">
567 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
568 <polyline points="20 6 9 17 4 12" />
569 </svg>
570 </span>
571 <div class="adv-healthy-text">
572 <strong>Healthy</strong>
573 <span>
574 No open advisories.
575 {isOwner && " Click Re-scan to check against the latest database."}
576 </span>
577 </div>
f60ccdeClaude578 </div>
579 )}
580
b1f2895Claude581 <nav class="adv-tabs" aria-label="Filter advisories">
f60ccdeClaude582 <a
583 href={`/${ownerName}/${repoName}/security/advisories`}
b1f2895Claude584 class={"adv-tab" + (status === "open" ? " is-active" : "")}
f60ccdeClaude585 >
586 Open
587 </a>
588 <a
589 href={`/${ownerName}/${repoName}/security/advisories/all`}
b1f2895Claude590 class={"adv-tab" + (status === "all" ? " is-active" : "")}
f60ccdeClaude591 >
592 All
593 </a>
b1f2895Claude594 </nav>
f60ccdeClaude595
596 {alerts.length === 0 ? (
b1f2895Claude597 <div class="adv-empty">
598 <div class="adv-empty-inner">
599 <strong>No advisories</strong>
600 <span>
601 {status === "open"
602 ? "No open vulnerabilities right now."
603 : "Nothing in the advisory history."}
604 {isOwner &&
605 status === "open" &&
606 " Click Re-scan to check against the advisory database."}
607 </span>
608 </div>
f60ccdeClaude609 </div>
610 ) : (
b1f2895Claude611 <div class="adv-list">
612 {alerts.map((a) => {
613 const sevClass = severityClass(a.advisory.severity);
614 const idText =
615 a.advisory.ghsaId || a.advisory.cveId || a.advisory.id || "ref";
616 return (
617 <article class={"adv-card " + sevClass}>
618 <div class="adv-card-head">
619 <div class="adv-card-id">
620 <span class={"adv-pill " + sevClass}>
621 <span class="dot" aria-hidden="true" />
622 {a.advisory.severity}
623 </span>
624 <span class="adv-card-cve">{idText}</span>
625 </div>
626 <span class={"adv-status is-" + a.status}>{a.status}</span>
f60ccdeClaude627 </div>
b1f2895Claude628 <h3 class="adv-card-title">{a.advisory.summary}</h3>
629 <div class="adv-card-meta">
630 <div class="adv-card-meta-item">
631 <span class="adv-card-meta-label">Component</span>
632 <span class="adv-card-meta-value">
633 {a.advisory.ecosystem} · {a.dependencyName}
634 {a.dependencyVersion ? ` ${a.dependencyVersion}` : ""}
635 </span>
636 </div>
637 <div class="adv-card-meta-item">
638 <span class="adv-card-meta-label">Affected</span>
639 <span class="adv-card-meta-value">
640 {a.advisory.affectedRange}
641 </span>
642 </div>
643 {a.advisory.fixedVersion && (
644 <div class="adv-card-meta-item">
645 <span class="adv-card-meta-label">Fixed in</span>
646 <span class="adv-card-fixed">
647 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
648 <polyline points="20 6 9 17 4 12" />
649 </svg>
650 ≥ {a.advisory.fixedVersion}
651 </span>
652 </div>
f60ccdeClaude653 )}
b1f2895Claude654 <div class="adv-card-meta-item">
655 <span class="adv-card-meta-label">Manifest</span>
656 <span class="adv-card-meta-value">
657 <a
658 href={`/${ownerName}/${repoName}/blob/HEAD/${a.manifestPath}`}
f60ccdeClaude659 >
b1f2895Claude660 {a.manifestPath}
661 </a>
662 </span>
663 </div>
664 {a.advisory.referenceUrl && (
665 <div class="adv-card-meta-item">
666 <span class="adv-card-meta-label">Reference</span>
667 <span class="adv-card-meta-value">
668 <a
669 href={a.advisory.referenceUrl}
670 rel="noreferrer"
671 target="_blank"
672 >
673 View details ↗
674 </a>
675 </span>
676 </div>
f60ccdeClaude677 )}
678 </div>
b1f2895Claude679 {a.status === "dismissed" && a.dismissedReason && (
680 <div class="adv-card-dismissed">
681 Dismissed: {a.dismissedReason}
682 </div>
683 )}
684 {isOwner && (a.status === "open" || a.status === "dismissed") && (
685 <div class="adv-card-actions">
686 {a.status === "open" && (
687 <form
688 method="post"
689 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/dismiss`}
690 >
691 <input
692 type="text"
693 name="reason"
694 placeholder="Reason (optional)"
695 maxLength={280}
696 aria-label="Dismiss reason"
697 />
698 <button type="submit" class="adv-btn">
699 Dismiss
700 </button>
701 </form>
702 )}
703 {a.status === "dismissed" && (
704 <form
705 method="post"
706 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/reopen`}
707 >
708 <button type="submit" class="adv-btn">
709 Reopen
710 </button>
711 </form>
712 )}
713 </div>
714 )}
715 </article>
716 );
717 })}
f60ccdeClaude718 </div>
719 )}
720 </div>
b1f2895Claude721 <style dangerouslySetInnerHTML={{ __html: styles }} />
f60ccdeClaude722 </Layout>
723 );
724}
725
726advisories.get("/:owner/:repo/security/advisories", async (c) => {
727 const { owner: ownerName, repo: repoName } = c.req.param();
728 return renderList(c, ownerName, repoName, "open");
729});
730
731advisories.get("/:owner/:repo/security/advisories/all", async (c) => {
732 const { owner: ownerName, repo: repoName } = c.req.param();
733 return renderList(c, ownerName, repoName, "all");
734});
735
736// ---------- Re-scan (owner-only) ----------
737
738advisories.post(
739 "/:owner/:repo/security/advisories/scan",
740 requireAuth,
741 async (c) => {
742 const user = c.get("user")!;
743 const { owner: ownerName, repo: repoName } = c.req.param();
744 const ctx = await loadRepo(ownerName, repoName);
745 if (!ctx) return c.notFound();
746 const { repo } = ctx;
747 if (user.id !== repo.ownerId) {
748 return c.redirect(
749 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
750 "Only the repo owner can scan"
751 )}`
752 );
753 }
a28cedeClaude754 await seedAdvisories().catch((err) => {
755 console.warn(
756 "[advisories] seedAdvisories failed:",
757 err instanceof Error ? err.message : err
758 );
759 });
f60ccdeClaude760 const result = await scanRepositoryForAlerts(repo.id);
761 await audit({
762 userId: user.id,
763 repositoryId: repo.id,
764 action: "advisories.scan",
765 metadata: result || {},
766 });
767 const to = `/${ownerName}/${repoName}/security/advisories`;
768 if (!result) {
769 return c.redirect(
770 `${to}?error=${encodeURIComponent("Scan failed")}`
771 );
772 }
773 const msg = `Scan complete — ${result.opened} new, ${result.closed} closed, ${result.matched} total matches.`;
774 return c.redirect(`${to}?message=${encodeURIComponent(msg)}`);
775 }
776);
777
778// ---------- Dismiss / reopen (owner-only) ----------
779
780advisories.post(
781 "/:owner/:repo/security/advisories/:id/dismiss",
782 requireAuth,
783 async (c) => {
784 const user = c.get("user")!;
785 const { owner: ownerName, repo: repoName, id } = c.req.param();
786 const ctx = await loadRepo(ownerName, repoName);
787 if (!ctx) return c.notFound();
788 const { repo } = ctx;
789 if (user.id !== repo.ownerId) {
790 return c.redirect(
791 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
792 "Only the repo owner can dismiss"
793 )}`
794 );
795 }
796 const body = await c.req.parseBody();
797 const reason = String(body.reason || "").trim();
798 const ok = await dismissAlert(id, repo.id, reason);
799 await audit({
800 userId: user.id,
801 repositoryId: repo.id,
802 action: "advisories.dismiss",
803 targetId: id,
804 metadata: { reason: reason || null },
805 });
806 const to = `/${ownerName}/${repoName}/security/advisories`;
807 return c.redirect(
808 `${to}?${ok ? "message" : "error"}=${encodeURIComponent(
809 ok ? "Alert dismissed." : "Dismiss failed"
810 )}`
811 );
812 }
813);
814
815advisories.post(
816 "/:owner/:repo/security/advisories/:id/reopen",
817 requireAuth,
818 async (c) => {
819 const user = c.get("user")!;
820 const { owner: ownerName, repo: repoName, id } = c.req.param();
821 const ctx = await loadRepo(ownerName, repoName);
822 if (!ctx) return c.notFound();
823 const { repo } = ctx;
824 if (user.id !== repo.ownerId) {
825 return c.redirect(
826 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
827 "Only the repo owner can reopen"
828 )}`
829 );
830 }
831 const ok = await reopenAlert(id, repo.id);
832 await audit({
833 userId: user.id,
834 repositoryId: repo.id,
835 action: "advisories.reopen",
836 targetId: id,
837 });
838 const to = `/${ownerName}/${repoName}/security/advisories`;
839 return c.redirect(
840 `${to}?${ok ? "message" : "error"}=${encodeURIComponent(
841 ok ? "Alert reopened." : "Reopen failed"
842 )}`
843 );
844 }
845);
846
847export default advisories;