| 1 | import { Download } from "lucide-react"; |
| 2 | import { Link, useSearchParams } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/security-graph"; |
| 5 | import { page } from "../../lib/meta"; |
| 6 | import { CARD, FilterSelect, SectionHeader } from "../../components/security-suite"; |
| 7 | import { Badge } from "../../components/ui/badge"; |
| 8 | import { Card } from "../../components/ui/card"; |
| 9 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"; |
| 10 | import { securitySuite } from "../../lib/services.server"; |
| 11 | import { getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 12 | import { requireInsider } from "../../lib/access.server"; |
| 13 | |
| 14 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 15 | return page(args, { title: `Dependency graph · ${params.owner}/${params.repo} · g1t` }); |
| 16 | } |
| 17 | |
| 18 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 19 | const viewer = getViewer(context) ?? requireUser(context, request); |
| 20 | await requireInsider(context, params, "security_alerts"); |
| 21 | return { graph: unwrap(await securitySuite.dependencyGraph({ namespace: params.owner, name: params.repo }, viewer)) }; |
| 22 | } |
| 23 | |
| 24 | /** Rows shown at once; the SBOM has them all. */ |
| 25 | const SHOWN = 500; |
| 26 | |
| 27 | export default function DependencyGraph({ loaderData, params }: Route.ComponentProps) { |
| 28 | const { graph } = loaderData; |
| 29 | const base = `/${params.owner}/${params.repo}`; |
| 30 | const [search, setSearch] = useSearchParams(); |
| 31 | const manifest = search.get("manifest") ?? "all"; |
| 32 | const relationship = search.get("relationship") ?? "all"; |
| 33 | const query = (search.get("q") ?? "").toLowerCase(); |
| 34 | const set = (key: string, value: string) => { |
| 35 | const next = new URLSearchParams(search); |
| 36 | if (value === "all" || !value) next.delete(key); |
| 37 | else next.set(key, value); |
| 38 | setSearch(next, { replace: true, preventScrollReset: true }); |
| 39 | }; |
| 40 | const shown = graph.dependencies.filter( |
| 41 | (dep) => |
| 42 | (manifest === "all" || dep.manifest === manifest) && |
| 43 | (relationship === "all" || dep.relationship === relationship) && |
| 44 | (!query || dep.name.toLowerCase().includes(query)), |
| 45 | ); |
| 46 | return ( |
| 47 | <div className="max-w-5xl space-y-6"> |
| 48 | <SectionHeader |
| 49 | title="Dependency graph" |
| 50 | about="Every package the lockfiles on the default branch resolve: direct or transitive where the lockfile says, for development or not, and its license where the lockfile records one. Read again on every push to the default branch, and daily." |
| 51 | actions={ |
| 52 | graph.dependencies.length > 0 ? ( |
| 53 | <a href={`${base}/security/dependency-graph/sbom.json`} download className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm hover:border-line-strong"> |
| 54 | <Download size={14} /> Export SBOM (SPDX) |
| 55 | </a> |
| 56 | ) : null |
| 57 | } |
| 58 | /> |
| 59 | {graph.manifests.length === 0 ? ( |
| 60 | <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted"> |
| 61 | <p> |
| 62 | No lockfiles on the default branch. g1t reads package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, go.mod, go.sum, |
| 63 | requirements.txt and poetry.lock. |
| 64 | </p> |
| 65 | </Card> |
| 66 | ) : ( |
| 67 | <> |
| 68 | <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 69 | {graph.manifests.map((item) => ( |
| 70 | <li key={item.path} className={`${CARD} p-3`}> |
| 71 | <p className="truncate font-mono text-sm">{item.path}</p> |
| 72 | <p className="mt-1 text-xs text-muted"> |
| 73 | {item.ecosystem} · {item.dependencies} packages{item.direct ? `, ${item.direct} direct` : ""} |
| 74 | </p> |
| 75 | </li> |
| 76 | ))} |
| 77 | </ul> |
| 78 | <div className="flex flex-wrap items-end gap-2"> |
| 79 | <label className="flex min-w-0 grow flex-col gap-1 text-xs text-muted sm:max-w-xs"> |
| 80 | Package |
| 81 | <input |
| 82 | defaultValue={search.get("q") ?? ""} |
| 83 | onChange={(event) => set("q", event.target.value)} |
| 84 | placeholder="Filter by name" |
| 85 | className="h-8 rounded-md border border-line bg-bg px-2.5 text-[0.8125rem] text-fg outline-none hover:border-line-strong focus:border-accent-dim" |
| 86 | /> |
| 87 | </label> |
| 88 | <FilterSelect label="Lockfile" value={manifest} options={[["all", "Every lockfile"], ...graph.manifests.map((item): [string, string] => [item.path, item.path])]} onChange={(value) => set("manifest", value)} /> |
| 89 | <FilterSelect |
| 90 | label="Relationship" |
| 91 | value={relationship} |
| 92 | options={[["all", "Any"], ["direct", "Direct"], ["transitive", "Transitive"], ["unknown", "Not said"]]} |
| 93 | onChange={(value) => set("relationship", value)} |
| 94 | /> |
| 95 | </div> |
| 96 | <div className={CARD}> |
| 97 | <Table className="min-w-[40rem]"> |
| 98 | <TableHeader> |
| 99 | <TableRow> |
| 100 | <TableHead className="px-4">Package</TableHead> |
| 101 | <TableHead>Version</TableHead> |
| 102 | <TableHead>Relationship</TableHead> |
| 103 | <TableHead>License</TableHead> |
| 104 | <TableHead>Lockfile</TableHead> |
| 105 | </TableRow> |
| 106 | </TableHeader> |
| 107 | <TableBody> |
| 108 | {shown.slice(0, SHOWN).map((dep) => ( |
| 109 | <TableRow key={`${dep.manifest}:${dep.name}@${dep.version}`}> |
| 110 | <TableCell className="px-4"> |
| 111 | <span className="font-mono text-xs">{dep.name}</span> |
| 112 | {dep.vulnerabilities > 0 && ( |
| 113 | <Link to={`${base}/security/vulnerabilities`} className="ml-2"> |
| 114 | <Badge tone="danger"> |
| 115 | {dep.vulnerabilities} {dep.vulnerabilities === 1 ? "vulnerability" : "vulnerabilities"} |
| 116 | </Badge> |
| 117 | </Link> |
| 118 | )} |
| 119 | </TableCell> |
| 120 | <TableCell className="font-mono text-xs">{dep.version}</TableCell> |
| 121 | <TableCell className="text-xs text-muted"> |
| 122 | {dep.relationship === "unknown" ? "not said" : dep.relationship} |
| 123 | {dep.development ? ", dev" : ""} |
| 124 | </TableCell> |
| 125 | <TableCell className="text-xs text-muted">{dep.license ?? "—"}</TableCell> |
| 126 | <TableCell className="font-mono text-xs text-faint">{dep.manifest}</TableCell> |
| 127 | </TableRow> |
| 128 | ))} |
| 129 | </TableBody> |
| 130 | </Table> |
| 131 | </div> |
| 132 | <p className="text-xs text-faint"> |
| 133 | {shown.length > SHOWN ? `The first ${SHOWN} of ${shown.length} shown; the SBOM has every package.` : `${shown.length} packages.`} |
| 134 | {graph.commit && <> Read at {graph.commit.slice(0, 7)}.</>} |
| 135 | </p> |
| 136 | </> |
| 137 | )} |
| 138 | </div> |
| 139 | ); |
| 140 | } |