| 1 | import { ChevronRight, FileSearch, KeyRound, Network, PackageSearch, ShieldCheck } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { Link, redirect } from "react-router"; |
| 4 | |
| 5 | import type { Route } from "./+types/security-overview"; |
| 6 | import { page } from "../../lib/meta"; |
| 7 | import { ScanSummary, SeverityBadge, SeverityCountsGrid } from "../../components/security"; |
| 8 | import { CARD, SectionHeader } from "../../components/security-suite"; |
| 9 | import { security, securitySuite } from "../../lib/services.server"; |
| 10 | import { getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 11 | import { requireInsider } from "../../lib/access.server"; |
| 12 | import { legacySecurityTarget, severityCounts, total, worstVulnerabilities } from "../../lib/security-suite"; |
| 13 | |
| 14 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 15 | return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` }); |
| 16 | } |
| 17 | |
| 18 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 19 | const base = `/${params.owner}/${params.repo}`; |
| 20 | // Links from before the sections (git's push refusals among them). |
| 21 | const moved = legacySecurityTarget(base, new URL(request.url).searchParams); |
| 22 | if (moved) throw redirect(moved); |
| 23 | const viewer = getViewer(context) ?? requireUser(context, request); |
| 24 | await requireInsider(context, params, "push"); |
| 25 | const repo = { namespace: params.owner, name: params.repo }; |
| 26 | const [overview, code] = await Promise.all([security.overview(repo, viewer), securitySuite.codeScanning(repo, viewer)]); |
| 27 | return { overview: unwrap(overview), code: code.ok ? code.value : null }; |
| 28 | } |
| 29 | |
| 30 | function Card({ to, icon, title, children, footer }: { to: string; icon: ReactNode; title: string; children: ReactNode; footer?: ReactNode }) { |
| 31 | return ( |
| 32 | <Link to={to} className={`${CARD} group flex flex-col p-4 transition-colors hover:border-line-strong`}> |
| 33 | <span className="flex items-center gap-2 text-sm font-medium"> |
| 34 | <span className="text-accent">{icon}</span> |
| 35 | {title} |
| 36 | <ChevronRight size={14} className="ml-auto text-faint group-hover:text-fg" /> |
| 37 | </span> |
| 38 | <div className="mt-2 grow text-sm text-muted">{children}</div> |
| 39 | {footer && <div className="mt-3 text-xs text-faint">{footer}</div>} |
| 40 | </Link> |
| 41 | ); |
| 42 | } |
| 43 | |
| 44 | export default function SecurityOverview({ loaderData, params }: Route.ComponentProps) { |
| 45 | const { overview, code } = loaderData; |
| 46 | const base = `/${params.owner}/${params.repo}`; |
| 47 | const secrets = overview.secretCounts; |
| 48 | const codeOpen = code ? severityCounts(code.alerts) : null; |
| 49 | // Open alerts of every kind, by severity. |
| 50 | const counts = { ...overview.counts }; |
| 51 | if (codeOpen) for (const key of Object.keys(counts) as (keyof typeof counts)[]) counts[key] += codeOpen[key]; |
| 52 | const worst = worstVulnerabilities(overview.vulnerabilities, 3); |
| 53 | const openVulns = overview.vulnerabilities.filter((vuln) => vuln.state === "open").length; |
| 54 | return ( |
| 55 | <div className="max-w-5xl space-y-8"> |
| 56 | <SectionHeader |
| 57 | title="Security" |
| 58 | about="Pushes that add a secret are refused before they land, and the history is scanned. Code scanning reads your tools' SARIF results, every package the lockfiles resolve is checked for known vulnerabilities, and g1t can fix what it finds." |
| 59 | /> |
| 60 | <div> |
| 61 | <SeverityCountsGrid counts={counts} /> |
| 62 | <p className="mt-2 text-xs text-faint"> |
| 63 | Open alerts of every kind by severity. A secret in the history that looks real counts as critical; blocked pushes and |
| 64 | likely test values do not. |
| 65 | </p> |
| 66 | </div> |
| 67 | <div className="grid gap-3 sm:grid-cols-2"> |
| 68 | <Card to={`${base}/security/secret-scanning`} icon={<KeyRound size={15} />} title="Secret scanning" footer="Push protection is on for every push."> |
| 69 | {secrets.open + secrets.blocked === 0 ? ( |
| 70 | <span className="inline-flex items-center gap-1.5 text-success"> |
| 71 | <ShieldCheck size={14} /> No open secrets |
| 72 | </span> |
| 73 | ) : ( |
| 74 | <> |
| 75 | <span className="font-medium text-fg">{secrets.open}</span> in the history to rotate,{" "} |
| 76 | <span className="font-medium text-fg">{secrets.blocked}</span> stopped at a push |
| 77 | </> |
| 78 | )} |
| 79 | </Card> |
| 80 | <Card to={`${base}/security/code-scanning`} icon={<FileSearch size={15} />} title="Code scanning" footer={code?.analyses[0] ? `Last analysis by ${code.analyses[0].tool}` : undefined}> |
| 81 | {!code || (code.analyses.length === 0 && code.alerts.length === 0) ? ( |
| 82 | "Not set up. Add a workflow that uploads SARIF, and results show here and on pull requests." |
| 83 | ) : total(codeOpen!) === 0 ? ( |
| 84 | <span className="inline-flex items-center gap-1.5 text-success"> |
| 85 | <ShieldCheck size={14} /> No open alerts |
| 86 | </span> |
| 87 | ) : ( |
| 88 | <> |
| 89 | <span className="font-medium text-fg">{total(codeOpen!)}</span> open {total(codeOpen!) === 1 ? "alert" : "alerts"} |
| 90 | </> |
| 91 | )} |
| 92 | </Card> |
| 93 | <Card to={`${base}/security/vulnerabilities`} icon={<PackageSearch size={15} />} title="Vulnerabilities"> |
| 94 | {openVulns === 0 ? ( |
| 95 | <span className="inline-flex items-center gap-1.5 text-success"> |
| 96 | <ShieldCheck size={14} /> No vulnerable dependencies |
| 97 | </span> |
| 98 | ) : ( |
| 99 | <ul className="space-y-1"> |
| 100 | {worst.map((vuln) => ( |
| 101 | <li key={vuln.id} className="flex items-center gap-2"> |
| 102 | <SeverityBadge severity={vuln.severity} /> |
| 103 | <span className="truncate font-mono text-xs text-fg-soft"> |
| 104 | {vuln.package}@{vuln.version} |
| 105 | </span> |
| 106 | </li> |
| 107 | ))} |
| 108 | {openVulns > worst.length && <li className="text-xs">and {openVulns - worst.length} more</li>} |
| 109 | </ul> |
| 110 | )} |
| 111 | </Card> |
| 112 | <Card to={`${base}/security/dependency-graph`} icon={<Network size={15} />} title="Dependency graph"> |
| 113 | {overview.scan.lockfiles.length === 0 |
| 114 | ? "No lockfiles found on the default branch." |
| 115 | : `${overview.scan.lockfiles.length} ${overview.scan.lockfiles.length === 1 ? "lockfile" : "lockfiles"}, with an SPDX SBOM to download.`} |
| 116 | </Card> |
| 117 | </div> |
| 118 | <ScanSummary scan={overview.scan} /> |
| 119 | </div> |
| 120 | ); |
| 121 | } |