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