| 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 { whyNot } from "../../lib/access"; |
| 26 | import { REPORT_STATES, REPORT_STATE_LABEL, REPORT_STATE_TONE, parseReportState } from "../../lib/advisories"; |
| 27 | |
| 28 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 29 | return page(args, { title: `Vulnerability reports · ${params.owner}/${params.repo} · g1t` }); |
| 30 | } |
| 31 | |
| 32 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 33 | const viewer = getViewer(context) ?? requireUser(context, request); |
| 34 | const { access } = await requireInsider(context, params, "security_alerts"); |
| 35 | const path = { namespace: params.owner, name: params.repo }; |
| 36 | const [reports, settings] = await Promise.all([advisories.reports(path, viewer), advisories.reportSettings(path, viewer)]); |
| 37 | return { reports: unwrap(reports), reporting: settings.ok && settings.value.privateVulnerabilityReporting, can: access.can }; |
| 38 | } |
| 39 | |
| 40 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 41 | assertSameOrigin(request); |
| 42 | const user = requireUser(context, request); |
| 43 | const refused = await refusal(context, params, "manage_security"); |
| 44 | if (refused) return { ok: false, error: refused }; |
| 45 | const form = await request.formData(); |
| 46 | const decision = String(form.get("decision") ?? ""); |
| 47 | if (decision !== "accept" && decision !== "decline") return { ok: false, error: "Nothing to do." }; |
| 48 | const reviewed = await advisories.review(user, { namespace: params.owner, name: params.repo }, String(form.get("id") ?? ""), decision, String(form.get("note") ?? "").slice(0, 1000)); |
| 49 | if (!reviewed.ok) return { ok: false, error: reviewed.error.message }; |
| 50 | return { ok: true, advisory: reviewed.value.advisory?.g1tsaId ?? null }; |
| 51 | } |
| 52 | |
| 53 | function ReportCard({ report, base, canReview, action }: { report: VulnerabilityReport; base: string; canReview: boolean; action: string }) { |
| 54 | const fetcher = useFetcher<{ ok: boolean; error?: string; advisory?: string | null }>(); |
| 55 | const working = fetcher.state !== "idle"; |
| 56 | return ( |
| 57 | <Card asChild> |
| 58 | <article className="space-y-3 p-4"> |
| 59 | <div className="flex flex-wrap items-center gap-2"> |
| 60 | {report.severity && <SeverityBadge severity={report.severity} />} |
| 61 | <h3 className="min-w-0 text-sm font-medium">{report.title}</h3> |
| 62 | <Badge tone={REPORT_STATE_TONE[report.state]}>{REPORT_STATE_LABEL[report.state]}</Badge> |
| 63 | </div> |
| 64 | <p className="text-xs text-muted"> |
| 65 | From {report.reporter ?? report.reporterEmail ?? "someone"}, <TimeAgo at={report.createdAt} /> |
| 66 | {report.reviewedBy && report.reviewedAt && ( |
| 67 | <> |
| 68 | {" · "} |
| 69 | {report.state} by {report.reviewedBy} <TimeAgo at={report.reviewedAt} /> |
| 70 | </> |
| 71 | )} |
| 72 | {report.reviewNote && <> · {report.reviewNote}</>} |
| 73 | </p> |
| 74 | <Markdown source={report.description} /> |
| 75 | {report.state === "new" && canReview ? ( |
| 76 | <fetcher.Form method="post" action={action} className="flex flex-wrap items-center gap-2 pt-1"> |
| 77 | <input type="hidden" name="id" value={report.id} /> |
| 78 | <Input name="note" placeholder="A note for the record (optional)" maxLength={1000} className="h-8 w-full sm:w-72" /> |
| 79 | <Button type="submit" name="decision" value="accept" size="sm" disabled={working}> |
| 80 | {working ? "…" : "Accept as a draft advisory"} |
| 81 | </Button> |
| 82 | <Button type="submit" name="decision" value="decline" size="sm" variant="outline" disabled={working}> |
| 83 | Decline |
| 84 | </Button> |
| 85 | {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>} |
| 86 | </fetcher.Form> |
| 87 | ) : report.state === "new" ? ( |
| 88 | <Hint label={whyNot(null, "manage_security")} disabled> |
| 89 | <Button size="sm" disabled> |
| 90 | Accept as a draft advisory |
| 91 | </Button> |
| 92 | </Hint> |
| 93 | ) : report.advisoryId ? ( |
| 94 | <ButtonLink to={`${base}/security/advisories/${report.advisoryId}`} variant="outline" size="sm"> |
| 95 | Open the advisory |
| 96 | </ButtonLink> |
| 97 | ) : null} |
| 98 | </article> |
| 99 | </Card> |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | export default function SecurityReports({ loaderData, params }: Route.ComponentProps) { |
| 104 | const { reports, reporting, can } = loaderData; |
| 105 | const base = `/${params.owner}/${params.repo}`; |
| 106 | const [search, setSearch] = useSearchParams(); |
| 107 | const state: ReportState | null = parseReportState(search.get("state")); |
| 108 | const shown = state ? reports.filter((report) => report.state === state) : reports; |
| 109 | return ( |
| 110 | <div className="max-w-4xl space-y-6"> |
| 111 | <SectionHeader |
| 112 | title="Vulnerability reports" |
| 113 | about={ |
| 114 | reporting |
| 115 | ? "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." |
| 116 | : "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." |
| 117 | } |
| 118 | actions={ |
| 119 | <ButtonLink to={`${base}/security/advisories`} variant="outline" size="sm" prefetch="intent"> |
| 120 | Advisories |
| 121 | </ButtonLink> |
| 122 | } |
| 123 | /> |
| 124 | <div className="flex flex-wrap items-center gap-2"> |
| 125 | <FilterSelect |
| 126 | label="State" |
| 127 | value={state ?? "all"} |
| 128 | options={[["all", "Any state"], ...REPORT_STATES.map((each): [string, string] => [each, REPORT_STATE_LABEL[each]])]} |
| 129 | onChange={(value) => { |
| 130 | const next = new URLSearchParams(search); |
| 131 | if (value !== "all") next.set("state", value); |
| 132 | else next.delete("state"); |
| 133 | setSearch(next, { replace: true, preventScrollReset: true }); |
| 134 | }} |
| 135 | /> |
| 136 | </div> |
| 137 | {shown.length === 0 ? ( |
| 138 | <EmptyState title={state ? `No ${REPORT_STATE_LABEL[state].toLowerCase()} reports` : "No reports"}> |
| 139 | {reporting ? "Nobody has reported a vulnerability privately yet." : "Nothing was reported while private reporting was on."} |
| 140 | </EmptyState> |
| 141 | ) : ( |
| 142 | <div className="space-y-3"> |
| 143 | {shown.map((report) => ( |
| 144 | <ReportCard key={report.id} report={report} base={base} canReview={Boolean(can.manage_security)} action={`${base}/security/reports`} /> |
| 145 | ))} |
| 146 | </div> |
| 147 | )} |
| 148 | </div> |
| 149 | ); |
| 150 | } |