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