| 1 | /** |
| 2 | * The vulnerability reports people sent this repository's maintainers |
| 3 | * privately: new ones first, each accepted (which drafts an advisory from |
| 4 | * it) or declined by an Admin or a security manager. Those who see |
| 5 | * findings read them; a reporter never sees another's. |
| 6 | */ |
| 7 | import { useFetcher, useSearchParams } from "react-router"; |
| 8 | |
| 9 | import type { ReportState, VulnerabilityReport } from "@g1t/contracts"; |
| 10 | |
| 11 | import type { Route } from "./+types/security-reports"; |
| 12 | import { page } from "../../lib/meta"; |
| 13 | import { Markdown } from "../../components/markdown"; |
| 14 | import { SeverityBadge } from "../../components/security"; |
| 15 | import { FilterSelect, SectionHeader } from "../../components/security-suite"; |
| 16 | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; |
| 17 | import { Badge } from "../../components/ui/badge"; |
| 18 | import { Button } from "../../components/ui/button"; |
| 19 | import { Card } from "../../components/ui/card"; |
| 20 | import { Hint } from "../../components/ui/hint"; |
| 21 | import { Input } from "../../components/ui/input"; |
| 22 | import { advisories } from "../../lib/services.server"; |
| 23 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 24 | import { refusal, requireInsider } from "../../lib/access.server"; |
| 25 | import { isFailed, part } from "../../lib/partial"; |
| 26 | import { whyNot } from "../../lib/access"; |
| 27 | import { REPORT_STATES, REPORT_STATE_LABEL, REPORT_STATE_TONE, parseReportState } from "../../lib/advisories"; |
| 28 | |
| 29 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 30 | return page(args, { title: `Vulnerability reports · ${params.owner}/${params.repo} · g1t` }); |
| 31 | } |
| 32 | |
| 33 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 34 | const viewer = getViewer(context) ?? requireUser(context, request); |
| 35 | const { access } = await requireInsider(context, params, "security_alerts"); |
| 36 | const path = { namespace: params.owner, name: params.repo }; |
| 37 | // The reports are what the page is about. Whether reporting is on only |
| 38 | // changes a line: unknown (null) when it cannot be read, so the page |
| 39 | // does not say it is off. |
| 40 | const [reports, settings] = await Promise.all([ |
| 41 | advisories.reports(path, viewer), |
| 42 | part(advisories.reportSettings(path, viewer), "Private vulnerability reporting"), |
| 43 | ]); |
| 44 | return { |
| 45 | reports: unwrap(reports), |
| 46 | reporting: isFailed(settings) ? null : settings.ok && settings.value.privateVulnerabilityReporting, |
| 47 | can: access.can, |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 52 | assertSameOrigin(request); |
| 53 | const user = requireUser(context, request); |
| 54 | const refused = await refusal(context, params, "manage_security"); |
| 55 | if (refused) return { ok: false, error: refused }; |
| 56 | const form = await request.formData(); |
| 57 | const decision = String(form.get("decision") ?? ""); |
| 58 | if (decision !== "accept" && decision !== "decline") return { ok: false, error: "Nothing to do." }; |
| 59 | const reviewed = await advisories.review( |
| 60 | user, |
| 61 | { namespace: params.owner, name: params.repo }, |
| 62 | String(form.get("id") ?? ""), |
| 63 | decision, |
| 64 | String(form.get("note") ?? "").slice(0, 1000), |
| 65 | ); |
| 66 | if (!reviewed.ok) return { ok: false, error: reviewed.error.message }; |
| 67 | return { ok: true, advisory: reviewed.value.advisory?.g1tsaId ?? null }; |
| 68 | } |
| 69 | |
| 70 | function ReportCard({ report, base, canReview, action }: { report: VulnerabilityReport; base: string; canReview: boolean; action: string }) { |
| 71 | const fetcher = useFetcher<{ ok: boolean; error?: string; advisory?: string | null }>(); |
| 72 | const working = fetcher.state !== "idle"; |
| 73 | return ( |
| 74 | <Card asChild> |
| 75 | <article className="space-y-3 p-4"> |
| 76 | <div className="flex flex-wrap items-center gap-2"> |
| 77 | {report.severity && <SeverityBadge severity={report.severity} />} |
| 78 | <h3 className="min-w-0 text-sm font-medium">{report.title}</h3> |
| 79 | <Badge tone={REPORT_STATE_TONE[report.state]}>{REPORT_STATE_LABEL[report.state]}</Badge> |
| 80 | </div> |
| 81 | <p className="text-xs text-muted"> |
| 82 | From {report.reporter ?? report.reporterEmail ?? "someone"}, <TimeAgo at={report.createdAt} /> |
| 83 | {report.reviewedBy && report.reviewedAt && ( |
| 84 | <> |
| 85 | {" · "} |
| 86 | {report.state} by {report.reviewedBy} <TimeAgo at={report.reviewedAt} /> |
| 87 | </> |
| 88 | )} |
| 89 | {report.reviewNote && <> · {report.reviewNote}</>} |
| 90 | </p> |
| 91 | <Markdown source={report.description} /> |
| 92 | {report.state === "new" && canReview ? ( |
| 93 | <fetcher.Form method="post" action={action} className="flex flex-wrap items-center gap-2 pt-1"> |
| 94 | <input type="hidden" name="id" value={report.id} /> |
| 95 | <Input name="note" placeholder="A note for the record (optional)" maxLength={1000} className="h-8 w-full sm:w-72" /> |
| 96 | <Button type="submit" name="decision" value="accept" size="sm" disabled={working}> |
| 97 | {working ? "…" : "Accept as a draft advisory"} |
| 98 | </Button> |
| 99 | <Button type="submit" name="decision" value="decline" size="sm" variant="outline" disabled={working}> |
| 100 | Decline |
| 101 | </Button> |
| 102 | {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>} |
| 103 | </fetcher.Form> |
| 104 | ) : report.state === "new" ? ( |
| 105 | <Hint label={whyNot(null, "manage_security")} disabled> |
| 106 | <Button size="sm" disabled> |
| 107 | Accept as a draft advisory |
| 108 | </Button> |
| 109 | </Hint> |
| 110 | ) : report.advisoryId ? ( |
| 111 | <ButtonLink to={`${base}/security/advisories/${report.advisoryId}`} variant="outline" size="sm"> |
| 112 | Open the advisory |
| 113 | </ButtonLink> |
| 114 | ) : null} |
| 115 | </article> |
| 116 | </Card> |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | export default function SecurityReports({ loaderData, params }: Route.ComponentProps) { |
| 121 | const { reports, reporting, can } = loaderData; |
| 122 | const base = `/${params.owner}/${params.repo}`; |
| 123 | const [search, setSearch] = useSearchParams(); |
| 124 | const state: ReportState | null = parseReportState(search.get("state")); |
| 125 | const shown = state ? reports.filter((report) => report.state === state) : reports; |
| 126 | return ( |
| 127 | <div className="max-w-4xl space-y-6"> |
| 128 | <SectionHeader |
| 129 | title="Vulnerability reports" |
| 130 | about={ |
| 131 | reporting !== false |
| 132 | ? "What people told this repository's maintainers privately. Accept a report to draft an advisory from it, in the reporter's words and crediting them; decline what is not a vulnerability. Reporters never see this list." |
| 133 | : "Private vulnerability reporting is off for this repository: nobody can send a new report. Turn it on from the Security overview. Reports already sent are here." |
| 134 | } |
| 135 | actions={ |
| 136 | <ButtonLink to={`${base}/security/advisories`} variant="outline" size="sm" prefetch="intent"> |
| 137 | Advisories |
| 138 | </ButtonLink> |
| 139 | } |
| 140 | /> |
| 141 | <div className="flex flex-wrap items-center gap-2"> |
| 142 | <FilterSelect |
| 143 | label="State" |
| 144 | value={state ?? "all"} |
| 145 | options={[["all", "Any state"], ...REPORT_STATES.map((each): [string, string] => [each, REPORT_STATE_LABEL[each]])]} |
| 146 | onChange={(value) => { |
| 147 | const next = new URLSearchParams(search); |
| 148 | if (value !== "all") next.set("state", value); |
| 149 | else next.delete("state"); |
| 150 | setSearch(next, { replace: true, preventScrollReset: true }); |
| 151 | }} |
| 152 | /> |
| 153 | </div> |
| 154 | {shown.length === 0 ? ( |
| 155 | <EmptyState title={state ? `No ${REPORT_STATE_LABEL[state].toLowerCase()} reports` : "No reports"}> |
| 156 | {reporting !== false ? "Nobody has reported a vulnerability privately yet." : "Nothing was reported while private reporting was on."} |
| 157 | </EmptyState> |
| 158 | ) : ( |
| 159 | <div className="space-y-3"> |
| 160 | {shown.map((report) => ( |
| 161 | <ReportCard key={report.id} report={report} base={base} canReview={Boolean(can.manage_security)} action={`${base}/security/reports`} /> |
| 162 | ))} |
| 163 | </div> |
| 164 | )} |
| 165 | </div> |
| 166 | ); |
| 167 | } |