Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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