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.

245 linesCodeBlameRaw
1import { Link, useFetcher } from "react-router";
2import type { ReactNode } from "react";
3
4import { CODE_SCANNING_GATES, type CodeScanningGate, type RepoSecuritySettings, type ReviewFailOn } from "@g1t/contracts";
5
6import type { Route } from "./+types/security-settings";
7import { page } from "../../lib/meta";
8import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite";
9import { Button } from "../../components/ui/button";
10import { Card } from "../../components/ui/card";
11import { Hint } from "../../components/ui/hint";
12import { SelectField } from "../../components/ui/select";
13import { Switch } from "../../components/ui/switch";
14import { security, securitySuite } from "../../lib/services.server";
15import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server";
16import { refusal, requireInsider } from "../../lib/access.server";
17import { whyNot } from "../../lib/access";
18import { planPrice } from "../../lib/security-suite.server";
19
20export function meta({ params, ...args }: Route.MetaArgs) {
21 return page(args, { title: `Security settings · ${params.owner}/${params.repo} · g1t` });
22}
23
24export async function loader({ params, context, request }: Route.LoaderArgs) {
25 const viewer = getViewer(context) ?? requireUser(context, request);
26 const { access } = await requireInsider(context, params, "push");
27 const repo = { namespace: params.owner, name: params.repo };
28 const [view, price] = await Promise.all([securitySuite.settings(repo, viewer), planPrice(params.owner, viewer)]);
29 return { view: unwrap(view), price, can: access.can, owner: managesSecurity(viewer, params.owner) };
30}
31
32const GATES = new Set(CODE_SCANNING_GATES.map((gate) => gate.gate));
33const FAIL_ON = new Set(["critical", "high", "medium", "low", "none"]);
34
35/** The settings a form posts. */
36export function settingsFrom(form: FormData): RepoSecuritySettings | string {
37 const gate = String(form.get("codeScanningGate") ?? "");
38 const failOn = String(form.get("reviewFailOn") ?? "");
39 if (!GATES.has(gate as CodeScanningGate)) return "Choose when code scanning fails.";
40 if (!FAIL_ON.has(failOn)) return "Choose when dependency review fails.";
41 return {
42 codeScanningGate: gate as CodeScanningGate,
43 dependencyReview: form.get("dependencyReview") === "on",
44 reviewFailOn: failOn as ReviewFailOn,
45 reviewDenyLicenses: String(form.get("reviewDenyLicenses") ?? "")
46 .split(/[\s,]+/)
47 .map((id) => id.trim())
48 .filter(Boolean)
49 .slice(0, 50),
50 reviewComment: form.get("reviewComment") === "on",
51 historyAlerts: form.get("historyAlerts") === "on",
52 privateVulnerabilityReporting: form.get("privateVulnerabilityReporting") === "on",
53 };
54}
55
56export async function action({ params, context, request }: Route.ActionArgs) {
57 assertSameOrigin(request);
58 const user = requireUser(context, request);
59 const refused = await refusal(context, params, "manage_settings");
60 if (refused) return { ok: false, error: refused };
61 const form = await request.formData();
62 const path = { namespace: params.owner, name: params.repo };
63 // Security updates are a setting of their own, saved as the switch moves.
64 if (form.get("intent") === "upkeep") {
65 const set = await security.setUpkeep(user, path, form.get("enabled") === "true");
66 return set.ok ? { ok: true } : { ok: false, error: set.error.message };
67 }
68 const settings = settingsFrom(form);
69 if (typeof settings === "string") return { ok: false, error: settings };
70 const saved = await securitySuite.setSettings(user, path, settings);
71 return saved.ok ? { ok: true } : { ok: false, error: saved.error.message };
72}
73
74const SELECT = "w-full sm:w-72";
75
76/** A setting that is on or off, with the words on what it does, as a card. */
77function SettingSwitch({
78 name,
79 title,
80 children,
81 defaultChecked,
82 disabled,
83}: {
84 name: string;
85 title: string;
86 children: ReactNode;
87 defaultChecked: boolean;
88 disabled: boolean;
89}) {
90 return (
91 <label className={`${CARD} flex items-start justify-between gap-4 p-4 ${disabled ? "opacity-60" : ""}`}>
92 <span className="min-w-0">
93 <span className="block text-sm font-medium">{title}</span>
94 <span className="mt-1 block text-sm text-muted">{children}</span>
95 </span>
96 <Switch name={name} defaultChecked={defaultChecked} disabled={disabled} className="mt-0.5" />
97 </label>
98 );
99}
100
101export default function SecuritySettings({ loaderData, params }: Route.ComponentProps) {
102 const { view, price, can, owner } = loaderData;
103 const base = `/${params.owner}/${params.repo}`;
104 const fetcher = useFetcher<{ ok: boolean; error?: string }>();
105 const { settings } = view;
106 const disabled = !can.manage_settings || !view.entitled;
107 // Free everywhere: switches that need no activation.
108 const locked = !can.manage_settings;
109 const upkeep = useFetcher<{ ok: boolean; error?: string }>();
110 const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : view.upkeep;
111 return (
112 <div className="max-w-3xl space-y-6">
113 <SectionHeader
114 title="Security settings"
115 about="What this repository's security does on its own, and when the pull request checks its security suite reports fail. Require those checks in branch protection to block merges on them, for people and agents alike."
116 />
117 <section aria-labelledby="alerts-and-updates" className="space-y-3">
118 <h3 id="alerts-and-updates" className="text-sm font-semibold">
119 Alerts, updates and reporting
120 </h3>
121 <Card asChild className="flex items-start justify-between gap-4 p-4">
122 <label>
123 <span className="min-w-0">
124 <span className="block text-sm font-medium">Security updates</span>
125 <span className="mt-1 block text-sm text-muted">
126 Open a pull request to upgrade each vulnerable dependency that has a fix. It lands through your branch's required checks. The same switch is on
127 the Vulnerabilities page.
128 </span>
129 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
130 </span>
131 <Hint label={whyNot(can, "manage_settings")} disabled={locked}>
132 <Switch
133 className="mt-0.5"
134 checked={upkeepOn}
135 disabled={upkeep.state !== "idle" || locked}
136 onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post" })}
137 />
138 </Hint>
139 </label>
140 </Card>
141 </section>
142 {!view.entitled && <ActivationPrompt workspace={params.owner} feature="Code scanning and dependency review" monthlyCents={price} isOwner={owner} />}
143 <fetcher.Form method="post" className="space-y-4">
144 <fieldset disabled={locked} className="space-y-3">
145 <legend className="sr-only">Alerts and reporting</legend>
146 <SettingSwitch name="historyAlerts" title="Alerts for secrets in the history" defaultChecked={settings.historyAlerts} disabled={locked}>
147 Secrets found in the history, and in pushes too large to scan first, open alerts and tell the workspace's owners and security managers. Off, they
148 are still listed under Secret scanning but tell nobody. Push protection is always on: a push that adds a secret is refused either way.
149 </SettingSwitch>
150 <SettingSwitch
151 name="privateVulnerabilityReporting"
152 title="Private vulnerability reporting"
153 defaultChecked={settings.privateVulnerabilityReporting}
154 disabled={locked}
155 >
156 Anyone who can see this repository can report a vulnerability to its maintainers privately from its Security tab; on a public repository, without an
157 account, with an email address. The owners and security managers are told at once.
158 </SettingSwitch>
159 </fieldset>
160 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
161 <legend className="sr-only">Code scanning</legend>
162 <p className="text-sm font-medium">Code scanning results</p>
163 <p className="text-sm text-muted">The Code scanning check fails when a pull request brings new results on the lines it changes at this level.</p>
164 <SelectField
165 name="codeScanningGate"
166 defaultValue={settings.codeScanningGate}
167 disabled={disabled}
168 className={SELECT}
169 aria-label="When code scanning fails"
170 options={CODE_SCANNING_GATES.map((gate) => ({ value: gate.gate, label: gate.label }))}
171 />
172 </fieldset>
173 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
174 <legend className="sr-only">Dependency review</legend>
175 <label className="flex items-start justify-between gap-4">
176 <span>
177 <span className="block text-sm font-medium">Dependency review</span>
178 <span className="mt-1 block text-sm text-muted">
179 Pull requests that change a lockfile get the Dependency review check, which fails when they add a package with a known vulnerability or a
180 license you do not allow.
181 </span>
182 </span>
183 <Switch name="dependencyReview" defaultChecked={settings.dependencyReview} className="mt-0.5" />
184 </label>
185 <label className="block">
186 <span className="mb-1.5 block text-xs font-medium text-muted">Fail on vulnerabilities of</span>
187 <SelectField
188 name="reviewFailOn"
189 defaultValue={settings.reviewFailOn}
190 disabled={disabled}
191 className={SELECT}
192 options={[
193 { value: "critical", label: "Critical severity" },
194 { value: "high", label: "High severity or higher" },
195 { value: "medium", label: "Medium severity or higher" },
196 { value: "low", label: "Any severity" },
197 { value: "none", label: "Never fail on vulnerabilities" },
198 ]}
199 />
200 </label>
201 <label className="block">
202 <span className="mb-1.5 block text-xs font-medium text-muted">Licenses not allowed (SPDX ids)</span>
203 <input
204 name="reviewDenyLicenses"
205 defaultValue={settings.reviewDenyLicenses.join(", ")}
206 placeholder="GPL-3.0-only, AGPL-3.0-only"
207 className="h-9 w-full rounded-md border border-line bg-bg px-2.5 font-mono text-sm outline-none hover:border-line-strong focus:border-accent-dim"
208 />
209 </label>
210 <label className="flex items-center justify-between gap-4">
211 <span className="text-sm">Comment the review's summary on the pull request</span>
212 <Switch name="reviewComment" defaultChecked={settings.reviewComment} />
213 </label>
214 </fieldset>
215 <div className="flex flex-wrap items-center gap-3">
216 <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}>
217 <Button type="submit" disabled={locked || fetcher.state !== "idle"}>
218 {fetcher.state !== "idle" ? "Saving…" : "Save"}
219 </Button>
220 </Hint>
221 <Link to={`${base}/settings/branches`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
222 Require the checks in branch protection
223 </Link>
224 {fetcher.data?.ok && <span className="text-sm text-success">Saved.</span>}
225 {fetcher.data?.error && <span className="text-sm text-danger">{fetcher.data.error}</span>}
226 </div>
227 </fetcher.Form>
228 <p className="text-sm text-muted">
229 Advisories and private reports are under{" "}
230 <Link to={`${base}/security/advisories`} className="underline underline-offset-2 hover:text-fg">
231 Advisories
232 </Link>
233 , and version updates on{" "}
234 <Link to={`${base}/security/dependency-updates`} className="underline underline-offset-2 hover:text-fg">
235 Dependency updates
236 </Link>
237 . Delegated bypass and validity checks are the workspace's, in{" "}
238 <Link to={`/${params.owner}/-/security/settings`} className="underline underline-offset-2 hover:text-fg">
239 its Security settings
240 </Link>
241 .
242 </p>
243 </div>
244 );
245}