Skip to content
858 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 }: { update: SecurityUpdate; name: string; pulls: Record<number, PullInfo>; base: string }) {
545 const meta = UPDATE_STATES[update.state];
546 const pull = update.pull != null ? pulls[update.pull] : undefined;
547 const about: Record<SecurityUpdate["state"], ReactNode> = {
548 requested: <>A sandbox is raising {name} to {update.target}.</>,
549 open: <>It raises {name} to {update.target} and lands through your branch's required checks.</>,
550 merged: <>{name} was raised to {update.target}.</>,
551 closed: <>The pull request was closed without merging.</>,
552 superseded: <>A newer update replaced it, or the package is no longer vulnerable, so g1t closed it.</>,
553 needs_code: (
554 <>
555 Raising {name} to {update.target} needs code changes, so g1t opened an issue to make them
556 {update.issue != null && (
557 <>
558 :{" "}
559 <Link to={`${base}/issues/${update.issue}`} className="font-medium text-fg-soft hover:text-fg hover:underline">
560 #{update.issue}
561 </Link>
562 </>
563 )}
564 .
565 </>
566 ),
567 failed: <>{update.error ?? "g1t could not make the change."}</>,
568 };
569 return (
570 <Card tone="plain" radius="lg" className="mt-2.5 bg-bg/40 px-3 py-2 text-xs sm:ml-7">
571 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
572 {update.state === "requested" ? (
573 <Loader size={13} className="animate-spin text-info motion-reduce:animate-none" />
574 ) : update.state === "failed" || update.state === "needs_code" ? (
575 <CircleAlert size={13} className={update.state === "failed" ? "text-danger" : "text-warn"} />
576 ) : (
577 PULL_ICON[pull?.status ?? (update.state === "merged" ? "merged" : update.state === "open" ? "open" : "closed")]
578 )}
579 <Badge tone={meta.tone}>{meta.label}</Badge>
580 {update.pull != null && (
581 <Link to={`${base}/pull/${update.pull}`} className="font-medium text-fg-soft hover:text-fg hover:underline">
582 #{update.pull}
583 {pull && <span className="font-normal text-muted"> {pull.status === "draft" ? "draft" : pull.status}</span>}
584 </Link>
585 )}
586 {update.branch && (
587 <span className="inline-flex min-w-0 items-center gap-1 font-mono text-muted">
588 <GitBranch size={12} className="shrink-0" />
589 <span className="truncate">{update.branch}</span>
590 </span>
591 )}
592 <span className="text-faint">
593 <TimeAgo at={update.updatedAt} />
594 </span>
595 </div>
596 <p className={`mt-1 ${update.state === "failed" ? "text-danger" : "text-muted"} wrap-anywhere`}>{about[update.state]}</p>
597 </Card>
598 );
599}
600
601export function VulnerabilityList({
602 vulnerabilities,
603 state,
604 activity,
605 fixes,
606 pulls,
607 upkeep,
608 base,
609 action,
610 focus,
611 canDismiss,
612}: {
613 /** Already filtered to `state`. */
614 vulnerabilities: Vulnerability[];
615 state: AlertState;
616 activity: AlertActivity[];
617 fixes: Record<number, UpgradeFix>;
618 pulls: Record<number, PullInfo>;
619 /** Whether security updates are on. */
620 upkeep: boolean;
621 base: string;
622 action: string;
623 focus: string | null;
624 /** Whether the viewer may dismiss and reopen dependency alerts (Write). */
625 canDismiss: boolean;
626}) {
627 const packages = groupByPackage(vulnerabilities);
628 if (packages.length === 0) {
629 return state === "open" ? (
630 <Empty title="No known vulnerabilities">
631 Every package the lockfiles resolve is checked against the OSV database on each push to the default branch, and daily.
632 </Empty>
633 ) : (
634 <Empty title={state === "dismissed" ? "No dismissed dependency alerts" : "Nothing fixed yet"}>
635 {state === "dismissed"
636 ? "Alerts someone dismissed, with their reason, are listed here."
637 : "Alerts whose package was upgraded, or is no longer vulnerable, are listed here."}
638 </Empty>
639 );
640 }
641 return (
642 <ul className={LIST}>
643 {packages.map((group) => (
644 <PackageItem
645 key={group.key}
646 group={group}
647 activity={activity}
648 fixes={fixes}
649 pulls={pulls}
650 upkeep={upkeep}
651 base={base}
652 action={action}
653 focus={focus}
654 canDismiss={canDismiss}
655 />
656 ))}
657 </ul>
658 );
659}
660
661function PackageItem({
662 group,
663 activity,
664 fixes,
665 pulls,
666 upkeep,
667 base,
668 action,
669 focus,
670 canDismiss,
671}: {
672 group: PackageGroup;
673 activity: AlertActivity[];
674 fixes: Record<number, UpgradeFix>;
675 pulls: Record<number, PullInfo>;
676 upkeep: boolean;
677 base: string;
678 action: string;
679 focus: string | null;
680 canDismiss: boolean;
681}) {
682 const versions = [...new Set(group.vulns.map((vuln) => vuln.version))];
683 const manifests = [...new Set(group.vulns.map((vuln) => vuln.manifest))];
684 const target = highestFix(group.vulns);
685 const update = latestUpdate(group.vulns);
686 const issue = update ? null : (group.vulns.find((vuln) => vuln.issue != null)?.issue ?? null);
687 return (
688 <li className="px-4 py-3">
689 <div className="flex items-start gap-3">
690 <Package size={15} className="mt-0.5 hidden shrink-0 text-muted sm:block" />
691 <div className="min-w-0 grow">
692 <div className="flex flex-wrap items-center gap-2">
693 <span className="font-mono text-sm font-medium break-all">{group.name}</span>
694 <span className="font-mono text-xs text-muted">{versions.join(", ")}</span>
695 <Badge>{group.ecosystem}</Badge>
696 <SeverityBadge severity={worstSeverity(group.vulns)} />
697 </div>
698 <p className="mt-1 text-xs text-faint wrap-anywhere">
699 {target ? <>Fixed in {target}</> : "No patched version"} · locked in{" "}
700 <span className="font-mono">{manifests.join(", ")}</span>
701 </p>
702 </div>
703 </div>
704 {update && <UpdateStatus update={update} name={group.name} pulls={pulls} base={base} />}
705 {issue != null && (
706 <div className="mt-2 sm:ml-7">
707 <FixLink issue={issue} fix={fixes[issue]} base={base} />
708 </div>
709 )}
710 <Card asChild tone="plain" radius="lg" className="mt-2.5 space-y-px overflow-hidden sm:ml-7">
711 <ul>
712 {group.vulns.map((vuln) => (
713 <AdvisoryItem
714 key={vuln.id}
715 vuln={vuln}
716 showManifest={manifests.length > 1}
717 activity={activity}
718 upkeep={upkeep}
719 base={base}
720 action={action}
721 focused={vuln.id === focus}
722 canDismiss={canDismiss}
723 />
724 ))}
725 </ul>
726 </Card>
727 </li>
728 );
729}
730
731function AdvisoryItem({
732 vuln,
733 showManifest,
734 activity,
735 upkeep,
736 base,
737 action,
738 focused,
739 canDismiss,
740}: {
741 vuln: Vulnerability;
742 showManifest: boolean;
743 activity: AlertActivity[];
744 upkeep: boolean;
745 base: string;
746 action: string;
747 focused: boolean;
748 canDismiss: boolean;
749}) {
750 const ref = useFocus<HTMLLIElement>(focused);
751 const osv = `https://osv.dev/vulnerability/${vuln.osvId}`;
752 const dismiss = (reason?: DismissReason, trigger?: string) => (
753 <DismissDialog
754 id={vuln.id}
755 title={`Dismiss ${vuln.advisory}`}
756 detail={`${vuln.package} ${vuln.version} · ${vuln.manifest}`}
757 reasons={DEPENDENCY_DISMISS_REASONS}
758 action={action}
759 defaultReason={reason}
760 trigger={trigger}
761 />
762 );
763 return (
764 <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 : ""}`}>
765 <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
766 <div className="min-w-0 grow text-xs">
767 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
768 <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-mono text-fg-soft hover:text-fg">
769 {vuln.advisory}
770 <ExternalLink size={10} />
771 </a>
772 <SeverityBadge severity={vuln.severity} />
773 {vuln.fixedVersion && <span className="text-faint">fixed in {vuln.fixedVersion}</span>}
774 {showManifest && <span className="font-mono text-faint">{vuln.manifest}</span>}
775 {vuln.state === "dismissed" && vuln.dismissedReason && <Badge>{dismissLabel(vuln.dismissedReason)}</Badge>}
776 </p>
777 <p className="mt-1 text-muted wrap-anywhere">{vuln.summary}</p>
778 </div>
779 {canDismiss && vuln.state !== "fixed" && (
780 <div className="flex shrink-0 items-center gap-1.5">
781 {vuln.state === "open" ? dismiss() : <ReopenButton id={vuln.id} action={action} />}
782 </div>
783 )}
784 </div>
785 {vuln.state === "open" && !vuln.fixedVersion && (
786 <div className="mt-2 rounded-md border border-warn/30 bg-warn/5 px-3 py-2 text-xs">
787 <p className="flex items-center gap-1.5 font-medium text-warn">
788 <ShieldAlert size={13} />
789 No patched version available
790 </p>
791 <p className="mt-1 text-muted">
792 No release fixes this yet. Dependencies are checked again daily
793 {upkeep
794 ? ", and g1t opens a pull request when a fix is published."
795 : "; turn on security updates and g1t opens a pull request when a fix is published."}
796 </p>
797 <div className="mt-2 flex flex-wrap items-center gap-2">
798 <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-fg-soft hover:text-fg hover:underline">
799 Read the advisory
800 <ExternalLink size={10} />
801 </a>
802 {canDismiss && dismiss("tolerable_risk", "Dismiss as tolerable risk")}
803 </div>
804 </div>
805 )}
806 <ActivityLog entries={alertActivity(vuln, activity)} base={base} open={focused && vuln.state !== "open"} />
807 </li>
808 );
809}
810
811export function ScanSummary({
812 scan,
813}: {
814 scan: { history: string; commitsScanned: number; historyFinishedAt: string | null; dependenciesScannedAt: string | null; dependenciesError: string | null; lockfiles: string[] };
815}) {
816 const history =
817 scan.history === "done" ? (
818 <>
819 History scanned: {scan.commitsScanned.toLocaleString()} commits
820 {scan.historyFinishedAt && (
821 <>
822 , <TimeAgo at={scan.historyFinishedAt} />
823 </>
824 )}
825 </>
826 ) : scan.history === "stopped" ? (
827 "History scan paused: the workspace reached its spending limit"
828 ) : scan.history === "running" ? (
829 `Scanning history: ${scan.commitsScanned.toLocaleString()} commits so far`
830 ) : (
831 "History scan queued"
832 );
833 return (
834 <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted">
835 <span className="inline-flex items-center gap-1.5">
836 <KeyRound size={12} />
837 {history}
838 </span>
839 <span className="inline-flex items-center gap-1.5">
840 <Package size={12} />
841 {scan.dependenciesScannedAt ? (
842 <>
843 Dependencies read <TimeAgo at={scan.dependenciesScannedAt} />
844 {scan.lockfiles.length > 0 ? <> from {scan.lockfiles.join(", ")}</> : " (no lockfiles found)"}
845 </>
846 ) : (
847 "Dependencies not read yet"
848 )}
849 </span>
850 {scan.dependenciesError && (
851 <span className="inline-flex items-center gap-1.5 text-warn">
852 <ShieldAlert size={12} />
853 {scan.dependenciesError}
854 </span>
855 )}
856 </div>
857 );
858}