Skip to content

g1t/apps/web/app/routes/workspace/security.tsx

152 lines7,192 bytesCodeBlame
1import { ChevronRight } from "lucide-react";
2import { Link, data } from "react-router";
3
4import { SEVERITIES, type SeverityCounts } from "@g1t/contracts";
5
6import type { Route } from "./+types/security";
7import { page } from "../../lib/meta";
8import { SeverityCountsGrid, SeverityCountsInline } from "../../components/security";
9import { ActivationPrompt, CARD, CoverageTable, TrendChart, countsLine } from "../../components/security-suite";
10import { WorkspaceSecurityTabs } from "../../components/workspace-security-tabs";
11import { TimeAgo } from "../../components/ui";
12import { Badge } from "../../components/ui/badge";
13import { repos, security, securitySuite } from "../../lib/services.server";
14import { getViewer, roleIn, unwrap } from "../../lib/session.server";
15import { activationPrice } from "../../lib/security-suite.server";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Security · ${params.owner} · g1t` });
19}
20
21export async function loader({ params, context }: Route.LoaderArgs) {
22 const viewer = getViewer(context);
23 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24 const [scanned, current, overview, requests, price] = await Promise.all([
25 security.workspace(params.owner, viewer),
26 repos.list(viewer, { namespace: params.owner.toLowerCase() }),
27 securitySuite.overview(params.owner, viewer, 30),
28 securitySuite.bypassRequests(params.owner, viewer, "pending"),
29 activationPrice(params.owner, viewer),
30 ]);
31 // Only repositories that are still there: a deleted one's alerts stay
32 // with it for its 30 days, but not on this page.
33 const live = new Set(current.map((repo) => repo.id));
34 // Whether each is private, from the repository itself: the security
35 // service's own record of it can lag behind.
36 const privacy = new Map(current.map((repo) => [repo.id, repo.isPrivate]));
37 const projects = unwrap(scanned).filter((project) => live.has(project.repoId));
38 const total = Object.fromEntries(SEVERITIES.map((severity) => [severity, 0])) as SeverityCounts;
39 for (const project of projects) {
40 for (const severity of SEVERITIES) total[severity] += project.counts[severity];
41 }
42 // Most to fix first.
43 projects.sort(
44 (a, b) =>
45 SEVERITIES.reduce((order, severity) => order || b.counts[severity] - a.counts[severity], 0) || a.name.localeCompare(b.name),
46 );
47 const full = overview.ok
48 ? {
49 ...overview.value,
50 repos: overview.value.repos
51 .filter((repo) => live.has(repo.repoId))
52 .map((repo) => ({ ...repo, private: privacy.get(repo.repoId) ?? repo.private })),
53 }
54 : null;
55 return {
56 projects,
57 total,
58 overview: full,
59 pending: requests.ok ? requests.value.length : 0,
60 price,
61 owner: roleIn(viewer, params.owner) === "owner",
62 };
63}
64
65const TYPE_LABEL: Record<string, string> = {
66 secret_scanning: "Secrets",
67 code_scanning: "Code scanning",
68 vulnerability: "Vulnerabilities",
69};
70
71export default function WorkspaceSecurity({ loaderData, params }: Route.ComponentProps) {
72 const { projects, total, overview, pending, price, owner } = loaderData;
73 // The full overview counts private repositories with the activation; without it, a workspace
74 // with private ones sees the free list of every repository's open alerts.
75 const full = overview && (overview.activated || overview.privateHidden === 0);
76 return (
77 <div className="space-y-8">
78 <WorkspaceSecurityTabs owner={params.owner} pending={pending} />
79 {full && overview ? (
80 <>
81 <div className="grid gap-3 sm:grid-cols-3">
82 {overview.totals.map((totals) => (
83 <div key={totals.alertType} className={`${CARD} p-4`}>
84 <p className="text-xs text-muted">{TYPE_LABEL[totals.alertType]}</p>
85 <p className="mt-1 text-2xl font-semibold tabular-nums">
86 {totals.open.critical + totals.open.high + totals.open.medium + totals.open.low + totals.open.unknown}
87 <span className="ml-1.5 text-sm font-normal text-muted">open</span>
88 </p>
89 <p className="mt-1 text-xs text-muted">{countsLine(totals.open)}</p>
90 <p className="mt-2 text-xs text-faint">
91 {totals.opened} opened · {totals.closed} closed in 30 days
92 </p>
93 </div>
94 ))}
95 </div>
96 <TrendChart points={overview.trend} />
97 <section className="space-y-3">
98 <h2 className="text-base font-semibold tracking-tight">Repositories, most in need first</h2>
99 <CoverageTable repos={overview.repos} owner={params.owner} />
100 </section>
101 </>
102 ) : (
103 <>
104 <ActivationPrompt workspace={params.owner} feature="The security overview" monthlyCents={price} isOwner={owner} />
105 <div>
106 <SeverityCountsGrid counts={total} />
107 <p className="mt-2 text-xs text-faint">
108 Open alerts by severity. A secret in the history that looks real counts as critical; blocked pushes and likely test
109 values do not.
110 </p>
111 </div>
112 {projects.length === 0 ? (
113 <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
114 No project has been scanned yet. Each one is scanned on its next push to its default branch, or when its Security
115 page is first opened.
116 </p>
117 ) : (
118 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
119 {projects.map((project) => (
120 <li key={project.repoId}>
121 <Link
122 to={`/${params.owner}/${project.name}/security`}
123 className="group flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-raised/50 sm:flex-row sm:items-center"
124 >
125 <span className="min-w-0 grow">
126 <span className="flex flex-wrap items-center gap-2">
127 <span className="font-mono text-sm font-medium">{project.name}</span>
128 {!project.upkeep && <Badge>security updates off</Badge>}
129 </span>
130 <span className="mt-0.5 block text-xs text-faint">
131 {project.secrets} {project.secrets === 1 ? "secret" : "secrets"} · {project.vulnerabilities}{" "}
132 {project.vulnerabilities === 1 ? "vulnerability" : "vulnerabilities"}
133 {project.dependenciesScannedAt && (
134 <>
135 {" "}
136 · read <TimeAgo at={project.dependenciesScannedAt} />
137 </>
138 )}
139 </span>
140 </span>
141 <SeverityCountsInline counts={project.counts} />
142 <ChevronRight size={15} className="hidden shrink-0 text-faint group-hover:text-fg sm:block" />
143 </Link>
144 </li>
145 ))}
146 </ul>
147 )}
148 </>
149 )}
150 </div>
151 );
152}