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.

324 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";
34
35export function meta({ params, ...args }: Route.MetaArgs) {
36 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
37}
38
39/** The security policy on the default branch, if any: the first of the places it may be. */
40async function policyPath(path: { namespace: string; name: string }, viewer: ReturnType<typeof getViewer>, defaultBranch: string): Promise<string | null> {
41 const found = await Promise.all(POLICY_PATHS.map((file) => repos.blob(path, viewer, defaultBranch, file).catch(() => null)));
42 return POLICY_PATHS.find((_, at) => found[at]?.ok) ?? null;
43}
44
45export async function loader({ params, context, request }: Route.LoaderArgs) {
46 const base = `/${params.owner}/${params.repo}`;
47 // Links from before the sections (git's push refusals among them).
48 const moved = legacySecurityTarget(base, new URL(request.url).searchParams);
49 if (moved) throw redirect(moved);
50 const viewer = getViewer(context);
51 const { repo, access } = await requireRepo(context, params, "read");
52 const path = { namespace: params.owner, name: params.repo };
53 if (!access.insider || !access.can.security_alerts) {
54 // The public face: for anyone who can see the repository.
55 const [policy, published, reporting] = await Promise.all([
56 policyPath(path, viewer, repo.defaultBranch),
57 advisories.list(path, viewer, "published"),
58 advisories.reportSettings(path, viewer),
59 ]);
60 return {
61 kind: "public" as const,
62 policy,
63 advisories: published.ok ? published.value : [],
64 reporting: reporting.ok && reporting.value.privateVulnerabilityReporting,
65 signedIn: Boolean(viewer),
66 };
67 }
68 const [overview, code, view, policy, published, reports] = await Promise.all([
69 security.overview(path, viewer),
70 securitySuite.codeScanning(path, viewer),
71 securitySuite.settings(path, viewer),
72 policyPath(path, viewer, repo.defaultBranch),
73 advisories.list(path, viewer, "published"),
74 advisories.reports(path, viewer, "new"),
75 ]);
76 const settings = unwrap(view);
77 return {
78 kind: "insider" as const,
79 overview: unwrap(overview),
80 code: code.ok ? code.value : null,
81 rows: overviewRows({
82 base,
83 policyPath: policy,
84 reporting: settings.settings.privateVulnerabilityReporting,
85 advisories: published.ok ? published.value.length : 0,
86 upkeep: settings.upkeep,
87 codeScanning: { configured: code.ok ? code.value.configured : false, analyses: code.ok ? code.value.analyses.length : 0, entitled: settings.entitled },
88 historyAlerts: settings.settings.historyAlerts,
89 canManage: Boolean(access.can.manage_settings),
90 canPush: Boolean(access.can.push),
91 }),
92 newReports: reports.ok ? reports.value.length : 0,
93 can: access.can,
94 };
95}
96
97/** The switches on the overview: security updates, and private vulnerability reporting. */
98export async function action({ params, context, request }: Route.ActionArgs) {
99 assertSameOrigin(request);
100 const user = requireUser(context, request);
101 const refused = await refusal(context, params, "manage_settings");
102 if (refused) return { ok: false, error: refused };
103 const path = { namespace: params.owner, name: params.repo };
104 const form = await request.formData();
105 const on = form.get("enabled") === "true";
106 switch (String(form.get("intent") ?? "")) {
107 case "upkeep": {
108 const set = await security.setUpkeep(user, path, on);
109 return set.ok ? { ok: true } : { ok: false, error: set.error.message };
110 }
111 case "reporting": {
112 const current = await securitySuite.settings(path, user);
113 if (!current.ok) return { ok: false, error: current.error.message };
114 const saved = await securitySuite.setSettings(user, path, { ...current.value.settings, privateVulnerabilityReporting: on });
115 return saved.ok ? { ok: true } : { ok: false, error: saved.error.message };
116 }
117 default:
118 return { ok: false, error: "Unknown action." };
119 }
120}
121
122function RowAction({ row, action, canManage }: { row: OverviewRow; action: string; canManage: boolean }) {
123 const fetcher = useFetcher<{ ok: boolean; error?: string }>();
124 const navigation = useNavigation();
125 const what = row.action;
126 if (!what) return null;
127 if (what.kind === "link") {
128 return (
129 <ButtonLink to={what.to} variant="outline" size="sm" prefetch="intent">
130 {what.label}
131 </ButtonLink>
132 );
133 }
134 if (what.kind === "post") {
135 const working = navigation.state !== "idle" && navigation.formAction === what.action;
136 return (
137 <Form method="post" action={what.action}>
138 <Button type="submit" size="sm" disabled={working}>
139 {working ? what.pending : what.label}
140 </Button>
141 </Form>
142 );
143 }
144 const on = fetcher.formData ? fetcher.formData.get("enabled") === "true" : what.on;
145 return (
146 <div className="flex flex-col items-end gap-1">
147 <Hint label={canManage ? undefined : whyNot(null, "manage_settings")} disabled={!canManage}>
148 <Switch
149 aria-label={what.label}
150 checked={on}
151 disabled={!canManage || fetcher.state !== "idle"}
152 onCheckedChange={(checked) => fetcher.submit({ intent: what.intent, enabled: String(checked) }, { method: "post", action })}
153 />
154 </Hint>
155 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
156 </div>
157 );
158}
159
160function FeatureRows({ title, rows, action, canManage }: { title: string; rows: OverviewRow[]; action: string; canManage: boolean }) {
161 return (
162 <section aria-labelledby={`rows-${title}`} className="space-y-2">
163 <h3 id={`rows-${title}`} className="text-sm font-semibold">
164 {title}
165 </h3>
166 <Card asChild divided>
167 <ul>
168 {rows.map((row) => (
169 <li key={row.key} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
170 <div className="min-w-0 grow">
171 <div className="flex flex-wrap items-center gap-2">
172 <span className="text-sm font-medium">{row.title}</span>
173 <Hint label={row.hint}>
174 <Badge tone={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>
175 </Hint>
176 </div>
177 <p className="mt-1 text-sm text-muted">{row.about}</p>
178 </div>
179 <div className="shrink-0 sm:text-right">
180 <RowAction row={row} action={action} canManage={canManage} />
181 </div>
182 </li>
183 ))}
184 </ul>
185 </Card>
186 </section>
187 );
188}
189
190function AdvisoryLine({ advisory, base }: { advisory: Advisory; base: string }) {
191 return (
192 <li className="flex items-center gap-3 px-4 py-3">
193 <SeverityBadge severity={advisory.severity} />
194 <Link to={`${base}/security/advisories/${advisory.g1tsaId}`} className="min-w-0 grow truncate text-sm font-medium hover:underline">
195 {advisory.title}
196 </Link>
197 <span className="shrink-0 font-mono text-xs text-faint">{advisory.g1tsaId}</span>
198 {advisory.publishedAt && (
199 <span className="hidden shrink-0 text-xs text-muted sm:inline">
200 <TimeAgo at={advisory.publishedAt} />
201 </span>
202 )}
203 </li>
204 );
205}
206
207export default function SecurityOverview({ loaderData, params }: Route.ComponentProps) {
208 const base = `/${params.owner}/${params.repo}`;
209 // Alerts opening, fixed and dismissed show as it happens.
210 useRepoLive({ match: repoPages.security() });
211 if (loaderData.kind === "public") {
212 const { policy, advisories: published, reporting, signedIn } = loaderData;
213 return (
214 <div className="max-w-5xl space-y-8">
215 <SectionHeader
216 title="Security"
217 about="How to report a vulnerability in this repository, and what its maintainers have published about vulnerabilities in what it ships."
218 actions={
219 reporting ? (
220 <ButtonLink to={`${base}/security/report`} prefetch="intent">
221 Report a vulnerability
222 </ButtonLink>
223 ) : null
224 }
225 />
226 <Card asChild divided>
227 <ul>
228 <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
229 <div className="min-w-0 grow">
230 <div className="flex flex-wrap items-center gap-2">
231 <span className="text-sm font-medium">Security policy</span>
232 <Badge tone={policy ? "success" : "neutral"}>{policy ? "Published" : "None yet"}</Badge>
233 </div>
234 <p className="mt-1 text-sm text-muted">
235 {policy ? `${policy} says how to report a vulnerability and which versions are supported.` : "This repository has not published a security policy."}
236 </p>
237 </div>
238 {policy && (
239 <ButtonLink to={`${base}/blob/HEAD/${policy}`} variant="outline" size="sm">
240 View policy
241 </ButtonLink>
242 )}
243 </li>
244 <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
245 <div className="min-w-0 grow">
246 <div className="flex flex-wrap items-center gap-2">
247 <span className="text-sm font-medium">Private vulnerability reporting</span>
248 <Badge tone={reporting ? "success" : "neutral"}>{reporting ? "Enabled" : "Disabled"}</Badge>
249 </div>
250 <p className="mt-1 text-sm text-muted">
251 {reporting
252 ? signedIn
253 ? "Tell the maintainers privately, under your name. Only they see the report."
254 : "Tell the maintainers privately. You can report without an account; leave an email so they can answer you."
255 : "This repository does not take private reports here. Its security policy, if any, says how to reach the maintainers."}
256 </p>
257 </div>
258 </li>
259 </ul>
260 </Card>
261 <section aria-labelledby="public-advisories" className="space-y-2">
262 <div className="flex items-center justify-between gap-4">
263 <h3 id="public-advisories" className="text-sm font-semibold">
264 Security advisories
265 </h3>
266 {published.length > 0 && (
267 <Link to={`${base}/security/advisories`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
268 All advisories
269 </Link>
270 )}
271 </div>
272 {published.length === 0 ? (
273 <EmptyState title="No published advisories">The maintainers have not published any security advisories for this repository.</EmptyState>
274 ) : (
275 <Card asChild divided>
276 <ul>
277 {published.slice(0, 5).map((advisory) => (
278 <AdvisoryLine key={advisory.id} advisory={advisory} base={base} />
279 ))}
280 </ul>
281 </Card>
282 )}
283 </section>
284 </div>
285 );
286 }
287 const { overview, code, rows, newReports, can } = loaderData;
288 const codeOpen = code ? severityCounts(code.alerts) : null;
289 // Open alerts of every kind, by severity.
290 const counts = { ...overview.counts };
291 if (codeOpen) for (const key of Object.keys(counts) as (keyof typeof counts)[]) counts[key] += codeOpen[key];
292 const action = `${base}/security`;
293 const canManage = Boolean(can.manage_settings);
294 return (
295 <div className="max-w-5xl space-y-8">
296 <SectionHeader
297 title="Security"
298 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."
299 actions={
300 newReports > 0 ? (
301 <ButtonLink to={`${base}/security/reports`} variant="outline" size="sm" prefetch="intent">
302 {newReports === 1 ? "1 new vulnerability report" : `${newReports} new vulnerability reports`}
303 </ButtonLink>
304 ) : null
305 }
306 />
307 <FeatureRows title="Policy" rows={rowsIn(rows, "policy")} action={action} canManage={canManage} />
308 <FeatureRows title="Findings" rows={rowsIn(rows, "findings")} action={action} canManage={canManage} />
309 <FeatureRows title="Reporting" rows={rowsIn(rows, "reporting")} action={action} canManage={canManage} />
310 <section aria-labelledby="open-alerts" className="space-y-3">
311 <h3 id="open-alerts" className="flex items-center gap-2 text-sm font-semibold">
312 <ShieldCheck size={15} className="text-accent" />
313 Open alerts
314 </h3>
315 <SeverityCountsGrid counts={counts} />
316 <p className="text-xs text-faint">
317 Open alerts of every kind by severity ({SEVERITY_LABEL.critical.toLowerCase()} to {SEVERITY_LABEL.low.toLowerCase()}). A secret in the history
318 that looks real counts as critical; blocked pushes and likely test values do not.
319 </p>
320 <ScanSummary scan={overview.scan} />
321 </section>
322 </div>
323 );
324}