Skip to content

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

207 linesCodeBlameRaw
1import { Plus } from "lucide-react";
2import { Link, useSearchParams } from "react-router";
3
4import type { AlertState, DismissReason } from "@g1t/contracts";
5
6import type { Route } from "./+types/security-secrets";
7import { page } from "../../lib/meta";
8import { SecretsList, StateFilter } from "../../components/security";
9import { ActivationPrompt, CARD, FilterSelect, SectionHeader } from "../../components/security-suite";
10import { Badge } from "../../components/ui/badge";
11import { Card } from "../../components/ui/card";
12import { security, securitySuite } from "../../lib/services.server";
13import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server";
14import { refusal, requireInsider } from "../../lib/access.server";
15import { Unavailable } from "../../components/boundary";
16import { isFailed, part } from "../../lib/partial";
17import { planPrice } from "../../lib/security-suite.server";
18import { countStates, keepSecret, secretFilters, secretTypes } from "../../lib/security-suite";
19import { repoPages } from "../../lib/repo-live";
20import { useRepoLive } from "../../lib/use-repo-live";
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Secret scanning · ${params.owner}/${params.repo} · g1t` });
24}
25
26export async function loader({ params, context, request }: Route.LoaderArgs) {
27 const viewer = getViewer(context) ?? requireUser(context, request);
28 const { access } = await requireInsider(context, params, "push");
29 const repo = { namespace: params.owner, name: params.repo };
30 // The alerts are what the page is about. Custom patterns are a section
31 // of their own: when they cannot be read it says so, rather than offer
32 // to turn on what may well be on.
33 const [overview, patterns, price] = await Promise.all([
34 security.overview(repo, viewer),
35 part(securitySuite.patterns(params.owner, repo, viewer), "Custom patterns"),
36 planPrice(params.owner, viewer),
37 ]);
38 return {
39 overview: unwrap(overview),
40 patterns: isFailed(patterns) ? patterns : patterns.ok ? patterns.value : { patterns: [], entitled: false },
41 price,
42 can: access.can,
43 owner: managesSecurity(viewer, params.owner),
44 };
45}
46
47const REASONS = new Set(["false_positive", "used_in_tests", "revoked", "wont_fix"]);
48
49export async function action({ params, context, request }: Route.ActionArgs) {
50 assertSameOrigin(request);
51 const user = requireUser(context, request);
52 const repo = { namespace: params.owner, name: params.repo };
53 const form = await request.formData();
54 const intent = String(form.get("intent") ?? "");
55 const id = String(form.get("id") ?? "");
56 // Dismissing a secret lets it through push protection: Admin.
57 const refused = await refusal(context, params, "manage_integrations");
58 if (refused) return { ok: false, error: refused };
59 if (intent === "dismiss") {
60 const reason = String(form.get("reason") ?? "");
61 if (!REASONS.has(reason)) return { ok: false, error: "Choose a reason." };
62 const done = await security.dismiss(
63 user,
64 repo,
65 id,
66 reason as DismissReason,
67 String(form.get("comment") ?? "")
68 .trim()
69 .slice(0, 500),
70 );
71 return done.ok ? { ok: true } : { ok: false, error: done.error.message };
72 }
73 if (intent === "reopen") {
74 const done = await security.reopen(user, repo, id);
75 return done.ok ? { ok: true } : { ok: false, error: done.error.message };
76 }
77 return { ok: false, error: "Unknown action." };
78}
79
80export default function SecretScanning({ loaderData, params }: Route.ComponentProps) {
81 const { overview, patterns, price, can, owner } = loaderData;
82 // Alerts opening, fixed and dismissed show as it happens.
83 useRepoLive({ match: repoPages.security() });
84 const base = `/${params.owner}/${params.repo}`;
85 const [search, setSearch] = useSearchParams();
86 const filters = secretFilters(search);
87 const counts = countStates(overview.secrets.filter((secret) => keepSecret(secret, { ...filters, state: secret.state })));
88 const shown = overview.secrets.filter((secret) => keepSecret(secret, filters));
89 const set = (key: string, value: string | null) => {
90 const next = new URLSearchParams(search);
91 if (value) next.set(key, value);
92 else next.delete(key);
93 setSearch(next, { replace: true, preventScrollReset: true });
94 };
95 const listed = isFailed(patterns) ? [] : patterns.patterns;
96 const own = listed.filter((pattern) => pattern.scope === "repository");
97 const inherited = listed.filter((pattern) => pattern.scope === "workspace");
98 return (
99 <div className="max-w-5xl space-y-8">
100 <SectionHeader
101 title="Secret scanning"
102 about="Pushes that add a key or a token are refused before they land; the person pushing can bypass with a reason. The default branch's history is scanned once, in the background, and again when a custom pattern is published."
103 />
104
105 <section className="space-y-3">
106 <div className="flex flex-wrap items-end justify-between gap-3">
107 <StateFilter counts={counts} value={filters.state} onChange={(state: AlertState) => set("state", state === "open" ? null : state)} />
108 <div className="flex flex-wrap gap-2">
109 <FilterSelect
110 label="Type"
111 value={filters.type ?? "all"}
112 options={[["all", "All types"], ...secretTypes(overview.secrets)]}
113 onChange={(value) => set("type", value === "all" ? null : value)}
114 />
115 <FilterSelect
116 label="Validity"
117 value={filters.validity ?? "all"}
118 options={[
119 ["all", "Any"],
120 ["active", "Active"],
121 ["inactive", "Inactive"],
122 ["unknown", "Unknown"],
123 ["unsupported", "No check"],
124 ]}
125 onChange={(value) => set("validity", value === "all" ? null : value)}
126 />
127 <FilterSelect
128 label="Bypassed"
129 value={filters.bypassed == null ? "all" : String(filters.bypassed)}
130 options={[
131 ["all", "Either"],
132 ["true", "Bypassed"],
133 ["false", "Not bypassed"],
134 ]}
135 onChange={(value) => set("bypassed", value === "all" ? null : value)}
136 />
137 </div>
138 </div>
139 <SecretsList
140 secrets={shown}
141 state={filters.state}
142 activity={overview.activity}
143 base={base}
144 action={`${base}/security/secret-scanning`}
145 focus={search.get("finding")}
146 canDismiss={can.manage_integrations}
147 />
148 <p className="text-xs text-faint">
149 Not a real secret, such as a test fixture? Add <code>g1t:allow-secret</code> in a comment on its line, or dismiss the alert. Dismissing takes the
150 Admin role and is recorded with your name and reason.
151 </p>
152 </section>
153
154 <section className="space-y-3" aria-labelledby="patterns">
155 <div className="flex flex-wrap items-end justify-between gap-3">
156 <div>
157 <h3 id="patterns" className="text-base font-semibold tracking-tight">
158 Custom patterns
159 </h3>
160 <p className="mt-1 text-sm text-muted">Formats of your own, found by push protection and history scans with the built-in ones.</p>
161 </div>
162 {!isFailed(patterns) && patterns.entitled && can.manage_integrations && (
163 <Link
164 to={`${base}/security/secret-scanning/patterns`}
165 className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm hover:border-line-strong"
166 >
167 <Plus size={14} /> New pattern
168 </Link>
169 )}
170 </div>
171 {isFailed(patterns) ? (
172 <Unavailable what={patterns.what} />
173 ) : !patterns.entitled ? (
174 <ActivationPrompt workspace={params.owner} feature="Custom patterns" monthlyCents={price} isOwner={owner} />
175 ) : own.length + inherited.length === 0 ? (
176 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted">
177 <p>No custom patterns yet. Add one for your own tokens' format, try it on test strings and a dry run, then publish it.</p>
178 </Card>
179 ) : (
180 <ul className={`${CARD} divide-y divide-line`}>
181 {[...own, ...inherited].map((pattern) => (
182 <li key={pattern.id} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-3">
183 <div className="min-w-0 grow">
184 <div className="flex flex-wrap items-center gap-2">
185 {pattern.scope === "repository" && can.manage_integrations ? (
186 <Link to={`${base}/security/secret-scanning/patterns?id=${pattern.id}`} className="text-sm font-medium hover:underline">
187 {pattern.name}
188 </Link>
189 ) : (
190 <span className="text-sm font-medium">{pattern.name}</span>
191 )}
192 <Badge tone={pattern.state === "published" ? "accent" : "neutral"}>{pattern.state === "published" ? "Published" : "Draft"}</Badge>
193 {pattern.scope === "workspace" && <Badge>From {pattern.workspace}</Badge>}
194 </div>
195 <p className="mt-0.5 truncate font-mono text-xs text-muted">{pattern.pattern}</p>
196 </div>
197 <span className="shrink-0 text-xs text-faint">
198 {pattern.openAlerts} open {pattern.openAlerts === 1 ? "alert" : "alerts"}
199 </span>
200 </li>
201 ))}
202 </ul>
203 )}
204 </section>
205 </div>
206 );
207}