Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

153 linesCodeBlameRaw
1/**
2 * One security advisory: what it says, what is affected and what fixes it,
3 * who found it. Admins (and security managers) publish a draft, withdraw a
4 * published one, or edit it; anyone who can see the repository reads a
5 * published one.
6 */
7import { useFetcher } from "react-router";
8
9import type { Route } from "./+types/security-advisory";
10import { page } from "../../lib/meta";
11import { Markdown } from "../../components/markdown";
12import { SeverityBadge } from "../../components/security";
13import { SectionHeader } from "../../components/security-suite";
14import { ButtonLink, TimeAgo } from "../../components/ui";
15import { Badge } from "../../components/ui/badge";
16import { Button } from "../../components/ui/button";
17import { Card } from "../../components/ui/card";
18import { advisories } from "../../lib/services.server";
19import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
20import { refusal, requireRepo } from "../../lib/access.server";
21import { STATE_LABEL, STATE_TONE, advisoryStanding } from "../../lib/advisories";
22
23export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `${loaderData?.advisory.title ?? "Advisory"} · ${params.owner}/${params.repo} · g1t` });
25}
26
27export async function loader({ params, context }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 const { access } = await requireRepo(context, params, "read");
30 const path = { namespace: params.owner, name: params.repo };
31 const advisory = unwrap(await advisories.get(path, params.id, viewer));
32 return { advisory, canManage: Boolean(access.can.manage_settings) };
33}
34
35/** Publishing and withdrawing: Admin, or a security manager. */
36export async function action({ params, context, request }: Route.ActionArgs) {
37 assertSameOrigin(request);
38 const user = requireUser(context, request);
39 const refused = await refusal(context, params, "manage_security");
40 if (refused) return { ok: false, error: refused };
41 const form = await request.formData();
42 const state = String(form.get("state") ?? "");
43 if (state !== "published" && state !== "withdrawn") return { ok: false, error: "Nothing to do." };
44 const changed = await advisories.setState(user, { namespace: params.owner, name: params.repo }, params.id, state);
45 return changed.ok ? { ok: true } : { ok: false, error: changed.error.message };
46}
47
48function Fact({ label, children }: { label: string; children: React.ReactNode }) {
49 return (
50 <div className="flex flex-col gap-0.5 px-4 py-3 sm:flex-row sm:gap-4">
51 <dt className="shrink-0 text-xs font-medium text-muted sm:w-40 sm:pt-0.5">{label}</dt>
52 <dd className="min-w-0 text-sm wrap-break-word">{children}</dd>
53 </div>
54 );
55}
56
57export default function SecurityAdvisory({ loaderData, params }: Route.ComponentProps) {
58 const { advisory, canManage } = loaderData;
59 const base = `/${params.owner}/${params.repo}`;
60 const fetcher = useFetcher<{ ok: boolean; error?: string }>();
61 const standing = advisoryStanding(advisory);
62 const working = fetcher.state !== "idle";
63 return (
64 <div className="max-w-5xl space-y-6">
65 <SectionHeader
66 title={advisory.title}
67 about={
68 <span className="flex flex-wrap items-center gap-2">
69 <SeverityBadge severity={advisory.severity} />
70 <Badge tone={STATE_TONE[advisory.state]}>{STATE_LABEL[advisory.state]}</Badge>
71 <span className="font-mono text-xs">{advisory.g1tsaId}</span>
72 {advisory.cveId && <span className="font-mono text-xs">{advisory.cveId}</span>}
73 <span>
74 {standing.verb}
75 {standing.at && (
76 <>
77 {" "}
78 <TimeAgo at={standing.at} />
79 </>
80 )}
81 </span>
82 </span>
83 }
84 actions={
85 canManage ? (
86 <fetcher.Form method="post" className="flex flex-wrap items-center gap-2">
87 <ButtonLink to={`${base}/security/advisories/${advisory.g1tsaId}/edit`} variant="outline" size="sm">
88 Edit
89 </ButtonLink>
90 {advisory.state === "published" ? (
91 <Button type="submit" name="state" value="withdrawn" variant="outline" size="sm" disabled={working}>
92 {working ? "Withdrawing…" : "Withdraw"}
93 </Button>
94 ) : (
95 <Button type="submit" name="state" value="published" size="sm" disabled={working || !advisory.description.trim()}>
96 {working ? "Publishing…" : advisory.state === "withdrawn" ? "Publish again" : "Publish"}
97 </Button>
98 )}
99 </fetcher.Form>
100 ) : null
101 }
102 />
103 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
104 {canManage && advisory.state === "draft" && (
105 <p className="text-sm text-muted">
106 A draft is seen by this repository's maintainers only. {advisory.description.trim() ? "Publish it when it is ready." : "Describe the vulnerability before publishing it."}
107 </p>
108 )}
109 <Card asChild divided>
110 <dl>
111 {(advisory.package || advisory.ecosystem) && (
112 <Fact label="Affected package">
113 <span className="font-mono">{advisory.package ?? "—"}</span>
114 {advisory.ecosystem && <span className="text-muted"> ({advisory.ecosystem})</span>}
115 </Fact>
116 )}
117 {advisory.affectedVersions && (
118 <Fact label="Affected versions">
119 <span className="font-mono">{advisory.affectedVersions}</span>
120 </Fact>
121 )}
122 {advisory.patchedVersions && (
123 <Fact label="Patched versions">
124 <span className="font-mono">{advisory.patchedVersions}</span>
125 </Fact>
126 )}
127 {(advisory.cvssScore != null || advisory.cvssVector) && (
128 <Fact label="CVSS">
129 {advisory.cvssScore != null && <span className="font-medium">{advisory.cvssScore.toFixed(1)}</span>}
130 {advisory.cvssVector && <span className="ml-2 font-mono text-xs text-muted">{advisory.cvssVector}</span>}
131 </Fact>
132 )}
133 {advisory.credits.length > 0 && <Fact label="Credits">{advisory.credits.join(", ")}</Fact>}
134 <Fact label="Written by">
135 {advisory.createdBy}, <TimeAgo at={advisory.createdAt} />
136 </Fact>
137 </dl>
138 </Card>
139 <section aria-labelledby="advisory-description">
140 <h3 id="advisory-description" className="sr-only">
141 Description
142 </h3>
143 {advisory.description.trim() ? (
144 <Card className="p-4">
145 <Markdown source={advisory.description} repo={{ namespace: params.owner, name: params.repo }} />
146 </Card>
147 ) : (
148 <p className="text-sm text-muted">No description yet.</p>
149 )}
150 </section>
151 </div>
152 );
153}