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.

159 linesCodeBlameRaw
1/**
2 * Writing a security advisory: a new draft, or changes to one in any
3 * state. Admin on the repository, or a security manager of its workspace.
4 * The same route serves `advisories/new` and `advisories/:id/edit`.
5 */
6import { Form, data, redirect, useNavigation } from "react-router";
7
8import type { Advisory, AdvisorySeverity } from "@g1t/contracts";
9
10import type { Route } from "./+types/security-advisory-edit";
11import { page } from "../../lib/meta";
12import { SectionHeader } from "../../components/security-suite";
13import { ButtonLink, ErrorText, Field, Input, SubmitButton, Textarea, notACredential } from "../../components/ui";
14import { Card } from "../../components/ui/card";
15import { SelectField } from "../../components/ui/select";
16import { advisories } from "../../lib/services.server";
17import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
18import { refusal, requireInsider } from "../../lib/access.server";
19import { SEVERITIES, SEVERITY_LABEL, advisoryFromForm } from "../../lib/advisories";
20
21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `${params.id ? "Edit advisory" : "New advisory"} · ${params.owner}/${params.repo} · g1t` });
23}
24
25export async function loader({ params, context, request }: Route.LoaderArgs) {
26 const viewer = getViewer(context) ?? requireUser(context, request);
27 await requireInsider(context, params, "manage_security");
28 const path = { namespace: params.owner, name: params.repo };
29 const advisory: Advisory | null = params.id ? unwrap(await advisories.get(path, params.id, viewer)) : null;
30 return { advisory };
31}
32
33export async function action({ params, context, request }: Route.ActionArgs) {
34 assertSameOrigin(request);
35 const user = requireUser(context, request);
36 const refused = await refusal(context, params, "manage_security");
37 if (refused) return data({ error: refused }, { status: 403 });
38 const input = advisoryFromForm(await request.formData());
39 if (typeof input === "string") return data({ error: input }, { status: 400 });
40 const path = { namespace: params.owner, name: params.repo };
41 const saved = params.id ? await advisories.update(user, path, params.id, input) : await advisories.create(user, path, input);
42 if (!saved.ok) return data({ error: saved.error.message }, { status: 400 });
43 throw redirect(`/${params.owner}/${params.repo}/security/advisories/${saved.value.g1tsaId}`);
44}
45
46export default function SecurityAdvisoryEdit({ loaderData, actionData, params }: Route.ComponentProps) {
47 const { advisory } = loaderData;
48 const base = `/${params.owner}/${params.repo}`;
49 const navigation = useNavigation();
50 const back = advisory ? `${base}/security/advisories/${advisory.g1tsaId}` : `${base}/security/advisories`;
51 return (
52 <div className="max-w-3xl space-y-6">
53 <SectionHeader
54 title={advisory ? `Edit ${advisory.g1tsaId}` : "New security advisory"}
55 about={
56 advisory
57 ? "Changes to a published advisory show at once; a draft stays yours until you publish it."
58 : "Say what the vulnerability is, what it affects and what fixes it. It starts as a draft only the maintainers see."
59 }
60 />
61 <Form method="post" className="space-y-4">
62 <Card className="space-y-4 p-4">
63 <Field label="Title">
64 <Input
65 name="title"
66 defaultValue={advisory?.title ?? ""}
67 required
68 maxLength={200}
69 placeholder="Path traversal in the archive reader"
70 {...notACredential()}
71 />
72 </Field>
73 <div className="grid gap-4 sm:grid-cols-2">
74 <Field label="Severity">
75 <SelectField
76 name="severity"
77 defaultValue={advisory?.severity ?? "high"}
78 options={SEVERITIES.map((severity: AdvisorySeverity) => ({ value: severity, label: SEVERITY_LABEL[severity] }))}
79 aria-label="Severity"
80 />
81 </Field>
82 <Field label="CVE id" hint="When one was issued elsewhere.">
83 <Input
84 name="cveId"
85 defaultValue={advisory?.cveId ?? ""}
86 placeholder="CVE-2026-12345"
87 maxLength={32}
88 className="font-mono"
89 {...notACredential()}
90 />
91 </Field>
92 </div>
93 <Field label="Description" hint="Markdown: what it is, how it is exploited, what to do. Needed before publishing.">
94 <Textarea name="description" defaultValue={advisory?.description ?? ""} rows={10} maxLength={20_000} />
95 </Field>
96 </Card>
97 <Card className="space-y-4 p-4">
98 <p className="text-sm font-medium">What is affected</p>
99 <div className="grid gap-4 sm:grid-cols-2">
100 <Field label="Ecosystem" hint="npm, crates.io, Go, PyPI, or how the project ships.">
101 <Input name="ecosystem" defaultValue={advisory?.ecosystem ?? ""} maxLength={50} {...notACredential()} />
102 </Field>
103 <Field label="Package">
104 <Input name="package" defaultValue={advisory?.package ?? ""} maxLength={200} className="font-mono" {...notACredential()} />
105 </Field>
106 <Field label="Affected versions">
107 <Input
108 name="affectedVersions"
109 defaultValue={advisory?.affectedVersions ?? ""}
110 placeholder=">= 1.2.0, < 1.4.1"
111 maxLength={200}
112 className="font-mono"
113 {...notACredential()}
114 />
115 </Field>
116 <Field label="Patched versions">
117 <Input
118 name="patchedVersions"
119 defaultValue={advisory?.patchedVersions ?? ""}
120 placeholder=">= 1.4.1"
121 maxLength={200}
122 className="font-mono"
123 {...notACredential()}
124 />
125 </Field>
126 </div>
127 </Card>
128 <Card className="space-y-4 p-4">
129 <p className="text-sm font-medium">Scoring and credits</p>
130 <div className="grid gap-4 sm:grid-cols-2">
131 <Field label="CVSS vector">
132 <Input
133 name="cvssVector"
134 defaultValue={advisory?.cvssVector ?? ""}
135 placeholder="CVSS:3.1/AV:N/AC:L/…"
136 maxLength={200}
137 className="font-mono"
138 {...notACredential()}
139 />
140 </Field>
141 <Field label="CVSS score" hint="0 to 10.">
142 <Input name="cvssScore" type="number" step="0.1" min="0" max="10" defaultValue={advisory?.cvssScore ?? ""} className="font-mono" />
143 </Field>
144 </div>
145 <Field label="Credits" hint="Who found or fixed it: usernames or names, one per line or separated by commas.">
146 <Textarea name="credits" defaultValue={(advisory?.credits ?? []).join("\n")} rows={2} />
147 </Field>
148 </Card>
149 <div className="flex flex-wrap items-center gap-3">
150 <SubmitButton pending="Saving…">{advisory ? "Save" : "Create draft"}</SubmitButton>
151 <ButtonLink to={back} variant="outline">
152 Cancel
153 </ButtonLink>
154 {navigation.state === "idle" && <ErrorText>{actionData?.error}</ErrorText>}
155 </div>
156 </Form>
157 </div>
158 );
159}