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.

397 linesCodeBlameRaw
1/**
2 * A repository's Security overview: one row per feature, each with its
3 * status, a line on what it does and the one thing to do about it, in the
4 * groups of the Security page (policy, findings, reporting); the open
5 * alerts by severity and the scan's state below. Anyone who can see a
6 * public repository but has no role on it sees the public face instead:
7 * the policy, the published advisories and, when it is on, the way to
8 * report a vulnerability privately.
9 */
10import { ShieldCheck } from "lucide-react";
11import { Form, Link, redirect, useFetcher, useNavigation } from "react-router";
12
13import type { Advisory } from "@g1t/contracts";
14
15import type { Route } from "./+types/security-overview";
16import { page } from "../../lib/meta";
17import { ScanSummary, SeverityBadge, SeverityCountsGrid } from "../../components/security";
18import { SectionHeader } from "../../components/security-suite";
19import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
20import { Badge } from "../../components/ui/badge";
21import { Button } from "../../components/ui/button";
22import { Card } from "../../components/ui/card";
23import { Hint } from "../../components/ui/hint";
24import { Switch } from "../../components/ui/switch";
25import { advisories, repos, security, securitySuite } from "../../lib/services.server";
26import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
27import { refusal, requireRepo } from "../../lib/access.server";
28import { whyNot } from "../../lib/access";
29import { legacySecurityTarget, severityCounts } from "../../lib/security-suite";
30import { type OverviewRow, POLICY_PATHS, STATUS_LABEL, STATUS_TONE, overviewRows, rowsIn } from "../../lib/security-overview";
31import { SEVERITY_LABEL } from "../../lib/advisories";
32import { repoPages } from "../../lib/repo-live";
33import { useRepoLive } from "../../lib/use-repo-live";
34import { Unavailable } from "../../components/boundary";
35import { type Failed, isFailed, part } from "../../lib/partial";
36
37export function meta({ params, ...args }: Route.MetaArgs) {
38 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
39}
40
41/**
42 * The security policy on the default branch, if any: the first of the
43 * places it may be. When none was found but a place could not be read, it
44 * is not known to be missing: Failed, so the row does not say "None yet".
45 */
46async function policyPath(
47 path: { namespace: string; name: string },
48 viewer: ReturnType<typeof getViewer>,
49 defaultBranch: string,
50): Promise<string | null | Failed> {
51 const found = await Promise.all(POLICY_PATHS.map((file) => part(repos.blob(path, viewer, defaultBranch, file), "The security policy")));
52 const at = POLICY_PATHS.find((_, index) => {
53 const one = found[index];
54 return !isFailed(one) && one.ok;
55 });
56 return at ?? found.find(isFailed) ?? null;
57}
58
59export async function loader({ params, context, request }: Route.LoaderArgs) {
60 const base = `/${params.owner}/${params.repo}`;
61 // Links from before the sections (git's push refusals among them).
62 const moved = legacySecurityTarget(base, new URL(request.url).searchParams);
63 if (moved) throw redirect(moved);
64 const viewer = getViewer(context);
65 const { repo, access } = await requireRepo(context, params, "read");
66 const path = { namespace: params.owner, name: params.repo };
67 if (!access.insider || !access.can.security_alerts) {
68 // The public face: for anyone who can see the repository.
69 // Each is a row or a section of its own, which says so when it could not be read.
70 const [policy, published, reporting] = await Promise.all([
71 policyPath(path, viewer, repo.defaultBranch),
72 part(advisories.list(path, viewer, "published"), "Published advisories"),
73 part(advisories.reportSettings(path, viewer), "Private vulnerability reporting"),
74 ]);
75 return {
76 kind: "public" as const,
77 policy,
78 advisories: isFailed(published) ? published : published.ok ? published.value : [],
79 reporting: isFailed(reporting) ? reporting : reporting.ok && reporting.value.privateVulnerabilityReporting,
80 signedIn: Boolean(viewer),
81 };
82 }
83 // The alerts and the settings are what the overview is about; code
84 // scanning, the policy, the advisories and the reports are a row or a
85 // count each, which says so in its place when it could not be read.
86 const [overview, code, view, policy, published, reports] = await Promise.all([
87 security.overview(path, viewer),
88 part(securitySuite.codeScanning(path, viewer), "Code scanning"),
89 securitySuite.settings(path, viewer),
90 policyPath(path, viewer, repo.defaultBranch),
91 part(advisories.list(path, viewer, "published"), "Security advisories"),
92 part(advisories.reports(path, viewer, "new"), "New vulnerability reports"),
93 ]);
94 const settings = unwrap(view);
95 const scanning = isFailed(code) ? null : code;
96 // Rows whose facts could not be read, by key, and what they are.
97 const unavailable: Record<string, string> = {};
98 if (isFailed(code)) unavailable.code_scanning = code.what;
99 if (isFailed(policy)) unavailable.policy = policy.what;
100 if (isFailed(published)) unavailable.advisories = published.what;
101 return {
102 kind: "insider" as const,
103 overview: unwrap(overview),
104 code: scanning?.ok ? scanning.value : null,
105 codeFailed: isFailed(code),
106 rows: overviewRows({
107 base,
108 policyPath: isFailed(policy) ? null : policy,
109 reporting: settings.settings.privateVulnerabilityReporting,
110 advisories: !isFailed(published) && published.ok ? published.value.length : 0,
111 upkeep: settings.upkeep,
112 codeScanning: {
113 configured: scanning?.ok ? scanning.value.configured : false,
114 analyses: scanning?.ok ? scanning.value.analyses.length : 0,
115 entitled: settings.entitled,
116 },
117 historyAlerts: settings.settings.historyAlerts,
118 canManage: Boolean(access.can.manage_settings),
119 canPush: Boolean(access.can.push),
120 }),
121 unavailable,
122 newReports: isFailed(reports) ? reports : reports.ok ? reports.value.length : 0,
123 can: access.can,
124 };
125}
126
127/** The switches on the overview: security updates, and private vulnerability reporting. */
128export async function action({ params, context, request }: Route.ActionArgs) {
129 assertSameOrigin(request);
130 const user = requireUser(context, request);
131 const refused = await refusal(context, params, "manage_settings");
132 if (refused) return { ok: false, error: refused };
133 const path = { namespace: params.owner, name: params.repo };
134 const form = await request.formData();
135 const on = form.get("enabled") === "true";
136 switch (String(form.get("intent") ?? "")) {
137 case "upkeep": {
138 const set = await security.setUpkeep(user, path, on);
139 return set.ok ? { ok: true } : { ok: false, error: set.error.message };
140 }
141 case "reporting": {
142 const current = await securitySuite.settings(path, user);
143 if (!current.ok) return { ok: false, error: current.error.message };
144 const saved = await securitySuite.setSettings(user, path, { ...current.value.settings, privateVulnerabilityReporting: on });
145 return saved.ok ? { ok: true } : { ok: false, error: saved.error.message };
146 }
147 default:
148 return { ok: false, error: "Unknown action." };
149 }
150}
151
152function RowAction({ row, action, canManage }: { row: OverviewRow; action: string; canManage: boolean }) {
153 const fetcher = useFetcher<{ ok: boolean; error?: string }>();
154 const navigation = useNavigation();
155 const what = row.action;
156 if (!what) return null;
157 if (what.kind === "link") {
158 return (
159 <ButtonLink to={what.to} variant="outline" size="sm" prefetch="intent">
160 {what.label}
161 </ButtonLink>
162 );
163 }
164 if (what.kind === "post") {
165 const working = navigation.state !== "idle" && navigation.formAction === what.action;
166 return (
167 <Form method="post" action={what.action}>
168 <Button type="submit" size="sm" disabled={working}>
169 {working ? what.pending : what.label}
170 </Button>
171 </Form>
172 );
173 }
174 const on = fetcher.formData ? fetcher.formData.get("enabled") === "true" : what.on;
175 return (
176 <div className="flex flex-col items-end gap-1">
177 <Hint label={canManage ? undefined : whyNot(null, "manage_settings")} disabled={!canManage}>
178 <Switch
179 aria-label={what.label}
180 checked={on}
181 disabled={!canManage || fetcher.state !== "idle"}
182 onCheckedChange={(checked) => fetcher.submit({ intent: what.intent, enabled: String(checked) }, { method: "post", action })}
183 />
184 </Hint>
185 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
186 </div>
187 );
188}
189
190function FeatureRows({
191 title,
192 rows,
193 action,
194 canManage,
195 unavailable,
196}: {
197 title: string;
198 rows: OverviewRow[];
199 action: string;
200 canManage: boolean;
201 /** Rows whose facts could not be read: each says so in place of its status. */
202 unavailable: Record<string, string>;
203}) {
204 return (
205 <section aria-labelledby={`rows-${title}`} className="space-y-2">
206 <h3 id={`rows-${title}`} className="text-sm font-semibold">
207 {title}
208 </h3>
209 <Card asChild divided>
210 <ul>
211 {rows.map((row) =>
212 unavailable[row.key] ? (
213 <UnavailableRow key={row.key} title={row.title} what={unavailable[row.key]} />
214 ) : (
215 <li key={row.key} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
216 <div className="min-w-0 grow">
217 <div className="flex flex-wrap items-center gap-2">
218 <span className="text-sm font-medium">{row.title}</span>
219 <Hint label={row.hint}>
220 <Badge tone={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>
221 </Hint>
222 </div>
223 <p className="mt-1 text-sm text-muted">{row.about}</p>
224 </div>
225 <div className="shrink-0 sm:text-right">
226 <RowAction row={row} action={action} canManage={canManage} />
227 </div>
228 </li>
229 ),
230 )}
231 </ul>
232 </Card>
233 </section>
234 );
235}
236
237/** A row whose status could not be read: its title, and that, in place of a status that would be a guess. */
238function UnavailableRow({ title, what }: { title: string; what: string }) {
239 return (
240 <li className="space-y-1 p-4">
241 <span className="text-sm font-medium">{title}</span>
242 <Unavailable what={what} compact />
243 </li>
244 );
245}
246
247function AdvisoryLine({ advisory, base }: { advisory: Advisory; base: string }) {
248 return (
249 <li className="flex items-center gap-3 px-4 py-3">
250 <SeverityBadge severity={advisory.severity} />
251 <Link to={`${base}/security/advisories/${advisory.g1tsaId}`} className="min-w-0 grow truncate text-sm font-medium hover:underline">
252 {advisory.title}
253 </Link>
254 <span className="shrink-0 font-mono text-xs text-faint">{advisory.g1tsaId}</span>
255 {advisory.publishedAt && (
256 <span className="hidden shrink-0 text-xs text-muted sm:inline">
257 <TimeAgo at={advisory.publishedAt} />
258 </span>
259 )}
260 </li>
261 );
262}
263
264export default function SecurityOverview({ loaderData, params }: Route.ComponentProps) {
265 const base = `/${params.owner}/${params.repo}`;
266 // Alerts opening, fixed and dismissed show as it happens.
267 useRepoLive({ match: repoPages.security() });
268 if (loaderData.kind === "public") {
269 const { policy, advisories: published, reporting, signedIn } = loaderData;
270 return (
271 <div className="max-w-5xl space-y-8">
272 <SectionHeader
273 title="Security"
274 about="How to report a vulnerability in this repository, and what its maintainers have published about vulnerabilities in what it ships."
275 actions={
276 reporting === true ? (
277 <ButtonLink to={`${base}/security/report`} prefetch="intent">
278 Report a vulnerability
279 </ButtonLink>
280 ) : null
281 }
282 />
283 <Card asChild divided>
284 <ul>
285 {isFailed(policy) ? (
286 <UnavailableRow title="Security policy" what={policy.what} />
287 ) : (
288 <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
289 <div className="min-w-0 grow">
290 <div className="flex flex-wrap items-center gap-2">
291 <span className="text-sm font-medium">Security policy</span>
292 <Badge tone={policy ? "success" : "neutral"}>{policy ? "Published" : "None yet"}</Badge>
293 </div>
294 <p className="mt-1 text-sm text-muted">
295 {policy
296 ? `${policy} says how to report a vulnerability and which versions are supported.`
297 : "This repository has not published a security policy."}
298 </p>
299 </div>
300 {policy && (
301 <ButtonLink to={`${base}/blob/HEAD/${policy}`} variant="outline" size="sm">
302 View policy
303 </ButtonLink>
304 )}
305 </li>
306 )}
307 {isFailed(reporting) ? (
308 <UnavailableRow title="Private vulnerability reporting" what={reporting.what} />
309 ) : (
310 <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
311 <div className="min-w-0 grow">
312 <div className="flex flex-wrap items-center gap-2">
313 <span className="text-sm font-medium">Private vulnerability reporting</span>
314 <Badge tone={reporting ? "success" : "neutral"}>{reporting ? "Enabled" : "Disabled"}</Badge>
315 </div>
316 <p className="mt-1 text-sm text-muted">
317 {reporting
318 ? signedIn
319 ? "Tell the maintainers privately, under your name. Only they see the report."
320 : "Tell the maintainers privately. You can report without an account; leave an email so they can answer you."
321 : "This repository does not take private reports here. Its security policy, if any, says how to reach the maintainers."}
322 </p>
323 </div>
324 </li>
325 )}
326 </ul>
327 </Card>
328 <section aria-labelledby="public-advisories" className="space-y-2">
329 <div className="flex items-center justify-between gap-4">
330 <h3 id="public-advisories" className="text-sm font-semibold">
331 Security advisories
332 </h3>
333 {!isFailed(published) && published.length > 0 && (
334 <Link to={`${base}/security/advisories`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
335 All advisories
336 </Link>
337 )}
338 </div>
339 {isFailed(published) ? (
340 <Unavailable what={published.what} />
341 ) : published.length === 0 ? (
342 <EmptyState title="No published advisories">The maintainers have not published any security advisories for this repository.</EmptyState>
343 ) : (
344 <Card asChild divided>
345 <ul>
346 {published.slice(0, 5).map((advisory) => (
347 <AdvisoryLine key={advisory.id} advisory={advisory} base={base} />
348 ))}
349 </ul>
350 </Card>
351 )}
352 </section>
353 </div>
354 );
355 }
356 const { overview, code, codeFailed, rows, unavailable, newReports, can } = loaderData;
357 const codeOpen = code ? severityCounts(code.alerts) : null;
358 // Open alerts of every kind, by severity.
359 const counts = { ...overview.counts };
360 if (codeOpen) for (const key of Object.keys(counts) as (keyof typeof counts)[]) counts[key] += codeOpen[key];
361 const action = `${base}/security`;
362 const canManage = Boolean(can.manage_settings);
363 return (
364 <div className="max-w-5xl space-y-8">
365 <SectionHeader
366 title="Security"
367 about="What keeps this repository safe, and where each part stands. Pushes that add a secret are refused before they land, every package the lockfiles resolve is checked for known vulnerabilities, code scanning reads your tools' results, and the maintainers publish advisories and take private reports."
368 actions={
369 isFailed(newReports) ? (
370 <Unavailable what={newReports.what} compact />
371 ) : newReports > 0 ? (
372 <ButtonLink to={`${base}/security/reports`} variant="outline" size="sm" prefetch="intent">
373 {newReports === 1 ? "1 new vulnerability report" : `${newReports} new vulnerability reports`}
374 </ButtonLink>
375 ) : null
376 }
377 />
378 <FeatureRows title="Policy" rows={rowsIn(rows, "policy")} action={action} canManage={canManage} unavailable={unavailable} />
379 <FeatureRows title="Findings" rows={rowsIn(rows, "findings")} action={action} canManage={canManage} unavailable={unavailable} />
380 <FeatureRows title="Reporting" rows={rowsIn(rows, "reporting")} action={action} canManage={canManage} unavailable={unavailable} />
381 <section aria-labelledby="open-alerts" className="space-y-3">
382 <h3 id="open-alerts" className="flex items-center gap-2 text-sm font-semibold">
383 <ShieldCheck size={15} className="text-accent" />
384 Open alerts
385 </h3>
386 <SeverityCountsGrid counts={counts} />
387 {/* Without code scanning's alerts, the counts above are of the rest. */}
388 {codeFailed && <Unavailable what="Code scanning alerts" compact />}
389 <p className="text-xs text-faint">
390 Open alerts of every kind by severity ({SEVERITY_LABEL.critical.toLowerCase()} to {SEVERITY_LABEL.low.toLowerCase()}). A secret in the history that
391 looks real counts as critical; blocked pushes and likely test values do not.
392 </p>
393 <ScanSummary scan={overview.scan} />
394 </section>
395 </div>
396 );
397}