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/routes/repo/security.tsx

163 lines8,019 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 API1import { RefreshCw, ShieldCheck } from "lucide-react";
2import { data, useFetcher, useSearchParams } from "react-router";
3
4import type { SecretDecision } from "@g1t/contracts";
5
6import type { Route } from "./+types/security";
7import { page } from "../../lib/meta";
8import { ScanSummary, SecretsList, SeverityCountsGrid, type UpgradeFix, VulnerabilityList } from "../../components/security";
9import { Switch } from "../../components/ui/switch";
10import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
11import { security, work } from "../../lib/services.server";
12import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
16}
17
18/** Upgrade issues looked up per page, at most. */
19const MAX_FIXES = 30;
20
21export async function loader({ params, context, request }: Route.LoaderArgs) {
22 // Git's refusal links here; someone signed out signs in first.
23 const viewer = getViewer(context) ?? requireUser(context, request);
24 // Findings are the workspace's own business, public project or not.
25 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
26 const repo = { namespace: params.owner, name: params.repo };
27 const overview = unwrap(await security.overview(repo, viewer));
28 const numbers = [...new Set(overview.vulnerabilities.map((vuln) => vuln.issue).filter((n): n is number => n != null))].slice(
29 0,
30 MAX_FIXES,
31 );
32 const details = await Promise.all(numbers.map((number) => work.getIssue(repo, number, viewer).catch(() => null)));
33 const fixes: Record<number, UpgradeFix> = {};
34 for (const found of details) {
35 if (!found?.ok) continue;
36 const { issue, pulls } = found.value;
37 const latest = pulls.at(-1) ?? null;
38 fixes[issue.number] = {
39 number: issue.number,
40 state: issue.state,
41 resolvedBy: issue.resolvedBy,
42 pull: latest ? { number: latest.number, status: latest.status, agent: latest.runtime === "hosted" ? latest.agent : null } : null,
43 };
44 }
45 return { overview, fixes };
46}
47
48export async function action({ params, context, request }: Route.ActionArgs) {
49 assertSameOrigin(request);
50 const user = requireUser(context, request);
51 const repo = { namespace: params.owner, name: params.repo };
52 const form = await request.formData();
53 const intent = String(form.get("intent") ?? "");
54 if (intent === "decide") {
55 const decision = String(form.get("decision") ?? "") as SecretDecision;
56 const decided = await security.decideSecret(user, repo, String(form.get("id") ?? ""), decision, String(form.get("reason") ?? ""));
57 return decided.ok ? { ok: true } : { ok: false, error: decided.error.message };
58 }
59 if (intent === "rescan") {
60 const scanned = await security.rescan(user, repo);
61 return scanned.ok ? { ok: true } : { ok: false, error: scanned.error.message };
62 }
63 if (intent === "upkeep") {
64 const set = await security.setUpkeep(user, repo, form.get("enabled") === "true");
65 return set.ok ? { ok: true } : { ok: false, error: set.error.message };
66 }
67 return { ok: false, error: "Unknown action." };
68}
69
70export default function ProjectSecurity({ loaderData, params }: Route.ComponentProps) {
71 const { overview, fixes } = loaderData;
72 const base = `/${params.owner}/${params.repo}`;
73 const action = `${base}/security`;
74 const [search, setSearch] = useSearchParams();
75 const rescan = useFetcher<{ ok: boolean; error?: string }>();
76 const upkeep = useFetcher<{ ok: boolean; error?: string }>();
77 const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : overview.upkeep;
78 const openSecrets = overview.secrets.filter((secret) => secret.status === "open" || secret.status === "blocked").length;
79 const openVulns = overview.vulnerabilities.filter((vuln) => vuln.status === "open").length;
80 const focus = search.get("finding");
81 const tab = search.get("tab") ?? (focus || (openSecrets > 0 && openVulns === 0) ? "secrets" : openVulns > 0 ? "dependencies" : "secrets");
82 return (
83 <div className="max-w-5xl">
84 <div className="flex flex-wrap items-start justify-between gap-4">
85 <div>
86 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
87 <ShieldCheck size={19} className="text-accent" />
88 Security
89 </h2>
90 <p className="mt-1.5 max-w-2xl text-sm text-muted">
91 Pushes that add a secret are refused before they land, and the history is scanned once. Every package the
92 lockfiles resolve is checked for known vulnerabilities, and each one with a fix becomes an upgrade issue a g1t
93 agent lands through checks, review and the merge queue.
94 </p>
95 </div>
96 <rescan.Form method="post" action={action}>
97 <input type="hidden" name="intent" value="rescan" />
98 <button
99 type="submit"
100 disabled={rescan.state !== "idle"}
101 className="inline-flex items-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-50"
102 >
103 <RefreshCw size={14} className={rescan.state !== "idle" ? "animate-spin" : ""} />
104 {rescan.state !== "idle" ? "Scanning…" : "Re-scan now"}
105 </button>
106 {rescan.data?.error && <p className="mt-1.5 text-xs text-danger">{rescan.data.error}</p>}
107 </rescan.Form>
108 </div>
109
110 <div className="mt-6">
111 <SeverityCountsGrid counts={overview.counts} />
112 <p className="mt-2 text-xs text-faint">Open vulnerabilities by severity. A secret that is open or blocked counts as critical.</p>
113 </div>
114
115 <div className="mt-4">
116 <ScanSummary scan={overview.scan} />
117 </div>
118
119 <label className="mt-6 flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
120 <span className="min-w-0">
121 <span className="block text-sm font-medium">Upkeep agents</span>
122 <span className="mt-1 block text-sm text-muted">
123 Open an upgrade issue for each vulnerable package with a fix, and put a g1t agent on it. Off, findings are still
124 listed here, and nothing is opened for them.
125 </span>
126 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
127 </span>
128 <Switch
129 className="mt-0.5"
130 checked={upkeepOn}
131 disabled={upkeep.state !== "idle"}
132 onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })}
133 />
134 </label>
135
136 <Tabs
137 value={tab}
138 onValueChange={(value) => {
139 const next = new URLSearchParams(search);
140 next.set("tab", value);
141 next.delete("finding");
142 setSearch(next, { replace: true, preventScrollReset: true });
143 }}
144 className="mt-8"
145 >
146 <TabsList>
147 <TabsTrigger value="secrets">Secrets{openSecrets > 0 ? ` · ${openSecrets}` : ""}</TabsTrigger>
148 <TabsTrigger value="dependencies">Dependencies{openVulns > 0 ? ` · ${openVulns}` : ""}</TabsTrigger>
149 </TabsList>
150 <TabsContent value="secrets">
151 <SecretsList secrets={overview.secrets} base={base} action={action} focus={focus} />
152 <p className="mt-3 text-xs text-faint">
153 Not a real secret, such as a test fixture? Add <code>g1t:allow-secret</code> in a comment on its line, or allow it
154 here. Allowing is recorded with your name and reason.
155 </p>
156 </TabsContent>
157 <TabsContent value="dependencies">
158 <VulnerabilityList vulnerabilities={overview.vulnerabilities} fixes={fixes} base={base} />
159 </TabsContent>
160 </Tabs>
161 </div>
162 );
163}