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.

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