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.

g1t.sh
235 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({ name, title, children, defaultChecked, disabled }: { name: string; title: string; children: ReactNode; defaultChecked: boolean; disabled: boolean }) {
78 return (
79 <label className={`${CARD} flex items-start justify-between gap-4 p-4 ${disabled ? "opacity-60" : ""}`}>
80 <span className="min-w-0">
81 <span className="block text-sm font-medium">{title}</span>
82 <span className="mt-1 block text-sm text-muted">{children}</span>
83 </span>
84 <Switch name={name} defaultChecked={defaultChecked} disabled={disabled} className="mt-0.5" />
85 </label>
86 );
87}
88
89export default function SecuritySettings({ loaderData, params }: Route.ComponentProps) {
90 const { view, price, can, owner } = loaderData;
91 const base = `/${params.owner}/${params.repo}`;
92 const fetcher = useFetcher<{ ok: boolean; error?: string }>();
93 const { settings } = view;
94 const disabled = !can.manage_settings || !view.entitled;
95 // Free everywhere: switches that need no activation.
96 const locked = !can.manage_settings;
97 const upkeep = useFetcher<{ ok: boolean; error?: string }>();
98 const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : view.upkeep;
99 return (
100 <div className="max-w-3xl space-y-6">
101 <SectionHeader
102 title="Security settings"
103 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."
104 />
105 <section aria-labelledby="alerts-and-updates" className="space-y-3">
106 <h3 id="alerts-and-updates" className="text-sm font-semibold">
107 Alerts, updates and reporting
108 </h3>
109 <Card asChild className="flex items-start justify-between gap-4 p-4">
110 <label>
111 <span className="min-w-0">
112 <span className="block text-sm font-medium">Security updates</span>
113 <span className="mt-1 block text-sm text-muted">
114 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
115 the Vulnerabilities page.
116 </span>
117 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
118 </span>
119 <Hint label={whyNot(can, "manage_settings")} disabled={locked}>
120 <Switch
121 className="mt-0.5"
122 checked={upkeepOn}
123 disabled={upkeep.state !== "idle" || locked}
124 onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post" })}
125 />
126 </Hint>
127 </label>
128 </Card>
129 </section>
130 {!view.entitled && <ActivationPrompt workspace={params.owner} feature="Code scanning and dependency review" monthlyCents={price} isOwner={owner} />}
131 <fetcher.Form method="post" className="space-y-4">
132 <fieldset disabled={locked} className="space-y-3">
133 <legend className="sr-only">Alerts and reporting</legend>
134 <SettingSwitch name="historyAlerts" title="Alerts for secrets in the history" defaultChecked={settings.historyAlerts} disabled={locked}>
135 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
136 are still listed under Secret scanning but tell nobody. Push protection is always on: a push that adds a secret is refused either way.
137 </SettingSwitch>
138 <SettingSwitch
139 name="privateVulnerabilityReporting"
140 title="Private vulnerability reporting"
141 defaultChecked={settings.privateVulnerabilityReporting}
142 disabled={locked}
143 >
144 Anyone who can see this repository can report a vulnerability to its maintainers privately from its Security tab; on a public repository, without
145 an account, with an email address. The owners and security managers are told at once.
146 </SettingSwitch>
147 </fieldset>
148 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
149 <legend className="sr-only">Code scanning</legend>
150 <p className="text-sm font-medium">Code scanning results</p>
151 <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>
152 <SelectField
153 name="codeScanningGate"
154 defaultValue={settings.codeScanningGate}
155 disabled={disabled}
156 className={SELECT}
157 aria-label="When code scanning fails"
158 options={CODE_SCANNING_GATES.map((gate) => ({ value: gate.gate, label: gate.label }))}
159 />
160 </fieldset>
161 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
162 <legend className="sr-only">Dependency review</legend>
163 <label className="flex items-start justify-between gap-4">
164 <span>
165 <span className="block text-sm font-medium">Dependency review</span>
166 <span className="mt-1 block text-sm text-muted">
167 Pull requests that change a lockfile get the Dependency review check, which fails when they add a package with a
168 known vulnerability or a license you do not allow.
169 </span>
170 </span>
171 <Switch name="dependencyReview" defaultChecked={settings.dependencyReview} className="mt-0.5" />
172 </label>
173 <label className="block">
174 <span className="mb-1.5 block text-xs font-medium text-muted">Fail on vulnerabilities of</span>
175 <SelectField
176 name="reviewFailOn"
177 defaultValue={settings.reviewFailOn}
178 disabled={disabled}
179 className={SELECT}
180 options={[
181 { value: "critical", label: "Critical severity" },
182 { value: "high", label: "High severity or higher" },
183 { value: "medium", label: "Medium severity or higher" },
184 { value: "low", label: "Any severity" },
185 { value: "none", label: "Never fail on vulnerabilities" },
186 ]}
187 />
188 </label>
189 <label className="block">
190 <span className="mb-1.5 block text-xs font-medium text-muted">Licenses not allowed (SPDX ids)</span>
191 <input
192 name="reviewDenyLicenses"
193 defaultValue={settings.reviewDenyLicenses.join(", ")}
194 placeholder="GPL-3.0-only, AGPL-3.0-only"
195 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"
196 />
197 </label>
198 <label className="flex items-center justify-between gap-4">
199 <span className="text-sm">Comment the review's summary on the pull request</span>
200 <Switch name="reviewComment" defaultChecked={settings.reviewComment} />
201 </label>
202 </fieldset>
203 <div className="flex flex-wrap items-center gap-3">
204 <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}>
205 <Button
206 type="submit"
207 disabled={locked || fetcher.state !== "idle"}>
208 {fetcher.state !== "idle" ? "Saving…" : "Save"}
209 </Button>
210 </Hint>
211 <Link to={`${base}/settings/branches`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
212 Require the checks in branch protection
213 </Link>
214 {fetcher.data?.ok && <span className="text-sm text-success">Saved.</span>}
215 {fetcher.data?.error && <span className="text-sm text-danger">{fetcher.data.error}</span>}
216 </div>
217 </fetcher.Form>
218 <p className="text-sm text-muted">
219 Advisories and private reports are under{" "}
220 <Link to={`${base}/security/advisories`} className="underline underline-offset-2 hover:text-fg">
221 Advisories
222 </Link>
223 , and version updates on{" "}
224 <Link to={`${base}/security/dependency-updates`} className="underline underline-offset-2 hover:text-fg">
225 Dependency updates
226 </Link>
227 . Delegated bypass and validity checks are the workspace's, in{" "}
228 <Link to={`/${params.owner}/-/security/settings`} className="underline underline-offset-2 hover:text-fg">
229 its Security settings
230 </Link>
231 .
232 </p>
233 </div>
234 );
235}