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