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

950 lines35,584 bytesCodeBlame
1/**
2 * Security, as a project's page shows it: open alerts by severity, the
3 * secrets found in pushes and history, vulnerable dependencies with the
4 * security update g1t opened for each, and what happened to every alert.
5 * The page posts the intents in `routes/repo/security.tsx`'s action.
6 */
7import {
8 Bot,
9 CircleAlert,
10 CircleCheck,
11 CircleDot,
12 CircleSlash,
13 ExternalLink,
14 FileWarning,
15 GitBranch,
16 GitMerge,
17 GitPullRequest,
18 GitPullRequestClosed,
19 History,
20 KeyRound,
21 Loader,
22 Package,
23 ShieldAlert,
24 ShieldCheck,
25} from "lucide-react";
26import { type ReactNode, useEffect, useRef, useState } from "react";
27import { Link, useFetcher } from "react-router";
28
29import {
30 type AlertActivity,
31 type AlertState,
32 DEPENDENCY_DISMISS_REASONS,
33 type DismissReason,
34 SECRET_DISMISS_REASONS,
35 SEVERITIES,
36 type PullStatus,
37 type SecretFinding,
38 type SecurityUpdate,
39 type Severity,
40 type SeverityCounts,
41 type VersionUpdatesState,
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 { Avatar, TimeAgo } from "./ui";
58import { Badge, type BadgeTone } from "./ui/badge";
59import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
60import { RadioGroup, RadioOption } from "./ui/radio-group";
61
62type Done = { ok: boolean; error?: string } | undefined;
63
64const SEVERITY: Record<Severity, { label: string; tone: BadgeTone }> = {
65 critical: { label: "Critical", tone: "danger" },
66 high: { label: "High", tone: "warn" },
67 medium: { label: "Medium", tone: "merged" },
68 low: { label: "Low", tone: "info" },
69 unknown: { label: "Unrated", tone: "neutral" },
70};
71
72/** Where the docs explain `.g1t/dependencies.yml`. */
73export const VERSION_UPDATES_DOCS = "https://docs.g1t.sh/guides/security/#version-updates";
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 <div key={severity} className="rounded-xl border border-line bg-surface 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 </div>
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-accent">
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. */
160function 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 className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
224 >
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
235function 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={`${base}/${entry.ref.kind === "pull" ? "pull" : "issues"}/${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: "accent", 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 <span className="text-sm font-medium first-letter:uppercase">{finding.label}</span>
350 <Badge tone={badge.tone} title={badge.about}>
351 {badge.label}
352 </Badge>
353 {finding.testValue && <Badge title={finding.testValue}>Likely test value</Badge>}
354 {finding.state === "dismissed" && finding.dismissedReason && <Badge>{dismissLabel(finding.dismissedReason)}</Badge>}
355 </div>
356 <p className="mt-1 truncate font-mono text-xs">
357 {landed ? (
358 <Link to={`${base}/blob/${finding.commit}/${finding.path}#L${finding.line}`} className="text-fg-soft hover:text-fg hover:underline">
359 {where}
360 </Link>
361 ) : (
362 <span className="text-fg-soft">{where}</span>
363 )}
364 <span className="text-faint"> · {finding.preview}</span>
365 </p>
366 {finding.testValue && <p className="mt-1 text-xs text-muted">{finding.testValue}</p>}
367 <p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint">
368 <span>
369 {finding.source === "push" ? "in a push" : "in history"}
370 {finding.foundBy && <> by {finding.foundBy}</>}, commit{" "}
371 {landed ? (
372 <Link to={`${base}/commit/${finding.commit}`} className="font-mono hover:text-fg">
373 {finding.commit.slice(0, 7)}
374 </Link>
375 ) : (
376 <span className="font-mono">{finding.commit.slice(0, 7)}</span>
377 )}
378 </span>
379 <span>
380 found <TimeAgo at={finding.foundAt} />
381 </span>
382 </p>
383 <ActivityLog entries={alertActivity(finding, activity)} base={base} open={focused && finding.state !== "open"} />
384 </div>
385 {canDismiss && (
386 <div className="flex shrink-0 items-center gap-1.5">
387 {finding.state === "open" ? (
388 <DismissDialog
389 id={finding.id}
390 title={`Dismiss ${finding.label}`}
391 detail={`${where} · ${finding.preview}`}
392 reasons={SECRET_DISMISS_REASONS}
393 note={
394 finding.status === "blocked" && !finding.testValue
395 ? "Dismissing it lets the same push through, unless you dismiss it as revoked."
396 : undefined
397 }
398 action={action}
399 />
400 ) : (
401 <ReopenButton id={finding.id} action={action} />
402 )}
403 </div>
404 )}
405 </li>
406 );
407}
408
409function Empty({ title, children }: { title: string; children: ReactNode }) {
410 return (
411 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
412 <ShieldCheck size={22} className="mx-auto text-accent" />
413 <p className="mt-2 font-medium">{title}</p>
414 <p className="mt-1 text-sm text-muted">{children}</p>
415 </div>
416 );
417}
418
419const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface";
420
421/** The secret alerts in one state: on Open, real ones first, then likely test values. */
422export function SecretsList({
423 secrets,
424 state,
425 activity,
426 base,
427 action,
428 focus,
429 canDismiss,
430}: {
431 /** Already filtered to `state`. */
432 secrets: SecretFinding[];
433 state: AlertState;
434 activity: AlertActivity[];
435 base: string;
436 action: string;
437 focus: string | null;
438 /** Whether the viewer may dismiss and reopen secret alerts (Admin). */
439 canDismiss: boolean;
440}) {
441 const item = (finding: SecretFinding) => (
442 <SecretItem
443 key={finding.id}
444 finding={finding}
445 activity={activity}
446 base={base}
447 action={action}
448 focused={finding.id === focus}
449 canDismiss={canDismiss}
450 />
451 );
452 if (secrets.length === 0) {
453 return state === "open" ? (
454 <Empty title="No open secret alerts">
455 Pushes that add a key or a token are refused before they land, and the history is scanned once in the background.
456 </Empty>
457 ) : (
458 <Empty title={state === "dismissed" ? "No dismissed secret alerts" : "No revoked secrets"}>
459 {state === "dismissed"
460 ? "Alerts someone dismissed as a false positive, a test value or accepted are listed here."
461 : "Secrets dismissed as revoked are listed here."}
462 </Empty>
463 );
464 }
465 if (state !== "open") return <ul className={LIST}>{secrets.map(item)}</ul>;
466 const { real, tests } = splitSecrets(secrets);
467 return (
468 <div className="space-y-5">
469 {real.length > 0 ? (
470 <ul className={LIST}>{real.map(item)}</ul>
471 ) : (
472 <Empty title="No secrets that look real">Only likely test values are open, and they never block a push.</Empty>
473 )}
474 {tests.length > 0 && (
475 <section>
476 <h4 className="text-sm font-medium">Likely test values</h4>
477 <p className="mt-0.5 mb-2 text-xs text-muted">
478 These look made for tests or documentation. They never block a push and are not counted as critical; dismiss them
479 to clear the list.
480 </p>
481 <ul className={LIST}>{tests.map(item)}</ul>
482 </section>
483 )}
484 </div>
485 );
486}
487
488/** Where the legacy upgrade issue for a package stands, as the page loads it. */
489export type UpgradeFix = {
490 number: number;
491 state: "open" | "closed";
492 /** The newest pull request for the issue, if any. */
493 pull: { number: number; status: PullStatus; agent: string | null } | null;
494 resolvedBy: number | null;
495};
496
497/** A security update's pull request as the page loads it. */
498export type PullInfo = { number: number; status: PullStatus; title: string };
499
500function FixLink({ issue, fix, base }: { issue: number; fix: UpgradeFix | undefined; base: string }) {
501 const pull = fix?.pull;
502 return (
503 <span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
504 <span className="text-muted">Upgrade issue</span>
505 <Link to={`${base}/issues/${issue}`} className="inline-flex items-center gap-1 text-fg-soft hover:text-fg">
506 {fix?.state === "closed" ? <CircleCheck size={12} className="text-merged" /> : <CircleDot size={12} className="text-accent" />}#{issue}
507 </Link>
508 {pull && (
509 <Link to={`${base}/pull/${pull.number}`} className="inline-flex items-center gap-1 text-muted hover:text-fg">
510 {pull.agent ? <Bot size={12} /> : <GitPullRequest size={12} />}#{pull.number} {pull.status === "draft" ? "in progress" : pull.status}
511 </Link>
512 )}
513 </span>
514 );
515}
516
517const PULL_ICON: Record<PullStatus, ReactNode> = {
518 draft: <GitPullRequest size={13} className="text-muted" />,
519 open: <GitPullRequest size={13} className="text-accent" />,
520 merged: <GitMerge size={13} className="text-merged" />,
521 closed: <GitPullRequestClosed size={13} className="text-danger" />,
522};
523
524/** Where g1t's security update for a package stands. */
525function UpdateStatus({ update, name, pulls, base }: { update: SecurityUpdate; name: string; pulls: Record<number, PullInfo>; base: string }) {
526 const meta = UPDATE_STATES[update.state];
527 const pull = update.pull != null ? pulls[update.pull] : undefined;
528 const about: Record<SecurityUpdate["state"], ReactNode> = {
529 requested: <>A sandbox is raising {name} to {update.target}.</>,
530 open: <>It raises {name} to {update.target} and lands through your branch's required checks.</>,
531 merged: <>{name} was raised to {update.target}.</>,
532 closed: <>The pull request was closed without merging.</>,
533 superseded: <>A newer update replaced it, or the package is no longer vulnerable, so g1t closed it.</>,
534 needs_code: (
535 <>
536 Raising {name} to {update.target} needs code changes, so g1t opened an issue to make them
537 {update.issue != null && (
538 <>
539 :{" "}
540 <Link to={`${base}/issues/${update.issue}`} className="font-medium text-fg-soft hover:text-fg hover:underline">
541 #{update.issue}
542 </Link>
543 </>
544 )}
545 .
546 </>
547 ),
548 failed: <>{update.error ?? "g1t could not make the change."}</>,
549 };
550 return (
551 <div className="mt-2.5 rounded-lg border border-line bg-bg/40 px-3 py-2 text-xs sm:ml-7">
552 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
553 {update.state === "requested" ? (
554 <Loader size={13} className="animate-spin text-info motion-reduce:animate-none" />
555 ) : update.state === "failed" || update.state === "needs_code" ? (
556 <CircleAlert size={13} className={update.state === "failed" ? "text-danger" : "text-warn"} />
557 ) : (
558 PULL_ICON[pull?.status ?? (update.state === "merged" ? "merged" : update.state === "open" ? "open" : "closed")]
559 )}
560 <Badge tone={meta.tone}>{meta.label}</Badge>
561 {update.pull != null && (
562 <Link to={`${base}/pull/${update.pull}`} className="font-medium text-fg-soft hover:text-fg hover:underline">
563 #{update.pull}
564 {pull && <span className="font-normal text-muted"> {pull.status === "draft" ? "draft" : pull.status}</span>}
565 </Link>
566 )}
567 {update.branch && (
568 <span className="inline-flex min-w-0 items-center gap-1 font-mono text-muted">
569 <GitBranch size={12} className="shrink-0" />
570 <span className="truncate">{update.branch}</span>
571 </span>
572 )}
573 <span className="text-faint">
574 <TimeAgo at={update.updatedAt} />
575 </span>
576 </div>
577 <p className={`mt-1 ${update.state === "failed" ? "text-danger" : "text-muted"} wrap-anywhere`}>{about[update.state]}</p>
578 </div>
579 );
580}
581
582export function VulnerabilityList({
583 vulnerabilities,
584 state,
585 activity,
586 fixes,
587 pulls,
588 upkeep,
589 base,
590 action,
591 focus,
592 canDismiss,
593}: {
594 /** Already filtered to `state`. */
595 vulnerabilities: Vulnerability[];
596 state: AlertState;
597 activity: AlertActivity[];
598 fixes: Record<number, UpgradeFix>;
599 pulls: Record<number, PullInfo>;
600 /** Whether security updates are on. */
601 upkeep: boolean;
602 base: string;
603 action: string;
604 focus: string | null;
605 /** Whether the viewer may dismiss and reopen dependency alerts (Write). */
606 canDismiss: boolean;
607}) {
608 const packages = groupByPackage(vulnerabilities);
609 if (packages.length === 0) {
610 return state === "open" ? (
611 <Empty title="No known vulnerabilities">
612 Every package the lockfiles resolve is checked against the OSV database on each push to the default branch, and daily.
613 </Empty>
614 ) : (
615 <Empty title={state === "dismissed" ? "No dismissed dependency alerts" : "Nothing fixed yet"}>
616 {state === "dismissed"
617 ? "Alerts someone dismissed, with their reason, are listed here."
618 : "Alerts whose package was upgraded, or is no longer vulnerable, are listed here."}
619 </Empty>
620 );
621 }
622 return (
623 <ul className={LIST}>
624 {packages.map((group) => (
625 <PackageItem
626 key={group.key}
627 group={group}
628 activity={activity}
629 fixes={fixes}
630 pulls={pulls}
631 upkeep={upkeep}
632 base={base}
633 action={action}
634 focus={focus}
635 canDismiss={canDismiss}
636 />
637 ))}
638 </ul>
639 );
640}
641
642function PackageItem({
643 group,
644 activity,
645 fixes,
646 pulls,
647 upkeep,
648 base,
649 action,
650 focus,
651 canDismiss,
652}: {
653 group: PackageGroup;
654 activity: AlertActivity[];
655 fixes: Record<number, UpgradeFix>;
656 pulls: Record<number, PullInfo>;
657 upkeep: boolean;
658 base: string;
659 action: string;
660 focus: string | null;
661 canDismiss: boolean;
662}) {
663 const versions = [...new Set(group.vulns.map((vuln) => vuln.version))];
664 const manifests = [...new Set(group.vulns.map((vuln) => vuln.manifest))];
665 const target = highestFix(group.vulns);
666 const update = latestUpdate(group.vulns);
667 const issue = update ? null : (group.vulns.find((vuln) => vuln.issue != null)?.issue ?? null);
668 return (
669 <li className="px-4 py-3">
670 <div className="flex items-start gap-3">
671 <Package size={15} className="mt-0.5 hidden shrink-0 text-muted sm:block" />
672 <div className="min-w-0 grow">
673 <div className="flex flex-wrap items-center gap-2">
674 <span className="font-mono text-sm font-medium break-all">{group.name}</span>
675 <span className="font-mono text-xs text-muted">{versions.join(", ")}</span>
676 <Badge>{group.ecosystem}</Badge>
677 <SeverityBadge severity={worstSeverity(group.vulns)} />
678 </div>
679 <p className="mt-1 text-xs text-faint wrap-anywhere">
680 {target ? <>Fixed in {target}</> : "No patched version"} · locked in{" "}
681 <span className="font-mono">{manifests.join(", ")}</span>
682 </p>
683 </div>
684 </div>
685 {update && <UpdateStatus update={update} name={group.name} pulls={pulls} base={base} />}
686 {issue != null && (
687 <div className="mt-2 sm:ml-7">
688 <FixLink issue={issue} fix={fixes[issue]} base={base} />
689 </div>
690 )}
691 <ul className="mt-2.5 space-y-px overflow-hidden rounded-lg border border-line sm:ml-7">
692 {group.vulns.map((vuln) => (
693 <AdvisoryItem
694 key={vuln.id}
695 vuln={vuln}
696 showManifest={manifests.length > 1}
697 activity={activity}
698 upkeep={upkeep}
699 base={base}
700 action={action}
701 focused={vuln.id === focus}
702 canDismiss={canDismiss}
703 />
704 ))}
705 </ul>
706 </li>
707 );
708}
709
710function AdvisoryItem({
711 vuln,
712 showManifest,
713 activity,
714 upkeep,
715 base,
716 action,
717 focused,
718 canDismiss,
719}: {
720 vuln: Vulnerability;
721 showManifest: boolean;
722 activity: AlertActivity[];
723 upkeep: boolean;
724 base: string;
725 action: string;
726 focused: boolean;
727 canDismiss: boolean;
728}) {
729 const ref = useFocus<HTMLLIElement>(focused);
730 const osv = `https://osv.dev/vulnerability/${vuln.osvId}`;
731 const dismiss = (reason?: DismissReason, trigger?: string) => (
732 <DismissDialog
733 id={vuln.id}
734 title={`Dismiss ${vuln.advisory}`}
735 detail={`${vuln.package} ${vuln.version} · ${vuln.manifest}`}
736 reasons={DEPENDENCY_DISMISS_REASONS}
737 action={action}
738 defaultReason={reason}
739 trigger={trigger}
740 />
741 );
742 return (
743 <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 : ""}`}>
744 <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
745 <div className="min-w-0 grow text-xs">
746 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
747 <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-mono text-fg-soft hover:text-fg">
748 {vuln.advisory}
749 <ExternalLink size={10} />
750 </a>
751 <SeverityBadge severity={vuln.severity} />
752 {vuln.fixedVersion && <span className="text-faint">fixed in {vuln.fixedVersion}</span>}
753 {showManifest && <span className="font-mono text-faint">{vuln.manifest}</span>}
754 {vuln.state === "dismissed" && vuln.dismissedReason && <Badge>{dismissLabel(vuln.dismissedReason)}</Badge>}
755 </p>
756 <p className="mt-1 text-muted wrap-anywhere">{vuln.summary}</p>
757 </div>
758 {canDismiss && vuln.state !== "fixed" && (
759 <div className="flex shrink-0 items-center gap-1.5">
760 {vuln.state === "open" ? dismiss() : <ReopenButton id={vuln.id} action={action} />}
761 </div>
762 )}
763 </div>
764 {vuln.state === "open" && !vuln.fixedVersion && (
765 <div className="mt-2 rounded-md border border-warn/30 bg-warn/5 px-3 py-2 text-xs">
766 <p className="flex items-center gap-1.5 font-medium text-warn">
767 <ShieldAlert size={13} />
768 No patched version available
769 </p>
770 <p className="mt-1 text-muted">
771 No release fixes this yet. Dependencies are checked again daily
772 {upkeep
773 ? ", and g1t opens a pull request when a fix is published."
774 : "; turn on security updates and g1t opens a pull request when a fix is published."}
775 </p>
776 <div className="mt-2 flex flex-wrap items-center gap-2">
777 <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-fg-soft hover:text-fg hover:underline">
778 Read the advisory
779 <ExternalLink size={10} />
780 </a>
781 {canDismiss && dismiss("tolerable_risk", "Dismiss as tolerable risk")}
782 </div>
783 </div>
784 )}
785 <ActivityLog entries={alertActivity(vuln, activity)} base={base} open={focused && vuln.state !== "open"} />
786 </li>
787 );
788}
789
790export function ScanSummary({
791 scan,
792}: {
793 scan: { history: string; commitsScanned: number; historyFinishedAt: string | null; dependenciesScannedAt: string | null; dependenciesError: string | null; lockfiles: string[] };
794}) {
795 const history =
796 scan.history === "done" ? (
797 <>
798 History scanned: {scan.commitsScanned.toLocaleString()} commits
799 {scan.historyFinishedAt && (
800 <>
801 , <TimeAgo at={scan.historyFinishedAt} />
802 </>
803 )}
804 </>
805 ) : scan.history === "stopped" ? (
806 "History scan paused: the workspace reached its spending limit"
807 ) : scan.history === "running" ? (
808 `Scanning history: ${scan.commitsScanned.toLocaleString()} commits so far`
809 ) : (
810 "History scan queued"
811 );
812 return (
813 <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted">
814 <span className="inline-flex items-center gap-1.5">
815 <KeyRound size={12} />
816 {history}
817 </span>
818 <span className="inline-flex items-center gap-1.5">
819 <Package size={12} />
820 {scan.dependenciesScannedAt ? (
821 <>
822 Dependencies read <TimeAgo at={scan.dependenciesScannedAt} />
823 {scan.lockfiles.length > 0 ? <> from {scan.lockfiles.join(", ")}</> : " (no lockfiles found)"}
824 </>
825 ) : (
826 "Dependencies not read yet"
827 )}
828 </span>
829 {scan.dependenciesError && (
830 <span className="inline-flex items-center gap-1.5 text-warn">
831 <ShieldAlert size={12} />
832 {scan.dependenciesError}
833 </span>
834 )}
835 </div>
836 );
837}
838
839function list(values: string[]): string {
840 return values.length > 0 ? values.join(", ") : "—";
841}
842
843/** What `.g1t/dependencies.yml` asks for, and that acting on it is still to come. */
844export function VersionUpdatesCard({ state }: { state: VersionUpdatesState }) {
845 const rows = state.updates.map((entry) => ({
846 key: `${entry.ecosystem}:${entry.directory}`,
847 ecosystem: entry.ecosystem,
848 directory: entry.directory,
849 interval: entry.interval,
850 groups: list(entry.groups.map((group) => `${group.name} (${group.patterns.join(", ")})`)),
851 ignore: list(entry.ignore.map((rule) => (rule.versions.length > 0 ? `${rule.dependency} ${rule.versions.join(", ")}` : rule.dependency))),
852 limit: String(entry.openPullRequestsLimit),
853 }));
854 return (
855 <div className="rounded-xl border border-line bg-surface p-4">
856 <div className="flex flex-wrap items-center gap-2">
857 <span className="text-sm font-medium">Version updates</span>
858 <Badge tone="merged">Coming soon</Badge>
859 </div>
860 <p className="mt-1 text-sm text-muted">
861 Ask for pull requests that raise your dependencies to new versions on a schedule, in{" "}
862 <code className="text-fg-soft">.g1t/dependencies.yml</code>. g1t reads and checks this file now; pull requests for new
863 versions are coming.
864 </p>
865 <p className="mt-3 flex items-center gap-1.5 text-xs text-muted">
866 {state.error ? (
867 <>
868 <FileWarning size={13} className="shrink-0 text-danger" />
869 <span className="text-danger wrap-anywhere">.g1t/dependencies.yml has a problem: {state.error}</span>
870 </>
871 ) : state.found ? (
872 <>
873 <CircleCheck size={13} className="shrink-0 text-accent" />
874 <span>
875 Read .g1t/dependencies.yml
876 {state.readAt && (
877 <>
878 {" "}
879 <TimeAgo at={state.readAt} />
880 </>
881 )}
882 : {rows.length} {rows.length === 1 ? "entry" : "entries"}
883 </span>
884 </>
885 ) : (
886 <>
887 <CircleDot size={13} className="shrink-0 text-faint" />
888 <span>No .g1t/dependencies.yml on the default branch.</span>
889 </>
890 )}
891 </p>
892 {rows.length > 0 && (
893 <>
894 <table className="mt-3 hidden w-full text-left text-xs sm:table">
895 <thead className="text-faint">
896 <tr className="border-b border-line">
897 <th className="py-1.5 pr-3 font-medium">Ecosystem</th>
898 <th className="py-1.5 pr-3 font-medium">Directory</th>
899 <th className="py-1.5 pr-3 font-medium">Interval</th>
900 <th className="py-1.5 pr-3 font-medium">Groups</th>
901 <th className="py-1.5 pr-3 font-medium">Ignored</th>
902 <th className="py-1.5 text-right font-medium">Limit</th>
903 </tr>
904 </thead>
905 <tbody className="text-muted">
906 {rows.map((row) => (
907 <tr key={row.key} className="border-b border-line/60 align-top last:border-0">
908 <td className="py-1.5 pr-3 font-mono text-fg-soft">{row.ecosystem}</td>
909 <td className="py-1.5 pr-3 font-mono">{row.directory}</td>
910 <td className="py-1.5 pr-3">{row.interval}</td>
911 <td className="py-1.5 pr-3 font-mono wrap-anywhere">{row.groups}</td>
912 <td className="py-1.5 pr-3 font-mono wrap-anywhere">{row.ignore}</td>
913 <td className="py-1.5 text-right tabular-nums">{row.limit}</td>
914 </tr>
915 ))}
916 </tbody>
917 </table>
918 <ul className="mt-3 space-y-2 sm:hidden">
919 {rows.map((row) => (
920 <li key={row.key} className="rounded-lg border border-line px-3 py-2 text-xs">
921 <p className="font-mono text-fg-soft">
922 {row.ecosystem} <span className="text-muted">{row.directory}</span>
923 </p>
924 <dl className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-muted">
925 <dt className="text-faint">Interval</dt>
926 <dd>{row.interval}</dd>
927 <dt className="text-faint">Groups</dt>
928 <dd className="font-mono wrap-anywhere">{row.groups}</dd>
929 <dt className="text-faint">Ignored</dt>
930 <dd className="font-mono wrap-anywhere">{row.ignore}</dd>
931 <dt className="text-faint">Limit</dt>
932 <dd className="tabular-nums">{row.limit}</dd>
933 </dl>
934 </li>
935 ))}
936 </ul>
937 </>
938 )}
939 <a
940 href={VERSION_UPDATES_DOCS}
941 target="_blank"
942 rel="noreferrer"
943 className="mt-3 inline-flex items-center gap-1 text-xs text-fg-soft hover:text-fg hover:underline"
944 >
945 How to write .g1t/dependencies.yml
946 <ExternalLink size={10} />
947 </a>
948 </div>
949 );
950}