| 1 | /** |
| 2 | * Security, as a project's page shows it: open alerts by severity, the |
| 3 | * secrets found in pushes and history, vulnerable dependencies with the |
| 4 | * security update g1t opened for each, and what happened to every alert. |
| 5 | * The page posts the intents in `routes/repo/security.tsx`'s action. |
| 6 | */ |
| 7 | import { |
| 8 | Bot, |
| 9 | CircleAlert, |
| 10 | CircleCheck, |
| 11 | CircleDot, |
| 12 | CircleSlash, |
| 13 | ExternalLink, |
| 14 | GitBranch, |
| 15 | GitMerge, |
| 16 | GitPullRequest, |
| 17 | GitPullRequestClosed, |
| 18 | History, |
| 19 | KeyRound, |
| 20 | Loader, |
| 21 | Package, |
| 22 | ShieldAlert, |
| 23 | ShieldCheck, |
| 24 | } from "lucide-react"; |
| 25 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| 26 | import { Link, useFetcher } from "react-router"; |
| 27 | |
| 28 | import { itemPath } from "../lib/refs"; |
| 29 | |
| 30 | import { |
| 31 | type AlertActivity, |
| 32 | type AlertState, |
| 33 | DEPENDENCY_DISMISS_REASONS, |
| 34 | type DismissReason, |
| 35 | SECRET_DISMISS_REASONS, |
| 36 | SEVERITIES, |
| 37 | type PullStatus, |
| 38 | type SecretFinding, |
| 39 | type SecurityUpdate, |
| 40 | type Severity, |
| 41 | type SeverityCounts, |
| 42 | type Vulnerability, |
| 43 | dismissLabel, |
| 44 | } from "@g1t/contracts"; |
| 45 | |
| 46 | import { |
| 47 | type ActivityEntry, |
| 48 | type PackageGroup, |
| 49 | UPDATE_STATES, |
| 50 | alertActivity, |
| 51 | groupByPackage, |
| 52 | highestFix, |
| 53 | latestUpdate, |
| 54 | splitSecrets, |
| 55 | worstSeverity, |
| 56 | } from "../lib/security-alerts"; |
| 57 | import { TimeAgo } from "./ui"; |
| 58 | import { Avatar } from "./ui/avatar"; |
| 59 | import { Badge, type BadgeTone } from "./ui/badge"; |
| 60 | import { Button } from "./ui/button"; |
| 61 | import { Card } from "./ui/card"; |
| 62 | import { Hint } from "./ui/hint"; |
| 63 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 64 | import { RadioGroup, RadioOption } from "./ui/radio-group"; |
| 65 | |
| 66 | type Done = { ok: boolean; error?: string } | undefined; |
| 67 | |
| 68 | const SEVERITY: Record<Severity, { label: string; tone: BadgeTone }> = { |
| 69 | critical: { label: "Critical", tone: "danger" }, |
| 70 | high: { label: "High", tone: "warn" }, |
| 71 | medium: { label: "Medium", tone: "merged" }, |
| 72 | low: { label: "Low", tone: "info" }, |
| 73 | unknown: { label: "Unrated", tone: "neutral" }, |
| 74 | }; |
| 75 | |
| 76 | export function SeverityBadge({ severity }: { severity: Severity }) { |
| 77 | return <Badge tone={SEVERITY[severity].tone}>{SEVERITY[severity].label}</Badge>; |
| 78 | } |
| 79 | |
| 80 | /** Open alerts by severity, one tile each. */ |
| 81 | export function SeverityCountsGrid({ counts }: { counts: SeverityCounts }) { |
| 82 | return ( |
| 83 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> |
| 84 | {SEVERITIES.map((severity) => ( |
| 85 | <Card key={severity} className="px-4 py-3"> |
| 86 | <p className="text-xs text-muted">{SEVERITY[severity].label}</p> |
| 87 | <p className={`mt-1 text-2xl font-semibold tabular-nums ${counts[severity] > 0 && severity === "critical" ? "text-danger" : ""}`}> |
| 88 | {counts[severity]} |
| 89 | </p> |
| 90 | </Card> |
| 91 | ))} |
| 92 | </div> |
| 93 | ); |
| 94 | } |
| 95 | |
| 96 | /** A compact row of severity counts, for a list of projects. */ |
| 97 | export function SeverityCountsInline({ counts }: { counts: SeverityCounts }) { |
| 98 | const shown = SEVERITIES.filter((severity) => counts[severity] > 0); |
| 99 | if (shown.length === 0) { |
| 100 | return ( |
| 101 | <span className="inline-flex items-center gap-1 text-xs text-success"> |
| 102 | <ShieldCheck size={13} /> |
| 103 | Nothing open |
| 104 | </span> |
| 105 | ); |
| 106 | } |
| 107 | return ( |
| 108 | <span className="flex flex-wrap gap-1.5"> |
| 109 | {shown.map((severity) => ( |
| 110 | <Badge key={severity} tone={SEVERITY[severity].tone}> |
| 111 | {counts[severity]} {SEVERITY[severity].label.toLowerCase()} |
| 112 | </Badge> |
| 113 | ))} |
| 114 | </span> |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | const STATE_FILTERS: { state: AlertState; label: string; icon: ReactNode }[] = [ |
| 119 | { state: "open", label: "Open", icon: <CircleDot size={14} /> }, |
| 120 | { state: "dismissed", label: "Dismissed", icon: <CircleSlash size={14} /> }, |
| 121 | { state: "fixed", label: "Fixed", icon: <CircleCheck size={14} /> }, |
| 122 | ]; |
| 123 | |
| 124 | /** Open, Dismissed and Fixed, with how many alerts each holds. */ |
| 125 | export function StateFilter({ counts, value, onChange }: { counts: Record<AlertState, number>; value: AlertState; onChange: (state: AlertState) => void }) { |
| 126 | return ( |
| 127 | <div role="group" aria-label="Filter alerts" className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"> |
| 128 | {STATE_FILTERS.map(({ state, label, icon }) => ( |
| 129 | <button |
| 130 | key={state} |
| 131 | type="button" |
| 132 | aria-pressed={value === state} |
| 133 | onClick={() => onChange(state)} |
| 134 | className={`inline-flex items-center gap-1.5 transition-colors ${value === state ? "font-medium text-fg" : "text-muted hover:text-fg"}`} |
| 135 | > |
| 136 | {icon} |
| 137 | {counts[state]} {label} |
| 138 | </button> |
| 139 | ))} |
| 140 | </div> |
| 141 | ); |
| 142 | } |
| 143 | |
| 144 | const TEXTAREA = |
| 145 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; |
| 146 | |
| 147 | const SMALL_BUTTON = |
| 148 | "rounded-md border border-line px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50"; |
| 149 | |
| 150 | /** Dismiss an alert with one of `reasons` and an optional comment. */ |
| 151 | export function DismissDialog({ |
| 152 | id, |
| 153 | title, |
| 154 | detail, |
| 155 | reasons, |
| 156 | note, |
| 157 | action, |
| 158 | defaultReason, |
| 159 | trigger = "Dismiss", |
| 160 | }: { |
| 161 | id: string; |
| 162 | title: string; |
| 163 | /** What the alert is, in a line of code type. */ |
| 164 | detail: string; |
| 165 | reasons: { reason: DismissReason; label: string; about: string }[]; |
| 166 | note?: string; |
| 167 | action: string; |
| 168 | defaultReason?: DismissReason; |
| 169 | trigger?: string; |
| 170 | }) { |
| 171 | const fetcher = useFetcher<Done>(); |
| 172 | const [open, setOpen] = useState(false); |
| 173 | const [reason, setReason] = useState<string>(defaultReason ?? ""); |
| 174 | useEffect(() => { |
| 175 | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); |
| 176 | }, [fetcher.state, fetcher.data]); |
| 177 | return ( |
| 178 | <Dialog |
| 179 | open={open} |
| 180 | onOpenChange={(next) => { |
| 181 | setOpen(next); |
| 182 | if (next) setReason(defaultReason ?? ""); |
| 183 | }} |
| 184 | > |
| 185 | <DialogTrigger className={SMALL_BUTTON}>{trigger}</DialogTrigger> |
| 186 | <DialogContent> |
| 187 | <DialogHeader> |
| 188 | <DialogTitle>{title}</DialogTitle> |
| 189 | <DialogDescription> |
| 190 | Say why it can stay. The alert keeps your name, the reason and your comment, and anyone with access can reopen it. |
| 191 | </DialogDescription> |
| 192 | </DialogHeader> |
| 193 | <fetcher.Form method="post" action={action} className="space-y-4"> |
| 194 | <input type="hidden" name="intent" value="dismiss" /> |
| 195 | <input type="hidden" name="id" value={id} /> |
| 196 | <p className="font-mono text-xs break-all text-muted">{detail}</p> |
| 197 | <fieldset> |
| 198 | <legend className="text-xs font-medium text-muted">Reason</legend> |
| 199 | <RadioGroup name="reason" value={reason} onValueChange={setReason} required className="mt-2 gap-3"> |
| 200 | {reasons.map((option) => ( |
| 201 | <RadioOption key={option.reason} value={option.reason} label={option.label} description={option.about} /> |
| 202 | ))} |
| 203 | </RadioGroup> |
| 204 | </fieldset> |
| 205 | {note && <p className="rounded-md border border-warn/30 bg-warn/5 px-3 py-2 text-xs text-warn">{note}</p>} |
| 206 | <label className="block"> |
| 207 | <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span> |
| 208 | <textarea name="comment" rows={3} maxLength={500} placeholder="What someone reading this later should know." className={TEXTAREA} /> |
| 209 | </label> |
| 210 | <div className="flex justify-end"> |
| 211 | <Button type="submit" disabled={fetcher.state !== "idle" || !reason}> |
| 212 | {fetcher.state !== "idle" ? "Dismissing…" : "Dismiss alert"} |
| 213 | </Button> |
| 214 | </div> |
| 215 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} |
| 216 | </fetcher.Form> |
| 217 | </DialogContent> |
| 218 | </Dialog> |
| 219 | ); |
| 220 | } |
| 221 | |
| 222 | export function ReopenButton({ id, action }: { id: string; action: string }) { |
| 223 | const fetcher = useFetcher<Done>(); |
| 224 | return ( |
| 225 | <span className="flex flex-col items-end gap-1"> |
| 226 | <button |
| 227 | type="button" |
| 228 | disabled={fetcher.state !== "idle"} |
| 229 | onClick={() => fetcher.submit({ intent: "reopen", id }, { method: "post", action })} |
| 230 | className={SMALL_BUTTON} |
| 231 | > |
| 232 | {fetcher.state !== "idle" ? "Reopening…" : "Reopen"} |
| 233 | </button> |
| 234 | {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>} |
| 235 | </span> |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | /** What happened to an alert, oldest first, folded away until asked for. */ |
| 240 | function ActivityLog({ entries, base, open }: { entries: ActivityEntry[]; base: string; open?: boolean }) { |
| 241 | if (entries.length === 0) return null; |
| 242 | return ( |
| 243 | <details className="group mt-2" open={open}> |
| 244 | <summary className="inline-flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted hover:text-fg [&::-webkit-details-marker]:hidden"> |
| 245 | <History size={12} /> |
| 246 | <span className="group-open:hidden">Show activity ({entries.length})</span> |
| 247 | <span className="hidden group-open:inline">Hide activity</span> |
| 248 | </summary> |
| 249 | <ol className="mt-2 space-y-2 border-l border-line pl-3"> |
| 250 | {entries.map((entry) => ( |
| 251 | <li key={entry.key} className="text-xs"> |
| 252 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-muted"> |
| 253 | {entry.actor ? ( |
| 254 | <> |
| 255 | <Avatar name={entry.actor} size={16} /> |
| 256 | <span className="font-medium text-fg">{entry.actor}</span> |
| 257 | </> |
| 258 | ) : ( |
| 259 | <CircleDot size={12} className="text-faint" /> |
| 260 | )} |
| 261 | <span>{entry.text}</span> |
| 262 | {entry.ref && ( |
| 263 | <Link |
| 264 | to={entry.ref.kind === "pull" ? `${base}/pull/${entry.ref.number}` : itemPath(base.split("/")[1]!, entry.ref.number)} |
| 265 | className="font-medium text-fg-soft hover:text-fg hover:underline" |
| 266 | > |
| 267 | #{entry.ref.number} |
| 268 | </Link> |
| 269 | )} |
| 270 | {entry.reason && <Badge>{dismissLabel(entry.reason)}</Badge>} |
| 271 | <span className="text-faint"> |
| 272 | <TimeAgo at={entry.at} /> |
| 273 | </span> |
| 274 | </p> |
| 275 | {entry.comment && <p className="mt-1 text-fg-soft wrap-anywhere">“{entry.comment}”</p>} |
| 276 | </li> |
| 277 | ))} |
| 278 | </ol> |
| 279 | </details> |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | function useFocus<T extends HTMLElement>(focused: boolean) { |
| 284 | const ref = useRef<T>(null); |
| 285 | useEffect(() => { |
| 286 | if (focused) ref.current?.scrollIntoView({ block: "center" }); |
| 287 | }, [focused]); |
| 288 | return ref; |
| 289 | } |
| 290 | |
| 291 | const FOCUSED = "bg-accent/5 ring-1 ring-accent/40 ring-inset"; |
| 292 | |
| 293 | function secretBadge(finding: SecretFinding): { label: string; tone: BadgeTone; about: string } { |
| 294 | if (finding.state === "dismissed") { |
| 295 | return { |
| 296 | label: "Dismissed", |
| 297 | tone: "neutral", |
| 298 | about: finding.status === "allowed" ? "Pushes carrying it go through." : "Dismissed.", |
| 299 | }; |
| 300 | } |
| 301 | if (finding.state === "fixed") return { label: "Revoked", tone: "success", about: "Revoked or rotated." }; |
| 302 | if (finding.status === "blocked") { |
| 303 | return { label: "Push blocked", tone: finding.testValue ? "neutral" : "warn", about: "A push carrying it was refused, so it never landed." }; |
| 304 | } |
| 305 | return { |
| 306 | label: "In history", |
| 307 | tone: finding.testValue ? "neutral" : "danger", |
| 308 | about: "In the repository's history. Rotate it with whoever issued it, then dismiss it as revoked.", |
| 309 | }; |
| 310 | } |
| 311 | |
| 312 | function SecretItem({ |
| 313 | finding, |
| 314 | activity, |
| 315 | base, |
| 316 | action, |
| 317 | focused, |
| 318 | canDismiss, |
| 319 | }: { |
| 320 | finding: SecretFinding; |
| 321 | activity: AlertActivity[]; |
| 322 | base: string; |
| 323 | action: string; |
| 324 | focused: boolean; |
| 325 | canDismiss: boolean; |
| 326 | }) { |
| 327 | const ref = useFocus<HTMLLIElement>(focused); |
| 328 | const badge = secretBadge(finding); |
| 329 | const landed = finding.source === "history" || finding.status === "open"; |
| 330 | const where = `${finding.path}:${finding.line}`; |
| 331 | return ( |
| 332 | <li ref={ref} id={finding.id} className={`flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start ${focused ? FOCUSED : ""}`}> |
| 333 | <KeyRound size={15} className="mt-0.5 hidden shrink-0 text-muted sm:block" /> |
| 334 | <div className="min-w-0 grow"> |
| 335 | <div className="flex flex-wrap items-center gap-2"> |
| 336 | <Link to={`${base}/security/secret-scanning/${finding.id}`} className="text-sm font-medium first-letter:uppercase hover:underline"> |
| 337 | {finding.label} |
| 338 | </Link> |
| 339 | <Hint label={badge.about}> |
| 340 | <Badge tone={badge.tone}>{badge.label}</Badge> |
| 341 | </Hint> |
| 342 | {finding.testValue && ( |
| 343 | <Hint label={finding.testValue}> |
| 344 | <Badge>Likely test value</Badge> |
| 345 | </Hint> |
| 346 | )} |
| 347 | {finding.validity === "active" && ( |
| 348 | <Hint label="Its issuer says it still works"> |
| 349 | <Badge tone="danger">Active</Badge> |
| 350 | </Hint> |
| 351 | )} |
| 352 | {finding.validity === "inactive" && ( |
| 353 | <Hint label="Its issuer refused it: revoked or expired"> |
| 354 | <Badge>Inactive</Badge> |
| 355 | </Hint> |
| 356 | )} |
| 357 | {finding.bypass && ( |
| 358 | <Hint label={`Bypassed by ${finding.bypass.by}`}> |
| 359 | <Badge tone="warn">Bypassed</Badge> |
| 360 | </Hint> |
| 361 | )} |
| 362 | {finding.state === "dismissed" && finding.dismissedReason && <Badge>{dismissLabel(finding.dismissedReason)}</Badge>} |
| 363 | </div> |
| 364 | <p className="mt-1 truncate font-mono text-xs"> |
| 365 | {landed ? ( |
| 366 | <Link to={`${base}/blob/${finding.commit}/${finding.path}#L${finding.line}`} className="text-fg-soft hover:text-fg hover:underline"> |
| 367 | {where} |
| 368 | </Link> |
| 369 | ) : ( |
| 370 | <span className="text-fg-soft">{where}</span> |
| 371 | )} |
| 372 | <span className="text-faint"> · {finding.preview}</span> |
| 373 | </p> |
| 374 | {finding.testValue && <p className="mt-1 text-xs text-muted">{finding.testValue}</p>} |
| 375 | <p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint"> |
| 376 | <span> |
| 377 | {finding.source === "push" ? "in a push" : "in history"} |
| 378 | {finding.foundBy && <> by {finding.foundBy}</>}, commit{" "} |
| 379 | {landed ? ( |
| 380 | <Link to={`${base}/commit/${finding.commit}`} className="font-mono hover:text-fg"> |
| 381 | {finding.commit.slice(0, 7)} |
| 382 | </Link> |
| 383 | ) : ( |
| 384 | <span className="font-mono">{finding.commit.slice(0, 7)}</span> |
| 385 | )} |
| 386 | </span> |
| 387 | <span> |
| 388 | found <TimeAgo at={finding.foundAt} /> |
| 389 | </span> |
| 390 | </p> |
| 391 | <ActivityLog entries={alertActivity(finding, activity)} base={base} open={focused && finding.state !== "open"} /> |
| 392 | </div> |
| 393 | {canDismiss && ( |
| 394 | <div className="flex shrink-0 items-center gap-1.5"> |
| 395 | {finding.state === "open" ? ( |
| 396 | <DismissDialog |
| 397 | id={finding.id} |
| 398 | title={`Dismiss ${finding.label}`} |
| 399 | detail={`${where} · ${finding.preview}`} |
| 400 | reasons={SECRET_DISMISS_REASONS} |
| 401 | note={ |
| 402 | finding.status === "blocked" && !finding.testValue ? "Dismissing it lets the same push through, unless you dismiss it as revoked." : undefined |
| 403 | } |
| 404 | action={action} |
| 405 | /> |
| 406 | ) : ( |
| 407 | <ReopenButton id={finding.id} action={action} /> |
| 408 | )} |
| 409 | </div> |
| 410 | )} |
| 411 | </li> |
| 412 | ); |
| 413 | } |
| 414 | |
| 415 | function Empty({ title, children }: { title: string; children: ReactNode }) { |
| 416 | return ( |
| 417 | <Card tone="plain" className="border-dashed px-6 py-10 text-center"> |
| 418 | <ShieldCheck size={22} className="mx-auto text-success" /> |
| 419 | <p className="mt-2 font-medium">{title}</p> |
| 420 | <p className="mt-1 text-sm text-muted">{children}</p> |
| 421 | </Card> |
| 422 | ); |
| 423 | } |
| 424 | |
| 425 | const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"; |
| 426 | |
| 427 | /** The secret alerts in one state: on Open, real ones first, then likely test values. */ |
| 428 | export function SecretsList({ |
| 429 | secrets, |
| 430 | state, |
| 431 | activity, |
| 432 | base, |
| 433 | action, |
| 434 | focus, |
| 435 | canDismiss, |
| 436 | }: { |
| 437 | /** Already filtered to `state`. */ |
| 438 | secrets: SecretFinding[]; |
| 439 | state: AlertState; |
| 440 | activity: AlertActivity[]; |
| 441 | base: string; |
| 442 | action: string; |
| 443 | focus: string | null; |
| 444 | /** Whether the viewer may dismiss and reopen secret alerts (Admin). */ |
| 445 | canDismiss: boolean; |
| 446 | }) { |
| 447 | const item = (finding: SecretFinding) => ( |
| 448 | <SecretItem key={finding.id} finding={finding} activity={activity} base={base} action={action} focused={finding.id === focus} canDismiss={canDismiss} /> |
| 449 | ); |
| 450 | if (secrets.length === 0) { |
| 451 | return state === "open" ? ( |
| 452 | <Empty title="No open secret alerts"> |
| 453 | Pushes that add a key or a token are refused before they land, and the history is scanned once in the background. |
| 454 | </Empty> |
| 455 | ) : ( |
| 456 | <Empty title={state === "dismissed" ? "No dismissed secret alerts" : "No revoked secrets"}> |
| 457 | {state === "dismissed" |
| 458 | ? "Alerts someone dismissed as a false positive, a test value or accepted are listed here." |
| 459 | : "Secrets dismissed as revoked are listed here."} |
| 460 | </Empty> |
| 461 | ); |
| 462 | } |
| 463 | if (state !== "open") return <ul className={LIST}>{secrets.map(item)}</ul>; |
| 464 | const { real, tests } = splitSecrets(secrets); |
| 465 | return ( |
| 466 | <div className="space-y-5"> |
| 467 | {real.length > 0 ? ( |
| 468 | <ul className={LIST}>{real.map(item)}</ul> |
| 469 | ) : ( |
| 470 | <Empty title="No secrets that look real">Only likely test values are open, and they never block a push.</Empty> |
| 471 | )} |
| 472 | {tests.length > 0 && ( |
| 473 | <section> |
| 474 | <h4 className="text-sm font-medium">Likely test values</h4> |
| 475 | <p className="mt-0.5 mb-2 text-xs text-muted"> |
| 476 | These look made for tests or documentation. They never block a push and are not counted as critical; dismiss them to clear the list. |
| 477 | </p> |
| 478 | <ul className={LIST}>{tests.map(item)}</ul> |
| 479 | </section> |
| 480 | )} |
| 481 | </div> |
| 482 | ); |
| 483 | } |
| 484 | |
| 485 | /** Where the legacy upgrade work item for a package stands, as the page loads it. */ |
| 486 | export type UpgradeFix = { |
| 487 | number: number; |
| 488 | state: "open" | "closed"; |
| 489 | /** The newest pull request for the work item, if any. */ |
| 490 | pull: { number: number; status: PullStatus; agent: string | null } | null; |
| 491 | resolvedBy: number | null; |
| 492 | }; |
| 493 | |
| 494 | /** A security update's pull request as the page loads it. */ |
| 495 | export type PullInfo = { number: number; status: PullStatus; title: string }; |
| 496 | |
| 497 | function FixLink({ item, fix, base }: { item: number; fix: UpgradeFix | undefined; base: string }) { |
| 498 | const pull = fix?.pull; |
| 499 | return ( |
| 500 | <span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"> |
| 501 | <span className="text-muted">Upgrade work item</span> |
| 502 | <Link to={itemPath(base.split("/")[1]!, item)} className="inline-flex items-center gap-1 text-fg-soft hover:text-fg"> |
| 503 | {fix?.state === "closed" ? <CircleCheck size={12} className="text-merged" /> : <CircleDot size={12} className="text-success" />}#{item} |
| 504 | </Link> |
| 505 | {pull && ( |
| 506 | <Link to={`${base}/pull/${pull.number}`} className="inline-flex items-center gap-1 text-muted hover:text-fg"> |
| 507 | {pull.agent ? <Bot size={12} /> : <GitPullRequest size={12} />}#{pull.number} {pull.status === "draft" ? "in progress" : pull.status} |
| 508 | </Link> |
| 509 | )} |
| 510 | </span> |
| 511 | ); |
| 512 | } |
| 513 | |
| 514 | const PULL_ICON: Record<PullStatus, ReactNode> = { |
| 515 | draft: <GitPullRequest size={13} className="text-muted" />, |
| 516 | open: <GitPullRequest size={13} className="text-success" />, |
| 517 | merged: <GitMerge size={13} className="text-merged" />, |
| 518 | closed: <GitPullRequestClosed size={13} className="text-danger" />, |
| 519 | }; |
| 520 | |
| 521 | /** Where g1t's security update for a package stands. */ |
| 522 | function UpdateStatus({ update, name, pulls, base }: { update: SecurityUpdate; name: string; pulls: Record<number, PullInfo>; base: string }) { |
| 523 | const meta = UPDATE_STATES[update.state]; |
| 524 | const pull = update.pull != null ? pulls[update.pull] : undefined; |
| 525 | const about: Record<SecurityUpdate["state"], ReactNode> = { |
| 526 | requested: ( |
| 527 | <> |
| 528 | A sandbox is raising {name} to {update.target}. |
| 529 | </> |
| 530 | ), |
| 531 | open: ( |
| 532 | <> |
| 533 | It raises {name} to {update.target} and lands through your branch's required checks. |
| 534 | </> |
| 535 | ), |
| 536 | merged: ( |
| 537 | <> |
| 538 | {name} was raised to {update.target}. |
| 539 | </> |
| 540 | ), |
| 541 | closed: <>The pull request was closed without merging.</>, |
| 542 | superseded: <>A newer update replaced it, or the package is no longer vulnerable, so g1t closed it.</>, |
| 543 | needs_code: ( |
| 544 | <> |
| 545 | Raising {name} to {update.target} needs code changes, so g1t opened a work item to make them |
| 546 | {update.item != null && ( |
| 547 | <> |
| 548 | :{" "} |
| 549 | <Link to={itemPath(base.split("/")[1]!, update.item)} className="font-medium text-fg-soft hover:text-fg hover:underline"> |
| 550 | #{update.item} |
| 551 | </Link> |
| 552 | </> |
| 553 | )} |
| 554 | . |
| 555 | </> |
| 556 | ), |
| 557 | failed: <>{update.error ?? "g1t could not make the change."}</>, |
| 558 | }; |
| 559 | return ( |
| 560 | <Card tone="plain" radius="lg" className="mt-2.5 bg-bg/40 px-3 py-2 text-xs sm:ml-7"> |
| 561 | <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5"> |
| 562 | {update.state === "requested" ? ( |
| 563 | <Loader size={13} className="animate-spin text-info motion-reduce:animate-none" /> |
| 564 | ) : update.state === "failed" || update.state === "needs_code" ? ( |
| 565 | <CircleAlert size={13} className={update.state === "failed" ? "text-danger" : "text-warn"} /> |
| 566 | ) : ( |
| 567 | PULL_ICON[pull?.status ?? (update.state === "merged" ? "merged" : update.state === "open" ? "open" : "closed")] |
| 568 | )} |
| 569 | <Badge tone={meta.tone}>{meta.label}</Badge> |
| 570 | {update.pull != null && ( |
| 571 | <Link to={`${base}/pull/${update.pull}`} className="font-medium text-fg-soft hover:text-fg hover:underline"> |
| 572 | #{update.pull} |
| 573 | {pull && <span className="font-normal text-muted"> {pull.status === "draft" ? "draft" : pull.status}</span>} |
| 574 | </Link> |
| 575 | )} |
| 576 | {update.branch && ( |
| 577 | <span className="inline-flex min-w-0 items-center gap-1 font-mono text-muted"> |
| 578 | <GitBranch size={12} className="shrink-0" /> |
| 579 | <span className="truncate">{update.branch}</span> |
| 580 | </span> |
| 581 | )} |
| 582 | <span className="text-faint"> |
| 583 | <TimeAgo at={update.updatedAt} /> |
| 584 | </span> |
| 585 | </div> |
| 586 | <p className={`mt-1 ${update.state === "failed" ? "text-danger" : "text-muted"} wrap-anywhere`}>{about[update.state]}</p> |
| 587 | </Card> |
| 588 | ); |
| 589 | } |
| 590 | |
| 591 | export function VulnerabilityList({ |
| 592 | vulnerabilities, |
| 593 | state, |
| 594 | activity, |
| 595 | fixes, |
| 596 | pulls, |
| 597 | upkeep, |
| 598 | base, |
| 599 | action, |
| 600 | focus, |
| 601 | canDismiss, |
| 602 | }: { |
| 603 | /** Already filtered to `state`. */ |
| 604 | vulnerabilities: Vulnerability[]; |
| 605 | state: AlertState; |
| 606 | activity: AlertActivity[]; |
| 607 | fixes: Record<number, UpgradeFix>; |
| 608 | pulls: Record<number, PullInfo>; |
| 609 | /** Whether security updates are on. */ |
| 610 | upkeep: boolean; |
| 611 | base: string; |
| 612 | action: string; |
| 613 | focus: string | null; |
| 614 | /** Whether the viewer may dismiss and reopen dependency alerts (Write). */ |
| 615 | canDismiss: boolean; |
| 616 | }) { |
| 617 | const packages = groupByPackage(vulnerabilities); |
| 618 | if (packages.length === 0) { |
| 619 | return state === "open" ? ( |
| 620 | <Empty title="No known vulnerabilities"> |
| 621 | Every package the lockfiles resolve is checked against the OSV database on each push to the default branch, and daily. |
| 622 | </Empty> |
| 623 | ) : ( |
| 624 | <Empty title={state === "dismissed" ? "No dismissed dependency alerts" : "Nothing fixed yet"}> |
| 625 | {state === "dismissed" |
| 626 | ? "Alerts someone dismissed, with their reason, are listed here." |
| 627 | : "Alerts whose package was upgraded, or is no longer vulnerable, are listed here."} |
| 628 | </Empty> |
| 629 | ); |
| 630 | } |
| 631 | return ( |
| 632 | <ul className={LIST}> |
| 633 | {packages.map((group) => ( |
| 634 | <PackageItem |
| 635 | key={group.key} |
| 636 | group={group} |
| 637 | activity={activity} |
| 638 | fixes={fixes} |
| 639 | pulls={pulls} |
| 640 | upkeep={upkeep} |
| 641 | base={base} |
| 642 | action={action} |
| 643 | focus={focus} |
| 644 | canDismiss={canDismiss} |
| 645 | /> |
| 646 | ))} |
| 647 | </ul> |
| 648 | ); |
| 649 | } |
| 650 | |
| 651 | function PackageItem({ |
| 652 | group, |
| 653 | activity, |
| 654 | fixes, |
| 655 | pulls, |
| 656 | upkeep, |
| 657 | base, |
| 658 | action, |
| 659 | focus, |
| 660 | canDismiss, |
| 661 | }: { |
| 662 | group: PackageGroup; |
| 663 | activity: AlertActivity[]; |
| 664 | fixes: Record<number, UpgradeFix>; |
| 665 | pulls: Record<number, PullInfo>; |
| 666 | upkeep: boolean; |
| 667 | base: string; |
| 668 | action: string; |
| 669 | focus: string | null; |
| 670 | canDismiss: boolean; |
| 671 | }) { |
| 672 | const versions = [...new Set(group.vulns.map((vuln) => vuln.version))]; |
| 673 | const manifests = [...new Set(group.vulns.map((vuln) => vuln.manifest))]; |
| 674 | const target = highestFix(group.vulns); |
| 675 | const update = latestUpdate(group.vulns); |
| 676 | const item = update ? null : (group.vulns.find((vuln) => vuln.item != null)?.item ?? null); |
| 677 | return ( |
| 678 | <li className="px-4 py-3"> |
| 679 | <div className="flex items-start gap-3"> |
| 680 | <Package size={15} className="mt-0.5 hidden shrink-0 text-muted sm:block" /> |
| 681 | <div className="min-w-0 grow"> |
| 682 | <div className="flex flex-wrap items-center gap-2"> |
| 683 | <span className="font-mono text-sm font-medium break-all">{group.name}</span> |
| 684 | <span className="font-mono text-xs text-muted">{versions.join(", ")}</span> |
| 685 | <Badge>{group.ecosystem}</Badge> |
| 686 | <SeverityBadge severity={worstSeverity(group.vulns)} /> |
| 687 | </div> |
| 688 | <p className="mt-1 text-xs text-faint wrap-anywhere"> |
| 689 | {target ? <>Fixed in {target}</> : "No patched version"} · locked in <span className="font-mono">{manifests.join(", ")}</span> |
| 690 | </p> |
| 691 | </div> |
| 692 | </div> |
| 693 | {update && <UpdateStatus update={update} name={group.name} pulls={pulls} base={base} />} |
| 694 | {item != null && ( |
| 695 | <div className="mt-2 sm:ml-7"> |
| 696 | <FixLink item={item} fix={fixes[item]} base={base} /> |
| 697 | </div> |
| 698 | )} |
| 699 | <Card asChild tone="plain" radius="lg" className="mt-2.5 space-y-px overflow-hidden sm:ml-7"> |
| 700 | <ul> |
| 701 | {group.vulns.map((vuln) => ( |
| 702 | <AdvisoryItem |
| 703 | key={vuln.id} |
| 704 | vuln={vuln} |
| 705 | showManifest={manifests.length > 1} |
| 706 | activity={activity} |
| 707 | upkeep={upkeep} |
| 708 | base={base} |
| 709 | action={action} |
| 710 | focused={vuln.id === focus} |
| 711 | canDismiss={canDismiss} |
| 712 | /> |
| 713 | ))} |
| 714 | </ul> |
| 715 | </Card> |
| 716 | </li> |
| 717 | ); |
| 718 | } |
| 719 | |
| 720 | function AdvisoryItem({ |
| 721 | vuln, |
| 722 | showManifest, |
| 723 | activity, |
| 724 | upkeep, |
| 725 | base, |
| 726 | action, |
| 727 | focused, |
| 728 | canDismiss, |
| 729 | }: { |
| 730 | vuln: Vulnerability; |
| 731 | showManifest: boolean; |
| 732 | activity: AlertActivity[]; |
| 733 | upkeep: boolean; |
| 734 | base: string; |
| 735 | action: string; |
| 736 | focused: boolean; |
| 737 | canDismiss: boolean; |
| 738 | }) { |
| 739 | const ref = useFocus<HTMLLIElement>(focused); |
| 740 | const osv = `https://osv.dev/vulnerability/${vuln.osvId}`; |
| 741 | const dismiss = (reason?: DismissReason, trigger?: string) => ( |
| 742 | <DismissDialog |
| 743 | id={vuln.id} |
| 744 | title={`Dismiss ${vuln.advisory}`} |
| 745 | detail={`${vuln.package} ${vuln.version} · ${vuln.manifest}`} |
| 746 | reasons={DEPENDENCY_DISMISS_REASONS} |
| 747 | action={action} |
| 748 | defaultReason={reason} |
| 749 | trigger={trigger} |
| 750 | /> |
| 751 | ); |
| 752 | return ( |
| 753 | <li ref={ref} id={vuln.id} className={`bg-bg/30 px-3 py-2.5 not-first:border-t not-first:border-line ${focused ? FOCUSED : ""}`}> |
| 754 | <div className="flex flex-col gap-2 sm:flex-row sm:items-start"> |
| 755 | <div className="min-w-0 grow text-xs"> |
| 756 | <p className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 757 | <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-mono text-fg-soft hover:text-fg"> |
| 758 | {vuln.advisory} |
| 759 | <ExternalLink size={10} /> |
| 760 | </a> |
| 761 | <SeverityBadge severity={vuln.severity} /> |
| 762 | {vuln.fixedVersion && <span className="text-faint">fixed in {vuln.fixedVersion}</span>} |
| 763 | {showManifest && <span className="font-mono text-faint">{vuln.manifest}</span>} |
| 764 | {vuln.state === "dismissed" && vuln.dismissedReason && <Badge>{dismissLabel(vuln.dismissedReason)}</Badge>} |
| 765 | </p> |
| 766 | <p className="mt-1 text-muted wrap-anywhere">{vuln.summary}</p> |
| 767 | </div> |
| 768 | {canDismiss && vuln.state !== "fixed" && ( |
| 769 | <div className="flex shrink-0 items-center gap-1.5">{vuln.state === "open" ? dismiss() : <ReopenButton id={vuln.id} action={action} />}</div> |
| 770 | )} |
| 771 | </div> |
| 772 | {vuln.state === "open" && !vuln.fixedVersion && ( |
| 773 | <div className="mt-2 rounded-md border border-warn/30 bg-warn/5 px-3 py-2 text-xs"> |
| 774 | <p className="flex items-center gap-1.5 font-medium text-warn"> |
| 775 | <ShieldAlert size={13} /> |
| 776 | No patched version available |
| 777 | </p> |
| 778 | <p className="mt-1 text-muted"> |
| 779 | No release fixes this yet. Dependencies are checked again daily |
| 780 | {upkeep |
| 781 | ? ", and g1t opens a pull request when a fix is published." |
| 782 | : "; turn on security updates and g1t opens a pull request when a fix is published."} |
| 783 | </p> |
| 784 | <div className="mt-2 flex flex-wrap items-center gap-2"> |
| 785 | <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-fg-soft hover:text-fg hover:underline"> |
| 786 | Read the advisory |
| 787 | <ExternalLink size={10} /> |
| 788 | </a> |
| 789 | {canDismiss && dismiss("tolerable_risk", "Dismiss as tolerable risk")} |
| 790 | </div> |
| 791 | </div> |
| 792 | )} |
| 793 | <ActivityLog entries={alertActivity(vuln, activity)} base={base} open={focused && vuln.state !== "open"} /> |
| 794 | </li> |
| 795 | ); |
| 796 | } |
| 797 | |
| 798 | export function ScanSummary({ |
| 799 | scan, |
| 800 | }: { |
| 801 | scan: { |
| 802 | history: string; |
| 803 | commitsScanned: number; |
| 804 | historyFinishedAt: string | null; |
| 805 | dependenciesScannedAt: string | null; |
| 806 | dependenciesError: string | null; |
| 807 | lockfiles: string[]; |
| 808 | }; |
| 809 | }) { |
| 810 | const history = |
| 811 | scan.history === "done" ? ( |
| 812 | <> |
| 813 | History scanned: {scan.commitsScanned.toLocaleString()} commits |
| 814 | {scan.historyFinishedAt && ( |
| 815 | <> |
| 816 | , <TimeAgo at={scan.historyFinishedAt} /> |
| 817 | </> |
| 818 | )} |
| 819 | </> |
| 820 | ) : scan.history === "stopped" ? ( |
| 821 | "History scan paused: the workspace reached its spending limit" |
| 822 | ) : scan.history === "running" ? ( |
| 823 | `Scanning history: ${scan.commitsScanned.toLocaleString()} commits so far` |
| 824 | ) : ( |
| 825 | "History scan queued" |
| 826 | ); |
| 827 | return ( |
| 828 | <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted"> |
| 829 | <span className="inline-flex items-center gap-1.5"> |
| 830 | <KeyRound size={12} /> |
| 831 | {history} |
| 832 | </span> |
| 833 | <span className="inline-flex items-center gap-1.5"> |
| 834 | <Package size={12} /> |
| 835 | {scan.dependenciesScannedAt ? ( |
| 836 | <> |
| 837 | Dependencies read <TimeAgo at={scan.dependenciesScannedAt} /> |
| 838 | {scan.lockfiles.length > 0 ? <> from {scan.lockfiles.join(", ")}</> : " (no lockfiles found)"} |
| 839 | </> |
| 840 | ) : ( |
| 841 | "Dependencies not read yet" |
| 842 | )} |
| 843 | </span> |
| 844 | {scan.dependenciesError && ( |
| 845 | <span className="inline-flex items-center gap-1.5 text-warn"> |
| 846 | <ShieldAlert size={12} /> |
| 847 | {scan.dependenciesError} |
| 848 | </span> |
| 849 | )} |
| 850 | </div> |
| 851 | ); |
| 852 | } |