flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/security.tsx

425 lines17,956 bytesCodeBlame

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 API1/**
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 */
7import { Bot, CircleCheck, CircleDot, ExternalLink, GitPullRequest, KeyRound, Package, ShieldAlert, ShieldCheck } from "lucide-react";
8import { useEffect, useRef, useState } from "react";
9import { Link, useFetcher } from "react-router";
10
11import { SEVERITIES, type SecretFinding, type SecretStatus, type Severity, type SeverityCounts, type Vulnerability } from "@g1t/contracts";
12
13import { TimeAgo } from "./ui";
14import { Badge, type BadgeTone } from "./ui/badge";
15import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
16
17type Done = { ok: boolean; error?: string } | undefined;
18
19const 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
27const 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
34export 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. */
39export 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. */
55export 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
76const 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. */
80function 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
138function 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
213export 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. */
245export 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
253function 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
271type PackageGroup = { key: string; ecosystem: string; name: string; vulns: Vulnerability[] };
272
273function 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
284function worst(vulns: Vulnerability[]): Severity {
285 return SEVERITIES.find((severity) => vulns.some((vuln) => vuln.severity === severity)) ?? "unknown";
286}
287
288export 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
332function 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
378export 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}