Skip to content
236 linesCodeBlameRaw
1import { RefreshCw, ShieldCheck } from "lucide-react";
2import { useFetcher, useSearchParams } from "react-router";
3
4import type { AlertState, DismissReason } from "@g1t/contracts";
5
6import type { Route } from "./+types/security";
7import { page } from "../../lib/meta";
8import {
9 type PullInfo,
10 ScanSummary,
11 SeverityCountsGrid,
12 StateFilter,
13 type UpgradeFix,
14 VulnerabilityList,
15} from "../../components/security";
16import { Button } from "../../components/ui/button";
17import { Card } from "../../components/ui/card";
18import { Hint } from "../../components/ui/hint";
19import { Switch } from "../../components/ui/switch";
20import { security, work } from "../../lib/services.server";
21import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
22import { refusal, requireInsider } from "../../lib/access.server";
23import { whyNot } from "../../lib/access";
24import { useRepoMirror } from "../../components/mirror";
25import { updatesWaitReason } from "../../lib/mirror";
26import { alertCapability, countByState, parseAlertState } from "../../lib/security-alerts";
27import { severityCounts } from "../../lib/security-suite";
28
29export function meta({ params, ...args }: Route.MetaArgs) {
30 return page(args, { title: `Vulnerabilities · ${params.owner}/${params.repo} · g1t` });
31}
32
33/** Upgrade issues and security update pull requests looked up per page, at most. */
34const MAX_FIXES = 30;
35
36export async function loader({ params, context, request }: Route.LoaderArgs) {
37 // Git's refusal links here; someone signed out signs in first.
38 const viewer = getViewer(context) ?? requireUser(context, request);
39 // Findings are for people who can push, public project or not: Write and up.
40 const { access } = await requireInsider(context, params, "security_alerts");
41 const repo = { namespace: params.owner, name: params.repo };
42 const overview = unwrap(await security.overview(repo, viewer));
43 const unique = (numbers: (number | null | undefined)[]) =>
44 [...new Set(numbers.filter((n): n is number => n != null))].slice(0, MAX_FIXES);
45 // Security updates' pull requests, for their live status.
46 const pullNumbers = unique(overview.vulnerabilities.map((vuln) => vuln.update?.pull));
47 // Older upgrade issues, from before g1t opened pull requests itself.
48 const issueNumbers = unique(overview.vulnerabilities.filter((vuln) => !vuln.update).map((vuln) => vuln.issue));
49 const [pullDetails, issueDetails] = await Promise.all([
50 Promise.all(pullNumbers.map((number) => work.getPull(repo, number, viewer).catch(() => null))),
51 Promise.all(issueNumbers.map((number) => work.getIssue(repo, number, viewer).catch(() => null))),
52 ]);
53 const pulls: Record<number, PullInfo> = {};
54 for (const found of pullDetails) {
55 if (!found?.ok) continue;
56 const { pull } = found.value;
57 pulls[pull.number] = { number: pull.number, status: pull.status, title: pull.title };
58 }
59 const fixes: Record<number, UpgradeFix> = {};
60 for (const found of issueDetails) {
61 if (!found?.ok) continue;
62 const { issue, pulls: issuePulls } = found.value;
63 const latest = issuePulls.at(-1) ?? null;
64 fixes[issue.number] = {
65 number: issue.number,
66 state: issue.state,
67 resolvedBy: issue.resolvedBy,
68 pull: latest ? { number: latest.number, status: latest.status, agent: latest.runtime === "hosted" ? latest.agent : null } : null,
69 };
70 }
71 return { overview, fixes, pulls, can: access.can };
72}
73
74const DISMISS_REASONS = new Set<string>([
75 "false_positive",
76 "used_in_tests",
77 "revoked",
78 "wont_fix",
79 "fix_started",
80 "no_bandwidth",
81 "tolerable_risk",
82 "inaccurate",
83 "not_used",
84]);
85
86export async function action({ params, context, request }: Route.ActionArgs) {
87 assertSameOrigin(request);
88 const user = requireUser(context, request);
89 const repo = { namespace: params.owner, name: params.repo };
90 const form = await request.formData();
91 const intent = String(form.get("intent") ?? "");
92 const id = String(form.get("id") ?? "");
93 // Scanning spends compute (Write); security updates are a setting
94 // (Maintain); an alert is dismissed or reopened with Write, or by a
95 // security manager of the workspace.
96 const capability =
97 intent === "rescan" ? "run" : intent === "upkeep" ? "manage_settings" : intent === "dismiss" || intent === "reopen" ? alertCapability(id) : null;
98 if (!capability) return { ok: false, error: "Unknown action." };
99 const refused = await refusal(context, params, capability);
100 if (refused) return { ok: false, error: refused };
101 if (intent === "dismiss") {
102 const reason = String(form.get("reason") ?? "");
103 if (!DISMISS_REASONS.has(reason)) return { ok: false, error: "Choose a reason." };
104 const comment = String(form.get("comment") ?? "").trim().slice(0, 500);
105 const dismissed = await security.dismiss(user, repo, id, reason as DismissReason, comment);
106 return dismissed.ok ? { ok: true } : { ok: false, error: dismissed.error.message };
107 }
108 if (intent === "reopen") {
109 const reopened = await security.reopen(user, repo, id);
110 return reopened.ok ? { ok: true } : { ok: false, error: reopened.error.message };
111 }
112 if (intent === "rescan") {
113 const scanned = await security.rescan(user, repo);
114 return scanned.ok ? { ok: true } : { ok: false, error: scanned.error.message };
115 }
116 const set = await security.setUpkeep(user, repo, form.get("enabled") === "true");
117 return set.ok ? { ok: true } : { ok: false, error: set.error.message };
118}
119
120export default function ProjectSecurity({ loaderData, params }: Route.ComponentProps) {
121 const { overview, fixes, pulls, can } = loaderData;
122 const base = `/${params.owner}/${params.repo}`;
123 const action = `${base}/security/vulnerabilities`;
124 const [search, setSearch] = useSearchParams();
125 const rescan = useFetcher<{ ok: boolean; error?: string }>();
126 const upkeep = useFetcher<{ ok: boolean; error?: string }>();
127 const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : overview.upkeep;
128 // A mirror g1t does not lead opens nothing on its own: the scan runs and
129 // the alerts show, but each update waits (lib/mirror.ts `offers`).
130 const updatesWait = updatesWaitReason(useRepoMirror().repo);
131 const vulnCounts = countByState(overview.vulnerabilities);
132 // A link to one alert shows it wherever it stands.
133 const focus = search.get("finding");
134 const focused = focus ? overview.vulnerabilities.find((vuln) => vuln.id === focus) : undefined;
135 const state: AlertState = search.get("state") ? parseAlertState(search.get("state")) : (focused?.state ?? "open");
136 const navigate = (change: (next: URLSearchParams) => void) => {
137 const next = new URLSearchParams(search);
138 change(next);
139 next.delete("finding");
140 setSearch(next, { replace: true, preventScrollReset: true });
141 };
142 const setState = (value: AlertState) =>
143 navigate((next) => (value === "open" ? next.delete("state") : next.set("state", value)));
144 return (
145 <div className="max-w-5xl">
146 <div className="flex flex-wrap items-start justify-between gap-4">
147 <div>
148 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
149 <ShieldCheck size={19} className="text-accent" />
150 Vulnerabilities
151 </h2>
152 <p className="mt-1.5 max-w-2xl text-sm text-muted">
153 Every package the lockfiles resolve is checked for known vulnerabilities, on every push to the default branch and
154 daily.{" "}
155 {updatesWait ?? "With security updates on, g1t opens a pull request to upgrade each one that has a fix."}
156 </p>
157 </div>
158 {can.run && (
159 <rescan.Form method="post" action={action}>
160 <input type="hidden" name="intent" value="rescan" />
161 <Button
162 type="submit"
163 disabled={rescan.state !== "idle"}
164 variant="outline"
165 size="sm"
166 className="gap-2 px-3 text-sm font-normal"
167 >
168 <RefreshCw size={14} className={rescan.state !== "idle" ? "animate-spin" : ""} />
169 {rescan.state !== "idle" ? "Scanning…" : "Re-scan now"}
170 </Button>
171 {rescan.data?.error && <p className="mt-1.5 text-xs text-danger">{rescan.data.error}</p>}
172 </rescan.Form>
173 )}
174 </div>
175
176 <div className="mt-6">
177 <SeverityCountsGrid counts={severityCounts(overview.vulnerabilities)} />
178 <p className="mt-2 text-xs text-faint">Open vulnerability alerts by severity.</p>
179 </div>
180
181 <div className="mt-4">
182 <ScanSummary scan={overview.scan} />
183 </div>
184
185 <div className="mt-8">
186 <div>
187 <StateFilter counts={vulnCounts} value={state} onChange={setState} />
188 <div className="mt-3">
189 <VulnerabilityList
190 vulnerabilities={overview.vulnerabilities.filter((vuln) => vuln.state === state)}
191 state={state}
192 activity={overview.activity}
193 fixes={fixes}
194 pulls={pulls}
195 upkeep={overview.upkeep}
196 updatesWait={updatesWait}
197 base={base}
198 action={action}
199 focus={focus}
200 canDismiss={can.push}
201 />
202 </div>
203 </div>
204 </div>
205
206 <section className="mt-10" aria-labelledby="security-settings">
207 <h3 id="security-settings" className="text-base font-semibold tracking-tight">
208 Settings
209 </h3>
210 <div className="mt-3 space-y-3">
211 <Card asChild className="flex cursor-pointer items-start justify-between gap-4 p-4 transition-colors hover:border-line-strong">
212 <label>
213 <span className="min-w-0">
214 <span className="block text-sm font-medium">Security updates</span>
215 <span className="mt-1 block text-sm text-muted">
216 Open a pull request to upgrade each vulnerable dependency that has a fix. It lands through your branch's
217 required checks.
218 {updatesWait && " On this mirror, it takes effect once the repository is taken over or moved to g1t."}
219 </span>
220 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
221 </span>
222 <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}>
223 <Switch
224 className="mt-0.5"
225 checked={upkeepOn}
226 disabled={upkeep.state !== "idle" || !can.manage_settings}
227 onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })}
228 />
229 </Hint>
230 </label>
231 </Card>
232 </div>
233 </section>
234 </div>
235 );
236}