| 1 | /** |
| 2 | * One security advisory: what it says, what is affected and what fixes it, |
| 3 | * who found it. Admins (and security managers) publish a draft, withdraw a |
| 4 | * published one, or edit it; anyone who can see the repository reads a |
| 5 | * published one. |
| 6 | */ |
| 7 | import { useFetcher } from "react-router"; |
| 8 | |
| 9 | import type { Route } from "./+types/security-advisory"; |
| 10 | import { page } from "../../lib/meta"; |
| 11 | import { Markdown } from "../../components/markdown"; |
| 12 | import { SeverityBadge } from "../../components/security"; |
| 13 | import { SectionHeader } from "../../components/security-suite"; |
| 14 | import { ButtonLink, TimeAgo } from "../../components/ui"; |
| 15 | import { Badge } from "../../components/ui/badge"; |
| 16 | import { Button } from "../../components/ui/button"; |
| 17 | import { Card } from "../../components/ui/card"; |
| 18 | import { advisories } from "../../lib/services.server"; |
| 19 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 20 | import { refusal, requireRepo } from "../../lib/access.server"; |
| 21 | import { STATE_LABEL, STATE_TONE, advisoryStanding } from "../../lib/advisories"; |
| 22 | |
| 23 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { |
| 24 | return page(args, { title: `${loaderData?.advisory.title ?? "Advisory"} · ${params.owner}/${params.repo} · g1t` }); |
| 25 | } |
| 26 | |
| 27 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 28 | const viewer = getViewer(context); |
| 29 | const { access } = await requireRepo(context, params, "read"); |
| 30 | const path = { namespace: params.owner, name: params.repo }; |
| 31 | const advisory = unwrap(await advisories.get(path, params.id, viewer)); |
| 32 | return { advisory, canManage: Boolean(access.can.manage_settings) }; |
| 33 | } |
| 34 | |
| 35 | /** Publishing and withdrawing: Admin, or a security manager. */ |
| 36 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 37 | assertSameOrigin(request); |
| 38 | const user = requireUser(context, request); |
| 39 | const refused = await refusal(context, params, "manage_security"); |
| 40 | if (refused) return { ok: false, error: refused }; |
| 41 | const form = await request.formData(); |
| 42 | const state = String(form.get("state") ?? ""); |
| 43 | if (state !== "published" && state !== "withdrawn") return { ok: false, error: "Nothing to do." }; |
| 44 | const changed = await advisories.setState(user, { namespace: params.owner, name: params.repo }, params.id, state); |
| 45 | return changed.ok ? { ok: true } : { ok: false, error: changed.error.message }; |
| 46 | } |
| 47 | |
| 48 | function Fact({ label, children }: { label: string; children: React.ReactNode }) { |
| 49 | return ( |
| 50 | <div className="flex flex-col gap-0.5 px-4 py-3 sm:flex-row sm:gap-4"> |
| 51 | <dt className="shrink-0 text-xs font-medium text-muted sm:w-40 sm:pt-0.5">{label}</dt> |
| 52 | <dd className="min-w-0 text-sm wrap-break-word">{children}</dd> |
| 53 | </div> |
| 54 | ); |
| 55 | } |
| 56 | |
| 57 | export default function SecurityAdvisory({ loaderData, params }: Route.ComponentProps) { |
| 58 | const { advisory, canManage } = loaderData; |
| 59 | const base = `/${params.owner}/${params.repo}`; |
| 60 | const fetcher = useFetcher<{ ok: boolean; error?: string }>(); |
| 61 | const standing = advisoryStanding(advisory); |
| 62 | const working = fetcher.state !== "idle"; |
| 63 | return ( |
| 64 | <div className="max-w-5xl space-y-6"> |
| 65 | <SectionHeader |
| 66 | title={advisory.title} |
| 67 | about={ |
| 68 | <span className="flex flex-wrap items-center gap-2"> |
| 69 | <SeverityBadge severity={advisory.severity} /> |
| 70 | <Badge tone={STATE_TONE[advisory.state]}>{STATE_LABEL[advisory.state]}</Badge> |
| 71 | <span className="font-mono text-xs">{advisory.g1tsaId}</span> |
| 72 | {advisory.cveId && <span className="font-mono text-xs">{advisory.cveId}</span>} |
| 73 | <span> |
| 74 | {standing.verb} |
| 75 | {standing.at && ( |
| 76 | <> |
| 77 | {" "} |
| 78 | <TimeAgo at={standing.at} /> |
| 79 | </> |
| 80 | )} |
| 81 | </span> |
| 82 | </span> |
| 83 | } |
| 84 | actions={ |
| 85 | canManage ? ( |
| 86 | <fetcher.Form method="post" className="flex flex-wrap items-center gap-2"> |
| 87 | <ButtonLink to={`${base}/security/advisories/${advisory.g1tsaId}/edit`} variant="outline" size="sm"> |
| 88 | Edit |
| 89 | </ButtonLink> |
| 90 | {advisory.state === "published" ? ( |
| 91 | <Button type="submit" name="state" value="withdrawn" variant="outline" size="sm" disabled={working}> |
| 92 | {working ? "Withdrawing…" : "Withdraw"} |
| 93 | </Button> |
| 94 | ) : ( |
| 95 | <Button type="submit" name="state" value="published" size="sm" disabled={working || !advisory.description.trim()}> |
| 96 | {working ? "Publishing…" : advisory.state === "withdrawn" ? "Publish again" : "Publish"} |
| 97 | </Button> |
| 98 | )} |
| 99 | </fetcher.Form> |
| 100 | ) : null |
| 101 | } |
| 102 | /> |
| 103 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} |
| 104 | {canManage && advisory.state === "draft" && ( |
| 105 | <p className="text-sm text-muted"> |
| 106 | A draft is seen by this repository's maintainers only. {advisory.description.trim() ? "Publish it when it is ready." : "Describe the vulnerability before publishing it."} |
| 107 | </p> |
| 108 | )} |
| 109 | <Card asChild divided> |
| 110 | <dl> |
| 111 | {(advisory.package || advisory.ecosystem) && ( |
| 112 | <Fact label="Affected package"> |
| 113 | <span className="font-mono">{advisory.package ?? "—"}</span> |
| 114 | {advisory.ecosystem && <span className="text-muted"> ({advisory.ecosystem})</span>} |
| 115 | </Fact> |
| 116 | )} |
| 117 | {advisory.affectedVersions && ( |
| 118 | <Fact label="Affected versions"> |
| 119 | <span className="font-mono">{advisory.affectedVersions}</span> |
| 120 | </Fact> |
| 121 | )} |
| 122 | {advisory.patchedVersions && ( |
| 123 | <Fact label="Patched versions"> |
| 124 | <span className="font-mono">{advisory.patchedVersions}</span> |
| 125 | </Fact> |
| 126 | )} |
| 127 | {(advisory.cvssScore != null || advisory.cvssVector) && ( |
| 128 | <Fact label="CVSS"> |
| 129 | {advisory.cvssScore != null && <span className="font-medium">{advisory.cvssScore.toFixed(1)}</span>} |
| 130 | {advisory.cvssVector && <span className="ml-2 font-mono text-xs text-muted">{advisory.cvssVector}</span>} |
| 131 | </Fact> |
| 132 | )} |
| 133 | {advisory.credits.length > 0 && <Fact label="Credits">{advisory.credits.join(", ")}</Fact>} |
| 134 | <Fact label="Written by"> |
| 135 | {advisory.createdBy}, <TimeAgo at={advisory.createdAt} /> |
| 136 | </Fact> |
| 137 | </dl> |
| 138 | </Card> |
| 139 | <section aria-labelledby="advisory-description"> |
| 140 | <h3 id="advisory-description" className="sr-only"> |
| 141 | Description |
| 142 | </h3> |
| 143 | {advisory.description.trim() ? ( |
| 144 | <Card className="p-4"> |
| 145 | <Markdown source={advisory.description} repo={{ namespace: params.owner, name: params.repo }} /> |
| 146 | </Card> |
| 147 | ) : ( |
| 148 | <p className="text-sm text-muted">No description yet.</p> |
| 149 | )} |
| 150 | </section> |
| 151 | </div> |
| 152 | ); |
| 153 | } |