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.

159 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((a, b) => SEVERITIES.reduce((order, severity) => order || b.counts[severity] - a.counts[severity], 0) || a.name.localeCompare(b.name));
50 const full =
51 !isFailed(overview) && overview.ok
52 ? {
53 ...overview.value,
54 repos: overview.value.repos.filter((repo) => live.has(repo.repoId)).map((repo) => ({ ...repo, private: privacy.get(repo.repoId) ?? repo.private })),
55 }
56 : null;
57 return {
58 projects,
59 total,
60 overview: full,
61 overviewFailed: isFailed(overview) ? overview : null,
62 pending: !isFailed(requests) && requests.ok ? requests.value.length : 0,
63 price,
64 owner: managesSecurity(viewer, params.owner),
65 };
66}
67
68const TYPE_LABEL: Record<string, string> = {
69 secret_scanning: "Secrets",
70 code_scanning: "Code scanning",
71 vulnerability: "Vulnerabilities",
72};
73
74export default function WorkspaceSecurity({ loaderData, params }: Route.ComponentProps) {
75 const { projects, total, overview, overviewFailed, pending, price, owner } = loaderData;
76 // The full overview counts private repositories with the plan; without it, a workspace
77 // with private ones sees the free list of every repository's open alerts.
78 const full = overview && (overview.activated || overview.privateHidden === 0);
79 return (
80 <div className="space-y-8">
81 <WorkspaceSecurityTabs owner={params.owner} pending={pending} />
82 {full && overview ? (
83 <>
84 <div className="grid gap-3 sm:grid-cols-3">
85 {overview.totals.map((totals) => (
86 <div key={totals.alertType} className={`${CARD} p-4`}>
87 <p className="text-xs text-muted">{TYPE_LABEL[totals.alertType]}</p>
88 <p className="mt-1 text-2xl font-semibold tabular-nums">
89 {totals.open.critical + totals.open.high + totals.open.medium + totals.open.low + totals.open.unknown}
90 <span className="ml-1.5 text-sm font-normal text-muted">open</span>
91 </p>
92 <p className="mt-1 text-xs text-muted">{countsLine(totals.open)}</p>
93 <p className="mt-2 text-xs text-faint">
94 {totals.opened} opened · {totals.closed} closed in 30 days
95 </p>
96 </div>
97 ))}
98 </div>
99 <TrendChart points={overview.trend} />
100 <section className="space-y-3">
101 <h2 className="text-base font-semibold tracking-tight">Repositories, most in need first</h2>
102 <CoverageTable repos={overview.repos} owner={params.owner} />
103 </section>
104 </>
105 ) : (
106 <>
107 {overviewFailed ? (
108 <Unavailable what={overviewFailed.what} />
109 ) : (
110 <ActivationPrompt workspace={params.owner} feature="The security overview" monthlyCents={price} isOwner={owner} />
111 )}
112 <div>
113 <SeverityCountsGrid counts={total} />
114 <p className="mt-2 text-xs text-faint">
115 Open alerts by severity. A secret in the history that looks real counts as critical; blocked pushes and likely test values do not.
116 </p>
117 </div>
118 {projects.length === 0 ? (
119 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted">
120 <p>No project has been scanned yet. Each one is scanned on its next push to its default branch, or when its Security page is first opened.</p>
121 </Card>
122 ) : (
123 <Card asChild divided className="overflow-hidden">
124 <ul>
125 {projects.map((project) => (
126 <li key={project.repoId}>
127 <Link
128 to={`/${params.owner}/${project.name}/security`}
129 className="group flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-raised/50 sm:flex-row sm:items-center"
130 >
131 <span className="min-w-0 grow">
132 <span className="flex flex-wrap items-center gap-2">
133 <span className="font-mono text-sm font-medium">{project.name}</span>
134 {!project.upkeep && <Badge>security updates off</Badge>}
135 </span>
136 <span className="mt-0.5 block text-xs text-faint">
137 {project.secrets} {project.secrets === 1 ? "secret" : "secrets"} · {project.vulnerabilities}{" "}
138 {project.vulnerabilities === 1 ? "vulnerability" : "vulnerabilities"}
139 {project.dependenciesScannedAt && (
140 <>
141 {" "}
142 · read <TimeAgo at={project.dependenciesScannedAt} />
143 </>
144 )}
145 </span>
146 </span>
147 <SeverityCountsInline counts={project.counts} />
148 <ChevronRight size={15} className="hidden shrink-0 text-faint group-hover:text-fg sm:block" />
149 </Link>
150 </li>
151 ))}
152 </ul>
153 </Card>
154 )}
155 </>
156 )}
157 </div>
158 );
159}