| 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 | */ |
| 6 | import { Form, data, redirect, useNavigation } from "react-router"; |
| 7 | |
| 8 | import type { Advisory, AdvisorySeverity } from "@g1t/contracts"; |
| 9 | |
| 10 | import type { Route } from "./+types/security-advisory-edit"; |
| 11 | import { page } from "../../lib/meta"; |
| 12 | import { SectionHeader } from "../../components/security-suite"; |
| 13 | import { ButtonLink, ErrorText, Field, Input, SubmitButton, Textarea, notACredential } from "../../components/ui"; |
| 14 | import { Card } from "../../components/ui/card"; |
| 15 | import { SelectField } from "../../components/ui/select"; |
| 16 | import { advisories } from "../../lib/services.server"; |
| 17 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 18 | import { refusal, requireInsider } from "../../lib/access.server"; |
| 19 | import { SEVERITIES, SEVERITY_LABEL, advisoryFromForm } from "../../lib/advisories"; |
| 20 | |
| 21 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 22 | return page(args, { title: `${params.id ? "Edit advisory" : "New advisory"} · ${params.owner}/${params.repo} · g1t` }); |
| 23 | } |
| 24 | |
| 25 | export 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 | |
| 33 | export 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 | |
| 46 | export 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 | } |