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