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.

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