g1t/apps/web/app/routes/repo/security.tsx

265 lines12,238 bytesCodeBlame
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 SecretsList,
12 SeverityCountsGrid,
13 StateFilter,
14 type UpgradeFix,
15 VersionUpdatesCard,
16 VulnerabilityList,
17} from "../../components/security";
18import { Switch } from "../../components/ui/switch";
19import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
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 { alertCapability, countByState, parseAlertState, tabOf } from "../../lib/security-alerts";
25
26export function meta({ params, ...args }: Route.MetaArgs) {
27 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
28}
29
30/** Upgrade issues and security update pull requests looked up per page, at most. */
31const MAX_FIXES = 30;
32
33export async function loader({ params, context, request }: Route.LoaderArgs) {
34 // Git's refusal links here; someone signed out signs in first.
35 const viewer = getViewer(context) ?? requireUser(context, request);
36 // Findings are for people who can push, public project or not: Write and up.
37 const { access } = await requireInsider(context, params, "push");
38 const repo = { namespace: params.owner, name: params.repo };
39 const overview = unwrap(await security.overview(repo, viewer));
40 const unique = (numbers: (number | null | undefined)[]) =>
41 [...new Set(numbers.filter((n): n is number => n != null))].slice(0, MAX_FIXES);
42 // Security updates' pull requests, for their live status.
43 const pullNumbers = unique(overview.vulnerabilities.map((vuln) => vuln.update?.pull));
44 // Older upgrade issues, from before g1t opened pull requests itself.
45 const issueNumbers = unique(overview.vulnerabilities.filter((vuln) => !vuln.update).map((vuln) => vuln.issue));
46 const [pullDetails, issueDetails] = await Promise.all([
47 Promise.all(pullNumbers.map((number) => work.getPull(repo, number, viewer).catch(() => null))),
48 Promise.all(issueNumbers.map((number) => work.getIssue(repo, number, viewer).catch(() => null))),
49 ]);
50 const pulls: Record<number, PullInfo> = {};
51 for (const found of pullDetails) {
52 if (!found?.ok) continue;
53 const { pull } = found.value;
54 pulls[pull.number] = { number: pull.number, status: pull.status, title: pull.title };
55 }
56 const fixes: Record<number, UpgradeFix> = {};
57 for (const found of issueDetails) {
58 if (!found?.ok) continue;
59 const { issue, pulls: issuePulls } = found.value;
60 const latest = issuePulls.at(-1) ?? null;
61 fixes[issue.number] = {
62 number: issue.number,
63 state: issue.state,
64 resolvedBy: issue.resolvedBy,
65 pull: latest ? { number: latest.number, status: latest.status, agent: latest.runtime === "hosted" ? latest.agent : null } : null,
66 };
67 }
68 return { overview, fixes, pulls, can: access.can };
69}
70
71const DISMISS_REASONS = new Set<string>([
72 "false_positive",
73 "used_in_tests",
74 "revoked",
75 "wont_fix",
76 "fix_started",
77 "no_bandwidth",
78 "tolerable_risk",
79 "inaccurate",
80 "not_used",
81]);
82
83export async function action({ params, context, request }: Route.ActionArgs) {
84 assertSameOrigin(request);
85 const user = requireUser(context, request);
86 const repo = { namespace: params.owner, name: params.repo };
87 const form = await request.formData();
88 const intent = String(form.get("intent") ?? "");
89 const id = String(form.get("id") ?? "");
90 // Scanning spends compute (Write); security updates are a setting
91 // (Maintain); a secret alert is dismissed or reopened by Admins, who
92 // manage the repository's secrets, and a dependency alert by Write.
93 const capability =
94 intent === "rescan" ? "run" : intent === "upkeep" ? "manage_settings" : intent === "dismiss" || intent === "reopen" ? alertCapability(id) : null;
95 if (!capability) return { ok: false, error: "Unknown action." };
96 const refused = await refusal(context, params, capability);
97 if (refused) return { ok: false, error: refused };
98 if (intent === "dismiss") {
99 const reason = String(form.get("reason") ?? "");
100 if (!DISMISS_REASONS.has(reason)) return { ok: false, error: "Choose a reason." };
101 const comment = String(form.get("comment") ?? "").trim().slice(0, 500);
102 const dismissed = await security.dismiss(user, repo, id, reason as DismissReason, comment);
103 return dismissed.ok ? { ok: true } : { ok: false, error: dismissed.error.message };
104 }
105 if (intent === "reopen") {
106 const reopened = await security.reopen(user, repo, id);
107 return reopened.ok ? { ok: true } : { ok: false, error: reopened.error.message };
108 }
109 if (intent === "rescan") {
110 const scanned = await security.rescan(user, repo);
111 return scanned.ok ? { ok: true } : { ok: false, error: scanned.error.message };
112 }
113 const set = await security.setUpkeep(user, repo, form.get("enabled") === "true");
114 return set.ok ? { ok: true } : { ok: false, error: set.error.message };
115}
116
117function Count({ n }: { n: number }) {
118 return n > 0 ? <span className="ml-1.5 rounded-full bg-line px-1.5 py-px text-[0.6875rem] tabular-nums">{n}</span> : null;
119}
120
121export default function ProjectSecurity({ loaderData, params }: Route.ComponentProps) {
122 const { overview, fixes, pulls, can } = loaderData;
123 const base = `/${params.owner}/${params.repo}`;
124 const action = `${base}/security`;
125 const [search, setSearch] = useSearchParams();
126 const rescan = useFetcher<{ ok: boolean; error?: string }>();
127 const upkeep = useFetcher<{ ok: boolean; error?: string }>();
128 const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : overview.upkeep;
129 const secretCounts = countByState(overview.secrets);
130 const vulnCounts = countByState(overview.vulnerabilities);
131 // A link to one alert (git's push refusal sends one) shows it wherever it stands.
132 const focus = search.get("finding");
133 const focused = focus
134 ? (overview.secrets.find((secret) => secret.id === focus) ?? overview.vulnerabilities.find((vuln) => vuln.id === focus))
135 : undefined;
136 const tab =
137 search.get("tab") ??
138 (focus ? tabOf(focus) : secretCounts.open > 0 && vulnCounts.open === 0 ? "secrets" : vulnCounts.open > 0 ? "dependencies" : "secrets");
139 const state: AlertState = search.get("state") ? parseAlertState(search.get("state")) : (focused?.state ?? "open");
140 const navigate = (change: (next: URLSearchParams) => void) => {
141 const next = new URLSearchParams(search);
142 change(next);
143 next.delete("finding");
144 setSearch(next, { replace: true, preventScrollReset: true });
145 };
146 const setState = (value: AlertState) =>
147 navigate((next) => (value === "open" ? next.delete("state") : next.set("state", value)));
148 return (
149 <div className="max-w-5xl">
150 <div className="flex flex-wrap items-start justify-between gap-4">
151 <div>
152 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
153 <ShieldCheck size={19} className="text-accent" />
154 Security
155 </h2>
156 <p className="mt-1.5 max-w-2xl text-sm text-muted">
157 Pushes that add a secret are refused before they land, and the history is scanned once. Every package the
158 lockfiles resolve is checked for known vulnerabilities, and with security updates on, g1t opens a pull request to
159 upgrade each one that has a fix.
160 </p>
161 </div>
162 {can.run && (
163 <rescan.Form method="post" action={action}>
164 <input type="hidden" name="intent" value="rescan" />
165 <button
166 type="submit"
167 disabled={rescan.state !== "idle"}
168 className="inline-flex items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-50"
169 >
170 <RefreshCw size={14} className={rescan.state !== "idle" ? "animate-spin" : ""} />
171 {rescan.state !== "idle" ? "Scanning…" : "Re-scan now"}
172 </button>
173 {rescan.data?.error && <p className="mt-1.5 text-xs text-danger">{rescan.data.error}</p>}
174 </rescan.Form>
175 )}
176 </div>
177
178 <div className="mt-6">
179 <SeverityCountsGrid counts={overview.counts} />
180 <p className="mt-2 text-xs text-faint">
181 Open alerts by severity. A secret in the history that looks real counts as critical; blocked pushes and likely test
182 values do not.
183 </p>
184 </div>
185
186 <div className="mt-4">
187 <ScanSummary scan={overview.scan} />
188 </div>
189
190 <Tabs value={tab} onValueChange={(value) => navigate((next) => next.set("tab", value))} className="mt-8">
191 <TabsList>
192 <TabsTrigger value="secrets">
193 Secrets
194 <Count n={secretCounts.open} />
195 </TabsTrigger>
196 <TabsTrigger value="dependencies">
197 Dependencies
198 <Count n={vulnCounts.open} />
199 </TabsTrigger>
200 </TabsList>
201 <TabsContent value="secrets" className="mt-4">
202 <StateFilter counts={secretCounts} value={state} onChange={setState} />
203 <div className="mt-3">
204 <SecretsList
205 secrets={overview.secrets.filter((secret) => secret.state === state)}
206 state={state}
207 activity={overview.activity}
208 base={base}
209 action={action}
210 focus={focus}
211 canDismiss={can.manage_integrations}
212 />
213 </div>
214 <p className="mt-3 text-xs text-faint">
215 Not a real secret, such as a test fixture? Add <code>g1t:allow-secret</code> in a comment on its line, or dismiss the
216 alert here. Dismissing takes the Admin role and is recorded with your name and reason.
217 </p>
218 </TabsContent>
219 <TabsContent value="dependencies" className="mt-4">
220 <StateFilter counts={vulnCounts} value={state} onChange={setState} />
221 <div className="mt-3">
222 <VulnerabilityList
223 vulnerabilities={overview.vulnerabilities.filter((vuln) => vuln.state === state)}
224 state={state}
225 activity={overview.activity}
226 fixes={fixes}
227 pulls={pulls}
228 upkeep={overview.upkeep}
229 base={base}
230 action={action}
231 focus={focus}
232 canDismiss={can.push}
233 />
234 </div>
235 </TabsContent>
236 </Tabs>
237
238 <section className="mt-10" aria-labelledby="security-settings">
239 <h3 id="security-settings" className="text-base font-semibold tracking-tight">
240 Settings
241 </h3>
242 <div className="mt-3 space-y-3">
243 <label className="flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
244 <span className="min-w-0">
245 <span className="block text-sm font-medium">Security updates</span>
246 <span className="mt-1 block text-sm text-muted">
247 Open a pull request to upgrade each vulnerable dependency that has a fix. It lands through your branch's
248 required checks.
249 </span>
250 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
251 </span>
252 <Switch
253 className="mt-0.5"
254 checked={upkeepOn}
255 disabled={upkeep.state !== "idle" || !can.manage_settings}
256 title={whyNot(can, "manage_settings")}
257 onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })}
258 />
259 </label>
260 <VersionUpdatesCard state={overview.versionUpdates} />
261 </div>
262 </section>
263 </div>
264 );
265}