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.

g1t.sh
150 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 { whyNot } from "../../lib/access";
26import { REPORT_STATES, REPORT_STATE_LABEL, REPORT_STATE_TONE, parseReportState } from "../../lib/advisories";
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return page(args, { title: `Vulnerability reports · ${params.owner}/${params.repo} · g1t` });
30}
31
32export 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
40export 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
53function 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
103export 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}