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.

167 linesCodeBlameRaw
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 */
7import { useFetcher, useSearchParams } from "react-router";
8
9import type { ReportState, VulnerabilityReport } from "@g1t/contracts";
10
11import type { Route } from "./+types/security-reports";
12import { page } from "../../lib/meta";
13import { Markdown } from "../../components/markdown";
14import { SeverityBadge } from "../../components/security";
15import { FilterSelect, SectionHeader } from "../../components/security-suite";
16import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
17import { Badge } from "../../components/ui/badge";
18import { Button } from "../../components/ui/button";
19import { Card } from "../../components/ui/card";
20import { Hint } from "../../components/ui/hint";
21import { Input } from "../../components/ui/input";
22import { advisories } from "../../lib/services.server";
23import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
24import { refusal, requireInsider } from "../../lib/access.server";
25import { isFailed, part } from "../../lib/partial";
26import { whyNot } from "../../lib/access";
27import { REPORT_STATES, REPORT_STATE_LABEL, REPORT_STATE_TONE, parseReportState } from "../../lib/advisories";
28
29export function meta({ params, ...args }: Route.MetaArgs) {
30 return page(args, { title: `Vulnerability reports · ${params.owner}/${params.repo} · g1t` });
31}
32
33export 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
51export 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
70function 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
120export 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}