Skip to content

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

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