Skip to content

g1t/apps/web/app/routes/repo/security-secrets.tsx

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