g1t/apps/web/app/components/security.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 1 | /** |
| 2 | * Security, as a project's page shows it: findings by severity, the | |
| 3 | * secrets found in pushes and history with what was decided about each, | |
| 4 | * and vulnerable dependencies with the upgrade fixing each. The page | |
| 5 | * posts the intents in `routes/repo/security.tsx`'s action. | |
| 6 | */ | |
| 7 | import { Bot, CircleCheck, CircleDot, ExternalLink, GitPullRequest, KeyRound, Package, ShieldAlert, ShieldCheck } from "lucide-react"; | |
| 8 | import { useEffect, useRef, useState } from "react"; | |
| 9 | import { Link, useFetcher } from "react-router"; | |
| 10 | ||
| 11 | import { SEVERITIES, type SecretFinding, type SecretStatus, type Severity, type SeverityCounts, type Vulnerability } from "@g1t/contracts"; | |
| 12 | ||
| 13 | import { TimeAgo } from "./ui"; | |
| 14 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 15 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 16 | ||
| 17 | type Done = { ok: boolean; error?: string } | undefined; | |
| 18 | ||
| 19 | const SEVERITY: Record<Severity, { label: string; tone: BadgeTone }> = { | |
| 20 | critical: { label: "Critical", tone: "danger" }, | |
| 21 | high: { label: "High", tone: "warn" }, | |
| 22 | medium: { label: "Medium", tone: "merged" }, | |
| 23 | low: { label: "Low", tone: "info" }, | |
| 24 | unknown: { label: "Unrated", tone: "neutral" }, | |
| 25 | }; | |
| 26 | ||
| 27 | const STATUS: Record<SecretStatus, { label: string; tone: BadgeTone; about: string }> = { | |
| 28 | open: { label: "Open", tone: "danger", about: "In the repository's history. Rotate it, then mark it resolved." }, | |
| 29 | blocked: { label: "Push blocked", tone: "warn", about: "A push carrying it was refused, so it never landed." }, | |
| 30 | allowed: { label: "Allowed", tone: "neutral", about: "Not a real secret, so pushes carrying it go through." }, | |
| 31 | resolved: { label: "Resolved", tone: "accent", about: "Rotated or removed." }, | |
| 32 | }; | |
| 33 | ||
| 34 | export function SeverityBadge({ severity }: { severity: Severity }) { | |
| 35 | return <Badge tone={SEVERITY[severity].tone}>{SEVERITY[severity].label}</Badge>; | |
| 36 | } | |
| 37 | ||
| 38 | /** Open findings by severity, one tile each. */ | |
| 39 | export function SeverityCountsGrid({ counts }: { counts: SeverityCounts }) { | |
| 40 | return ( | |
| 41 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> | |
| 42 | {SEVERITIES.map((severity) => ( | |
| 43 | <div key={severity} className="rounded-xl border border-line bg-surface px-4 py-3"> | |
| 44 | <p className="text-xs text-muted">{SEVERITY[severity].label}</p> | |
| 45 | <p className={`mt-1 text-2xl font-semibold tabular-nums ${counts[severity] > 0 && severity === "critical" ? "text-danger" : ""}`}> | |
| 46 | {counts[severity]} | |
| 47 | </p> | |
| 48 | </div> | |
| 49 | ))} | |
| 50 | </div> | |
| 51 | ); | |
| 52 | } | |
| 53 | ||
| 54 | /** A compact row of severity counts, for a list of projects. */ | |
| 55 | export function SeverityCountsInline({ counts }: { counts: SeverityCounts }) { | |
| 56 | const shown = SEVERITIES.filter((severity) => counts[severity] > 0); | |
| 57 | if (shown.length === 0) { | |
| 58 | return ( | |
| 59 | <span className="inline-flex items-center gap-1 text-xs text-accent"> | |
| 60 | <ShieldCheck size={13} /> | |
| 61 | Nothing open | |
| 62 | </span> | |
| 63 | ); | |
| 64 | } | |
| 65 | return ( | |
| 66 | <span className="flex flex-wrap gap-1.5"> | |
| 67 | {shown.map((severity) => ( | |
| 68 | <Badge key={severity} tone={SEVERITY[severity].tone}> | |
| 69 | {counts[severity]} {SEVERITY[severity].label.toLowerCase()} | |
| 70 | </Badge> | |
| 71 | ))} | |
| 72 | </span> | |
| 73 | ); | |
| 74 | } | |
| 75 | ||
| 76 | const TEXTAREA = | |
| 77 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 78 | ||
| 79 | /** Allow or resolve a secret, with the reason the record keeps. */ | |
| 80 | function Decide({ finding, decision, action }: { finding: SecretFinding; decision: "allow" | "resolve"; action: string }) { | |
| 81 | const fetcher = useFetcher<Done>(); | |
| 82 | const [open, setOpen] = useState(false); | |
| 83 | useEffect(() => { | |
| 84 | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); | |
| 85 | }, [fetcher.state, fetcher.data]); | |
| 86 | const allow = decision === "allow"; | |
| 87 | return ( | |
| 88 | <Dialog open={open} onOpenChange={setOpen}> | |
| 89 | <DialogTrigger | |
| 90 | className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${ | |
| 91 | allow ? "border-line text-muted hover:border-line-strong hover:text-fg" : "border-accent/40 text-accent hover:bg-accent/10" | |
| 92 | }`} | |
| 93 | > | |
| 94 | {allow ? "Allow" : "Resolve"} | |
| 95 | </DialogTrigger> | |
| 96 | <DialogContent> | |
| 97 | <DialogHeader> | |
| 98 | <DialogTitle>{allow ? `Allow ${finding.label}?` : `Mark ${finding.label} resolved?`}</DialogTitle> | |
| 99 | <DialogDescription> | |
| 100 | {allow | |
| 101 | ? finding.status === "blocked" | |
| 102 | ? "Say why it is not a real secret. The push it stopped can then be pushed again as it is, and the record keeps your name and reason." | |
| 103 | : "Say why it is not a real secret. The record keeps your name and reason." | |
| 104 | : "Rotate it with whoever issued it first: removing it from the code leaves it in history. The record keeps your name and reason."} | |
| 105 | </DialogDescription> | |
| 106 | </DialogHeader> | |
| 107 | <fetcher.Form method="post" action={action} className="space-y-3"> | |
| 108 | <input type="hidden" name="intent" value="decide" /> | |
| 109 | <input type="hidden" name="id" value={finding.id} /> | |
| 110 | <input type="hidden" name="decision" value={decision} /> | |
| 111 | <p className="font-mono text-xs text-muted"> | |
| 112 | {finding.path}:{finding.line} · {finding.preview} | |
| 113 | </p> | |
| 114 | <textarea | |
| 115 | name="reason" | |
| 116 | required | |
| 117 | rows={3} | |
| 118 | maxLength={500} | |
| 119 | placeholder={allow ? "A fake key in a test fixture." : "Rotated in the AWS console; the old key is disabled."} | |
| 120 | className={TEXTAREA} | |
| 121 | /> | |
| 122 | <div className="flex justify-end"> | |
| 123 | <button | |
| 124 | type="submit" | |
| 125 | disabled={fetcher.state !== "idle"} | |
| 126 | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 127 | > | |
| 128 | {allow ? "Allow" : "Mark resolved"} | |
| 129 | </button> | |
| 130 | </div> | |
| 131 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 132 | </fetcher.Form> | |
| 133 | </DialogContent> | |
| 134 | </Dialog> | |
| 135 | ); | |
| 136 | } | |
| 137 | ||
| 138 | function SecretItem({ finding, base, action, focused }: { finding: SecretFinding; base: string; action: string; focused: boolean }) { | |
| 139 | const reopen = useFetcher<Done>(); | |
| 140 | const ref = useRef<HTMLLIElement>(null); | |
| 141 | useEffect(() => { | |
| 142 | if (focused) ref.current?.scrollIntoView({ block: "center" }); | |
| 143 | }, [focused]); | |
| 144 | const status = STATUS[finding.status]; | |
| 145 | const landed = finding.source === "history" || finding.status === "open"; | |
| 146 | return ( | |
| 147 | <li ref={ref} className={`flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start ${focused ? "bg-accent/5 ring-1 ring-accent/40 ring-inset" : ""}`}> | |
| 148 | <KeyRound size={15} className="mt-0.5 hidden shrink-0 text-muted sm:block" /> | |
| 149 | <div className="min-w-0 grow"> | |
| 150 | <div className="flex flex-wrap items-center gap-2"> | |
| 151 | <span className="text-sm font-medium first-letter:uppercase">{finding.label}</span> | |
| 152 | <Badge tone={status.tone} title={status.about}> | |
| 153 | {status.label} | |
| 154 | </Badge> | |
| 155 | </div> | |
| 156 | <p className="mt-1 truncate font-mono text-xs"> | |
| 157 | {landed ? ( | |
| 158 | <Link to={`${base}/blob/${finding.commit}/${finding.path}#L${finding.line}`} className="text-fg-soft hover:text-fg hover:underline"> | |
| 159 | {finding.path}:{finding.line} | |
| 160 | </Link> | |
| 161 | ) : ( | |
| 162 | <span className="text-fg-soft"> | |
| 163 | {finding.path}:{finding.line} | |
| 164 | </span> | |
| 165 | )} | |
| 166 | <span className="text-faint"> · {finding.preview}</span> | |
| 167 | </p> | |
| 168 | <p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint"> | |
| 169 | <span> | |
| 170 | {finding.source === "push" ? "in a push" : "in history"} | |
| 171 | {finding.foundBy && <> by {finding.foundBy}</>}, commit{" "} | |
| 172 | {landed ? ( | |
| 173 | <Link to={`${base}/commit/${finding.commit}`} className="font-mono hover:text-fg"> | |
| 174 | {finding.commit.slice(0, 7)} | |
| 175 | </Link> | |
| 176 | ) : ( | |
| 177 | <span className="font-mono">{finding.commit.slice(0, 7)}</span> | |
| 178 | )} | |
| 179 | </span> | |
| 180 | <span> | |
| 181 | found <TimeAgo at={finding.foundAt} /> | |
| 182 | </span> | |
| 183 | {finding.decidedBy && finding.decidedAt && ( | |
| 184 | <span> | |
| 185 | {finding.status === "allowed" ? "allowed" : "resolved"} by {finding.decidedBy} <TimeAgo at={finding.decidedAt} /> | |
| 186 | {finding.reason && <>: “{finding.reason}”</>} | |
| 187 | </span> | |
| 188 | )} | |
| 189 | </p> | |
| 190 | {reopen.data?.error && <p className="mt-1.5 text-xs text-danger">{reopen.data.error}</p>} | |
| 191 | </div> | |
| 192 | <div className="flex shrink-0 items-center gap-1.5"> | |
| 193 | {finding.status === "open" || finding.status === "blocked" ? ( | |
| 194 | <> | |
| 195 | <Decide finding={finding} decision="allow" action={action} /> | |
| 196 | {finding.status === "open" && <Decide finding={finding} decision="resolve" action={action} />} | |
| 197 | </> | |
| 198 | ) : ( | |
| 199 | <button | |
| 200 | type="button" | |
| 201 | disabled={reopen.state !== "idle"} | |
| 202 | onClick={() => reopen.submit({ intent: "decide", id: finding.id, decision: "reopen" }, { method: "post", action })} | |
| 203 | className="rounded-md border border-line px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50" | |
| 204 | > | |
| 205 | Reopen | |
| 206 | </button> | |
| 207 | )} | |
| 208 | </div> | |
| 209 | </li> | |
| 210 | ); | |
| 211 | } | |
| 212 | ||
| 213 | export function SecretsList({ | |
| 214 | secrets, | |
| 215 | base, | |
| 216 | action, | |
| 217 | focus, | |
| 218 | }: { | |
| 219 | secrets: SecretFinding[]; | |
| 220 | base: string; | |
| 221 | action: string; | |
| 222 | focus: string | null; | |
| 223 | }) { | |
| 224 | if (secrets.length === 0) { | |
| 225 | return ( | |
| 226 | <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center"> | |
| 227 | <ShieldCheck size={22} className="mx-auto text-accent" /> | |
| 228 | <p className="mt-2 font-medium">No secrets found</p> | |
| 229 | <p className="mt-1 text-sm text-muted"> | |
| 230 | Pushes that add a key or a token are refused before they land, and the history is scanned once in the background. | |
| 231 | </p> | |
| 232 | </div> | |
| 233 | ); | |
| 234 | } | |
| 235 | return ( | |
| 236 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 237 | {secrets.map((finding) => ( | |
| 238 | <SecretItem key={finding.id} finding={finding} base={base} action={action} focused={finding.id === focus} /> | |
| 239 | ))} | |
| 240 | </ul> | |
| 241 | ); | |
| 242 | } | |
| 243 | ||
| 244 | /** Where the upgrade issue for a package stands, as the page loads it. */ | |
| 245 | export type UpgradeFix = { | |
| 246 | number: number; | |
| 247 | state: "open" | "closed"; | |
| 248 | /** The newest pull request for the issue, if any. */ | |
| 249 | pull: { number: number; status: "draft" | "open" | "merged" | "closed"; agent: string | null } | null; | |
| 250 | resolvedBy: number | null; | |
| 251 | }; | |
| 252 | ||
| 253 | function FixLink({ issue, fix, base }: { issue: number | null; fix: UpgradeFix | undefined; base: string }) { | |
| 254 | if (issue == null) return <span className="text-xs text-faint">No upgrade issue</span>; | |
| 255 | const pull = fix?.pull; | |
| 256 | return ( | |
| 257 | <span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"> | |
| 258 | <Link to={`${base}/issues/${issue}`} className="inline-flex items-center gap-1 text-fg-soft hover:text-fg"> | |
| 259 | {fix?.state === "closed" ? <CircleCheck size={12} className="text-merged" /> : <CircleDot size={12} className="text-accent" />}#{issue} | |
| 260 | </Link> | |
| 261 | {pull && ( | |
| 262 | <Link to={`${base}/pull/${pull.number}`} className="inline-flex items-center gap-1 text-muted hover:text-fg"> | |
| 263 | {pull.agent ? <Bot size={12} /> : <GitPullRequest size={12} />} | |
| 264 | #{pull.number} {pull.status === "draft" ? "in progress" : pull.status} | |
| 265 | </Link> | |
| 266 | )} | |
| 267 | </span> | |
| 268 | ); | |
| 269 | } | |
| 270 | ||
| 271 | type PackageGroup = { key: string; ecosystem: string; name: string; vulns: Vulnerability[] }; | |
| 272 | ||
| 273 | function groups(vulnerabilities: Vulnerability[]): PackageGroup[] { | |
| 274 | const map = new Map<string, PackageGroup>(); | |
| 275 | for (const vuln of vulnerabilities) { | |
| 276 | const key = `${vuln.ecosystem}:${vuln.package}`; | |
| 277 | const group = map.get(key) ?? { key, ecosystem: vuln.ecosystem, name: vuln.package, vulns: [] }; | |
| 278 | group.vulns.push(vuln); | |
| 279 | map.set(key, group); | |
| 280 | } | |
| 281 | return [...map.values()]; | |
| 282 | } | |
| 283 | ||
| 284 | function worst(vulns: Vulnerability[]): Severity { | |
| 285 | return SEVERITIES.find((severity) => vulns.some((vuln) => vuln.severity === severity)) ?? "unknown"; | |
| 286 | } | |
| 287 | ||
| 288 | export function VulnerabilityList({ | |
| 289 | vulnerabilities, | |
| 290 | fixes, | |
| 291 | base, | |
| 292 | }: { | |
| 293 | vulnerabilities: Vulnerability[]; | |
| 294 | fixes: Record<number, UpgradeFix>; | |
| 295 | base: string; | |
| 296 | }) { | |
| 297 | const open = groups(vulnerabilities.filter((vuln) => vuln.status === "open")); | |
| 298 | const fixed = groups(vulnerabilities.filter((vuln) => vuln.status === "fixed")); | |
| 299 | return ( | |
| 300 | <div className="space-y-6"> | |
| 301 | {open.length === 0 ? ( | |
| 302 | <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center"> | |
| 303 | <ShieldCheck size={22} className="mx-auto text-accent" /> | |
| 304 | <p className="mt-2 font-medium">No known vulnerabilities</p> | |
| 305 | <p className="mt-1 text-sm text-muted"> | |
| 306 | Every package the lockfiles resolve is checked against the OSV database on each push to the default branch, and daily. | |
| 307 | </p> | |
| 308 | </div> | |
| 309 | ) : ( | |
| 310 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 311 | {open.map((group) => ( | |
| 312 | <PackageItem key={group.key} group={group} fixes={fixes} base={base} /> | |
| 313 | ))} | |
| 314 | </ul> | |
| 315 | )} | |
| 316 | {fixed.length > 0 && ( | |
| 317 | <details className="group"> | |
| 318 | <summary className="cursor-pointer text-sm text-muted hover:text-fg"> | |
| 319 | Fixed ({fixed.reduce((sum, group) => sum + group.vulns.length, 0)}) | |
| 320 | </summary> | |
| 321 | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface opacity-80"> | |
| 322 | {fixed.map((group) => ( | |
| 323 | <PackageItem key={group.key} group={group} fixes={fixes} base={base} /> | |
| 324 | ))} | |
| 325 | </ul> | |
| 326 | </details> | |
| 327 | )} | |
| 328 | </div> | |
| 329 | ); | |
| 330 | } | |
| 331 | ||
| 332 | function PackageItem({ group, fixes, base }: { group: PackageGroup; fixes: Record<number, UpgradeFix>; base: string }) { | |
| 333 | const first = group.vulns[0]; | |
| 334 | const versions = [...new Set(group.vulns.map((vuln) => vuln.version))]; | |
| 335 | const targets = group.vulns.map((vuln) => vuln.fixedVersion).filter((version): version is string => !!version); | |
| 336 | const manifests = [...new Set(group.vulns.map((vuln) => vuln.manifest))]; | |
| 337 | const issue = group.vulns.find((vuln) => vuln.issue != null)?.issue ?? null; | |
| 338 | const advisories = [...new Map(group.vulns.map((vuln) => [vuln.advisory, vuln])).values()]; | |
| 339 | return ( | |
| 340 | <li className="px-4 py-3"> | |
| 341 | <div className="flex flex-col gap-2 sm:flex-row sm:items-start"> | |
| 342 | <Package size={15} className="mt-0.5 hidden shrink-0 text-muted sm:block" /> | |
| 343 | <div className="min-w-0 grow"> | |
| 344 | <div className="flex flex-wrap items-center gap-2"> | |
| 345 | <span className="font-mono text-sm font-medium">{group.name}</span> | |
| 346 | <span className="font-mono text-xs text-muted">{versions.join(", ")}</span> | |
| 347 | <Badge>{first.ecosystem}</Badge> | |
| 348 | <SeverityBadge severity={worst(group.vulns)} /> | |
| 349 | </div> | |
| 350 | <p className="mt-1 text-xs text-faint"> | |
| 351 | {targets.length > 0 ? <>Fixed in {targets.sort().at(-1)}</> : "No fixed version yet"} · locked in{" "} | |
| 352 | <span className="font-mono">{manifests.join(", ")}</span> | |
| 353 | </p> | |
| 354 | </div> | |
| 355 | <FixLink issue={issue} fix={issue != null ? fixes[issue] : undefined} base={base} /> | |
| 356 | </div> | |
| 357 | <ul className="mt-2 space-y-1 sm:pl-7"> | |
| 358 | {advisories.map((vuln) => ( | |
| 359 | <li key={vuln.advisory} className="flex flex-wrap items-baseline gap-x-2 text-xs"> | |
| 360 | <a | |
| 361 | href={`https://osv.dev/vulnerability/${vuln.osvId}`} | |
| 362 | target="_blank" | |
| 363 | rel="noreferrer" | |
| 364 | className="inline-flex items-center gap-1 font-mono text-fg-soft hover:text-fg" | |
| 365 | > | |
| 366 | {vuln.advisory} | |
| 367 | <ExternalLink size={10} /> | |
| 368 | </a> | |
| 369 | <span className="text-muted">{SEVERITY[vuln.severity].label.toLowerCase()}</span> | |
| 370 | <span className="min-w-0 truncate text-muted">{vuln.summary}</span> | |
| 371 | </li> | |
| 372 | ))} | |
| 373 | </ul> | |
| 374 | </li> | |
| 375 | ); | |
| 376 | } | |
| 377 | ||
| 378 | export function ScanSummary({ | |
| 379 | scan, | |
| 380 | }: { | |
| 381 | scan: { history: string; commitsScanned: number; historyFinishedAt: string | null; dependenciesScannedAt: string | null; dependenciesError: string | null; lockfiles: string[] }; | |
| 382 | }) { | |
| 383 | const history = | |
| 384 | scan.history === "done" ? ( | |
| 385 | <> | |
| 386 | History scanned: {scan.commitsScanned.toLocaleString()} commits | |
| 387 | {scan.historyFinishedAt && ( | |
| 388 | <> | |
| 389 | , <TimeAgo at={scan.historyFinishedAt} /> | |
| 390 | </> | |
| 391 | )} | |
| 392 | </> | |
| 393 | ) : scan.history === "stopped" ? ( | |
| 394 | "History scan paused: the workspace reached its spending limit" | |
| 395 | ) : scan.history === "running" ? ( | |
| 396 | `Scanning history: ${scan.commitsScanned.toLocaleString()} commits so far` | |
| 397 | ) : ( | |
| 398 | "History scan queued" | |
| 399 | ); | |
| 400 | return ( | |
| 401 | <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted"> | |
| 402 | <span className="inline-flex items-center gap-1.5"> | |
| 403 | <KeyRound size={12} /> | |
| 404 | {history} | |
| 405 | </span> | |
| 406 | <span className="inline-flex items-center gap-1.5"> | |
| 407 | <Package size={12} /> | |
| 408 | {scan.dependenciesScannedAt ? ( | |
| 409 | <> | |
| 410 | Dependencies read <TimeAgo at={scan.dependenciesScannedAt} /> | |
| 411 | {scan.lockfiles.length > 0 ? <> from {scan.lockfiles.join(", ")}</> : " (no lockfiles found)"} | |
| 412 | </> | |
| 413 | ) : ( | |
| 414 | "Dependencies not read yet" | |
| 415 | )} | |
| 416 | </span> | |
| 417 | {scan.dependenciesError && ( | |
| 418 | <span className="inline-flex items-center gap-1.5 text-warn"> | |
| 419 | <ShieldAlert size={12} /> | |
| 420 | {scan.dependenciesError} | |
| 421 | </span> | |
| 422 | )} | |
| 423 | </div> | |
| 424 | ); | |
| 425 | } |