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