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.

124 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 name="title" defaultValue={advisory?.title ?? ""} required maxLength={200} placeholder="Path traversal in the archive reader" {...notACredential()} />
65 </Field>
66 <div className="grid gap-4 sm:grid-cols-2">
67 <Field label="Severity">
68 <SelectField
69 name="severity"
70 defaultValue={advisory?.severity ?? "high"}
71 options={SEVERITIES.map((severity: AdvisorySeverity) => ({ value: severity, label: SEVERITY_LABEL[severity] }))}
72 aria-label="Severity"
73 />
74 </Field>
75 <Field label="CVE id" hint="When one was issued elsewhere.">
76 <Input name="cveId" defaultValue={advisory?.cveId ?? ""} placeholder="CVE-2026-12345" maxLength={32} className="font-mono" {...notACredential()} />
77 </Field>
78 </div>
79 <Field label="Description" hint="Markdown: what it is, how it is exploited, what to do. Needed before publishing.">
80 <Textarea name="description" defaultValue={advisory?.description ?? ""} rows={10} maxLength={20_000} />
81 </Field>
82 </Card>
83 <Card className="space-y-4 p-4">
84 <p className="text-sm font-medium">What is affected</p>
85 <div className="grid gap-4 sm:grid-cols-2">
86 <Field label="Ecosystem" hint="npm, crates.io, Go, PyPI, or how the project ships.">
87 <Input name="ecosystem" defaultValue={advisory?.ecosystem ?? ""} maxLength={50} {...notACredential()} />
88 </Field>
89 <Field label="Package">
90 <Input name="package" defaultValue={advisory?.package ?? ""} maxLength={200} className="font-mono" {...notACredential()} />
91 </Field>
92 <Field label="Affected versions">
93 <Input name="affectedVersions" defaultValue={advisory?.affectedVersions ?? ""} placeholder=">= 1.2.0, < 1.4.1" maxLength={200} className="font-mono" {...notACredential()} />
94 </Field>
95 <Field label="Patched versions">
96 <Input name="patchedVersions" defaultValue={advisory?.patchedVersions ?? ""} placeholder=">= 1.4.1" maxLength={200} className="font-mono" {...notACredential()} />
97 </Field>
98 </div>
99 </Card>
100 <Card className="space-y-4 p-4">
101 <p className="text-sm font-medium">Scoring and credits</p>
102 <div className="grid gap-4 sm:grid-cols-2">
103 <Field label="CVSS vector">
104 <Input name="cvssVector" defaultValue={advisory?.cvssVector ?? ""} placeholder="CVSS:3.1/AV:N/AC:L/…" maxLength={200} className="font-mono" {...notACredential()} />
105 </Field>
106 <Field label="CVSS score" hint="0 to 10.">
107 <Input name="cvssScore" type="number" step="0.1" min="0" max="10" defaultValue={advisory?.cvssScore ?? ""} className="font-mono" />
108 </Field>
109 </div>
110 <Field label="Credits" hint="Who found or fixed it: usernames or names, one per line or separated by commas.">
111 <Textarea name="credits" defaultValue={(advisory?.credits ?? []).join("\n")} rows={2} />
112 </Field>
113 </Card>
114 <div className="flex flex-wrap items-center gap-3">
115 <SubmitButton pending="Saving…">{advisory ? "Save" : "Create draft"}</SubmitButton>
116 <ButtonLink to={back} variant="outline">
117 Cancel
118 </ButtonLink>
119 {navigation.state === "idle" && <ErrorText>{actionData?.error}</ErrorText>}
120 </div>
121 </Form>
122 </div>
123 );
124}