| 1 | /** |
| 2 | * "Report a vulnerability": tells a repository's maintainers privately, |
| 3 | * when the repository takes such reports. Anyone who can see the |
| 4 | * repository may; on a public one someone signed out may too, with an |
| 5 | * email address the maintainers can answer at. The front door limits how |
| 6 | * fast one address can post here (lib/front-door-limits.ts), and the |
| 7 | * service takes five reports a day from one reporter. |
| 8 | */ |
| 9 | import { Form, Link, data, useNavigation } from "react-router"; |
| 10 | |
| 11 | import type { AdvisorySeverity } from "@g1t/contracts"; |
| 12 | |
| 13 | import type { Route } from "./+types/security-report"; |
| 14 | import { page } from "../../lib/meta"; |
| 15 | import { SectionHeader } from "../../components/security-suite"; |
| 16 | import { ButtonLink, ErrorText, Field, Input, SubmitButton, Textarea, notACredential } from "../../components/ui"; |
| 17 | import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert"; |
| 18 | import { Card } from "../../components/ui/card"; |
| 19 | import { SelectField } from "../../components/ui/select"; |
| 20 | import { advisories } from "../../lib/services.server"; |
| 21 | import { assertSameOrigin, getViewer } from "../../lib/session.server"; |
| 22 | import { requireRepo } from "../../lib/access.server"; |
| 23 | import { SEVERITIES, SEVERITY_LABEL, reportFromForm } from "../../lib/advisories"; |
| 24 | |
| 25 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 26 | return page(args, { title: `Report a vulnerability · ${params.owner}/${params.repo} · g1t` }); |
| 27 | } |
| 28 | |
| 29 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 30 | const viewer = getViewer(context); |
| 31 | await requireRepo(context, params, "read"); |
| 32 | const settings = await advisories.reportSettings({ namespace: params.owner, name: params.repo }, viewer); |
| 33 | return { reporting: settings.ok && settings.value.privateVulnerabilityReporting, signedIn: Boolean(viewer), username: viewer?.username ?? null }; |
| 34 | } |
| 35 | |
| 36 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 37 | assertSameOrigin(request); |
| 38 | const viewer = getViewer(context); |
| 39 | await requireRepo(context, params, "read"); |
| 40 | const draft = reportFromForm(await request.formData(), Boolean(viewer)); |
| 41 | if (typeof draft === "string") return data({ error: draft, sent: null }, { status: 400 }); |
| 42 | const sent = await advisories.report(viewer, { namespace: params.owner, name: params.repo }, draft); |
| 43 | if (!sent.ok) return data({ error: sent.error.message, sent: null }, { status: sent.error.code === "limit" ? 429 : 400 }); |
| 44 | return { error: null, sent: sent.value.id }; |
| 45 | } |
| 46 | |
| 47 | export default function SecurityReport({ loaderData, actionData, params }: Route.ComponentProps) { |
| 48 | const { reporting, signedIn, username } = loaderData; |
| 49 | const base = `/${params.owner}/${params.repo}`; |
| 50 | const navigation = useNavigation(); |
| 51 | if (actionData?.sent) { |
| 52 | return ( |
| 53 | <div className="max-w-3xl space-y-6"> |
| 54 | <SectionHeader title="Thank you" about="Your report reached the maintainers, and only them. They will look into it and answer you when there is news." /> |
| 55 | <ButtonLink to={`${base}/security`} variant="outline"> |
| 56 | Back to Security |
| 57 | </ButtonLink> |
| 58 | </div> |
| 59 | ); |
| 60 | } |
| 61 | if (!reporting) { |
| 62 | return ( |
| 63 | <div className="max-w-3xl space-y-6"> |
| 64 | <SectionHeader title="Report a vulnerability" about="This repository does not take private vulnerability reports here." /> |
| 65 | <Alert tone="info"> |
| 66 | <AlertTitle>Reach the maintainers another way</AlertTitle> |
| 67 | <AlertDescription> |
| 68 | Its{" "} |
| 69 | <Link to={`${base}/security`} className="underline underline-offset-2"> |
| 70 | Security page |
| 71 | </Link>{" "} |
| 72 | shows its security policy, if it has one, which says how to report. Please do not open a public issue about a vulnerability. |
| 73 | </AlertDescription> |
| 74 | </Alert> |
| 75 | </div> |
| 76 | ); |
| 77 | } |
| 78 | return ( |
| 79 | <div className="max-w-3xl space-y-6"> |
| 80 | <SectionHeader |
| 81 | title="Report a vulnerability" |
| 82 | about={`Tell the maintainers of ${params.owner}/${params.repo} privately. Only they see what you write here; nothing is published until they publish an advisory.`} |
| 83 | /> |
| 84 | <Form method="post" className="space-y-4"> |
| 85 | <Card className="space-y-4 p-4"> |
| 86 | <Field label="Title" hint="One line: what you found."> |
| 87 | <Input name="title" required maxLength={200} placeholder="Path traversal in the archive reader" {...notACredential()} /> |
| 88 | </Field> |
| 89 | <Field label="Description" hint="What is affected, how to reproduce it, and what you think it allows. Markdown is fine."> |
| 90 | <Textarea name="description" required rows={10} maxLength={20_000} /> |
| 91 | </Field> |
| 92 | <div className="grid gap-4 sm:grid-cols-2"> |
| 93 | <Field label="Severity" hint="How bad you think it is, if you have a view."> |
| 94 | <SelectField |
| 95 | name="severity" |
| 96 | defaultValue="medium" |
| 97 | options={SEVERITIES.map((severity: AdvisorySeverity) => ({ value: severity, label: SEVERITY_LABEL[severity] }))} |
| 98 | aria-label="Severity" |
| 99 | /> |
| 100 | </Field> |
| 101 | {signedIn ? ( |
| 102 | <Field label="Reported as" hint="The maintainers answer you on g1t."> |
| 103 | <Input value={username ?? ""} readOnly className="font-mono" /> |
| 104 | </Field> |
| 105 | ) : ( |
| 106 | <Field label="Your email" hint="Where the maintainers can answer you. Never published."> |
| 107 | <Input name="email" type="email" required maxLength={254} autoComplete="email" placeholder="you@example.com" /> |
| 108 | </Field> |
| 109 | )} |
| 110 | </div> |
| 111 | </Card> |
| 112 | <div className="flex flex-wrap items-center gap-3"> |
| 113 | <SubmitButton pending="Sending…">Send report</SubmitButton> |
| 114 | <ButtonLink to={`${base}/security`} variant="outline"> |
| 115 | Cancel |
| 116 | </ButtonLink> |
| 117 | {navigation.state === "idle" && <ErrorText>{actionData?.error}</ErrorText>} |
| 118 | </div> |
| 119 | {!signedIn && ( |
| 120 | <p className="text-xs text-faint"> |
| 121 | Have an account?{" "} |
| 122 | <Link to={`/login?next=${encodeURIComponent(`${base}/security/report`)}`} className="underline underline-offset-2 hover:text-fg"> |
| 123 | Sign in |
| 124 | </Link>{" "} |
| 125 | to report under your name. |
| 126 | </p> |
| 127 | )} |
| 128 | </Form> |
| 129 | </div> |
| 130 | ); |
| 131 | } |