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