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