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.

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