| 1 | import { Link, useFetcher } from "react-router"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | |
| 4 | import { CODE_SCANNING_GATES, type CodeScanningGate, type RepoSecuritySettings, type ReviewFailOn } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/security-settings"; |
| 7 | import { page } from "../../lib/meta"; |
| 8 | import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite"; |
| 9 | import { Button } from "../../components/ui/button"; |
| 10 | import { Card } from "../../components/ui/card"; |
| 11 | import { Hint } from "../../components/ui/hint"; |
| 12 | import { SelectField } from "../../components/ui/select"; |
| 13 | import { Switch } from "../../components/ui/switch"; |
| 14 | import { security, securitySuite } from "../../lib/services.server"; |
| 15 | import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server"; |
| 16 | import { refusal, requireInsider } from "../../lib/access.server"; |
| 17 | import { whyNot } from "../../lib/access"; |
| 18 | import { planPrice } from "../../lib/security-suite.server"; |
| 19 | |
| 20 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 21 | return page(args, { title: `Security settings · ${params.owner}/${params.repo} · g1t` }); |
| 22 | } |
| 23 | |
| 24 | export 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 | |
| 32 | const GATES = new Set(CODE_SCANNING_GATES.map((gate) => gate.gate)); |
| 33 | const FAIL_ON = new Set(["critical", "high", "medium", "low", "none"]); |
| 34 | |
| 35 | /** The settings a form posts. */ |
| 36 | export 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 | |
| 56 | export 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 | |
| 74 | const 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. */ |
| 77 | function 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 | |
| 89 | export 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 | } |