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.

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