Skip to content

g1t/apps/web/app/components/security.tsx

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