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.

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