170 lines8,624 bytesCodeBlame
1import { RefreshCw, ShieldCheck } from "lucide-react";
2import { data, useFetcher, useSearchParams } from "react-router";
3
4import type { SecretDecision } from "@g1t/contracts";
5
6import type { Route } from "./+types/security";
7import { page } from "../../lib/meta";
8import { ScanSummary, SecretsList, SeverityCountsGrid, type UpgradeFix, VulnerabilityList } from "../../components/security";
9import { Switch } from "../../components/ui/switch";
10import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
11import { security, work } from "../../lib/services.server";
12import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
13import { refusal, requireInsider } from "../../lib/access.server";
14import { whyNot } from "../../lib/access";
15
16export function meta({ params, ...args }: Route.MetaArgs) {
17 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
18}
19
20/** Upgrade issues looked up per page, at most. */
21const MAX_FIXES = 30;
22
23export async function loader({ params, context, request }: Route.LoaderArgs) {
24 // Git's refusal links here; someone signed out signs in first.
25 const viewer = getViewer(context) ?? requireUser(context, request);
26 // Findings are for people who can push, public project or not: Write and up.
27 const { access } = await requireInsider(context, params, "push");
28 const repo = { namespace: params.owner, name: params.repo };
29 const overview = unwrap(await security.overview(repo, viewer));
30 const numbers = [...new Set(overview.vulnerabilities.map((vuln) => vuln.issue).filter((n): n is number => n != null))].slice(
31 0,
32 MAX_FIXES,
33 );
34 const details = await Promise.all(numbers.map((number) => work.getIssue(repo, number, viewer).catch(() => null)));
35 const fixes: Record<number, UpgradeFix> = {};
36 for (const found of details) {
37 if (!found?.ok) continue;
38 const { issue, pulls } = found.value;
39 const latest = pulls.at(-1) ?? null;
40 fixes[issue.number] = {
41 number: issue.number,
42 state: issue.state,
43 resolvedBy: issue.resolvedBy,
44 pull: latest ? { number: latest.number, status: latest.status, agent: latest.runtime === "hosted" ? latest.agent : null } : null,
45 };
46 }
47 return { overview, fixes, can: access.can };
48}
49
50export async function action({ params, context, request }: Route.ActionArgs) {
51 assertSameOrigin(request);
52 const user = requireUser(context, request);
53 const repo = { namespace: params.owner, name: params.repo };
54 const form = await request.formData();
55 const intent = String(form.get("intent") ?? "");
56 // Scanning spends compute (Write); upkeep is a setting (Maintain); allowing
57 // or resolving a finding is for Admins, who manage the repository's secrets.
58 const refused = await refusal(context, params, intent === "rescan" ? "run" : intent === "upkeep" ? "manage_settings" : "manage_integrations");
59 if (refused) return { ok: false, error: refused };
60 if (intent === "decide") {
61 const decision = String(form.get("decision") ?? "") as SecretDecision;
62 const decided = await security.decideSecret(user, repo, String(form.get("id") ?? ""), decision, String(form.get("reason") ?? ""));
63 return decided.ok ? { ok: true } : { ok: false, error: decided.error.message };
64 }
65 if (intent === "rescan") {
66 const scanned = await security.rescan(user, repo);
67 return scanned.ok ? { ok: true } : { ok: false, error: scanned.error.message };
68 }
69 if (intent === "upkeep") {
70 const set = await security.setUpkeep(user, repo, form.get("enabled") === "true");
71 return set.ok ? { ok: true } : { ok: false, error: set.error.message };
72 }
73 return { ok: false, error: "Unknown action." };
74}
75
76export default function ProjectSecurity({ loaderData, params }: Route.ComponentProps) {
77 const { overview, fixes, can } = loaderData;
78 const base = `/${params.owner}/${params.repo}`;
79 const action = `${base}/security`;
80 const [search, setSearch] = useSearchParams();
81 const rescan = useFetcher<{ ok: boolean; error?: string }>();
82 const upkeep = useFetcher<{ ok: boolean; error?: string }>();
83 const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : overview.upkeep;
84 const openSecrets = overview.secrets.filter((secret) => secret.status === "open" || secret.status === "blocked").length;
85 const openVulns = overview.vulnerabilities.filter((vuln) => vuln.status === "open").length;
86 const focus = search.get("finding");
87 const tab = search.get("tab") ?? (focus || (openSecrets > 0 && openVulns === 0) ? "secrets" : openVulns > 0 ? "dependencies" : "secrets");
88 return (
89 <div className="max-w-5xl">
90 <div className="flex flex-wrap items-start justify-between gap-4">
91 <div>
92 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
93 <ShieldCheck size={19} className="text-accent" />
94 Security
95 </h2>
96 <p className="mt-1.5 max-w-2xl text-sm text-muted">
97 Pushes that add a secret are refused before they land, and the history is scanned once. Every package the
98 lockfiles resolve is checked for known vulnerabilities, and each one with a fix becomes an upgrade issue a g1t
99 agent lands through checks, review and the merge queue.
100 </p>
101 </div>
102 {can.run && <rescan.Form method="post" action={action}>
103 <input type="hidden" name="intent" value="rescan" />
104 <button
105 type="submit"
106 disabled={rescan.state !== "idle"}
107 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"
108 >
109 <RefreshCw size={14} className={rescan.state !== "idle" ? "animate-spin" : ""} />
110 {rescan.state !== "idle" ? "Scanning…" : "Re-scan now"}
111 </button>
112 {rescan.data?.error && <p className="mt-1.5 text-xs text-danger">{rescan.data.error}</p>}
113 </rescan.Form>}
114 </div>
115
116 <div className="mt-6">
117 <SeverityCountsGrid counts={overview.counts} />
118 <p className="mt-2 text-xs text-faint">Open vulnerabilities by severity. A secret that is open or blocked counts as critical.</p>
119 </div>
120
121 <div className="mt-4">
122 <ScanSummary scan={overview.scan} />
123 </div>
124
125 <label className="mt-6 flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
126 <span className="min-w-0">
127 <span className="block text-sm font-medium">Upkeep agents</span>
128 <span className="mt-1 block text-sm text-muted">
129 Open an upgrade issue for each vulnerable package with a fix, and put a g1t agent on it. Off, findings are still
130 listed here, and nothing is opened for them.
131 </span>
132 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
133 </span>
134 <Switch
135 className="mt-0.5"
136 checked={upkeepOn}
137 disabled={upkeep.state !== "idle" || !can.manage_settings}
138 title={whyNot(can, "manage_settings")}
139 onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })}
140 />
141 </label>
142
143 <Tabs
144 value={tab}
145 onValueChange={(value) => {
146 const next = new URLSearchParams(search);
147 next.set("tab", value);
148 next.delete("finding");
149 setSearch(next, { replace: true, preventScrollReset: true });
150 }}
151 className="mt-8"
152 >
153 <TabsList>
154 <TabsTrigger value="secrets">Secrets{openSecrets > 0 ? ` · ${openSecrets}` : ""}</TabsTrigger>
155 <TabsTrigger value="dependencies">Dependencies{openVulns > 0 ? ` · ${openVulns}` : ""}</TabsTrigger>
156 </TabsList>
157 <TabsContent value="secrets">
158 <SecretsList secrets={overview.secrets} base={base} action={action} focus={focus} decide={can.manage_integrations} />
159 <p className="mt-3 text-xs text-faint">
160 Not a real secret, such as a test fixture? Add <code>g1t:allow-secret</code> in a comment on its line, or allow it
161 here. Allowing is recorded with your name and reason.
162 </p>
163 </TabsContent>
164 <TabsContent value="dependencies">
165 <VulnerabilityList vulnerabilities={overview.vulnerabilities} fixes={fixes} base={base} />
166 </TabsContent>
167 </Tabs>
168 </div>
169 );
170}