| 1 | import { ChevronRight } from "lucide-react"; |
| 2 | import { Link, data } from "react-router"; |
| 3 | |
| 4 | import { SEVERITIES, type SeverityCounts } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/security"; |
| 7 | import { page } from "../../lib/meta"; |
| 8 | import { SeverityCountsGrid, SeverityCountsInline } from "../../components/security"; |
| 9 | import { ActivationPrompt, CARD, CoverageTable, TrendChart, countsLine } from "../../components/security-suite"; |
| 10 | import { WorkspaceSecurityTabs } from "../../components/workspace-security-tabs"; |
| 11 | import { TimeAgo } from "../../components/ui"; |
| 12 | import { Badge } from "../../components/ui/badge"; |
| 13 | import { repos, security, securitySuite } from "../../lib/services.server"; |
| 14 | import { getViewer, managesSecurity, roleIn, unwrap } from "../../lib/session.server"; |
| 15 | import { activationPrice } from "../../lib/security-suite.server"; |
| 16 | |
| 17 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 18 | return page(args, { title: `Security · ${params.owner} · g1t` }); |
| 19 | } |
| 20 | |
| 21 | export 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: managesSecurity(viewer, params.owner), |
| 62 | }; |
| 63 | } |
| 64 | |
| 65 | const TYPE_LABEL: Record<string, string> = { |
| 66 | secret_scanning: "Secrets", |
| 67 | code_scanning: "Code scanning", |
| 68 | vulnerability: "Vulnerabilities", |
| 69 | }; |
| 70 | |
| 71 | export 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 | } |