SLOPSHOPPER

prompt-redact

Replaces secrets and credentials with stable placeholders inside Claude Code before the model or the session file ever sees them — prompts, tool results and…

newbandguardcommandprompt
v0.3.4MITupdated 2026-10-21ezoushen/claude-mods/prompt-redact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-redact
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /redact ⎿ prompt-redact: prompt-redact — on (tier: balanced) ⎿ prompt-redact: hid 3 values this session: ⎿ prompt-redact: URL_PASSWORD x3 ⎿ prompt-redact: restored 0 placeholders into edit arguments. ⎿ prompt-redact: vault: 1 value in session state (never on disk). ⎿ prompt-redact: Values are never shown here — only labels and counts. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-redact

Puts a placeholder where a secret used to be — before the model or the session file sees it — and puts the real value back only where the disk demands it.

Requires Claude Code ≥ 2.1.287. No network, no subprocess, no model call: a detector that transmits the value it is hiding would defeat itself, so a scan that needs egress is not a scan this mod runs.

claude plugin marketplace add ezoushen/claude-mods
claude plugin install prompt-redact@claude-mods

Or try it for one session without installing: claude --plugin-dir ./prompt-redact.

What it does

  • Prompts — a typed secret becomes [REDACTED:LABEL#hash] before the prompt is queued. The transcript and prompt history show the placeholder.
  • Tool output — file reads, shell output and MCP results are rewritten before the model reads them. A cat of a file holding a key hands the model a placeholder.
  • Reply rows and stored rows — session.append rewrites a stored row before both the model and the transcript see it, which closes the echo-back leak where the model restates a secret. Caveat: an organization account seats the builtin cc-plugin-sec-default outermost. That guard forwards past the user tier (next.to(e, "append")) for the rows the engine injects itself — the attachment, hook-context, compaction and skill tool-message doors, plus an organization plugin's own notes — so those four are not reached at user tier. Ordinary prompt, reply and tool-result rows are reached: the guard does not skip those doors. Two carriers have no hook at all, at any tier: prompt.context (where managed CLAUDE.md most likely travels) and skill.prompt. An @-mentioned file is not a stored row; it travels on the next carrier.
  • Injected context — prompt.attachment scrubs text another settings hook injects into the request (SessionStart/UserPromptSubmit additional context), which session.append never sees because it is not a stored row. A file referenced with @ also travels through this carrier. On an organization account this hook is skipped, and the file's content reaches the model unredacted — a measured leak, quantified under Known limitation.
  • Edits — the real value is restored only inside Edit, Write and NotebookEdit arguments, so an edit to a line holding a real key still matches the file on disk. Bash never receives a real value.

A one-line band above the prompt shows what it has hidden, by label and count — never a value.

Detection

Five layers, auto-selected per machine and per row:

  1. Structural — PEM private keys (linear scan, whole block), JWTs (header decoded to JSON with an alg), IBAN mod-97, Luhn card numbers, SSA numbers under the issuance rules. These carry their own proof, so they need no entropy gate or context.
  2. Rule corpus — 220 rules generated from gitleaks (MIT): 95 prefix-anchored, 123 vendor-keyword, 2 generic. Every rule carries a provenance URL, checked by tools/check-rules.mjs.
  3. Generic — the corrected keyword = value shape, with a tolerant prefix, a quote tolerated before the separator, and a low length floor.
  4. Context score — for the generic family only, weighted by nearby deployment vocabulary and by value shape.
  5. Local rules — an optional .claude/prompt-redact.rules (one LABEL <javascript-regex> per line) is probed at session start and merged. A session that only has the built-in corpus starts silently; the readiness line appears when local rules or the optional classifier are loaded, and /redact answers on demand.

Allowlisted (left untouched, because redacting them breaks the task): git and content hashes, UUID/ULID/ObjectId, lockfile integrity, bcrypt, SSH public keys, data: URIs, SRI, ARNs, account ids, IPs, emails, timestamps, versions, and the placeholder lexicon (changeme, ${ENV}, <your-key>, …).

Fail closed: a row whose scan throws or overruns its budget is withheld ([redact withheld this content: the redactor failed while scanning it]); a prompt that cannot be scanned is dropped rather than sent.

Commands

/redact            counts by label, restore count, vault size
/redact on|off     enable or disable scanning for this session
/redact tier strict|balanced|precision
/redact allow <re> never redact values matching <re>
/redact deny <re>  always redact values matching <re>
/redact why        how detection works, and this session's posture

strict redacts anything suspicious, balanced is the default, precision only clear-cut secrets. Rules can be added (.claude/prompt-redact.rules, one LABEL <regex> per line) and removed: userConfig.disabledRules or /redact rule off <id> switches a shipped rule off for the session, /redact rule on <id> puts it back, and /redact rule list shows what is off. /redact is also where on|off, tier, allow, deny and why live.

Configuration also lives in the plugin's userConfig (enabled, tier, minEntropy, scanBudgetMs), so /config can set it.

Using a credential

Everything above keeps the model from holding a real value — which also means the model cannot hand one to a command. A credential reaches the shell on one of three paths, each measured on a live Haiku session with the plugin loaded from source and a synthetic 35-character canary (never a real key). The placeholder that would stand in for it, [REDACTED:…#…], is 43 characters, so a command printing 35 proves the shell held the real value — the placeholder is not 35 characters long.

1. Export before launch. Bash inherits the launching shell's environment. With E2E_ENV_CANARY exported before Claude Code starts, the model was asked to run printf %s "$E2E_ENV_CANARY" | wc -c; it printed 35, the real value's length. The model sees the variable's name, runs the command, and reports a number: the value never enters its context, so there is nothing to redact in this path. grep -c on the session file returned 0, and tools/verify-transcript.mjs reported 0 model-facing, 0 tool-result, 0 residual occurrences.

2. The ! prefix. A command typed with ! runs in the same shell. !printf %s "$E2E_ENV_CANARY" | wc -c printed 35 in the same measured session, and the transcript check held: 0 model-facing, 0 tool-result, 0 residual. The stored row shows only the command line and its status line.

3. Through a file an edit tool writes. Type or paste the secret once — prompt redaction turns it into [REDACTED:LABEL#hash] across the prompt and the transcript. Then have Claude write the file you actually need:

> create env.sh with the line STRIPE_KEY=<the redacted placeholder>

The Write call carries the placeholder — the measured session's tool argument read STRIPE_KEY=[REDACTED:GENERIC_API_KEY#6997db91011b1685] — and rehydration swaps it for the real value in the file on disk, the one channel Edit, Write and NotebookEdit get (see the Edits bullet above). The shell then picks it up:

set -a; . ./env.sh; set +a        # export all; source; stop exporting
printf %s "$STRIPE_KEY" | wc -c   # → 35

The transcript check passed with 0 model-facing, 0 tool-result, 0 residual occurrences, and the transcript carried the two placeholders for the redacted values rather than either raw value.

Three things these paths do not permit:

  • Bash never receives a real value. Rehydration is scoped to Edit, Write and NotebookEdit (DECISIONS.md §8, which is what keeps a restored placeholder from becoming an exfiltration primitive). A literal secret typed into a Bash command becomes a placeholder before the shell sees it, so an inline curl -H "Authorization: Bearer …" with the secret cannot work — the command would carry the placeholder. That call is now refused outright rather than run as a silent no-op (see Teaching the model what a placeholder means).
  • A credential that exists only in prompt text cannot be used by Bash at all. It must live in the environment before launch (path 1) or be materialised on disk (path 3). Handing the value to Claude in prose buys you neither.
  • The file path 3 creates holds the real value in plaintext at rest. The redactor protects the transcript and the model, never the disk. Write is the channel that materialises it, so treat the result as a secret file: chmod 600 env.sh, list it in the repository's .gitignore before the write, and delete it when the task is done.

Captures: paths 1 and 2 in reports/evidence/06-operator-capture.txt and session 435fbaf4-ee44-45a4-b723-9556a00f5077.jsonl; path 3 in session abb84738-c36b-4c70-bcc3-86bf25c64e7f.jsonl.

Teaching the model what a placeholder means

A placeholder is opaque, and the model has to know both what it is and what it cannot do with it. Three layers, weakest to strongest:

  1. A skill — skills/redacted-values/SKILL.md, also runnable as /prompt-redact:redacted-values. It sorts the three origins (the user's own message, tool output, the model's own reply) and states the correct move for each. Advisory by nature: Claude invokes it when the description matches, and can miss the turn it matters.
  2. A standing notice — prompt.compose / prompt.section append the opaque description to the system prompt, so the model is told on every turn what the placeholders are, whichever of the two hooks the engine reaches.
  3. Two deterministic hooks — these do not depend on the model reading anything:
  4. On the turn a secret is redacted out of the user's message, the injected context says so, tells the model to stop rather than work around it, forbids asking the user to paste the value, and gives the route that works (set -a; . ./.env; set +a, or reference by variable name).
  5. tool.call refuses a Bash, WebFetch or WebSearch call whose arguments carry a placeholder. Those tools are deliberately never rehydrated, so without this a command would run with [REDACTED:…#…] as literal text and report success. The refusal names the file-loading route.

The honest split: "stop and tell the user" cannot be forced as prose — a hook cannot dictate the model's words. What can be forced is the refusal, and a refusal with the instruction attached leaves the model no way to continue without surfacing it to the user.

Troubleshooting: "its session.append hook was skipped"

On a machine with managed settings, cc-plugin-sec-default forwards some events past the user tier and Claude Code prints one line per event and kind. That line names the event, not the door, so it reads as if the whole hook were dead.

Cause: Claude Code's own injected rows, not another plugin. Measured with claude-mem disabled entirely: the line still appeared, with 2 bypassed session.append runs. The engine emits environment, model, skill_listing, date, instructions and session_context attachments at startup, and every one takes the attachment door the guard forwards. claude-mem's SessionStart context adds more of the same (upstream issue #1269 calls them saved_hook_context).

Since 0.3.2 this plugin no longer registers those doors. session.append is matched only for the doors a user-tier mod can actually read — prompt, command, response, tool-result, delivery, notice, queue — so the engine has no skipped hook of this plugin's to report. The injected doors were never readable at this tier, so no carrier is lost. The exclusion is unconditional — no registration names those doors, so a deployment seated above the guard would not receive them either.

Since 0.3.3, guardedDeployment removes the remaining three. At this tier the guard also forwards prompt.attachment, prompt.compose and prompt.section, so registering them produced one line each at startup for hooks that could never run. Set guardedDeployment: true in the plugin's config — pluginConfigs in user settings or, org-wide, in managed settings:

{ "pluginConfigs": { "prompt-redact@claude-mods": { "options": { "guardedDeployment": true } } } }

With that in place those three hooks are not registered at all: no skipped-hook line from this plugin, on any event. Measured end to end: 0 skip lines, prompt.submit x1, session.append x8, canary absent from the transcript.

The option stays off by default. On a machine with no policy mod it must stay off: there those three hooks are what deliver the opaque notice and scan engine attachments (origin.kind === "hook" context, which session.append never sees).

/redact why shows what did arrive. Measured on a quiet session before 0.3.2: 21 session.append runs reached the mod while 2–9 injected runs were bypassed; prompt.submit fired normally.

prompt.context (managed CLAUDE.md) and skill.prompt have no hook at all, at any tier, so those carriers remain uncovered regardless of seating.

Known limitation

Measured on a live Haiku session with tools/verify-transcript.mjs, plugin loaded from source, memory plugin excluded so no third party pre-injected text. The session exercised the typed-prompt and tool-result carriers, not an @-mentioned file (see item 1 below):

model-facing canary occurrences: 0
toolUseResult canary occurrences: 0
RESIDUAL (not hook-reachable): 0
rehydration: saved3.txt holds the real value

grep -c '<canary>' <session>.jsonl returns 0 for both canaries. The model never saw a real value, no tool sidecar kept one, and the file on disk still got the real key because rehydration fired.

Two things that number does not claim:

  1. Hook reach depends on deployment. On an organization account the builtin cc-plugin-sec-default seats outermost and forwards past the user tier on this mod's prompt.section, prompt.compose, prompt.attachment hooks and on the engine-injected session.append doors ("runs first and sent the event on past it"). Prompt, reply and tool-result rows still reach session.append; typed prompts and tool results stay covered by prompt.submit and tool.call.

Registering this plugin org-wide from a GitHub marketplace (for example extraKnownMarketplaces + enabledPlugins in managed settings) makes it available to every member, but it does not change the seating: a plugin Claude Code copies into its cache counts as the person's, even when managed settings enable it. To seat it ahead of the guard, an administrator needs all of: a marketplace declared with a directory source on each machine, managed enabledPlugins, and prependPlugins listing this plugin before sec-default@builtin (the list replaces the default seating, so the guard must be named). The remote payload also needs managedSourcesBehavior: "merge", or a local managed-settings.json is ignored under first-wins.

For the attachment carrier this is not a theoretical caveat. Measured on this account (organization OAuth, live Haiku session, policy plugin outermost): a file holding two synthetic canaries was referenced from the prompt as an @-mention, and both canaries reached model-facing content unredacted — 2 raw occurrences of each canary in the session file, api-request-blob(role=system) at line 21. tools/verify-transcript.mjs exited non-zero:

   FAIL GUARANTEE VIOLATED: canary appears in model-facing content x1 — line 21 (api-request-blob(role=system))
   FAIL GUARANTEE VIOLATED: canary appears in model-facing content x1 — line 21 (api-request-blob(role=system))
   2 verification failure(s)

prompt.attachment never fired for the event, and the platform reported at session start that its prompt.attachment hook was skipped because cc-plugin-sec-default runs first. So attachments are not safe to use with this mod on this deployment: @-mentioning a file hands its contents to the model unscrubbed. This cannot be fixed from inside the mod — a skipped hook cannot be made to fire from here. On an account where the hook does fire the same content would be scanned by the same detectors, but that path is not what this account measured. Captures: reports/evidence/05/attachment-probe.txt and session 219934f7-2129-4185-a6f5-973a584cfc47.jsonl.

  1. Prose passwords remain a gap. Measured recall on the synthetic fixture set is 0% at L1 and 40% with a local classifier (tools/measure-recall.mjs). A bare 8-character alphabetic password with no keyword anchor has nothing for a rule to grip. The local classifier closes the shapeless-token tier (0% → 100%) but not this one, and it ships without weights: training needs data that leaves the machine, which this mod forbids.

Development

node tools/check-rules.mjs          # corpus integrity: provenance, compile, bounded
claude plugin validate ./prompt-redact
claude plugin test ./prompt-redact  # 80 tests
python3 tools/gen-rules.py          # regenerate rules + RULES.md

Research record: ../research/prompt-redaction.md.

Source 8 files
hooks/register.ts 1105 lines
1// prompt-redact: put a placeholder where a secret used to be, before the model
2// and the session file ever see it, and put the real value back only where the
3// disk demands it.
4//
5// Design constraints that shaped every line below:
6//
7//   * Reach L0. No network call, no subprocess, no model call -- the static
8//     posture check greps for exactly those. A detector that transmits the value
9//     it is trying to hide would defeat itself. The one capability probed is
10//     filesystem access, for an optional local rules file.
11//   * Fail closed. A row that throws or overruns its budget is withheld, never
12//     stored raw.
13//   * Rehydrate narrowly. Only `Edit`/`Write`/`NotebookEdit` get real values
14//     back. `Bash` never does: otherwise any command the model writes becomes an
15//     exfiltration primitive and the placeholder resolves on the way out.
16//   * Deterministic placeholders, so an edit to a line holding a real key still
17//     matches the file on disk.
18//   * The vault lives in `$.state` only: session-scoped, survives a hot reload,
19//     gone at exit, never written to disk or `$.store`.
20//
21// The state accessors below are written out one per key on purpose. The engine
22// requires every `$.state.get`/`$.state.set` reference to name its plugin and key
23// as string literals written at the call site, so the full state surface can be
24// listed from the compiled module. A generic `read($, key)` helper would hide it.
25
26import {
27  asRehydratable,
28  detectorsFor,
29  placeholderCount,
30  placeholdersIn,
31  rehydrate,
32  scrub,
33  scrubMessage,
34  type Rehydratable,
35  type ScrubHit,
36  type ScrubOptions,
37  type Tier,
38} from "./scrub.ts";
39import { parseClassifier, type ClassifierModel } from "./classifier.ts";
40import { RULES } from "./rules.ts";
41
42const WITHHELD =
43  "[redact withheld this content: the redactor failed while scanning it]";
44
45/** Fields a rewrite must not touch; the engine refuses them anyway. */
46const RESERVED = new Set(["tool", "tool_use_id", "agentId"]);
47
48/** Tools whose arguments may receive a real value back. */
49const REHYDRATE_TOOLS = ["Edit", "Write", "NotebookEdit"] as const;
50
51/**
52 * Tools that must never carry a placeholder onward. Rehydration is deliberately
53 * absent here, so a placeholder that reached one of these would run or travel as
54 * literal text: the command "succeeds" while doing nothing, which is worse than
55 * refusing. A refusal can carry the route that works; a silent no-op cannot.
56 */
57const GUARDED_TOOLS = ["Bash", "WebFetch", "WebSearch"] as const;
58
59const OPAQUE_NOTICE =
60  "\n\nSome values in this conversation appear as `[REDACTED:<LABEL>#<hash>]`. " +
61  "They are opaque placeholders the session minted for credentials, API keys and " +
62  "private keys that never left this machine. Treat them as opaque identifiers: " +
63  "you may reference them, and copy them into `Edit`/`Write`/`NotebookEdit` " +
64  "arguments (the real value is restored there, so an edit to a line holding a key " +
65  "still matches the file), but you cannot read or reveal their contents, and " +
66  "asking the person to paste the real value would defeat the point. Do not guess " +
67  "or reconstruct them. They are stable for the whole session.";
68
69const LOCAL_RULES_PATHS = [".claude/prompt-redact.rules", ".prompt-redact.rules"];
70
71/**
72 * Where an L2 weights file may sit. Both are project-local: the model is read
73 * with `$.fs` from the session's own directory, so nothing about the value being
74 * judged ever leaves the machine.
75 */
76const MODEL_PATHS = [".claude/prompt-redact.model.json", ".prompt-redact.model.json"];
77
78/** Re-probe interval for the optional model: a trainer can edit it mid-session. */
79const MODEL_CACHE_MS = 30_000;
80
81/** Section id the notice is appended under; a plugin's own ids are namespaced. */
82const NOTICE_SECTION_ID = "prompt-redact:notice";
83
84const TIERS: readonly Tier[] = ["strict", "balanced", "precision"];
85
86/**
87 * The interception surface, in the order `/redact why` lists it.
88 *
89 * `prompt.submit` and `tool.call` carry the typed prompt and the tool results on
90 * every deployment. The other four are the prompt-content hooks a managed
91 * account's builtin policy plugin can keep from ever reaching this mod: the
92 * platform seats such a plugin outermost, and a run it forwards past the user
93 * tier (`next.to(e, "append")`) means this mod never sees that run. Whether that happened is written only by the
94 * platform, outside this process (its skip notice reaches the transcript or the
95 * debug log and no hook), so the report below can state exactly one thing:
96 * how many runs of each hook arrived. It can never state that a carrier was
97 * covered, because a skipped run is indistinguishable from no run.
98 */
99const INTERCEPTION_HOOKS = [
100  "prompt.submit",
101  "tool.call",
102  "session.append",
103  "prompt.attachment",
104  "prompt.compose",
105  "prompt.section",
106] as const;
107type InterceptionHook = (typeof INTERCEPTION_HOOKS)[number];
108
109/**
110 * Rule ids the engine honours that are not rows in the generated corpus:
111 * `local-classifier` switches the optional L2 stage, the other three switch
112 * detector families delivered alongside it. They are validated by the command
113 * exactly like a corpus id, so a real id never reports "no such rule".
114 */
115const ENGINE_RULE_IDS = ["local-classifier", "url-userinfo", "basic-auth", "password-value"] as const;
116
117// --- state surface, one literal reference per key ---------------------------
118
119async function getSalt($: any): Promise<string | undefined> {
120  try {
121    const got = await $.state.get({ plugin: "prompt-redact", key: "salt" });
122    return typeof got?.value === "string" ? got.value : undefined;
123  } catch {
124    return undefined;
125  }
126}
127async function setSalt($: any, value: string): Promise<void> {
128  try {
129    await $.state.set({ plugin: "prompt-redact", key: "salt" }, value);
130  } catch {
131    /* a failed state write must never block the redaction itself */
132  }
133}
134
135async function getVault($: any): Promise<Record<string, string>> {
136  try {
137    const got = await $.state.get({ plugin: "prompt-redact", key: "vault" });
138    return got?.value && typeof got.value === "object" ? (got.value as Record<string, string>) : {};
139  } catch {
140    return {};
141  }
142}
143async function setVault($: any, value: Record<string, string>): Promise<void> {
144  try {
145    await $.state.set({ plugin: "prompt-redact", key: "vault" }, value);
146  } catch {
147    /* best-effort */
148  }
149}
150
151async function getCounts($: any): Promise<Record<string, number>> {
152  try {
153    const got = await $.state.get({ plugin: "prompt-redact", key: "counts" });
154    return got?.value && typeof got.value === "object" ? (got.value as Record<string, number>) : {};
155  } catch {
156    return {};
157  }
158}
159async function setCounts($: any, value: Record<string, number>): Promise<void> {
160  try {
161    await $.state.set({ plugin: "prompt-redact", key: "counts" }, value);
162  } catch {
163    /* best-effort */
164  }
165}
166
167async function getRestored($: any): Promise<number> {
168  try {
169    const got = await $.state.get({ plugin: "prompt-redact", key: "restored" });
170    return typeof got?.value === "number" ? got.value : 0;
171  } catch {
172    return 0;
173  }
174}
175async function setRestored($: any, value: number): Promise<void> {
176  try {
177    await $.state.set({ plugin: "prompt-redact", key: "restored" }, value);
178  } catch {
179    /* best-effort */
180  }
181}
182
183async function getEnabled($: any): Promise<boolean | undefined> {
184  try {
185    const got = await $.state.get({ plugin: "prompt-redact", key: "enabled" });
186    return typeof got?.value === "boolean" ? got.value : undefined;
187  } catch {
188    return undefined;
189  }
190}
191async function setEnabled($: any, value: boolean): Promise<void> {
192  try {
193    await $.state.set({ plugin: "prompt-redact", key: "enabled" }, value);
194  } catch {
195    /* best-effort */
196  }
197}
198
199async function getTier($: any): Promise<Tier | undefined> {
200  try {
201    const got = await $.state.get({ plugin: "prompt-redact", key: "tier" });
202    const value = got?.value;
203    return TIERS.includes(value as Tier) ? (value as Tier) : undefined;
204  } catch {
205    return undefined;
206  }
207}
208async function setTier($: any, value: Tier): Promise<void> {
209  try {
210    await $.state.set({ plugin: "prompt-redact", key: "tier" }, value);
211  } catch {
212    /* best-effort */
213  }
214}
215
216async function getAllow($: any): Promise<string[]> {
217  try {
218    const got = await $.state.get({ plugin: "prompt-redact", key: "allow" });
219    return Array.isArray(got?.value) ? (got.value as string[]) : [];
220  } catch {
221    return [];
222  }
223}
224async function setAllow($: any, value: string[]): Promise<void> {
225  try {
226    await $.state.set({ plugin: "prompt-redact", key: "allow" }, value);
227  } catch {
228    /* best-effort */
229  }
230}
231
232async function getDeny($: any): Promise<string[]> {
233  try {
234    const got = await $.state.get({ plugin: "prompt-redact", key: "deny" });
235    return Array.isArray(got?.value) ? (got.value as string[]) : [];
236  } catch {
237    return [];
238  }
239}
240async function setDeny($: any, value: string[]): Promise<void> {
241  try {
242    await $.state.set({ plugin: "prompt-redact", key: "deny" }, value);
243  } catch {
244    /* best-effort */
245  }
246}
247
248/** Cached probe result; `null` model means L1 only. */
249let modelCache: { at: number; model: ClassifierModel | null } | null = null;
250
251async function getDisabled($: any): Promise<string[] | undefined> {
252  try {
253    const got = await $.state.get({ plugin: "prompt-redact", key: "disabled" });
254    return Array.isArray(got?.value) ? (got.value as string[]) : undefined;
255  } catch {
256    return undefined;
257  }
258}
259async function setDisabled($: any, value: string[]): Promise<void> {
260  try {
261    await $.state.set({ plugin: "prompt-redact", key: "disabled" }, value);
262  } catch {
263    /* best-effort */
264  }
265}
266
267async function getFires($: any): Promise<Record<string, number>> {
268  try {
269    const got = await $.state.get({ plugin: "prompt-redact", key: "hookFires" });
270    return got?.value && typeof got.value === "object" ? (got.value as Record<string, number>) : {};
271  } catch {
272    return {};
273  }
274}
275async function setFires($: any, value: Record<string, number>): Promise<void> {
276  try {
277    await $.state.set({ plugin: "prompt-redact", key: "hookFires" }, value);
278  } catch {
279    /* best-effort */
280  }
281}
282
283/**
284 * Whether this prompt already carries the opaque-placeholder notice. Two hooks
285 * deliver it (`prompt.section` and `prompt.compose`), and both fire on a surface
286 * where neither is pre-empted, so the first one to land sets this and the second
287 * stands down.
288 */
289async function getNoticeShown($: any): Promise<boolean> {
290  try {
291    const got = await $.state.get({ plugin: "prompt-redact", key: "noticeShown" });
292    return got?.value === true;
293  } catch {
294    return false;
295  }
296}
297async function setNoticeShown($: any, value: boolean): Promise<void> {
298  try {
299    await $.state.set({ plugin: "prompt-redact", key: "noticeShown" }, value);
300  } catch {
301    /* best-effort */
302  }
303}
304
305/**
306 * How many runs of each interception hook reached this code. This is the only
307 * in-process evidence there is: an engine-skipped run never enters a hook, and
308 * the platform's skip notice is written outside this process, where no hook
309 * can read it.
310 *
311 * One read plus one write per delivered run. `session.append` runs on every
312 * stored row but already reads several state keys per row, so this stays in the
313 * cost class the hot path already pays.
314 */
315async function markObserved($: any, hook: InterceptionHook): Promise<void> {
316  try {
317    const fires = await getFires($);
318    await setFires($, { ...fires, [hook]: (fires[hook] ?? 0) + 1 });
319  } catch {
320    /* a failed state write must never block the hook protecting the row */
321  }
322}
323
324// --- helpers ----------------------------------------------------------------
325
326/**
327 * A per-session salt. A placeholder minted under one salt is meaningless under
328 * another, so a placeholder copied in from a different session restores nothing.
329 */
330function newSalt(): string {
331  let out = "";
332  for (let i = 0; i < 4; i++) out += Math.random().toString(36).slice(2, 10);
333  return `${out}${Date.now().toString(36)}`;
334}
335
336function compile(source: string): RegExp | null {
337  try {
338    return new RegExp(source, "i");
339  } catch {
340    return null;
341  }
342}
343
344/**
345 * Which sources may steer detection on this run.
346 *
347 * `repo` covers the project-local rules file and the optional classifier: both
348 * are read from the session's own directory, so a cloned repository controls
349 * them. That is the point where this mod sits in the person's own tier. It is
350 * not acceptable where the mod is the organization's, because a repository would
351 * then gain a say over what the organization put in front of the model.
352 */
353type Sourcing = {
354  readonly repo: boolean;
355  /** Session allow/deny lists: the person's steering, applied to user content only. */
356  readonly sessionLists: boolean;
357};
358
359/** Tiers where this mod counts as the organization's, not the person's. */
360function isOrgSeated(next: unknown): boolean {
361  const tier = (next as { origin?: { tier?: unknown } } | undefined)?.origin?.tier;
362  return tier === "prepend" || tier === "append";
363}
364
365function sourcingFor(next: unknown, sessionLists: boolean): Sourcing {
366  return { repo: !isOrgSeated(next), sessionLists };
367}
368
369async function optionsFor(
370  $: any,
371  salt: string,
372  tier: Tier,
373  minEntropy: number,
374  budgetMs: number,
375  disabledRules: readonly string[],
376  sourcing: Sourcing,
377): Promise<ScrubOptions> {
378  return {
379    salt,
380    tier,
381    minEntropy,
382    // Repository-supplied detection only where this mod sits in the person's tier.
383    extraRules: sourcing.repo ? await localRules($) : [],
384    allowExtra: sourcing.sessionLists ? (await getAllow($)).map(compile).filter((r): r is RegExp => r !== null) : [],
385    deny: sourcing.sessionLists ? (await getDeny($)).map(compile).filter((r): r is RegExp => r !== null) : [],
386    budgetMs,
387    classifier: sourcing.repo ? await localClassifier($) : null,
388    disabledRules: (await getDisabled($)) ?? disabledRules,
389  };
390}
391
392/**
393 * Merge a local rules file when one exists. This is the capability probe: the
394 * redactor adapts to what the machine already has, and degrades silently to the
395 * built-in corpus when it has nothing. Format, one rule per line:
396 *
397 *   LABEL_NAME <javascript-regex>
398 */
399async function localRules($: any): Promise<ScrubOptions["extraRules"]> {
400  for (const rel of LOCAL_RULES_PATHS) {
401    try {
402      if (!(await $.fs.exists(rel))) continue;
403      const raw = await $.fs.read(rel);
404      const text = typeof raw === "string" ? raw : String(raw ?? "");
405      const rules = text
406        .split("\n")
407        .map((line) => line.trim())
408        .filter((line) => line.length > 0 && !line.startsWith("#"))
409        .flatMap((line) => {
410          const separator = line.indexOf(" ");
411          if (separator < 1) return [];
412          const label = line.slice(0, separator);
413          const pattern = line.slice(separator + 1).trim();
414          if (!pattern) return [];
415          if (!compile(pattern)) return [];
416          return [
417            {
418              id: `local:${label}`,
419              label: label.toUpperCase().replace(/[^A-Z0-9]+/g, "_"),
420              regex: pattern,
421              flags: "i",
422              groups: 0,
423              secretGroup: null,
424              entropy: null,
425              keywords: [] as string[],
426              allow: [] as string[],
427              kind: "generic" as const,
428              source: `local file ${rel}`,
429            },
430          ];
431        });
432      if (rules.length > 0) return rules;
433    } catch {
434      // Unreadable or unparseable rules file: fall back to the built-in corpus.
435    }
436  }
437  return [];
438}
439
440/**
441 * Probe for an optional L2 model and return it, or null.
442 *
443 * This is the ladder's capability probe for the prose/multilingual tier: the
444 * model is a project-local weights file, evaluated in-process by classifier.ts.
445 * Absent, unreadable or malformed, this returns null and the pipeline stays at
446 * L1 -- silently, because a missing model is the normal case, not an error.
447 */
448async function localClassifier($: any): Promise<ClassifierModel | null> {
449  const now = Date.now();
450  if (modelCache && now - modelCache.at < MODEL_CACHE_MS) return modelCache.model;
451  let model: ClassifierModel | null = null;
452  for (const rel of MODEL_PATHS) {
453    try {
454      if (!(await $.fs.exists(rel))) continue;
455      const raw = await $.fs.read(rel);
456      const text = typeof raw === "string" ? raw : String(raw ?? "");
457      const parsed = parseClassifier(text);
458      if (parsed) {
459        model = parsed;
460        break;
461      }
462    } catch {
463      // Unreadable weights are not a reason to stop redacting: stay at L1.
464    }
465  }
466  modelCache = { at: now, model };
467  return model;
468}
469
470function totals(hits: readonly ScrubHit[], counts: Record<string, number>): Record<string, number> {
471  const next = { ...counts };
472  for (const hit of hits) next[hit.label] = (next[hit.label] ?? 0) + 1;
473  return next;
474}
475
476async function recordHits($: any, hits: readonly ScrubHit[]): Promise<void> {
477  if (hits.length === 0) return;
478  const vault = await getVault($);
479  for (const hit of hits) vault[hit.placeholder] = hit.value;
480  await setVault($, vault);
481  await setCounts($, totals(hits, await getCounts($)));
482}
483
484function report(
485  counts: Record<string, number>,
486  restored: number,
487  enabled: boolean,
488  tier: Tier,
489  vaultSize: number,
490): string {
491  const lines = [`prompt-redact — ${enabled ? "on" : "OFF"} (tier: ${tier})`];
492  const labels = Object.entries(counts).sort((a, b) => b[1] - a[1]);
493  if (labels.length === 0) {
494    lines.push("hid nothing yet this session.");
495  } else {
496    const total = labels.reduce((n, [, count]) => n + count, 0);
497    lines.push(`hid ${total} value${total === 1 ? "" : "s"} this session:`);
498    for (const [label, count] of labels) lines.push(`  ${label} x${count}`);
499  }
500  lines.push(`restored ${restored} placeholder${restored === 1 ? "" : "s"} into edit arguments.`);
501  lines.push(`vault: ${vaultSize} value${vaultSize === 1 ? "" : "s"} in session state (never on disk).`);
502  lines.push("Values are never shown here — only labels and counts.");
503  return lines.join("\n");
504}
505
506/**
507 * The interception posture: how many runs each hook delivered this session, and
508 * which have never reached us. Names, counts and a fixed reason only — never a
509 * value, a placeholder or a hash.
510 *
511 * The word "armed" is never printed. It is the word a live session contradicted:
512 * counts say what ran here; they say nothing about runs the platform sent past
513 * the mod, so presenting them as coverage would repeat exactly that lie.
514 */
515function interceptionPosture(fires: Readonly<Record<string, number>>): string[] {
516  const known = INTERCEPTION_HOOKS.filter((hook) => typeof fires[hook] === "number" && fires[hook] > 0);
517  const unseen = INTERCEPTION_HOOKS.filter((hook) => !(typeof fires[hook] === "number" && fires[hook] > 0));
518  const lines = [`interception hooks: ${known.length} fired this session, ${unseen.length} not observed`];
519  lines.push(
520    `  fired this session: ${
521      known.length === 0 ? "none" : known.map((hook) => `${hook} x${fires[hook]}`).join(", ")
522    }`,
523  );
524  if (unseen.length > 0) {
525    lines.push(
526      `  not observed: ${unseen.join(", ")} (an outer policy plugin can keep an event from reaching this mod, or no such event has occurred yet — this mod cannot see which)`,
527    );
528  }
529  lines.push(
530    "  observed firing is not coverage: an outer policy plugin can send the event past any of these hooks without this mod seeing it, so the counts name only the runs that arrived",
531  );
532  return lines;
533}
534
535type Cfg = {
536  readonly enabled: boolean;
537  readonly tier: Tier;
538  readonly minEntropy: number;
539  /** Wall-clock ceiling for scanning one row; exceeding it fails closed. */
540  readonly budgetMs: number;
541  /** Rule ids off at session start, from the manifest's `disabledRules`. */
542  readonly disabledRules: readonly string[];
543};
544
545async function posture($: any, cfg: Cfg): Promise<{ enabled: boolean; tier: Tier; salt: string }> {
546  const salt = (await getSalt($)) ?? "";
547  const enabled = (await getEnabled($)) ?? cfg.enabled;
548  const tier = (await getTier($)) ?? cfg.tier;
549  return { enabled, tier, salt };
550}
551
552async function initialized($: any, cfg: Cfg): Promise<void> {
553  if (!(await getSalt($))) await setSalt($, newSalt());
554  if ((await getEnabled($)) === undefined) await setEnabled($, cfg.enabled);
555  if (!(await getTier($))) await setTier($, cfg.tier);
556  await setVault($, await getVault($));
557  await setCounts($, await getCounts($));
558  await setRestored($, await getRestored($));
559  await setAllow($, await getAllow($));
560  await setDeny($, await getDeny($));
561  if ((await getDisabled($)) === undefined) await setDisabled($, [...cfg.disabledRules]);
562}
563
564async function scrubText(
565  $: any,
566  text: string,
567  cfg: Cfg,
568  sourcing: Sourcing,
569): Promise<{ enabled: boolean; text: string; hits: readonly ScrubHit[] }> {
570  const { enabled, tier, salt } = await posture($, cfg);
571  if (!enabled || !salt) return { enabled: false, text, hits: [] as const };
572  const result = scrub(text, await optionsFor($, salt, tier, cfg.minEntropy, cfg.budgetMs, cfg.disabledRules, sourcing));
573  return { enabled: true, text: result.text, hits: result.hits };
574}
575
576export function register(on: any, options: Record<string, unknown>): void {
577  const configuredEnabled = options.enabled !== false;
578  const configuredTier = (TIERS.includes(options.tier as Tier) ? options.tier : "balanced") as Tier;
579  const configuredEntropy = typeof options.minEntropy === "number" ? options.minEntropy : 3.2;
580  const configuredBudget = typeof options.scanBudgetMs === "number" ? options.scanBudgetMs : 1500;
581  // `disabledRules` arrives from the manifest as "a,b,c"; the live list is session
582  // state, seeded from this once so `/redact rule on` can put a rule back.
583  const configuredDisabled =
584    typeof options.disabledRules === "string"
585      ? options.disabledRules
586          .split(",")
587          .map((id) => id.trim())
588          .filter((id) => id.length > 0)
589      : [];
590
591  // Helpers that take `$` are module-level declarations (the engine's static
592  // analysis requires it), and `cfg` is passed explicitly rather than closed over.
593  const cfg: Cfg = {
594    enabled: configuredEnabled,
595    tier: configuredTier,
596    minEntropy: configuredEntropy,
597    budgetMs: configuredBudget,
598    disabledRules: configuredDisabled,
599  };
600
601  // A policy mod that seats itself outermost (managed settings, Team and
602  // Enterprise organizations) forwards these events past the user tier. On that
603  // machine the three hooks below can never run, and registering them makes
604  // Claude Code announce a skipped hook for each on every session. A deployment
605  // behind a guard opts out of them at load; with the option unset they stay,
606  // because on a machine with no guard they carry the opaque notice and the
607  // attachment scan.
608  const guardedDeployment = options.guardedDeployment === true;
609
610  // --- session.start: initial readings, capability probe, command ------------
611  on("session.start", async ($: any, e: any, next: (e: any) => unknown) => {
612    // `next(e)` resolves to the session's `{ cwd }`, and this event's hook must
613    // echo that shape back. Returning `undefined` made the engine log "returned
614    // the wrong shape (no result)" and skip the answer in a live session.
615    const answer = (await next(e)) as { cwd?: string } | undefined;
616    try {
617      await initialized($, cfg);
618    } catch {
619      // never fail a session start over state
620    }
621
622    let probed = "built-in 220-rule corpus";
623    // Anything the person configured is worth announcing; the shipped baseline
624    // is not. A clean session should start silently -- an unconditional banner
625    // on every start is noise, and `/redact` is the on-demand answer.
626    let custom = false;
627    try {
628      const found = await localRules($);
629      if (found && found.length > 0) {
630        probed = `built-in corpus + ${found.length} local rule(s)`;
631        custom = true;
632      }
633    } catch {
634      probed = "built-in 220-rule corpus";
635    }
636    try {
637      const model = await localClassifier($);
638      if (model) {
639        probed += ` + L2 local classifier (threshold ${model.threshold})`;
640        custom = true;
641      }
642    } catch {
643      // the ladder simply stays at L1
644    }
645
646    try {
647      await $.command.register({
648        name: "redact",
649        description: "Inspect and control prompt-redact: /redact [stats|on|off|tier|allow|deny|why|help]",
650      });
651    } catch {
652      // a duplicate registration on hot reload is not fatal
653    }
654    if (custom) {
655      try {
656        $.ui.log(`prompt-redact ready — ${probed}`);
657      } catch {
658        // drawing is best-effort
659      }
660    }
661    return answer ?? { cwd: String(e.cwd ?? "") };
662  });
663
664  // --- prompt.submit: the typed prompt, before it is queued ------------------
665  on("prompt.submit", async ($: any, e: any, next: (e: any) => unknown) => {
666    await markObserved($, "prompt.submit");
667    try {
668      const result = await scrubText($, String(e.text ?? ""), cfg, sourcingFor(next, true));
669      if (!result.enabled || result.hits.length === 0) return next(e);
670      await recordHits($, result.hits);
671      const labels = result.hits.map((hit) => hit.label).join(", ");
672      try {
673        $.ui.log(`prompt-redact hid ${result.hits.length} value(s) in this prompt: ${labels}`);
674      } catch {
675        // best-effort
676      }
677      // Context has to ride on the way *down*: an entry added after `next` has
678      // resolved is not attached, the prompt already entered. This is the one
679      // delivery channel that cannot be short-circuited by another plugin, so
680      // the model learns what it is looking at on the very turn it appears.
681      const existing = Array.isArray(e.context) ? (e.context as readonly string[]) : [];
682      const note =
683        `prompt-redact hid ${result.hits.length} value(s) in this message (${labels}). ` +
684        `They appear below as opaque [REDACTED:<LABEL>#<hash>] placeholders, and they came from the user's own ` +
685        `message, so stop rather than work around them: do not guess, decode or reconstruct the value, and do not ` +
686        `ask the user to paste it into the conversation. Tell them it was redacted before it reached you, then give ` +
687        `them the route that works: keep the secret in a file (for example .env) and load it with ` +
688        `\`set -a; . ./.env; set +a\`, or refer to it by variable name. Copy a placeholder verbatim into ` +
689        `Edit/Write/NotebookEdit arguments only when the user wants the value written somewhere: prompt-redact ` +
690        `restores the real value on the way to disk.`;
691      return next({ ...e, text: result.text, context: [...existing, note] });
692    } catch {
693      // Fail closed. A dropped prompt is visible and recoverable; a leaked key is
694      // neither, so an unscannable prompt must not be sent.
695      try {
696        $.ui.log("prompt-redact could not scan this prompt; it was withheld rather than sent unscanned.");
697      } catch {
698        // best-effort
699      }
700      return { drop: "prompt-redact withheld this prompt: the redactor failed while scanning it" };
701    }
702  }).catch((_$: any, _e: any, _next: any) => ({
703    // Engine-level failure (timeout, misreturn, worker loss) is still a leak
704    // risk: fail closed the way the in-code path does.
705    drop: "prompt-redact withheld this prompt: the redactor failed or overran its budget",
706  }));
707
708  // --- session.append: every stored row this tier can see --------------------
709  // Doors: prompt, command, response, tool-result, tool-message, delivery,
710  // attachment, hook-context, note, compaction, notice, queue. This is the one
711  // event that sees a row before BOTH the model and the transcript file, so it
712  // closes the echo-back leak where the model restates a secret in its reply.
713  //
714  // Only doors a user-tier mod can actually read are matched. On an organization
715  // account the guard forwards the injected doors (attachment, hook-context,
716  // compaction), skill rows and org notes past this tier, so registering them
717  // would add a hook that is skipped on every session and print a skip notice per
718  // event for rows this mod can never see. Narrowing the matcher keeps the
719  // transcript quiet and loses no carrier. The exclusion is unconditional: a
720  // deployment seated above the guard would still not see these doors, because
721  // no registration names them.
722  const VISIBLE_DOORS = ["prompt", "command", "response", "tool-result", "delivery", "notice", "queue"] as const;
723  for (const door of VISIBLE_DOORS) {
724    on("session.append", { door }, async ($: any, e: any, next: (e: any) => unknown) => {
725    await markObserved($, "session.append");
726    try {
727      const { enabled, tier, salt } = await posture($, cfg);
728      if (!enabled || !salt) return next(e);
729      const result = scrubMessage(
730        e.message,
731        await optionsFor($, salt, tier, cfg.minEntropy, cfg.budgetMs, cfg.disabledRules, sourcingFor(next, String(e.door ?? "") === "prompt")),
732      );
733      if (result.hits.length === 0) return next(e);
734      await recordHits($, result.hits);
735      return next({ ...e, message: result.message });
736    } catch {
737      // Only doors a person's content travels arrive here, so a failed scan is a
738      // leak risk, never managed content: fail closed.
739      return next({ ...e, message: { ...e.message, content: [{ type: "text", text: WITHHELD }] } });
740    }
741  }).catch(async ($: any, e: any, next: any) => {
742    // The engine's own failure paths (timeout, overrun, worker loss) land here.
743    // Same rule: the doors reaching this mod carry person content, so fail closed.
744    return next({ ...e, message: { ...(e?.message ?? {}), content: [{ type: "text", text: WITHHELD }] } });
745    });
746  }
747
748  if (!guardedDeployment) {
749  // --- prompt.attachment: every message the engine injects on its own ------
750  // This is the event that sees an injected message as a request is about to
751  // carry it, and `e.text` is exactly "what the model reads".
752  //
753  // It matters because a settings hook's additional context arrives here with
754  // `origin.kind === "hook"` (SessionStart, UserPromptSubmit, PostToolUse). A
755  // live session showed that context carrying a raw canary straight into the
756  // request body's system channel: `session.append` never sees it, because it is
757  // not a conversation row, and no hook in this plugin was registered for it.
758  // Redacting it here is the only place that reaches it.
759  on("prompt.attachment", async ($: any, e: any, next: (e: any) => unknown) => {
760    await markObserved($, "prompt.attachment");
761    try {
762      const { enabled, tier, salt } = await posture($, cfg);
763      const got = (await next(e)) as { text: string | null } | undefined;
764      const text = got?.text ?? e.text;
765      if (!enabled || !salt || typeof text !== "string") {
766        return got ?? { text: typeof text === "string" ? text : null };
767      }
768      if (text === "") return { text };
769      const result = scrub(text, await optionsFor($, salt, tier, cfg.minEntropy, cfg.budgetMs, cfg.disabledRules, sourcingFor(next, false)));
770      if (result.hits.length === 0) return { text };
771      await recordHits($, result.hits);
772      return { text: result.text };
773    } catch {
774      // Fail closed: leaving one attachment out is recoverable; carrying it raw
775      // into the request is not. Say so, rather than dropping it quietly.
776      try {
777        $.ui.log("prompt-redact could not scan an attachment; it was withheld rather than carried raw.");
778      } catch {
779        // best-effort
780      }
781      return { text: null };
782    }
783  }).catch(async ($: any, _e: any, _next: any) => {
784    try {
785      $.ui.log("prompt-redact could not scan an attachment; it was withheld rather than carried raw.");
786    } catch {
787      // best-effort
788    }
789    return { text: null };
790  });
791
792  // --- prompt.compose: the standing notice, on the system-prompt surface -----
793  // `prompt.section` was skipped in a live session because another plugin's hook
794  // ran first and passed the event on without calling `next`. `prompt.compose`
795  // is the composition of the whole system prompt, so appending a `session`
796  // section here delivers the notice when the per-section chain is reachable.
797  // Both are registered because the two can be pre-empted independently: on an
798  // organization account the guard forwards each past the user tier, and a
799  // `prompt.section` run that lands while `prompt.compose` is skipped still
800  // delivers it. Whichever lands first sets the state flag, so the notice is not
801  // duplicated when both fire.
802  on("prompt.compose", async ($: any, e: any, next: (e: any) => unknown) => {
803    await markObserved($, "prompt.compose");
804    const got = (await next(e)) as
805      | { sections?: readonly { id: string; text: string; scope: "shared" | "session" }[] }
806      | undefined;
807    if (!got || !Array.isArray(got.sections)) return got;
808    const { enabled } = await posture($, cfg);
809    if (!enabled) return got;
810    if (got.sections.some((section) => section.id === NOTICE_SECTION_ID)) return got;
811    // One notice per prompt: `prompt.section` and `prompt.compose` both deliver
812    // it when both fire, so the second one stands down.
813    if (await getNoticeShown($)) return got;
814    await setNoticeShown($, true);
815    return {
816      sections: [...got.sections, { id: NOTICE_SECTION_ID, text: OPAQUE_NOTICE.trim(), scope: "session" as const }],
817    };
818  });
819
820  // --- prompt.section: tell the model the placeholders are opaque -----------
821  on("prompt.section", async ($: any, e: any, next: (e: any) => unknown) => {
822    // The event reaching this handler is the evidence, even for a section this
823    // mod does not rewrite.
824    await markObserved($, "prompt.section");
825    if (e.name !== "env_info_simple") return next(e);
826    const { enabled } = await posture($, cfg);
827    if (!enabled) return next(e);
828    const got = (await next(e)) as { text: string | null } | undefined;
829    const text = got?.text ?? e.text;
830    if (typeof text !== "string") return got ?? { text: null };
831    if (await getNoticeShown($)) return { text };
832    await setNoticeShown($, true);
833    return { text: text + OPAQUE_NOTICE };
834  });
835  }
836
837  // --- tool.call: rehydrate ONLY the three file-editing tools ---------------
838  for (const tool of REHYDRATE_TOOLS) {
839    on("tool.call", { tool }, async ($: any, e: any, next: (e: any) => unknown) => {
840      await markObserved($, "tool.call");
841      const { enabled } = await posture($, cfg);
842      if (!enabled) return next(e);
843      const vault = await getVault($);
844      if (Object.keys(vault).length === 0) return next(e);
845      const rewritten: Record<string, unknown> = {};
846      let restored = 0;
847      for (const [key, value] of Object.entries(e as Record<string, unknown>)) {
848        if (RESERVED.has(key)) {
849          rewritten[key] = value;
850          continue;
851        }
852        const parsed = asRehydratable(value);
853        if (parsed === undefined) {
854          rewritten[key] = value;
855          continue;
856        }
857        // Count what is about to be restored, before restoring it.
858        restored += placeholdersIn(parsed);
859        rewritten[key] = rehydrate(parsed, vault);
860      }
861      if (restored > 0) await setRestored($, (await getRestored($)) + restored);
862      return next(rewritten);
863    });
864  }
865
866  // --- tool.call: refuse a placeholder on tools that never receive real values
867  // Bash and the network tools are absent from REHYDRATE_TOOLS on purpose: that
868  // absence is what stops a model-written command or URL from becoming an
869  // exfiltration path. The cost is that a placeholder reaches them as literal
870  // text, so a command that "works" silently does nothing. Refusing is the honest
871  // move, and the refusal carries the route that does work.
872  for (const tool of GUARDED_TOOLS) {
873    on("tool.call", { tool }, async ($: any, e: any, next: (e: any) => unknown) => {
874      await markObserved($, "tool.call");
875      const { enabled } = await posture($, cfg);
876      if (!enabled) return next(e);
877      const vault = await getVault($);
878      // No vault means no placeholder of ours is in play: nothing to refuse.
879      if (Object.keys(vault).length === 0) return next(e);
880      let carried = 0;
881      for (const [key, value] of Object.entries(e as Record<string, unknown>)) {
882        if (RESERVED.has(key)) continue;
883        const parsed = asRehydratable(value);
884        if (parsed === undefined) continue;
885        carried += placeholdersIn(parsed);
886      }
887      if (carried === 0) return next(e);
888      return {
889        deny:
890          `prompt-redact: this ${tool} call carries ${carried} redacted placeholder(s). ` +
891          `${tool} never receives real values — that rule is what keeps a model-written command or URL from ` +
892          `becoming an exfiltration path — so this call would have run with [REDACTED:...] as literal text, ` +
893          `not as the secret. Nothing was executed. Use the value without typing it: keep the secret in a file ` +
894          `and load it (\`set -a; . ./.env; set +a\`) then reference $NAME, or ask the user where the value ` +
895          `should be supplied. Do not ask them to paste it into the conversation.`,
896      };
897    });
898  }
899
900  // --- command.run: /redact -------------------------------------------------
901  on("command.run", { command: "redact" }, async ($: any, e: any) => {
902    const args = String(e.args ?? "").trim();
903    const [sub, ...rest] = args.split(/\s+/);
904    const counts = await getCounts($);
905    const restored = await getRestored($);
906    const enabled = (await getEnabled($)) ?? configuredEnabled;
907    const tier = (await getTier($)) ?? configuredTier;
908    const vault = await getVault($);
909    const allow = await getAllow($);
910    const deny = await getDeny($);
911    const disabled = (await getDisabled($)) ?? [...cfg.disabledRules];
912    const fires = await getFires($);
913
914    switch (sub) {
915      case "":
916      case "stats":
917        return { text: report(counts, restored, enabled, tier, Object.keys(vault).length) };
918      case "on":
919        await setEnabled($, true);
920        return { text: "prompt-redact on." };
921      case "off":
922        await setEnabled($, false);
923        return {
924          text:
925            "prompt-redact OFF for this session. Nothing is scanned: secrets in prompts, " +
926            "tool results and replies will reach the model and the transcript file as typed. " +
927            "`/redact on` restores it.",
928        };
929      case "tier": {
930        const wanted = rest[0] as Tier;
931        if (!TIERS.includes(wanted)) {
932          return { text: `usage: /redact tier <${TIERS.join("|")}> — strict redacts most, precision only clear-cut secrets` };
933        }
934        await setTier($, wanted);
935        return { text: `prompt-redact tier set to ${wanted}.` };
936      }
937      case "allow": {
938        const pattern = rest.join(" ");
939        if (!pattern) return { text: "usage: /redact allow <regex> — values matching are never redacted" };
940        if (!compile(pattern)) return { text: `not a valid regex: ${pattern}` };
941        await setAllow($, [...allow, pattern]);
942        return { text: `allowlisted: ${pattern}` };
943      }
944      case "deny": {
945        const pattern = rest.join(" ");
946        if (!pattern) return { text: "usage: /redact deny <regex> — values matching are always redacted" };
947        if (!compile(pattern)) return { text: `not a valid regex: ${pattern}` };
948        await setDeny($, [...deny, pattern]);
949        return { text: `force-redact: ${pattern}` };
950      }
951      case "rule": {
952        // The "remove rules" half of L3. Adding a rule has always been possible
953        // through `.claude/prompt-redact.rules`; until now there was no way to
954        // switch a shipped rule off, which left a person stuck with a noisy rule
955        // and no recourse but disabling the whole mod.
956        const action = rest[0];
957        const ids = rest.slice(1);
958        if (action === undefined || action === "list") {
959          return {
960            text:
961              disabled.length === 0
962                ? "no rules disabled. /redact rule off <id> switches one off; ids are listed in RULES.md."
963                : `disabled (${disabled.length}): ${disabled.join(", ")}`,
964          };
965        }
966        if (action !== "on" && action !== "off") {
967          return { text: "usage: /redact rule on|off <id> [<id>...] — ids are listed in RULES.md" };
968        }
969        if (ids.length === 0) {
970          return { text: `usage: /redact rule ${action} <id> — ids are listed in RULES.md` };
971        }
972        // Validate before writing: a typo must not silently disable nothing.
973        const known = new Set<string>([...RULES.map((rule) => rule.id), ...ENGINE_RULE_IDS]);
974        const unknown = ids.filter((id) => !known.has(id));
975        if (unknown.length > 0) {
976          return { text: `no such rule: ${unknown.join(", ")} — ids are listed in RULES.md` };
977        }
978        const next =
979          action === "off"
980            ? [...new Set([...disabled, ...ids])]
981            : disabled.filter((id) => !ids.includes(id));
982        await setDisabled($, next);
983        return {
984          text: `${action === "off" ? "disabled" : "re-enabled"}: ${ids.join(", ")}\nnow disabled: ${
985            next.length === 0 ? "none" : next.join(", ")
986          }`,
987        };
988      }
989      case "why": {
990        return {
991          text: [
992            "How a value becomes a placeholder:",
993            "  1. structural detectors (PEM private keys, JWTs, IBANs, Luhn card numbers, SSA numbers) — self-proving, no entropy gate.",
994            `  2. the generated rule corpus, prefiltered by keyword — ${detectorsFor(4096)}.`,
995            "  3. the allowlist — benign high-entropy shapes (git SHAs, UUIDs, checksums, public keys, placeholders) are left alone.",
996            "  4. a context score for the generic family only.",
997            "",
998            `tier ${tier}: strict redacts most, balanced is the default, precision only clear-cut secrets.`,
999            `user allowlist entries: ${allow.length}; force-redact entries: ${deny.length}`,
1000            `rules switched off: ${disabled.length === 0 ? "none" : `${disabled.length} (${disabled.join(", ")})`}`,
1001            `labels hidden this session: ${Object.keys(counts).length}`,
1002            ...interceptionPosture(fires),
1003            "No network, no subprocess, no model call: a detector never transmits the value it hides.",
1004          ].join("\n"),
1005        };
1006      }
1007      case "help":
1008      default:
1009        return {
1010          text: [
1011            "/redact            counts by label, restore count, vault size",
1012            "/redact on|off     enable or disable scanning for this session",
1013            `/redact tier <${TIERS.join("|")}>`,
1014            "/redact allow <re> stop redacting values matching <re>",
1015            "/redact deny <re>  always redact values matching <re>",
1016            "/redact rule on|off <id>  switch a shipped rule off or back on",
1017            "/redact rule list  which rules are off",
1018            "/redact why        how detection works, and this session's posture",
1019            "/redact help       this text",
1020          ].join("\n"),
1021        };
1022    }
1023  });
1024
1025  // --- tool.call (every tool): redact the tool's own result -----------------
1026  // `session.append` rewrites `message.content`, but the transcript ALSO stores
1027  // the tool's structured result as a sidecar (`toolUseResult`) outside that
1028  // field. A live run showed the model reading a redacted row while the sidecar
1029  // kept the raw file content. Rewriting the result here is what makes the
1030  // sidecar match what the model reads, because the engine records a hook's
1031  // returned result in the transcript as the tool's result.
1032  on("tool.call", async ($: any, e: any, next: (e: any) => unknown) => {
1033    await markObserved($, "tool.call");
1034    const answer = await next(e);
1035    const { enabled, tier, salt } = await posture($, cfg);
1036    if (!enabled || !salt) return answer;
1037    if (!answer || typeof answer !== "object" || !("result" in (answer as Record<string, unknown>))) {
1038      return answer;
1039    }
1040    try {
1041      const opts = await optionsFor($, salt, tier, cfg.minEntropy, cfg.budgetMs, cfg.disabledRules, sourcingFor(next, true));
1042      const hits: ScrubHit[] = [];
1043      // A tool's result is JSON-shaped; `asRehydratable` is the boundary parse,
1044      // so the walk below is typed end to end instead of threading `unknown`.
1045      const scrubNode = (node: Rehydratable): Rehydratable => {
1046        if (typeof node === "string") {
1047          const result = scrub(node, opts);
1048          if (result.hits.length > 0) hits.push(...result.hits);
1049          return result.text;
1050        }
1051        if (Array.isArray(node)) return [...node].map(scrubNode);
1052        if (node !== null && typeof node === "object") {
1053          const out: Record<string, Rehydratable> = {};
1054          for (const [key, child] of Object.entries(node)) out[key] = scrubNode(child);
1055          return out;
1056        }
1057        return node;
1058      };
1059      const rewritten = { ...(answer as Record<string, unknown>) };
1060      // Parse each field at its boundary. A field that is not JSON-shaped (a
1061      // Buffer, a function) has no parsed form, so it is left alone for the
1062      // row-level pass rather than coerced into one.
1063      for (const field of ["text", "result"] as const) {
1064        if (!(field in rewritten)) continue;
1065        const parsed = asRehydratable(rewritten[field]);
1066        if (parsed !== undefined) rewritten[field] = scrubNode(parsed);
1067      }
1068      if (hits.length > 0) await recordHits($, hits);
1069      return rewritten;
1070    } catch {
1071      // Fail closed: the tool ran, but its raw output must not reach the model
1072      // or the transcript. An error result is recoverable; a leak is not.
1073      return { deny: "prompt-redact could not scan this tool result; it was withheld rather than returned raw" };
1074    }
1075  }).catch(() => ({
1076    // Engine-level failure: the tool ran, but its output must not reach the model raw.
1077    deny: "prompt-redact could not scan this tool result; it was withheld rather than returned raw",
1078  }));
1079
1080  // --- ui.render: the transparent band above the prompt ---------------------
1081  // Draws only when there is something to say. Elements come from the surface's
1082  // own constructors (`$.ui.resolve`), never hand-built plain objects: a tree
1083  // that does not validate draws the engine's own component instead, silently.
1084  on("ui.render", { component: "AbovePrompt" }, async ($: any, e: any, next: (e: any) => unknown) => {
1085    const counts = await getCounts($);
1086    const enabled = (await getEnabled($)) ?? configuredEnabled;
1087    const total = Object.values(counts).reduce((n, count) => n + count, 0);
1088    if (enabled && total === 0) return next(e);
1089    const labels = Object.entries(counts)
1090      .sort((a, b) => b[1] - a[1])
1091      .slice(0, 4)
1092      .map(([label, count]) => `${label} x${count}`);
1093    const line = enabled
1094      ? `\u{1F6E1} prompt-redact hid ${total}${labels.length ? ` \u2014 ${labels.join(" \u00b7 ")}` : ""}`
1095      : `\u{1F6E1} prompt-redact OFF${total > 0 ? ` (${total} hidden earlier)` : ""}`;
1096    try {
1097      const { Box, Text } = $.ui.resolve(e);
1098      return Box({ paddingLeft: 1 }, Text({ dimColor: true }, line));
1099    } catch {
1100      // A surface with no drawable element set: say nothing rather than draw wrong.
1101      return next(e);
1102    }
1103  });
1104}
1105
hooks/scrub.ts 700 lines
1// The scrub engine: pure functions, no `$`, no I/O. register.ts owns state; this
2// file owns the decision "is this span a secret, and what does it become".
3//
4// Shape of a decision, in order:
5//
6//   1. structural detectors (PEM, JWT, IBAN, Luhn, SSA) -- self-proving, so they
7//      are accepted without an entropy gate and always win span conflicts.
8//   2. the generated rule corpus, prefiltered by keyword, bounded quantifiers.
9//      The captured value is chosen as the *highest-entropy group* rather than a
10//      fixed group number: gitleaks' own rules disagree (in `atlassian-api-token`
11//      group 1 is the vendor name, group 2 is the secret).
12//   3. the allowlist, which is what lets the generic detector run at a low
13//      threshold: benign high-entropy shapes are named explicitly and left alone,
14//      so the aggression goes where the secrets are.
15//   4. a context score for the generic family only -- the family whose precision
16//      depends on surrounding words.
17//
18// Placeholders are deterministic: fnv1a(salt + value) twice, 16 hex. Same secret
19// -> same placeholder in the prompt row, the tool-result row, an edit, and a
20// compaction summary, so an edit to a line that holds a real key still matches.
21// A placeholder minted in another session's salt means nothing here.
22
23import {
24  GLOBAL_ALLOW_REGEXES,
25  GLOBAL_STOPWORDS,
26  PASSWORD_VALUE_RULE_ID,
27  RULES,
28  type Rule,
29} from "./rules.ts";
30import { isAllowed } from "./allowlist.ts";
31import { isSecret, type ClassifierModel } from "./classifier.ts";
32import { structuralHits, type Hit } from "./structural.ts";
33
34export type Tier = "strict" | "balanced" | "precision";
35
36export type ScrubOptions = {
37  /** Per-session salt. A placeholder from another session is meaningless. */
38  readonly salt: string;
39  readonly tier: Tier;
40  /** Entropy floor for the generic family, after a user override. */
41  readonly minEntropy: number;
42  /** User-supplied rules merged ahead of the corpus. */
43  readonly extraRules?: readonly Rule[];
44  /** User allowlist patterns, matched against the whole candidate value. */
45  readonly allowExtra?: readonly RegExp[];
46  /** User force-redact patterns: a match here bypasses every gate. */
47  readonly deny?: readonly RegExp[];
48  /**
49   * Rule ids switched off. The "remove" half of the override surface: a person
50   * who finds a rule noisy must be able to turn it off without forking the
51   * corpus. `local-classifier` disables the optional L2 stage.
52   */
53  readonly disabledRules?: readonly string[];
54  /** Wall-clock ceiling for one row; exceeding it fails closed. */
55  readonly budgetMs?: number;
56  /**
57   * L2: an optional local model, probed from disk by the caller (register.ts).
58   *
59   * Absent or null means the pipeline is L1 only, which is the default and the
60   * normal case. A model here is a weights file evaluated in-process; it never
61   * implies a network, a subprocess or a remote model.
62   */
63  readonly classifier?: ClassifierModel | null;
64};
65
66export type ScrubHit = {
67  readonly label: string;
68  readonly placeholder: string;
69  readonly value: string;
70  readonly by: string;
71};
72
73export type ScrubResult = {
74  readonly text: string;
75  readonly hits: readonly ScrubHit[];
76};
77
78/** Tier thresholds for the generic family's context score, in [0,1]. */
79const TIER_THRESHOLD: Record<Tier, number> = {
80  // A redactor's false positive costs a degraded-looking hash, not a lost task,
81  // so the operating point sits far below a scanner's.
82  strict: 0.15,
83  balanced: 0.35,
84  precision: 0.6,
85};
86
87/** Rows above this are scanned with the structural and generic families only. */
88const FULL_SCAN_LIMIT = 60_000;
89
90/**
91 * Hard ceiling on placeholders minted from one row.
92 *
93 * Exceeding it fails closed. Accepting the first 512 candidates and returning
94 * the text as though it were scrubbed would leave the rest of the row raw while
95 * reporting success -- a partial scrub that reads as a complete one, which is
96 * the worst outcome available to a redactor. A withheld row is visible and
97 * recoverable; a half-scrubbed row is neither.
98 */
99const MAX_HITS = 512;
100
101/**
102 * A run long enough to hold a credential, for the L2 sweep. The class is the
103 * union of what base64, base64url, hex, dashed and dotted tokens use, so one
104 * pass reaches every shape a model could judge without a vendor anchor.
105 */
106const TOKEN_RE = /[A-Za-z0-9_+/=.-]{12,4096}/g;
107
108const GLOBAL_ALLOW = GLOBAL_ALLOW_REGEXES.map((s) => {
109  try {
110    return new RegExp(s, "i");
111  } catch {
112    return null;
113  }
114}).filter((r): r is RegExp => r !== null);
115
116/**
117 * A field-name assignment carrying a password a person may have chosen.
118 *
119 * The field name is the anchor: `password`, `passwd`, `pwd`, `pass` or `secret`,
120 * optionally behind `DB_`/`db.`-style qualifiers, with the separator abutting the
121 * name (`KEY=value`, `key: value`). The value is 6-20 characters and is not
122 * judged by entropy: `S3cr3tP4ssw0rd` measures 3.38 bits and is exactly the
123 * ordinary password the generic rule misses.
124 *
125 * Whitespace before the separator is deliberately not matched: `password =
126 * "hunter2"` is a prose-shaped mention, not a `KEY=value` assignment. The
127 * allowlist gates every candidate, so `changeme`, templates and `required`
128 * survive byte-identical.
129 */
130const PASSWORD_VALUE_RE =
131  /(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,30}[_.-]){0,6}(?:password|passwd|pwd|pass|secret)(?::=|=>|:|=)[ \t]{0,4}(["']?)([^\s"'`<>;,]{6,20})\1(?=$|[\s"'`<>,;)\]}])/gdi;
132
133/** Entropy-free detectors whose rule id L3 can switch off; not in the corpus. */
134const ENGINE_RULE_IDS = new Set<string>([PASSWORD_VALUE_RULE_ID]);
135
136/**
137 * True when a candidate is a filesystem path, not a password.
138 *
139 * `pwd` and `PWD` belong to the field-name keyword set, and a shell environment
140 * dump carries a working directory beside every real secret. A path has nothing
141 * to revoke and is needed verbatim to run the task. A value containing a colon
142 * is not a bare path (it carries a scheme, drive or port), so the slash clause
143 * stays narrow.
144 */
145function isPathValue(value: string): boolean {
146  if (value.startsWith("/") || value.startsWith("~") || value.startsWith("./") || value.startsWith("../")) {
147    return true;
148  }
149  return value.includes("/") && !value.includes(":");
150}
151
152// --- primitives -------------------------------------------------------------
153
154/** Shannon entropy in bits per character, over the value's own distribution. */
155export function shannon(s: string): number {
156  if (s.length === 0) return 0;
157  const counts = new Map<string, number>();
158  for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
159  let h = 0;
160  for (const count of counts.values()) {
161    const p = count / s.length;
162    h -= p * Math.log2(p);
163  }
164  return h;
165}
166
167function fnv1a(input: string, basis: number): string {
168  let h = basis >>> 0;
169  for (let i = 0; i < input.length; i++) {
170    h ^= input.charCodeAt(i);
171    h = Math.imul(h, 0x01000193) >>> 0;
172  }
173  return (h >>> 0).toString(16).padStart(8, "0");
174}
175
176/** 64 bits from two independent FNV-1a passes: collisions are not a concern. */
177function valueHash(salt: string, value: string): string {
178  return fnv1a(salt + value, 0x811c9dc5) + fnv1a(value + salt, 0x7a3d1f29);
179}
180
181const PLACEHOLDER_RE = /^\[REDACTED:[A-Z0-9_]+#[0-9a-f]{16}\]$/;
182
183/** True for a value that is already one of our placeholders (idempotence). */
184export function isPlaceholder(s: string): boolean {
185  return PLACEHOLDER_RE.test(s.trim());
186}
187
188export function placeholderFor(label: string, salt: string, value: string): string {
189  return `[REDACTED:${label}#${valueHash(salt, value)}]`;
190}
191
192const PROSE_PLACEHOLDER_RE = /\[REDACTED:[A-Z0-9_]+#[0-9a-f]{16}\]/g;
193
194// --- context scoring -------------------------------------------------------
195
196const BOOST = ["prod", "production", "live", "deploy", "secret", "token", "credential", "password", "private", "auth", "bearer", "apikey", "api_key"];
197const DAMP = ["example", "sample", "dummy", "fake", "fixture", "mock", "test", "docs", "readme", "placeholder", "lorem"];
198
199/**
200 * Confidence that a generic-family candidate is a real credential, in [0,1].
201 *
202 * Deliberately conservative in what it *suppresses*: a damping word lowers the
203 * score but the explicit allowlist is what actually lets a value through, so a
204 * test fixture still gets redacted unless it is one of the named benign shapes.
205 */
206export function contextScore(text: string, start: number, end: number, value: string): number {
207  let score = 0.45;
208  const window = text.slice(Math.max(0, start - 80), Math.min(text.length, end + 40)).toLowerCase();
209  for (const word of BOOST) if (window.includes(word)) score += 0.12;
210  for (const word of DAMP) if (window.includes(word)) score -= 0.1;
211  // A value that mixes character classes is much more likely to be generated.
212  const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(value)).length;
213  score += (classes - 1) * 0.08;
214  if (value.length >= 32) score += 0.1;
215  if (value.length >= 64) score += 0.05;
216  return Math.max(0, Math.min(1, score));
217}
218
219// --- rule matching ---------------------------------------------------------
220
221type Compiled = { rule: Rule; re: RegExp | null };
222
223/**
224 * Compile once per load. A pattern that fails to compile is dropped rather than
225 * thrown: one bad user rule must not disable the whole redactor.
226 */
227function compile(rules: readonly Rule[]): Compiled[] {
228  return rules.map((rule) => {
229    try {
230      // `d` gives match indices, so only the chosen group's span is redacted.
231      return { rule, re: new RegExp(rule.regex, `${rule.flags}g${rule.flags.includes("d") ? "" : "d"}`) };
232    } catch {
233      return { rule, re: null };
234    }
235  });
236}
237
238/**
239 * The corpus scan. Engine-owned detectors are excluded: they run in their own
240 * passes, where a tier gate and an exact value span replace the generic
241 * family's entropy and context-score gates.
242 */
243const COMPILED = compile(RULES.filter((rule) => !ENGINE_RULE_IDS.has(rule.id)));
244
245/** keyword -> compiled rules, so a row only runs rules whose anchor it mentions. */
246const BY_KEYWORD = new Map<string, Compiled[]>();
247for (const c of COMPILED) {
248  for (const k of c.rule.keywords) {
249    const list = BY_KEYWORD.get(k);
250    if (list) list.push(c);
251    else BY_KEYWORD.set(k, [c]);
252  }
253}
254
255const KEYWORDS = [...BY_KEYWORD.keys()];
256
257function rulesForRow(size: number, extra: readonly Rule[]): Compiled[] {
258  if (extra.length) {
259    return [...compile(extra), ...(size <= FULL_SCAN_LIMIT ? COMPILED : COMPILED.filter((c) => c.rule.kind !== "vendor-keyword"))];
260  }
261  return size <= FULL_SCAN_LIMIT ? COMPILED : COMPILED.filter((c) => c.rule.kind !== "vendor-keyword");
262}
263
264/** Which detectors a row of this size will actually run (`/redact why`). */
265export function detectorsFor(size: number): string {
266  const full = size <= FULL_SCAN_LIMIT;
267  return full
268    ? "structural + 220-rule corpus + generic"
269    : `structural + generic (row is ${size} chars, over the ${FULL_SCAN_LIMIT} full-scan limit)`;
270}
271
272/**
273 * Pick the span to redact from one match.
274 *
275 * gitleaks rules disagree about which capture group holds the value, so rather
276 * than trusting a group number we take the highest-entropy non-empty group and
277 * break ties on length. `secretGroup` wins when upstream states it explicitly.
278 */
279function chosenSpan(m: RegExpExecArray, rule: Rule): { start: number; end: number } | null {
280  const indices = m.indices;
281  if (!indices) return null;
282  const declared = rule.secretGroup;
283  if (declared !== null && declared > 0 && indices[declared]) {
284    const span = indices[declared];
285    return span ? { start: span[0], end: span[1] } : null;
286  }
287  let best: { start: number; end: number } | null = null;
288  let bestScore = -1;
289  let bestLength = -1;
290  for (let i = 1; i < indices.length; i++) {
291    const span = indices[i];
292    if (!span || span[1] <= span[0]) continue;
293    const value = m[0].slice(span[0] - (m.index ?? 0), span[1] - (m.index ?? 0));
294    const score = shannon(value);
295    const length = span[1] - span[0];
296    if (score > bestScore || (score === bestScore && length > bestLength)) {
297      best = { start: span[0], end: span[1] };
298      bestScore = score;
299      bestLength = length;
300    }
301  }
302  if (best) return best;
303  const whole = indices[0];
304  return whole ? { start: whole[0], end: whole[1] } : null;
305}
306
307type Candidate = Hit & { by: string };
308
309/**
310 * Scan one string. Returns the rewritten text and every hit, but mints no state:
311 * the caller decides what to persist.
312 *
313 * Fails closed by throwing, never by returning partially-scrubbed text: a row
314 * that overruns the budget must not reach the model at all.
315 */
316export function scrub(text: string, opts: ScrubOptions): ScrubResult {
317  if (!text) return { text, hits: [] };
318  const started = Date.now();
319  const budget = opts.budgetMs ?? 1500;
320  const threshold = TIER_THRESHOLD[opts.tier];
321  const candidates: Candidate[] = [];
322  const disabled = opts.disabledRules && opts.disabledRules.length > 0 ? new Set(opts.disabledRules) : null;
323
324  // 1. structural: self-proving, so they bypass every gate -- except the L3
325  // remove surface. The URL and Basic-auth detectors carry rule ids, so a
326  // disabled id stops them the same way it stops a rule in step 2 ("a disabled
327  // rule is not consulted, whichever family it belongs to"); the checksum
328  // families have no id a command can name, so they stay unconditional.
329  for (const hit of structuralHits(text)) {
330    if (opts.disabledRules?.includes(hit.by)) continue;
331    candidates.push(hit);
332  }
333
334  // 1b. force-redact patterns are detectors in their own right. A person who
335  // says "always redact this" must get that even when no rule matches the value
336  // -- otherwise the override is silently unreachable for exactly the values
337  // they cared about. `/redact deny <re>` documents value semantics, so the
338  // pattern is tested against each token as well as against the whole text
339  // (the latter lets a pattern span whitespace when someone wants that).
340  const DENY_TOKEN = /[^\s"'`<>()[\]{},;]+/g;
341  for (const pattern of opts.deny ?? []) {
342    const push = (start: number, end: number, value: string) => {
343      candidates.push({ label: "USER_DENY", start, end, value, by: "deny" });
344    };
345    pattern.lastIndex = 0;
346    let scanner: RegExp;
347    try {
348      scanner = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
349    } catch {
350      continue;
351    }
352    scanner.lastIndex = 0;
353    for (;;) {
354      const m = scanner.exec(text);
355      if (!m) break;
356      if (m[0].length === 0) {
357        scanner.lastIndex += 1;
358        continue;
359      }
360      push(m.index, m.index + m[0].length, m[0]);
361    }
362    DENY_TOKEN.lastIndex = 0;
363    for (;;) {
364      const token = DENY_TOKEN.exec(text);
365      if (!token) break;
366      scanner.lastIndex = 0;
367      // Only what the pattern matched inside the token disappears, so the token
368      // pass preserves the surrounding URL or sentence. The whole-text pass is
369      // what lets a pattern span whitespace; this pass adds value-anchored
370      // semantics (`^`/`$` bind to the token, not to the row).
371      for (;;) {
372        const m = scanner.exec(token[0]);
373        if (!m) break;
374        if (m[0].length === 0) {
375          scanner.lastIndex += 1;
376          continue;
377        }
378        push(token.index + m.index, token.index + m.index + m[0].length, m[0]);
379      }
380    }
381  }
382
383  // 1c. password values, keyed on the field name.
384  //
385  // The generic family cannot reach an ordinary password: its 3.5-bit floor is
386  // what separates generated keys from words, and a person's password sits below
387  // it. This pass trades that gate for the allowlist alone, and runs only at the
388  // default tier: `strict` keeps its shipped precision baseline.
389  if (opts.tier === "balanced" && !disabled?.has(PASSWORD_VALUE_RULE_ID)) {
390    PASSWORD_VALUE_RE.lastIndex = 0;
391    for (;;) {
392      const m = PASSWORD_VALUE_RE.exec(text);
393      if (!m) break;
394      if (Date.now() - started > budget) throw new Error("prompt-redact: scan budget exceeded");
395      const span = m.indices?.[2];
396      if (!span) continue;
397      const value = text.slice(span[0], span[1]);
398      if (!value || isPlaceholder(value)) continue;
399      if (isPathValue(value)) continue;
400      if ((opts.deny ?? []).some((re) => re.test(value))) {
401        candidates.push({ label: "PASSWORD_VALUE", start: span[0], end: span[1], value, by: PASSWORD_VALUE_RULE_ID });
402        continue;
403      }
404      if (isAllowed(value, opts.allowExtra ?? [])) continue;
405      if (GLOBAL_ALLOW.some((re) => re.test(value))) continue;
406      if (GLOBAL_STOPWORDS.some((w) => value.toLowerCase() === w)) continue;
407      candidates.push({ label: "PASSWORD_VALUE", start: span[0], end: span[1], value, by: PASSWORD_VALUE_RULE_ID });
408    }
409  }
410
411  // 2. rules, keyword-prefiltered.
412  const lower = text.toLowerCase();
413  const wanted = new Set<Compiled>();
414  for (const keyword of KEYWORDS) {
415    if (lower.includes(keyword)) {
416      for (const c of BY_KEYWORD.get(keyword) ?? []) wanted.add(c);
417    }
418  }
419  for (const c of rulesForRow(text.length, opts.extraRules ?? [])) {
420    if (c.rule === undefined || c.re === null) continue;
421    // A disabled rule is not consulted, whichever family it belongs to.
422    if (disabled?.has(c.rule.id)) continue;
423    if (c.rule.keywords.length > 0 && !wanted.has(c)) continue;
424    // A rule with no keywords runs unconditionally; those are cheap and rare.
425    if (Date.now() - started > budget) throw new Error("prompt-redact: scan budget exceeded");
426    c.re.lastIndex = 0;
427    for (;;) {
428      const m = c.re.exec(text);
429      if (!m) break;
430      if (m[0].length === 0) {
431        c.re.lastIndex += 1;
432        continue;
433      }
434      const span = chosenSpan(m, c.rule);
435      if (!span) continue;
436      const value = text.slice(span.start, span.end);
437      if (!value || isPlaceholder(value)) continue;
438      // A force-redact rule is the person's explicit override and outranks every
439      // other gate: allowlist, stopwords, entropy and context score.
440      if ((opts.deny ?? []).some((re) => re.test(value))) {
441        candidates.push({ label: c.rule.label, start: span.start, end: span.end, value, by: c.rule.id });
442        continue;
443      }
444      if (isAllowed(value, opts.allowExtra ?? [])) continue;
445      if (GLOBAL_ALLOW.some((re) => re.test(value))) continue;
446      if (GLOBAL_STOPWORDS.some((w) => value.toLowerCase() === w)) continue;
447      // `minEntropy` is the floor for the generic family, and it has to be able
448      // to *raise* the rule's own floor as well as lower it. A rule that carries
449      // `entropy: 3.5` upstream does not stop being 3.5 because a person asked
450      // for 2.0; and asking for 4.0 must actually tighten it, or the setting is
451      // decorative. Specific rules keep their upstream value only.
452      const floor =
453        c.rule.kind === "generic"
454          ? Math.max(c.rule.entropy ?? 0, opts.minEntropy)
455          : (c.rule.entropy ?? 0);
456      if (floor > 0 && shannon(value) < floor) continue;
457      if (c.rule.kind === "generic" && contextScore(text, span.start, span.end, value) < threshold) continue;
458      candidates.push({ label: c.rule.label, start: span.start, end: span.end, value, by: c.rule.id });
459    }
460  }
461
462  // 2b. L2: the optional local classifier.
463  //
464  // This is the only detector that runs without an anchor. A rule corpus cannot
465  // reach a secret that carries no vendor prefix and no keyword beside it -- the
466  // prose/multilingual tier -- so when a weights file was probed and loaded the
467  // row is swept for high-entropy runs and each one is scored. Absent the model
468  // this block is skipped entirely and nothing about L1 changes.
469  if (opts.classifier && !disabled?.has("local-classifier")) {
470    const model = opts.classifier;
471    TOKEN_RE.lastIndex = 0;
472    for (;;) {
473      const token = TOKEN_RE.exec(text);
474      if (!token) break;
475      if (Date.now() - started > budget) throw new Error("prompt-redact: scan budget exceeded");
476      const value = token[0];
477      if (isPlaceholder(value)) continue;
478      if (isAllowed(value, opts.allowExtra ?? [])) continue;
479      if (GLOBAL_ALLOW.some((re) => re.test(value))) continue;
480      if (shannon(value) < opts.minEntropy) continue;
481      if (!isSecret(model, value)) continue;
482      candidates.push({
483        label: "LOCAL_CLASSIFIER_SECRET",
484        start: token.index,
485        end: token.index + value.length,
486        value,
487        by: "classifier",
488      });
489    }
490  }
491
492  // 3. resolve overlaps: structural first, then widest/highest-entropy.
493  // Rank decides which of two overlapping candidates keeps its span. Self-proving
494  // evidence outranks a pattern, a pattern outranks a statistical guess: a vendor
495  // rule names the credential, so its label is worth more than the classifier's.
496  const rank = (by: string): number => {
497    if (by === "deny" || by === "pem" || by === "jwt" || by === "luhn" || by === "iban" || by === "fica" || by === "base32") return 2;
498    if (by === "classifier") return 0;
499    return 1;
500  };
501  const ordered = candidates
502    .map((c, i) => ({ c, i }))
503    .sort((a, b) => {
504      const sa = rank(a.c.by);
505      const sb = rank(b.c.by);
506      if (sa !== sb) return sb - sa;
507      if (a.c.end - a.c.start !== b.c.end - b.c.start) return b.c.end - b.c.start - (a.c.end - a.c.start);
508      return shannon(b.c.value) - shannon(a.c.value);
509    });
510
511  const accepted: Candidate[] = [];
512  let overflowed = false;
513  for (const { c } of ordered) {
514    if (accepted.some((a) => c.start < a.end && a.start < c.end)) continue;
515    if (accepted.length >= MAX_HITS) {
516      // Count only candidates that would genuinely have been accepted, so a row
517      // full of overlapping matches is not mistaken for an over-cap row.
518      overflowed = true;
519      break;
520    }
521    accepted.push(c);
522  }
523  if (overflowed) {
524    throw new Error(`prompt-redact: more than ${MAX_HITS} candidate secrets in one row`);
525  }
526
527  // 4. apply right-to-left so earlier spans keep their offsets.
528  accepted.sort((a, b) => a.start - b.start);
529  const hits: ScrubHit[] = [];
530  let out = text;
531  for (let i = accepted.length - 1; i >= 0; i--) {
532    const hit = accepted[i];
533    if (!hit) continue;
534    const placeholder = placeholderFor(hit.label, opts.salt, hit.value);
535    out = out.slice(0, hit.start) + placeholder + out.slice(hit.end);
536    hits.push({ label: hit.label, placeholder, value: hit.value, by: hit.by });
537  }
538  hits.reverse();
539  if (Date.now() - started > budget) throw new Error("prompt-redact: scan budget exceeded");
540  return { text: out, hits };
541}
542
543// --- block walking ---------------------------------------------------------
544
545type Block = { type?: string; [field: string]: unknown };
546type Message = { type?: string; content?: unknown; [field: string]: unknown };
547
548const TEXT_TYPES = new Set(["text"]);
549
550/**
551 * Rewrite every rewritable part of a stored row.
552 *
553 * On `session.append` the engine pins `type`, `name`, `role` and `isMeta`, and
554 * puts back thinking blocks, `tool_use` blocks and every `tool_result.tool_use_id`.
555 * What a hook may change is exactly: text blocks, a `tool_result`'s `content`,
556 * and `is_error`. This function touches nothing else.
557 */
558export function scrubMessage(message: Message, opts: ScrubOptions): { message: Message; hits: ScrubHit[] } {
559  const hits: ScrubHit[] = [];
560  const content = message.content;
561  if (typeof content === "string") {
562    const result = scrub(content, opts);
563    hits.push(...result.hits);
564    return { message: { ...message, content: result.text }, hits };
565  }
566  if (!Array.isArray(content)) return { message, hits };
567
568  const next = content.map((raw) => {
569    const block = raw as Block;
570    if (!block || typeof block !== "object") return raw;
571    if (TEXT_TYPES.has(String(block.type)) && typeof block.text === "string") {
572      const result = scrub(block.text, opts);
573      hits.push(...result.hits);
574      return result.hits.length ? { ...block, text: result.text } : block;
575    }
576    if (block.type === "tool_result") {
577      const inner = block.content;
578      if (typeof inner === "string") {
579        const result = scrub(inner, opts);
580        hits.push(...result.hits);
581        return result.hits.length ? { ...block, content: result.text } : block;
582      }
583      if (Array.isArray(inner)) {
584        const blocks = inner.map((nested) => {
585          const b = nested as Block;
586          if (b && typeof b === "object" && typeof b.text === "string") {
587            const result = scrub(b.text, opts);
588            hits.push(...result.hits);
589            return result.hits.length ? { ...b, text: result.text } : b;
590          }
591          return nested;
592        });
593        return hits.length ? { ...block, content: blocks } : block;
594      }
595    }
596    return block;
597  });
598  return { message: { ...message, content: next }, hits };
599}
600
601// --- rehydration -----------------------------------------------------------
602
603/**
604 * The domain rehydration operates on: JSON-shaped tool input. Declaring it here
605 * rather than threading `unknown` through keeps the walk typed at every level.
606 */
607export type Rehydratable =
608  | string
609  | number
610  | boolean
611  | null
612  | readonly Rehydratable[]
613  | { readonly [key: string]: Rehydratable };
614
615/**
616 * Parse an engine-supplied tool input into the rehydration domain.
617 *
618 * `undefined` means "not rehydratable shape" and the caller keeps the original
619 * value; `null` is the genuine JSON null. Anything a tool can carry that is not
620 * string/array/object (a timeout number, a boolean flag) round-trips untouched.
621 */
622export function asRehydratable(value: unknown): Rehydratable | undefined {
623  if (value === null) return null;
624  switch (typeof value) {
625    case "string":
626    case "number":
627    case "boolean":
628      return value;
629    case "object":
630      break;
631    default:
632      return undefined;
633  }
634  if (Array.isArray(value)) {
635    const items: Rehydratable[] = [];
636    for (const item of value) {
637      const parsed = asRehydratable(item);
638      items.push(parsed === undefined ? String(item) : parsed);
639    }
640    return items;
641  }
642  const record: Record<string, Rehydratable> = {};
643  for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
644    const parsed = asRehydratable(item);
645    if (parsed !== undefined) record[key] = parsed;
646  }
647  return record;
648}
649
650function rehydrateNode(node: Rehydratable, vault: Readonly<Record<string, string>>): Rehydratable {
651  if (typeof node === "string") {
652    let out = node;
653    for (const [placeholder, value] of Object.entries(vault)) {
654      if (out.includes(placeholder)) out = out.split(placeholder).join(value);
655    }
656    return out;
657  }
658  if (Array.isArray(node)) return node.map((child) => rehydrateNode(child, vault));
659  if (node !== null && typeof node === "object") {
660    const out: Record<string, Rehydratable> = {};
661    for (const [key, child] of Object.entries(node)) out[key] = rehydrateNode(child, vault);
662    return out;
663  }
664  return node;
665}
666
667/**
668 * Put real values back. Callers must only ever pass `Edit`, `Write` and
669 * `NotebookEdit` arguments: rehydrating anywhere the model can reach the network
670 * turns a placeholder into an exfiltration primitive.
671 */
672export function rehydrate(input: Rehydratable, vault: Readonly<Record<string, string>>): Rehydratable {
673  if (Object.keys(vault).length === 0) return input;
674  return rehydrateNode(input, vault);
675}
676
677/** Count placeholders inside a structured value (pre-rehydration), for `/redact`. */
678export function placeholdersIn(node: Rehydratable): number {
679  if (typeof node === "string") return placeholderCount(node);
680  // Explicit loops rather than reduce: the accumulator type of `reduce` over a
681  // union of readonly array and record resolves to the element type, not number.
682  if (Array.isArray(node)) {
683    let total = 0;
684    for (const child of node as readonly Rehydratable[]) total += placeholdersIn(child);
685    return total;
686  }
687  if (node !== null && typeof node === "object") {
688    let total = 0;
689    for (const child of Object.values(node) as Rehydratable[]) total += placeholdersIn(child);
690    return total;
691  }
692  return 0;
693}
694
695/** Count placeholders still present in a string, for `/redact` reporting. */
696export function placeholderCount(text: string): number {
697  PROSE_PLACEHOLDER_RE.lastIndex = 0;
698  return (text.match(PROSE_PLACEHOLDER_RE) ?? []).length;
699}
700
hooks/classifier.ts 163 lines
1// L2: an optional local classifier, probed from disk and run in-process.
2//
3// The rule corpus is precise because every rule carries an anchor: a vendor
4// prefix or a keyword. That is also its ceiling. A password written as prose, or
5// a proprietary token with no vendor prefix, has no anchor for a regex to grip,
6// which is the tier where regex recall collapses (ProwlBench T3).
7//
8// So this module adds the second way to detect: a linear model over features of
9// a candidate value. It is honest about what it is -- a weights file the person
10// (or their trainer) supplies, evaluated with ordinary arithmetic. No weights
11// ship with the mod, and none can: training needs data that leaves the machine,
12// which the no-egress invariant forbids. When the file is absent, unreadable, or
13// malformed, `parseClassifier` returns null and the pipeline silently stays at
14// L1. That fallback is the normal case, not an error case.
15//
16// The invariant still holds. Inference is `bias + sum(w[f] * x[f])`, then a
17// sigmoid. Nothing is transmitted, spawned or delegated: the value being judged
18// never leaves this process, and this file cannot reach the network because it
19// imports nothing and the posture gate forbids the call sites that could.
20
21/** A local linear model: one weight per named feature, plus a bias. */
22export type ClassifierModel = {
23  readonly version: 1;
24  /** Log-odds offset: a positive bias makes the model fire more readily. */
25  readonly bias: number;
26  /** Probability above which a candidate counts as a secret, in (0,1). */
27  readonly threshold: number;
28  /** Feature name -> weight. Names outside `FEATURE_NAMES` are ignored. */
29  readonly features: Readonly<Record<string, number>>;
30};
31
32/**
33 * The feature vocabulary. A weights file may name any subset; an unknown name is
34 * ignored rather than rejected, so a newer trainer's extra features degrade
35 * quietly instead of disabling the model.
36 */
37export const FEATURE_NAMES = [
38  "length",
39  "entropy",
40  "upper",
41  "lower",
42  "digit",
43  "symbol",
44  "hexish",
45  "base64ish",
46  "camel",
47  "separator",
48  "prefixed",
49  "wordlike",
50] as const;
51
52export type FeatureName = (typeof FEATURE_NAMES)[number];
53
54/** Sigmoid input clamp: past this the answer is already 0 or 1 to the bit. */
55const CLAMP = 60;
56
57function finite(value: unknown): value is number {
58  return typeof value === "number" && Number.isFinite(value);
59}
60
61/**
62 * Parse a weights file. Anything malformed yields null -- a broken model must
63 * fall back to L1, never half-load and misjudge every candidate.
64 *
65 * Shape:
66 *   { "version": 1, "bias": -3.2, "threshold": 0.5,
67 *     "features": { "entropy": 1.4, "digit": 2.1, "symbol": 0.8 } }
68 */
69export function parseClassifier(raw: string): ClassifierModel | null {
70  let parsed: unknown;
71  try {
72    parsed = JSON.parse(raw);
73  } catch {
74    return null;
75  }
76  if (typeof parsed !== "object" || parsed === null) return null;
77  const record = parsed as Record<string, unknown>;
78  if (record.version !== 1) return null;
79  if (!finite(record.bias)) return null;
80  if (!finite(record.threshold) || record.threshold <= 0 || record.threshold >= 1) return null;
81  const rawFeatures = record.features;
82  if (typeof rawFeatures !== "object" || rawFeatures === null) return null;
83  const features: Record<string, number> = {};
84  for (const [name, weight] of Object.entries(rawFeatures as Record<string, unknown>)) {
85    if (!finite(weight)) return null;
86    features[name] = weight;
87  }
88  if (Object.keys(features).length === 0) return null;
89  return { version: 1, bias: record.bias, threshold: record.threshold, features };
90}
91
92/** Shannon entropy in bits per character. Kept local so this file imports nothing. */
93function entropyOf(value: string): number {
94  if (value.length === 0) return 0;
95  const counts = new Map<string, number>();
96  for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1);
97  let bits = 0;
98  for (const count of counts.values()) {
99    const p = count / value.length;
100    bits -= p * Math.log2(p);
101  }
102  return bits;
103}
104
105/**
106 * The feature vector of one candidate value, in [0,1] per feature so a weight's
107 * size means the same thing across features.
108 */
109export function featuresOf(value: string): Record<FeatureName, number> {
110  const length = value.length;
111  let upper = 0;
112  let lower = 0;
113  let digit = 0;
114  let symbol = 0;
115  for (const ch of value) {
116    if (ch >= "A" && ch <= "Z") upper += 1;
117    else if (ch >= "a" && ch <= "z") lower += 1;
118    else if (ch >= "0" && ch <= "9") digit += 1;
119    else symbol += 1;
120  }
121  const ratio = (n: number) => (length === 0 ? 0 : n / length);
122  return {
123    // Length is capped at 64: past that the signal is already saturated.
124    length: Math.min(length, 64) / 64,
125    entropy: Math.min(entropyOf(value), 6) / 6,
126    upper: ratio(upper),
127    lower: ratio(lower),
128    digit: ratio(digit),
129    symbol: ratio(symbol),
130    hexish: /^[0-9a-fA-F]+$/.test(value) && length >= 16 ? 1 : 0,
131    base64ish: /^[A-Za-z0-9+/=_-]+$/.test(value) && length >= 16 ? 1 : 0,
132    camel: upper > 0 && lower > 0 ? 1 : 0,
133    separator: /[_-]/.test(value) ? 1 : 0,
134    // A vendor prefix (`sk_`, `ghp_`, `xoxb-`, `AIza`) sits in the first few
135    // characters; its presence is the strongest single predictor there is.
136    prefixed: /^[A-Za-z]{2,6}[_-]/.test(value) ? 1 : 0,
137    wordlike: /^[A-Za-z]+$/.test(value) ? 1 : 0,
138  };
139}
140
141/**
142 * Probability that a candidate value is a secret, in [0,1].
143 *
144 * Pure arithmetic over the weights file: no I/O, no clock, no randomness, so the
145 * same value always scores the same and a placeholder stays stable.
146 */
147export function scoreValue(model: ClassifierModel, value: string): number {
148  const features = featuresOf(value);
149  let logit = model.bias;
150  for (const [name, weight] of Object.entries(model.features)) {
151    if (!(FEATURE_NAMES as readonly string[]).includes(name)) continue;
152    logit += weight * features[name as FeatureName];
153  }
154  if (logit > CLAMP) return 1;
155  if (logit < -CLAMP) return 0;
156  return 1 / (1 + Math.exp(-logit));
157}
158
159/** Whether the model calls this value a secret, at its own threshold. */
160export function isSecret(model: ClassifierModel, value: string): boolean {
161  return scoreValue(model, value) >= model.threshold;
162}
163
hooks/rules.ts 75 lines
1// The rule corpus, as a module.
2//
3// `rules.generated.ts` is generated data: 220 rules with per-rule provenance,
4// written by `tools/gen-rules.py` from gitleaks (MIT) and never edited by hand.
5// This file is the API the rest of the mod imports, so the engine has one place
6// to read "what rules exist" without depending on the generator's output layout.
7//
8// Keeping the generated data and the accessor apart matters for review: the gate
9// (`tools/check-rules.mjs`) checks the data file directly, so a change to this
10// accessor cannot hide a rule that lost its provenance.
11
12import { RULES as GENERATED_RULES, type Rule } from "./rules.generated.ts";
13
14export {
15  CORPUS_SHA256,
16  GLOBAL_ALLOW_REGEXES,
17  GLOBAL_STOPWORDS,
18  type Rule,
19} from "./rules.generated.ts";
20
21export { ALLOWLIST, isAllowed, type AllowEntry } from "./allowlist.ts";
22
23/**
24 * The id a person writes after `/redact rule off` to silence the field-name
25 * password detector.
26 *
27 * The detector is engine-owned rather than gitleaks-derived: its rule is the
28 * field name, and it deliberately runs without an entropy floor, which the
29 * generated corpus cannot express. Listing it here puts it on the same L3 id
30 * surface as every shipped rule.
31 */
32export const PASSWORD_VALUE_RULE_ID = "password-value";
33
34const PASSWORD_VALUE_DESCRIPTOR: Rule = {
35  id: PASSWORD_VALUE_RULE_ID,
36  label: "PASSWORD_VALUE",
37  // Never compiled into the corpus scan (scrub.ts filters ENGINE_RULE_IDS); the
38  // id exists so L3 can validate it. A never-matching source keeps an accidental
39  // compile inert rather than redacting the word "password".
40  regex: "(?!)",
41  flags: "",
42  groups: 0,
43  secretGroup: null,
44  entropy: null,
45  keywords: [],
46  allow: [],
47  kind: "generic",
48  source: "https://github.com/ezoushen/claude-mods/blob/main/prompt-redact/hooks/scrub.ts#password-value",
49};
50
51/**
52 * Every id L3 may switch. The generated corpus first, then engine-owned
53 * detectors that are not part of it. `scrub.ts` excludes the latter from the
54 * corpus scan and runs each in its own pass.
55 */
56export const RULES: readonly Rule[] = [...GENERATED_RULES, PASSWORD_VALUE_DESCRIPTOR];
57
58/** Rules grouped by the family that makes them cheap to run. */
59export function rulesByKind(): { prefix: Rule[]; vendorKeyword: Rule[]; generic: Rule[] } {
60  const prefix: Rule[] = [];
61  const vendorKeyword: Rule[] = [];
62  const generic: Rule[] = [];
63  for (const rule of GENERATED_RULES) {
64    if (rule.kind === "prefix") prefix.push(rule);
65    else if (rule.kind === "generic") generic.push(rule);
66    else vendorKeyword.push(rule);
67  }
68  return { prefix, vendorKeyword, generic };
69}
70
71/** The generated corpus size, for log lines and `/redact why`. */
72export function ruleCount(): number {
73  return GENERATED_RULES.length;
74}
75
hooks/allowlist.ts 83 lines
1// The allowlist is the reason the generic detector can run at a low threshold.
2//
3// A redactor's false positive is cheap: Claude reads `[REDACTED:...]` where a
4// meaningless hash used to be. But there is one false positive it cannot afford
5// -- redacting a value the *task needs*. A git SHA, an object id, a lockfile
6// integrity string, a resource ARN: take those away and the model can no longer
7// do the thing you asked. So the rule is:
8//
9//   be maximally aggressive on secrets, and name every benign high-entropy
10//   shape you must leave alone.
11//
12// Without this list the generic rule has to be tuned so conservatively that it
13// misses real credentials. With it, the threshold drops and recall rises.
14//
15// Each entry matches a *whole candidate value* (anchored), never a substring, so
16// an allowlist hit can never carve a hole in the middle of a real secret.
17
18export type AllowEntry = {
19  /** Why this shape is benign; quoted in `RULES.md` and `/redact why`. */
20  readonly why: string;
21  readonly pattern: RegExp;
22};
23
24const A = (why: string, source: string, flags = ""): AllowEntry => ({
25  why,
26  pattern: new RegExp(`^(?:${source})$`, flags),
27});
28
29export const ALLOWLIST: readonly AllowEntry[] = [
30  // --- content hashes: the classic high-entropy non-secret -------------------
31  A("git commit SHA-1 (content address, not a credential)", "[0-9a-f]{40}", "i"),
32  A("git blob/tree SHA-1 with prefix", "(?:blob|tree|commit)\\s[0-9a-f]{40}", "i"),
33  A("SHA-256 digest (content address, not a credential)", "[0-9a-f]{64}", "i"),
34  A("`sha256:` image digest", "sha256:[0-9a-f]{64}", "i"),
35  A("MD5 / 32-hex checksum", "[0-9a-f]{32}", "i"),
36
37  // --- identifiers: needed to reference objects in code ---------------------
38  A("UUID / database id / k8s resource UID", "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "i"),
39  A("MongoDB ObjectId", "[0-9a-f]{24}", "i"),
40  A("ULID -- a time-sortable identifier, not a credential", "[0-9A-HJKMNP-TV-Z]{26}", "i"),
41  A("AWS ARN -- a resource identifier; nothing to revoke", "arn:aws[a-z-]*:[a-z0-9-]+:[a-z0-9-]*:[0-9]{0,12}:[-\\w./:*]+", "i"),
42  A("AWS account id (12 digits)", "[0-9]{12}"),
43  A("IPv4 address -- a host, not a credential", "(?:\\d{1,3}\\.){3}\\d{1,3}"),
44  A("email address (PII is out of scope; names are not rule-detectable)", "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"),
45  A("semantic version", "v?\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?"),
46  A("ISO-8601 timestamp", "\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?"),
47  A("hex colour value from a stylesheet", "#[0-9a-f]{3,8}", "i"),
48  A("localhost URL", "https?://(?:localhost|127\\.0\\.0\\.1)(?::\\d+)?(?:/\\S*)?", "i"),
49
50  // --- integrity / provenance metadata --------------------------------------
51  A("subresource integrity (SRI)", "sha(?:256|384|512)-[A-Za-z0-9+/]{27,}={0,2}"),
52  A("npm / lockfile integrity -- must carry the sha<N>- prefix; a bare base64 run is a credential, not a digest", "sha(?:1|256|384|512)-[A-Za-z0-9+/]{27,}={0,2}"),
53  A("base64 data URI (inline asset)", "data:[a-z-]+/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+", "i"),
54  A("bcrypt hash -- already hashed, not a usable credential", "\\$2[abxy]\\$\\d{2}\\$[./A-Za-z0-9]{53}"),
55  A("SSH *public* key (public half; nothing to revoke)", "(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-\\S+)\\s+AAAA[A-Za-z0-9+/]{20,}={0,3}(?:\\s+\\S+)?"),
56  A("PUBLIC certificate block", "-----BEGIN CERTIFICATE-----[\\s\\S]*?-----END CERTIFICATE-----"),
57
58  // --- the placeholder lexicon: documentation and fixtures -------------------
59  // These are the words that mark a value as an example. They are the single
60  // most valuable allowlist family, and the reason not to mask the value before
61  // judging it: masking would delete the word "EXAMPLE", which is the evidence.
62  A("documentation placeholder", "(?:changeme|change_me|change-me|replace[-_ ]?me|your[-_ ]?key|your[-_ ]?token|your[-_ ]?secret|placeholder|redacted|dummy|fake|sample|example|test[-_ ]?value)", "i"),
63  A("schema keyword that fills a password field, not a password", "required", "i"),
64  A("template variable", "\\$\\{[^}]{0,200}\\}|\\{\\{[^}]{0,200}\\}\\}|<[A-Za-z_][A-Za-z0-9_ .:-]{0,60}>|\\$\\w+|%[A-Z_]+%"),
65  A("explicit redaction marker", "[xX]{3,}|\\*{3,}|\\.{3,}|<redacted>|\\[REDACTED[^\\]]*\\]"),
66
67  // --- the vendor sample credentials that are *known* public ----------------
68  A("AWS documented example key pair (public sample, never usable)", "REDACTED-AWS-ACCESS-KEY-BY-SLOPSHOPPER|wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", ""),
69];
70
71/** True when `value` is a benign shape that must reach the model unchanged. */
72export function isAllowed(value: string, extra: readonly RegExp[] = []): string | null {
73  const v = value.trim();
74  if (!v) return "empty";
75  for (const entry of ALLOWLIST) {
76    if (entry.pattern.test(v)) return entry.why;
77  }
78  for (const re of extra) {
79    if (re.test(v)) return "user allowlist";
80  }
81  return null;
82}
83
hooks/structural.ts 551 lines
1// Structural detectors: shapes that carry their own proof.
2//
3// A regex says "this looks like a key". A checksum says "this *is* one". Luhn,
4// IBAN mod-97, a JWT whose header decodes to JSON with an `alg`, a PEM block with
5// a matching END line -- these carry a self-check, so they run at effectively
6// 100% precision and need no entropy gate, no keyword anchor and no context.
7// The URL and Basic-auth detectors below prove themselves the same way: a URL
8// userinfo *is* the credential slot the RFC defines, and a Basic-auth value
9// decodes to `user:pass` or it is not a Basic-auth credential.
10//
11// They are also the one family that must NOT be a regex. `private-key` upstream
12// ends in `{64,}`: capping it would redact a key's head and leave its tail in the
13// transcript. So the PEM scanner walks the block linearly and redacts the whole
14// span. Same for a JWT: decode the header rather than pattern-match base64url.
15//
16// Everything here is linear in the row length -- no backtracking, no `.*`.
17
18import { isAllowed } from "./allowlist.ts";
19
20export type Hit = {
21  /** Placeholder label: [REDACTED:<label>#<hash>]. */
22  readonly label: string;
23  readonly start: number;
24  readonly end: number;
25  readonly value: string;
26  /** Which detector produced it, for `/redact why`. */
27  readonly by: string;
28};
29
30const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/g;
31const PEM_END_ANY = /-----END ([A-Z0-9 ]*)PRIVATE KEY-----/g;
32
33/**
34 * PEM private-key blocks: BEGIN..END, entirely redacted.
35 *
36 * Linear scan, no regex over the body. An unterminated block redacts from BEGIN
37 * to end-of-row (fail closed: a truncated key still must not survive).
38 */
39export function pemPrivateKeys(text: string): Hit[] {
40  const hits: Hit[] = [];
41  let cursor = 0;
42  for (;;) {
43    PEM_BEGIN.lastIndex = cursor;
44    const begin = PEM_BEGIN.exec(text);
45    if (!begin) break;
46    const label = begin[1] ?? "";
47    const from = begin.index;
48    PEM_END_ANY.lastIndex = begin.index + begin[0].length;
49    let end = text.length; // beyond the last body character (unterminated case)
50    let endMarkerAt = -1; // where the matching END marker text starts
51    for (;;) {
52      const candidate = PEM_END_ANY.exec(text);
53      if (!candidate) break;
54      if ((candidate[1] ?? "") === label) {
55        endMarkerAt = candidate.index;
56        end = endMarkerAt + candidate[0].length;
57        break;
58      }
59    }
60    // Fail closed on an unterminated block: the row itself may have been cut
61    // mid-paste, so a truncated key still must not survive (BEGIN..EOL).
62    const body = text.slice(begin.index + begin[0].length, endMarkerAt < 0 ? end : endMarkerAt);
63    if (endMarkerAt >= 0 && !derHeaderMatchesPayload(body)) {
64      cursor = end;
65      continue;
66    }
67    hits.push({
68      label: label.trim() ? `${label.trim().replace(/\s+/g, "_")}_PRIVATE_KEY` : "PRIVATE_KEY",
69      start: from,
70      end,
71      value: text.slice(from, end),
72      by: "pem",
73    });
74    cursor = end;
75  }
76  return hits;
77}
78
79/**
80 * The DER length header against the payload it precedes (X.690 / RFC 7468).
81 *
82 * A PEM key body is base64 DER whose outer element is a SEQUENCE (0x30) and
83 * whose length octets must be well formed and must not be contradicted by
84 * the payload. Two directions, not one:
85 *
86 * - The payload running PAST the declared structure end (content declares
87 *   fewer bytes than follow the header) is a contradiction: the block is
88 *   not the DER it claims to be, so it is not treated as a key.
89 * - The payload falling SHORT of the declared structure end is a truncated
90 *   paste of a real key. The row may have been cut mid-paste, so this side
91 *   fails closed and the block stays redacted (DECISIONS #3: truncation
92 *   must not survive).
93 *
94 * The length octets themselves must be a canonical DER encoding: 0x80
95 * (indefinite) and long forms over four octets are rejected, and a long
96 * form under 0x80 bytes is non-minimal, so rejected.
97 */
98function derHeaderMatchesPayload(body: string): boolean {
99  const bytes = pemBodyBytes(body);
100  if (!bytes || bytes.length < 4) return false;
101  if (bytes[0] !== 0x30) return false;
102  const first = bytes[1];
103  let headerOctets: number;
104  let claimed: number;
105  if (first < 0x80) {
106    headerOctets = 2;
107    claimed = first;
108  } else if (first === 0x80 || first > 0x84 || bytes.length < 2 + (first - 0x80)) {
109    return false; // indefinite, non-representable, or truncated header
110  } else {
111    headerOctets = 2 + (first - 0x80);
112    claimed = 0;
113    for (const octet of bytes.slice(2, headerOctets)) claimed = (claimed << 8) | octet;
114    if (claimed < 0x80) return false; // non-minimal long form
115  }
116  const available = bytes.length - headerOctets;
117  if (claimed === 0) return false; // a SEQUENCE with no content is no key
118  return claimed >= available;
119}
120
121/**
122 * Decode a PEM body to bytes, whitespace ignored. A `=`-padded tail is
123 * trimmed -- only whole bytes count, and the leftover shift bits of the
124 * final sextet are discarded. Returns null for a body carrying anything
125 * outside the base64 alphabet.
126 */
127function pemBodyBytes(body: string): number[] | null {
128  const out: number[] = [];
129  let buffer = 0;
130  let bits = 0;
131  for (const ch of body.replace(/\s+/g, "").replace(/=+$/, "")) {
132    const value = B64_ALPHABET.indexOf(ch);
133    if (value < 0) return null;
134    buffer = (buffer << 6) | value;
135    bits += 6;
136    if (bits >= 8) {
137      bits -= 8;
138      out.push((buffer >> bits) & 0xff);
139      buffer &= (1 << bits) - 1;
140    }
141  }
142  return out;
143}
144
145const B64URL = /[A-Za-z0-9_-]{8,}/g;
146
147const B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
148
149/**
150 * Decode base64url without `atob`: the mod sandbox is not guaranteed to expose
151 * web globals, and a silently missing decoder would mean silent JWT blindness --
152 * a false negative in a redactor, the one kind it cannot afford.
153 */
154function decodeSegment(seg: string): string | null {
155  const clean = seg.replace(/-/g, "+").replace(/_/g, "/");
156  let out = "";
157  let buffer = 0;
158  let bits = 0;
159  for (const ch of clean) {
160    const value = B64_ALPHABET.indexOf(ch);
161    if (value < 0) return null;
162    buffer = (buffer << 6) | value;
163    bits += 6;
164    if (bits >= 8) {
165      bits -= 8;
166      out += String.fromCharCode((buffer >> bits) & 0xff);
167    }
168  }
169  return out;
170}
171
172/**
173 * JSON Web Tokens, proven structurally: the first segment must base64url-decode
174 * to a JSON object with an `alg` field. That check is what separates a real JWT
175 * from any three dotted base64url strings, and it costs no entropy tuning.
176 */
177export function jwts(text: string): Hit[] {
178  const hits: Hit[] = [];
179  const seen = new Set<number>();
180  B64URL.lastIndex = 0;
181  for (;;) {
182    const first = B64URL.exec(text);
183    if (!first) break;
184    const start = first.index;
185    if (seen.has(start)) continue;
186    // The token runs to the end of its dotted run.
187    const rest = /^[A-Za-z0-9_-]*\.([A-Za-z0-9_-]{8,})\.([A-Za-z0-9_-]*)/.exec(text.slice(start + first[0].length));
188    if (!rest) continue;
189    const payload = rest[1] ?? "";
190    const headerText = decodeSegment(first[0]);
191    if (!headerText) continue;
192    let header: unknown;
193    try {
194      header = JSON.parse(headerText);
195    } catch {
196      continue;
197    }
198    if (typeof header !== "object" || header === null) continue;
199    const alg = (header as Record<string, unknown>).alg;
200    if (typeof alg !== "string" || !alg) continue;
201    const end = start + first[0].length + 1 + payload.length + 1 + (rest[2] ?? "").length;
202    seen.add(start);
203    hits.push({ label: "JWT", start, end, value: text.slice(start, end), by: "jwt" });
204  }
205  return hits;
206}
207
208/** Luhn checksum: the mod-10 test every payment card number satisfies. */
209export function luhnOk(digits: string): boolean {
210  let sum = 0;
211  let double = false;
212  for (let i = digits.length - 1; i >= 0; i--) {
213    const code = digits.charCodeAt(i) - 48;
214    if (code < 0 || code > 9) return false;
215    let d = code;
216    if (double) {
217      d *= 2;
218      if (d > 9) d -= 9;
219    }
220    sum += d;
221    double = !double;
222  }
223  return sum % 10 === 0;
224}
225
226const CARDISH = /(?<![0-9])(?:\d[ -]?){12,18}\d(?![0-9])/g;
227
228/** Payment card numbers: 13-19 digits whose Luhn checksum verifies. */
229export function cards(text: string): Hit[] {
230  const hits: Hit[] = [];
231  for (const m of text.matchAll(CARDISH)) {
232    const raw = m[0];
233    const digits = raw.replace(/[^0-9]/g, "");
234    if (digits.length < 13 || digits.length > 19) continue;
235    if (!luhnOk(digits)) continue;
236    const start = m.index ?? 0;
237    hits.push({ label: "CARD_NUMBER", start, end: start + raw.length, value: raw, by: "luhn" });
238  }
239  return hits;
240}
241
242/** IBAN mod-97, the checksum defined by ISO 13616. */
243export function ibanOk(candidate: string): boolean {
244  const iban = candidate.replace(/\s+/g, "").toUpperCase();
245  if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/.test(iban)) return false;
246  const rearranged = iban.slice(4) + iban.slice(0, 4);
247  let remainder = 0;
248  for (const ch of rearranged) {
249    const code = ch.charCodeAt(0);
250    const chunk = code >= 48 && code <= 57 ? String(code - 48) : String(code - 55);
251    for (const digit of chunk) remainder = (remainder * 10 + Number(digit)) % 97;
252  }
253  return remainder === 1;
254}
255
256const IBANISH = /(?<![A-Za-z0-9])[A-Z]{2}[0-9]{2}[A-Z0-9 ]{10,30}(?![A-Za-z0-9])/g;
257
258/** IBANs whose mod-97 checksum verifies. */
259export function ibans(text: string): Hit[] {
260  const hits: Hit[] = [];
261  for (const m of text.matchAll(IBANISH)) {
262    if (!ibanOk(m[0])) continue;
263    const start = m.index ?? 0;
264    hits.push({ label: "IBAN", start, end: start + m[0].length, value: m[0], by: "iban" });
265  }
266  return hits;
267}
268
269/**
270 * US Social Security numbers, under the SSA's own issuance rules (FICA):
271 * area 001-899 except 666, group 01-99, serial 0001-9999. A number that also
272 * carries a Verhoeff check digit is accepted as well, but the digit is not a
273 * precondition.
274 *
275 * The issuance exclusions are the discriminator, and they are what keeps this
276 * from matching every 9-digit run. Verhoeff is *not* part of the real SSN
277 * scheme: it was proposed for it and never adopted, so a genuinely issued
278 * number carries no check digit. Requiring one as a conjunct dropped roughly
279 * nine of every ten real SSNs from the redactor, which is the wrong direction
280 * for a redactor's guarantee. Hence the union: issuance rules OR a valid
281 * check digit.
282 */
283export function ssns(text: string): Hit[] {
284  const hits: Hit[] = [];
285  for (const m of text.matchAll(/(?<![0-9])(\d{3})-(\d{2})-(\d{4})(?![0-9])/g)) {
286    const area = Number(m[1]);
287    const group = Number(m[2]);
288    const serial = Number(m[3]);
289    const issuanceOk = area !== 0 && area !== 666 && area <= 899 && group !== 0 && serial !== 0;
290    if (!issuanceOk && !verhoeffOk(m[1] + m[2] + m[3])) continue;
291    const start = m.index ?? 0;
292    hits.push({ label: "SSN", start, end: start + m[0].length, value: m[0], by: "fica" });
293  }
294  return hits;
295}
296
297// --- Verhoeff check digit ----------------------------------------------------
298
299/**
300 * The Verhoeff check digit (Verhoeff 1969): the decimal checksum over the
301 * dihedral group D5 that detects every single-digit error and every adjacent
302 * transposition. Tables are the ones Verhoeff published; worked examples
303 * (check digit 3 for 236, validating 2363; check digit 7 for 942; 12345 -> 1
304 * with 123451 valid and its twins 122451/128451 invalid) settle the reading
305 * of the tables in tests.
306 */
307const VERHOEFF_D = [
308  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
309  [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
310  [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
311  [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
312  [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
313  [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
314  [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
315  [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
316  [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
317  [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
318];
319
320const VERHOEFF_P = [
321  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
322  [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
323  [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
324  [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
325  [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
326  [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
327  [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
328  [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
329];
330
331const VERHOEFF_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
332
333/** D5 checksum of a digit string, digits taken right-to-left. */
334function verhoeffSum(digits: string, positionShift: number): number {
335  let c = 0;
336  for (let i = digits.length - 1; i >= 0; i--) {
337    c = VERHOEFF_D[c][VERHOEFF_P[((digits.length - 1 - i) + positionShift) % 8][Number(digits[i])]];
338  }
339  return c;
340}
341
342/**
343 * The check digit to append to `body` so the concatenation validates.
344 */
345export function verhoeffCheckDigit(body: string): number {
346  return VERHOEFF_INV[verhoeffSum(body, 1)];
347}
348
349/**
350 * True when `digits` carries a correct Verhoeff check digit as its last
351 * digit -- equivalently, `digits` is `prefix + verhoeffCheckDigit(prefix)`.
352 */
353export function verhoeffOk(digits: string): boolean {
354  return verhoeffSum(digits, 0) === 0;
355}
356
357
358const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
359// A base32 run is uppercase only: `0`, `1`, `8`, `9` are not in the alphabet, and
360// lowercase means base64 or an identifier. RFC 4648 pads to a multiple of eight.
361const BASE32_RUN = /(?<![A-Za-z0-9])[A-Z2-7]{16,64}={0,6}(?![A-Za-z0-9])/g;
362// TOTP secrets have no vendor prefix, so the anchor has to be the surrounding
363// words. This is the one structural family that needs context, and the reason it
364// is checked last: without one of these words nearby, 16 uppercase characters are
365// far more likely to be an identifier than an HMAC key.
366const TOTP_CONTEXT =
367  /(?:totp|otp|2fa|mfa|two[-_ ]?factor|authenticator|one[-_ ]?time|gauth|otpauth|backup[-_ ]?code|otp_secret|seed)/i;
368
369/**
370 * Bytes encoded by a base32 body, or null when the body is not base32.
371 *
372 * The byte count is what distinguishes a real HMAC key from a coincidence: RFC
373 * 6238 secrets are 10, 20, 32 or 64 bytes in practice, so a 16-character run
374 * (10 bytes) verifies and a 34-character word does not land on a key length.
375 */
376function base32Bytes(body: string): number | null {
377  let buffer = 0;
378  let bits = 0;
379  let bytes = 0;
380  for (const ch of body) {
381    const value = BASE32_ALPHABET.indexOf(ch);
382    if (value < 0) return null;
383    buffer = (buffer << 5) | value;
384    bits += 5;
385    while (bits >= 8) {
386      bits -= 8;
387      bytes += 1;
388      buffer &= (1 << bits) - 1;
389    }
390  }
391  return bytes;
392}
393
394/**
395 * base32 TOTP secrets, proven structurally: a base32 body whose decoded length is
396 * a plausible HMAC key size, next to a word that says it is a one-time-password
397 * seed. Neither half alone is enough, which is what keeps the precision high.
398 */
399export function totpSecrets(text: string): Hit[] {
400  const hits: Hit[] = [];
401  for (const m of text.matchAll(BASE32_RUN)) {
402    const raw = m[0];
403    const body = raw.replace(/=+$/, "");
404    const bytes = base32Bytes(body);
405    if (bytes === null) continue;
406    if (bytes < 10 || bytes > 64) continue;
407    const start = m.index ?? 0;
408    const before = text.slice(Math.max(0, start - 48), start);
409    const after = text.slice(start + raw.length, Math.min(text.length, start + raw.length + 24));
410    if (!TOTP_CONTEXT.test(before) && !TOTP_CONTEXT.test(after)) continue;
411    hits.push({ label: "TOTP_SECRET", start, end: start + raw.length, value: raw, by: "base32" });
412  }
413  return hits;
414}
415
416// --- URL userinfo -----------------------------------------------------------
417
418/**
419 * The userinfo slot of an HTTP(S) family URL: `scheme://user:password@host`.
420 *
421 * The password, and only the password, is the credential. Scheme, user, host,
422 * port and path are the structure a person pastes a config to keep working, so
423 * the span ends exactly at the character before `@` and begins exactly at the
424 * character after the user's `:`. An empty password (`scheme://user:@host`) is
425 * no credential and never matches.
426 *
427 * Every quantifier is bounded and the scan is linear in the row length: the
428 * class runs stop at `:`, `@`, `/` and whitespace, which is where the userinfo
429 * definition ends anyway.
430 */
431const URL_USERINFO =
432  /(?<![-_.A-Za-z0-9])([A-Za-z][A-Za-z0-9+.-]{1,20}:\/\/[A-Za-z0-9._~%!$&'()*+,;={}-]{1,128}:)([^:/@\s"'`\\]{1,2048})@/g;
433
434/**
435 * Passwords inside URL userinfo, with rule id `url-userinfo`.
436 *
437 * The placeholder lexicon is honoured inside the detector: the allowlist's
438 * whole-value contract means a templated or documented password
439 * (`${DATABASE_PASSWORD}`, `changeme`, `<password>`) is not redactable in the
440 * first place, so no candidate is emitted for one.
441 */
442export function urlPasswords(text: string): Hit[] {
443  const hits: Hit[] = [];
444  for (const m of text.matchAll(URL_USERINFO)) {
445    const password = m[2];
446    if (!password) continue;
447    if (isAllowed(password) !== null) continue;
448    const start = (m.index ?? 0) + (m[1] ?? "").length;
449    hits.push({
450      label: "URL_PASSWORD",
451      start,
452      end: start + password.length,
453      value: password,
454      by: "url-userinfo",
455    });
456  }
457  return hits;
458}
459
460// --- Basic-auth headers -----------------------------------------------------
461
462/**
463 * A base64 candidate: the standard alphabet with optional padding, delimited by
464 * anything that is not itself a base64 or base64url character. The floor is six
465 * alphabet characters -- the smallest *padded* form (`dXNlcg==`), so the
466 * lower-boundary case reaches the decode proof instead of bouncing off a length
467 * gate no test can see. Bounded, and the `.` in the boundary classes keeps a
468 * JWT's dotted segments out of a single run.
469 */
470const B64_TOKEN = /(?<![A-Za-z0-9+/=.-])([A-Za-z0-9+/]{6,4096}={0,2})(?![A-Za-z0-9+/=.-])/g;
471
472/**
473 * The word `Basic` immediately before a candidate: the one header this
474 * detector answers to, and the anchor that separates an HTTP Basic credential
475 * from any base64 run. Variants beyond `Basic` are out of scope by design.
476 */
477const BASIC_SCHEME = /\bbasic[ \t]{1,8}$/i;
478
479/**
480 * Decode standard base64 (with optional padding) without `atob`, for the same
481 * reason the JWT header decodes here instead of trusting a shape: a missing
482 * decoder would mean silent blindness. Rejects anything whose bytes are not
483 * printable ASCII -- that requirement is most of the precision, because it
484 * refuses the prose words and identifiers a b64-charset regex happily matches.
485 */
486function decodeB64(seg: string): string | null {
487  // A run whose length is 1 mod 4 cannot be base64, padded or not.
488  if (seg.length % 4 === 1 && !seg.endsWith("=")) return null;
489  const body = seg.replace(/=+$/, "");
490  let out = "";
491  let buffer = 0;
492  let bits = 0;
493  for (const ch of body) {
494    const value = B64_ALPHABET.indexOf(ch);
495    if (value < 0) return null;
496    buffer = (buffer << 6) | value;
497    bits += 6;
498    if (bits >= 8) {
499      bits -= 8;
500      const byte = (buffer >> bits) & 0xff;
501      if (byte < 0x20 || byte > 0x7e) return null;
502      out += String.fromCharCode(byte);
503    }
504  }
505  return out;
506}
507
508/**
509 * `Authorization: Basic` credentials, with rule id `basic-auth`.
510 *
511 * The span is the base64 value only, so the header keeps its scheme word.
512 * The proof is the same shape the URL detector leans on, decoded: the value
513 * must decode (padding allowed) to printable ASCII carrying a `user:pass`
514 * split with both halves non-empty. A base64 value that does not decode to
515 * that shape is left untouched -- `Basic dXNlcg==` and `Basic not-base64!`
516 * both survive verbatim.
517 */
518export function basicAuthCredentials(text: string): Hit[] {
519  const hits: Hit[] = [];
520  for (const m of text.matchAll(B64_TOKEN)) {
521    const token = m[1];
522    if (!token) continue;
523    const start = m.index ?? 0;
524    const before = text.slice(Math.max(0, start - 64), start);
525    if (!BASIC_SCHEME.test(before)) continue;
526    const decoded = decodeB64(token);
527    if (decoded === null) continue;
528    const colon = decoded.indexOf(":");
529    if (colon < 1) continue;
530    if (decoded.length - colon < 2) continue;
531    hits.push({ label: "BASIC_AUTH", start, end: start + token.length, value: token, by: "basic-auth" });
532  }
533  return hits;
534}
535
536/**
537 * Every structural detector, in the order their spans should win conflicts.
538 */
539export function structuralHits(text: string): Hit[] {
540  return [
541    ...pemPrivateKeys(text),
542    ...jwts(text),
543    ...ibans(text),
544    ...cards(text),
545    ...ssns(text),
546    ...totpSecrets(text),
547    ...urlPasswords(text),
548    ...basicAuthCredentials(text),
549  ];
550}
551
hooks/rules.generated.ts 265 lines
1// GENERATED FILE -- do not edit by hand.
2// Source: tools/gen-rules.py over prompt-redact/vendor/gitleaks.toml
3// gitleaks.toml sha256: e163e53b9e7e8a8511e77271e2b323ed057759542a6d988258afe3a1fa329caf
4// 220 rules: 95 prefix-anchored, 123 vendor-keyword, 2 generic.
5
6export type Rule = {
7  /** gitleaks rule id, the provenance key. */
8  readonly id: string;
9  /** Label a placeholder carries: [REDACTED:<label>#<hash>]. */
10  readonly label: string;
11  /** JavaScript RegExp source, converted from the upstream RE2 pattern. */
12  readonly regex: string;
13  readonly flags: string;
14  /** Capture groups present; the runtime redacts the highest-entropy one. */
15  readonly groups: number;
16  /** Which group holds the value, when gitleaks states it explicitly. */
17  readonly secretGroup: number | null;
18  /** Minimum Shannon entropy of the value, when the rule gates on it. */
19  readonly entropy: number | null;
20  /** Prefilter: the rule is only run when one of these appears in the row. */
21  readonly keywords: readonly string[];
22  /** Per-rule allowlist: a value matching one of these is left alone. */
23  readonly allow: readonly string[];
24  readonly kind: 'prefix' | 'vendor-keyword' | 'generic';
25  /** Provenance, one URL per rule. */
26  readonly source: string;
27};
28
29export const RULES: readonly Rule[] = [
30  { id: "1password-secret-key", label: "1PASSWORD_SECRET_KEY", regex: "\\bA3-[A-Z0-9]{6}-(?:(?:[A-Z0-9]{11})|(?:[A-Z0-9]{6}-[A-Z0-9]{5}))-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}\\b", flags: "i", groups: 0, secretGroup: null, entropy: 3.8, keywords: ["a3-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#1password-secret-key" },
31  { id: "1password-service-account-token", label: "1PASSWORD_SERVICE_ACCOUNT_TOKEN", regex: "ops_eyJ[a-zA-Z0-9+/]{250,4096}={0,3}", flags: "i", groups: 0, secretGroup: null, entropy: 4.0, keywords: ["ops_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#1password-service-account-token" },
32  { id: "adafruit-api-key", label: "ADAFRUIT_API_KEY", regex: "[\\w.-]{0,50}?(?:adafruit)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9_-]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["adafruit"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#adafruit-api-key" },
33  { id: "adobe-client-id", label: "ADOBE_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:adobe)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["adobe"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#adobe-client-id" },
34  { id: "adobe-client-secret", label: "ADOBE_CLIENT_SECRET", regex: "\\b(p8e-[a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["p8e-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#adobe-client-secret" },
35  { id: "age-secret-key", label: "AGE_SECRET_KEY", regex: "AGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{58}", flags: "i", groups: 0, secretGroup: null, entropy: null, keywords: ["age-secret-key-1"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#age-secret-key" },
36  { id: "airtable-api-key", label: "AIRTABLE_API_KEY", regex: "[\\w.-]{0,50}?(?:airtable)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{17})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["airtable"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#airtable-api-key" },
37  { id: "airtable-personnal-access-token", label: "AIRTABLE_PERSONNAL_ACCESS_TOKEN", regex: "\\b(pat[A-Za-z0-9]{14}\\.[a-f0-9]{64})\\b", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["airtable"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#airtable-personnal-access-token" },
38  { id: "algolia-api-key", label: "ALGOLIA_API_KEY", regex: "[\\w.-]{0,50}?(?:algolia)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["algolia"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#algolia-api-key" },
39  { id: "alibaba-access-key-id", label: "ALIBABA_ACCESS_KEY_ID", regex: "\\b(LTAI[a-z0-9]{20})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["ltai"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#alibaba-access-key-id" },
40  { id: "alibaba-secret-key", label: "ALIBABA_SECRET_KEY", regex: "[\\w.-]{0,50}?(?:alibaba)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{30})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["alibaba"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#alibaba-secret-key" },
41  { id: "anthropic-admin-api-key", label: "ANTHROPIC_ADMIN_API_KEY", regex: "\\b(sk-ant-admin01-[a-zA-Z0-9_\\-]{93}AA)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["sk-ant-admin01"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#anthropic-admin-api-key" },
42  { id: "anthropic-api-key", label: "ANTHROPIC_API_KEY", regex: "\\b(sk-ant-api03-[a-zA-Z0-9_\\-]{93}AA)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["sk-ant-api03"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#anthropic-api-key" },
43  { id: "artifactory-api-key", label: "ARTIFACTORY_API_KEY", regex: "\\bAKCp[A-Za-z0-9]{69}\\b", flags: "i", groups: 0, secretGroup: null, entropy: 4.5, keywords: ["akcp"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#artifactory-api-key" },
44  { id: "artifactory-reference-token", label: "ARTIFACTORY_REFERENCE_TOKEN", regex: "\\bcmVmd[A-Za-z0-9]{59}\\b", flags: "i", groups: 0, secretGroup: null, entropy: 4.5, keywords: ["cmvmd"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#artifactory-reference-token" },
45  { id: "asana-client-id", label: "ASANA_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:asana)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9]{16})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["asana"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#asana-client-id" },
46  { id: "asana-client-secret", label: "ASANA_CLIENT_SECRET", regex: "[\\w.-]{0,50}?(?:asana)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["asana"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#asana-client-secret" },
47  { id: "atlassian-api-token", label: "ATLASSIAN_API_TOKEN", regex: "[\\w.-]{0,50}?(?:(?:ATLASSIAN|[Aa]tlassian)|(?:CONFLUENCE|[Cc]onfluence)|(?:JIRA|[Jj]ira))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{20}[a-f0-9]{4})(?:[\\x60'\"\\s;]|\\\\[nr]|$)|\\b(ATATT3[A-Za-z0-9_\\-=]{186})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: null, entropy: 3.5, keywords: ["atlassian", "confluence", "jira", "atatt3"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#atlassian-api-token" },
48  { id: "authress-service-client-access-key", label: "AUTHRESS_SERVICE_CLIENT_ACCESS_KEY", regex: "\\b((?:sc|ext|scauth|authress)_[a-z0-9]{5,30}\\.[a-z0-9]{4,6}\\.(?:acc)[_-][a-z0-9-]{10,32}\\.[a-z0-9+/_=-]{30,120})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["sc_", "ext_", "scauth_", "authress_"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#authress-service-client-access-key" },
49  { id: "aws-access-token", label: "AWS_ACCESS_TOKEN", regex: "\\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\\b", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["a3t", "akia", "asia", "abia", "acca"], allow: [".+EXAMPLE$"], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#aws-access-token" },
50  { id: "aws-amazon-bedrock-api-key-long-lived", label: "AWS_AMAZON_BEDROCK_API_KEY_LONG_LIVED", regex: "\\b(ABSK[A-Za-z0-9+/]{109,269}={0,2})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["absk"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#aws-amazon-bedrock-api-key-long-lived" },
51  { id: "aws-amazon-bedrock-api-key-short-lived", label: "AWS_AMAZON_BEDROCK_API_KEY_SHORT_LIVED", regex: "bedrock-api-key-YmVkcm9jay5hbWF6b25hd3MuY29t", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["bedrock-api-key-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#aws-amazon-bedrock-api-key-short-lived" },
52  { id: "azure-ad-client-secret", label: "AZURE_AD_CLIENT_SECRET", regex: "(?:^|[\\\\'\"\\x60\\s>=:(,)])([a-zA-Z0-9_~.]{3}\\dQ~[a-zA-Z0-9_~.-]{31,34})(?:$|[\\\\'\"\\x60\\s<),])", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["q~"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#azure-ad-client-secret" },
53  { id: "beamer-api-token", label: "BEAMER_API_TOKEN", regex: "[\\w.-]{0,50}?(?:beamer)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(b_[a-z0-9=_\\-]{44})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["beamer"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#beamer-api-token" },
54  { id: "bitbucket-client-id", label: "BITBUCKET_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:bitbucket)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["bitbucket"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#bitbucket-client-id" },
55  { id: "bitbucket-client-secret", label: "BITBUCKET_CLIENT_SECRET", regex: "[\\w.-]{0,50}?(?:bitbucket)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["bitbucket"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#bitbucket-client-secret" },
56  { id: "bittrex-access-key", label: "BITTREX_ACCESS_KEY", regex: "[\\w.-]{0,50}?(?:bittrex)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["bittrex"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#bittrex-access-key" },
57  { id: "bittrex-secret-key", label: "BITTREX_SECRET_KEY", regex: "[\\w.-]{0,50}?(?:bittrex)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["bittrex"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#bittrex-secret-key" },
58  { id: "cisco-meraki-api-key", label: "CISCO_MERAKI_API_KEY", regex: "[\\w.-]{0,50}?(?:[\\w.-]{0,50}?(?:(?:[Mm]eraki|MERAKI))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3})(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9a-f]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["meraki"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#cisco-meraki-api-key" },
59  { id: "clickhouse-cloud-api-secret-key", label: "CLICKHOUSE_CLOUD_API_SECRET_KEY", regex: "\\b(4b1d[A-Za-z0-9]{38})\\b", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["4b1d"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#clickhouse-cloud-api-secret-key" },
60  { id: "clojars-api-token", label: "CLOJARS_API_TOKEN", regex: "CLOJARS_[a-z0-9]{60}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["clojars_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#clojars-api-token" },
61  { id: "cloudflare-api-key", label: "CLOUDFLARE_API_KEY", regex: "[\\w.-]{0,50}?(?:cloudflare)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9_-]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["cloudflare"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#cloudflare-api-key" },
62  { id: "cloudflare-global-api-key", label: "CLOUDFLARE_GLOBAL_API_KEY", regex: "[\\w.-]{0,50}?(?:cloudflare)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{37})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["cloudflare"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#cloudflare-global-api-key" },
63  { id: "cloudflare-origin-ca-key", label: "CLOUDFLARE_ORIGIN_CA_KEY", regex: "\\b(v1\\.0-[a-f0-9]{24}-[a-f0-9]{146})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["cloudflare", "v1.0-"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#cloudflare-origin-ca-key" },
64  { id: "codecov-access-token", label: "CODECOV_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:codecov)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["codecov"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#codecov-access-token" },
65  { id: "cohere-api-token", label: "COHERE_API_TOKEN", regex: "[\\w.-]{0,50}?(?:[\\w.-]{0,50}?(?:cohere|CO_API_KEY)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3})(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-zA-Z0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["cohere", "co_api_key"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#cohere-api-token" },
66  { id: "coinbase-access-token", label: "COINBASE_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:coinbase)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9_-]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["coinbase"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#coinbase-access-token" },
67  { id: "confluent-access-token", label: "CONFLUENT_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:confluent)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{16})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["confluent"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#confluent-access-token" },
68  { id: "confluent-secret-key", label: "CONFLUENT_SECRET_KEY", regex: "[\\w.-]{0,50}?(?:confluent)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["confluent"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#confluent-secret-key" },
69  { id: "contentful-delivery-api-token", label: "CONTENTFUL_DELIVERY_API_TOKEN", regex: "[\\w.-]{0,50}?(?:contentful)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{43})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["contentful"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#contentful-delivery-api-token" },
70  { id: "curl-auth-header", label: "CURL_AUTH_HEADER", regex: "\\bcurl\\b(?:.{0,4096}?|.{0,4096}?(?:[\\r\\n]{1,2}.{0,4096}?){1,5})[ \\t\\n\\r](?:-H|--header)(?:=|[ \\t]{0,5})(?:\"(?:Authorization:[ \\t]{0,5}(?:Basic[ \\t]([a-z0-9+/]{8,4096}={0,3})|(?:Bearer|(?:Api-)?Token)[ \\t]([\\w=~@.+/-]{8,4096})|([\\w=~@.+/-]{8,4096}))|(?:(?:X-(?:[a-z]{1,4096}-)?)?(?:Api-?)?(?:Key|Token)):[ \\t]{0,5}([\\w=~@.+/-]{8,4096}))\"|'(?:Authorization:[ \\t]{0,5}(?:Basic[ \\t]([a-z0-9+/]{8,4096}={0,3})|(?:Bearer|(?:Api-)?Token)[ \\t]([\\w=~@.+/-]{8,4096})|([\\w=~@.+/-]{8,4096}))|(?:(?:X-(?:[a-z]{1,4096}-)?)?(?:Api-?)?(?:Key|Token)):[ \\t]{0,5}([\\w=~@.+/-]{8,4096}))')(?:\\B|\\s|$)", flags: "i", groups: 8, secretGroup: null, entropy: 2.75, keywords: ["curl"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#curl-auth-header" },
71  { id: "curl-auth-user", label: "CURL_AUTH_USER", regex: "\\bcurl\\b(?:.{0,4096}|.{0,4096}(?:[\\r\\n]{1,2}.{0,4096}){1,5})[ \\t\\n\\r](?:-u|--user)(?:=|[ \\t]{0,5})(\"(:[^\"]{3,4096}|[^:\"]{3,4096}:|[^:\"]{3,4096}:[^\"]{3,4096})\"|'([^:']{3,4096}:[^']{3,4096})'|((?:\"[^\"]{3,4096}\"|'[^']{3,4096}'|[\\w$@.-]{1,4096}):(?:\"[^\"]{3,4096}\"|'[^']{3,4096}'|[\\w${}@.-]{1,4096})))(?:\\s|$)", flags: "i", groups: 4, secretGroup: null, entropy: 2.0, keywords: ["curl"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#curl-auth-user" },
72  { id: "databricks-api-token", label: "DATABRICKS_API_TOKEN", regex: "\\b(dapi[a-f0-9]{32}(?:-\\d)?)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["dapi"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#databricks-api-token" },
73  { id: "datadog-access-token", label: "DATADOG_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:datadog)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["datadog"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#datadog-access-token" },
74  { id: "defined-networking-api-token", label: "DEFINED_NETWORKING_API_TOKEN", regex: "[\\w.-]{0,50}?(?:dnkey)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(dnkey-[a-z0-9=_\\-]{26}-[a-z0-9=_\\-]{52})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["dnkey"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#defined-networking-api-token" },
75  { id: "digitalocean-access-token", label: "DIGITALOCEAN_ACCESS_TOKEN", regex: "\\b(doo_v1_[a-f0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["doo_v1_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#digitalocean-access-token" },
76  { id: "digitalocean-pat", label: "DIGITALOCEAN_PAT", regex: "\\b(dop_v1_[a-f0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["dop_v1_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#digitalocean-pat" },
77  { id: "digitalocean-refresh-token", label: "DIGITALOCEAN_REFRESH_TOKEN", regex: "\\b(dor_v1_[a-f0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["dor_v1_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#digitalocean-refresh-token" },
78  { id: "discord-api-token", label: "DISCORD_API_TOKEN", regex: "[\\w.-]{0,50}?(?:discord)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["discord"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#discord-api-token" },
79  { id: "discord-client-id", label: "DISCORD_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:discord)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9]{18})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["discord"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#discord-client-id" },
80  { id: "discord-client-secret", label: "DISCORD_CLIENT_SECRET", regex: "[\\w.-]{0,50}?(?:discord)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["discord"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#discord-client-secret" },
81  { id: "doppler-api-token", label: "DOPPLER_API_TOKEN", regex: "dp\\.pt\\.[a-z0-9]{43}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["dp.pt."], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#doppler-api-token" },
82  { id: "droneci-access-token", label: "DRONECI_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:droneci)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["droneci"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#droneci-access-token" },
83  { id: "dropbox-api-token", label: "DROPBOX_API_TOKEN", regex: "[\\w.-]{0,50}?(?:dropbox)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{15})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["dropbox"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#dropbox-api-token" },
84  { id: "dropbox-long-lived-api-token", label: "DROPBOX_LONG_LIVED_API_TOKEN", regex: "[\\w.-]{0,50}?(?:dropbox)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{11}(AAAAAAAAAA)[a-z0-9\\-_=]{43})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: null, entropy: null, keywords: ["dropbox"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#dropbox-long-lived-api-token" },
85  { id: "dropbox-short-lived-api-token", label: "DROPBOX_SHORT_LIVED_API_TOKEN", regex: "[\\w.-]{0,50}?(?:dropbox)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(sl\\.[a-z0-9\\-=_]{135})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["dropbox"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#dropbox-short-lived-api-token" },
86  { id: "duffel-api-token", label: "DUFFEL_API_TOKEN", regex: "duffel_(?:test|live)_[a-z0-9_\\-=]{43}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["duffel_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#duffel-api-token" },
87  { id: "dynatrace-api-token", label: "DYNATRACE_API_TOKEN", regex: "dt0c01\\.[a-z0-9]{24}\\.[a-z0-9]{64}", flags: "i", groups: 0, secretGroup: null, entropy: 4.0, keywords: ["dt0c01."], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#dynatrace-api-token" },
88  { id: "easypost-api-token", label: "EASYPOST_API_TOKEN", regex: "\\bEZAK[a-z0-9]{54}\\b", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["ezak"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#easypost-api-token" },
89  { id: "easypost-test-api-token", label: "EASYPOST_TEST_API_TOKEN", regex: "\\bEZTK[a-z0-9]{54}\\b", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["eztk"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#easypost-test-api-token" },
90  { id: "etsy-access-token", label: "ETSY_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:(?:ETSY|[Ee]tsy))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{24})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["etsy"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#etsy-access-token" },
91  { id: "facebook-access-token", label: "FACEBOOK_ACCESS_TOKEN", regex: "\\b(\\d{15,16}(\\||%)[0-9a-z\\-_]{27,40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: null, entropy: 3.0, keywords: ["facebook"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#facebook-access-token" },
92  { id: "facebook-page-access-token", label: "FACEBOOK_PAGE_ACCESS_TOKEN", regex: "\\b(EAA[MC][a-z0-9]{100,4096})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["eaam", "eaac"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#facebook-page-access-token" },
93  { id: "facebook-secret", label: "FACEBOOK_SECRET", regex: "[\\w.-]{0,50}?(?:facebook)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["facebook"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#facebook-secret" },
94  { id: "fastly-api-token", label: "FASTLY_API_TOKEN", regex: "[\\w.-]{0,50}?(?:fastly)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["fastly"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#fastly-api-token" },
95  { id: "finicity-api-token", label: "FINICITY_API_TOKEN", regex: "[\\w.-]{0,50}?(?:finicity)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["finicity"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#finicity-api-token" },
96  { id: "finicity-client-secret", label: "FINICITY_CLIENT_SECRET", regex: "[\\w.-]{0,50}?(?:finicity)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{20})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["finicity"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#finicity-client-secret" },
97  { id: "finnhub-access-token", label: "FINNHUB_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:finnhub)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{20})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["finnhub"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#finnhub-access-token" },
98  { id: "flickr-access-token", label: "FLICKR_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:flickr)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["flickr"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#flickr-access-token" },
99  { id: "flutterwave-encryption-key", label: "FLUTTERWAVE_ENCRYPTION_KEY", regex: "FLWSECK_TEST-[a-h0-9]{12}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["flwseck_test"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#flutterwave-encryption-key" },
100  { id: "flutterwave-public-key", label: "FLUTTERWAVE_PUBLIC_KEY", regex: "FLWPUBK_TEST-[a-h0-9]{32}-X", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["flwpubk_test"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#flutterwave-public-key" },
101  { id: "flutterwave-secret-key", label: "FLUTTERWAVE_SECRET_KEY", regex: "FLWSECK_TEST-[a-h0-9]{32}-X", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["flwseck_test"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#flutterwave-secret-key" },
102  { id: "flyio-access-token", label: "FLYIO_ACCESS_TOKEN", regex: "\\b((?:fo1_[\\w-]{43}|fm1[ar]_[a-zA-Z0-9+\\/]{100,4096}={0,3}|fm2_[a-zA-Z0-9+\\/]{100,4096}={0,3}))(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["fo1_", "fm1", "fm2_"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#flyio-access-token" },
103  { id: "frameio-api-token", label: "FRAMEIO_API_TOKEN", regex: "fio-u-[a-z0-9\\-_=]{64}", flags: "i", groups: 0, secretGroup: null, entropy: null, keywords: ["fio-u-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#frameio-api-token" },
104  { id: "freemius-secret-key", label: "FREEMIUS_SECRET_KEY", regex: "[\"']secret_key[\"']\\s{0,4096}=>\\s{0,4096}[\"'](sk_[\\S]{29})[\"']", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["secret_key"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#freemius-secret-key" },
105  { id: "freshbooks-access-token", label: "FRESHBOOKS_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:freshbooks)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["freshbooks"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#freshbooks-access-token" },
106  { id: "gcp-api-key", label: "GCP_API_KEY", regex: "\\b(AIza[\\w-]{35})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["aiza"], allow: ["REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER", "REDACTED-GOOGLE-API-KEY-BY-SLOPSHOPPER"], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gcp-api-key" },
107  { id: "generic-api-key", label: "GENERIC_API_KEY", regex: "[\\w.-]{0,50}?(?:access|auth|(?:[Aa]pi|API)|credential|creds|key|passw(?:or)?d|secret|token)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([\\w.=-]{10,150}|[a-z0-9][a-z0-9+/]{11,4096}={0,3})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.5, keywords: ["access", "api", "auth", "key", "credential", "creds", "passwd", "password", "secret", "token"], allow: ["--mount=type=secret,"], kind: "generic", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#generic-api-key" },
108  { id: "github-app-token", label: "GITHUB_APP_TOKEN", regex: "(?:ghu|ghs)_[0-9a-zA-Z]{36}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["ghu_", "ghs_"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#github-app-token" },
109  { id: "github-fine-grained-pat", label: "GITHUB_FINE_GRAINED_PAT", regex: "github_pat_\\w{82}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["github_pat_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#github-fine-grained-pat" },
110  { id: "github-oauth", label: "GITHUB_OAUTH", regex: "gho_[0-9a-zA-Z]{36}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["gho_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#github-oauth" },
111  { id: "github-pat", label: "GITHUB_PAT", regex: "ghp_[0-9a-zA-Z]{36}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["ghp_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#github-pat" },
112  { id: "github-refresh-token", label: "GITHUB_REFRESH_TOKEN", regex: "ghr_[0-9a-zA-Z]{36}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["ghr_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#github-refresh-token" },
113  { id: "gitlab-cicd-job-token", label: "GITLAB_CICD_JOB_TOKEN", regex: "glcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glcbt-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-cicd-job-token" },
114  { id: "gitlab-deploy-token", label: "GITLAB_DEPLOY_TOKEN", regex: "gldt-[0-9a-zA-Z_\\-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["gldt-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-deploy-token" },
115  { id: "gitlab-feature-flag-client-token", label: "GITLAB_FEATURE_FLAG_CLIENT_TOKEN", regex: "glffct-[0-9a-zA-Z_\\-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glffct-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-feature-flag-client-token" },
116  { id: "gitlab-feed-token", label: "GITLAB_FEED_TOKEN", regex: "glft-[0-9a-zA-Z_\\-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glft-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-feed-token" },
117  { id: "gitlab-incoming-mail-token", label: "GITLAB_INCOMING_MAIL_TOKEN", regex: "glimt-[0-9a-zA-Z_\\-]{25}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glimt-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-incoming-mail-token" },
118  { id: "gitlab-kubernetes-agent-token", label: "GITLAB_KUBERNETES_AGENT_TOKEN", regex: "glagent-[0-9a-zA-Z_\\-]{50}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glagent-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-kubernetes-agent-token" },
119  { id: "gitlab-oauth-app-secret", label: "GITLAB_OAUTH_APP_SECRET", regex: "gloas-[0-9a-zA-Z_\\-]{64}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["gloas-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-oauth-app-secret" },
120  { id: "gitlab-pat", label: "GITLAB_PAT", regex: "glpat-[\\w-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glpat-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-pat" },
121  { id: "gitlab-pat-routable", label: "GITLAB_PAT_ROUTABLE", regex: "\\bglpat-[0-9a-zA-Z_-]{27,300}\\.[0-9a-z]{2}[0-9a-z]{7}\\b", flags: "i", groups: 0, secretGroup: null, entropy: 4.0, keywords: ["glpat-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-pat-routable" },
122  { id: "gitlab-ptt", label: "GITLAB_PTT", regex: "glptt-[0-9a-f]{40}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glptt-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-ptt" },
123  { id: "gitlab-rrt", label: "GITLAB_RRT", regex: "GR1348941[\\w-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["gr1348941"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-rrt" },
124  { id: "gitlab-runner-authentication-token", label: "GITLAB_RUNNER_AUTHENTICATION_TOKEN", regex: "glrt-[0-9a-zA-Z_\\-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glrt-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-runner-authentication-token" },
125  { id: "gitlab-runner-authentication-token-routable", label: "GITLAB_RUNNER_AUTHENTICATION_TOKEN_ROUTABLE", regex: "\\bglrt-t\\d_[0-9a-zA-Z_\\-]{27,300}\\.[0-9a-z]{2}[0-9a-z]{7}\\b", flags: "i", groups: 0, secretGroup: null, entropy: 4.0, keywords: ["glrt-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-runner-authentication-token-routable" },
126  { id: "gitlab-scim-token", label: "GITLAB_SCIM_TOKEN", regex: "glsoat-[0-9a-zA-Z_\\-]{20}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["glsoat-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-scim-token" },
127  { id: "gitlab-session-cookie", label: "GITLAB_SESSION_COOKIE", regex: "_gitlab_session=[0-9a-z]{32}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["_gitlab_session="], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitlab-session-cookie" },
128  { id: "gitter-access-token", label: "GITTER_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:gitter)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9_-]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["gitter"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gitter-access-token" },
129  { id: "gocardless-api-token", label: "GOCARDLESS_API_TOKEN", regex: "[\\w.-]{0,50}?(?:gocardless)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(live_[a-z0-9\\-_=]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["live_", "gocardless"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#gocardless-api-token" },
130  { id: "grafana-api-key", label: "GRAFANA_API_KEY", regex: "\\b(eyJrIjoi[A-Za-z0-9]{70,400}={0,3})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["eyjrijoi"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#grafana-api-key" },
131  { id: "grafana-cloud-api-token", label: "GRAFANA_CLOUD_API_TOKEN", regex: "\\b(glc_[A-Za-z0-9+/]{32,400}={0,3})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["glc_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#grafana-cloud-api-token" },
132  { id: "grafana-service-account-token", label: "GRAFANA_SERVICE_ACCOUNT_TOKEN", regex: "\\b(glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["glsa_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#grafana-service-account-token" },
133  { id: "harness-api-key", label: "HARNESS_API_KEY", regex: "(?:pat|sat)\\.[a-zA-Z0-9_-]{22}\\.[a-zA-Z0-9]{24}\\.[a-zA-Z0-9]{20}", flags: "i", groups: 0, secretGroup: null, entropy: null, keywords: ["pat.", "sat."], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#harness-api-key" },
134  { id: "hashicorp-tf-api-token", label: "HASHICORP_TF_API_TOKEN", regex: "[a-z0-9]{14}\\.(?:atlasv1)\\.[a-z0-9\\-_=]{60,70}", flags: "i", groups: 0, secretGroup: null, entropy: 3.5, keywords: ["atlasv1"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#hashicorp-tf-api-token" },
135  { id: "hashicorp-tf-password", label: "HASHICORP_TF_PASSWORD", regex: "[\\w.-]{0,50}?(?:administrator_login_password|password)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(\"[a-z0-9=_\\-]{8,20}\")(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["administrator_login_password", "password"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#hashicorp-tf-password" },
136  { id: "heroku-api-key", label: "HEROKU_API_KEY", regex: "[\\w.-]{0,50}?(?:heroku)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["heroku"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#heroku-api-key" },
137  { id: "heroku-api-key-v2", label: "HEROKU_API_KEY_V2", regex: "\\b((HRKU-AA[0-9a-zA-Z_-]{58}))(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: null, entropy: 4.0, keywords: ["hrku-aa"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#heroku-api-key-v2" },
138  { id: "hubspot-api-key", label: "HUBSPOT_API_KEY", regex: "[\\w.-]{0,50}?(?:hubspot)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["hubspot"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#hubspot-api-key" },
139  { id: "huggingface-access-token", label: "HUGGINGFACE_ACCESS_TOKEN", regex: "\\b(hf_(?:[a-z]{34}))(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["hf_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#huggingface-access-token" },
140  { id: "huggingface-organization-api-token", label: "HUGGINGFACE_ORGANIZATION_API_TOKEN", regex: "\\b(api_org_(?:[a-z]{34}))(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["api_org_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#huggingface-organization-api-token" },
141  { id: "infracost-api-token", label: "INFRACOST_API_TOKEN", regex: "\\b(ico-[a-zA-Z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["ico-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#infracost-api-token" },
142  { id: "intercom-api-key", label: "INTERCOM_API_KEY", regex: "[\\w.-]{0,50}?(?:intercom)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{60})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["intercom"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#intercom-api-key" },
143  { id: "intra42-client-secret", label: "INTRA42_CLIENT_SECRET", regex: "\\b(s-s4t2(?:ud|af)-[abcdef0123456789]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["intra", "s-s4t2ud-", "s-s4t2af-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#intra42-client-secret" },
144  { id: "jfrog-api-key", label: "JFROG_API_KEY", regex: "[\\w.-]{0,50}?(?:jfrog|artifactory|bintray|xray)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{73})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["jfrog", "artifactory", "bintray", "xray"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#jfrog-api-key" },
145  { id: "jfrog-identity-token", label: "JFROG_IDENTITY_TOKEN", regex: "[\\w.-]{0,50}?(?:jfrog|artifactory|bintray|xray)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["jfrog", "artifactory", "bintray", "xray"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#jfrog-identity-token" },
146  { id: "jwt", label: "JWT", regex: "\\b(ey[a-zA-Z0-9]{17,4096}\\.ey[a-zA-Z0-9\\/\\\\_-]{17,4096}\\.(?:[a-zA-Z0-9\\/\\\\_-]{10,4096}={0,2})?)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["ey"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#jwt" },
147  { id: "jwt-base64", label: "JWT_BASE64", regex: "\\bZXlK(?:(?<alg>aGJHY2lPaU)|(?<apu>aGNIVWlPaU)|(?<apv>aGNIWWlPaU)|(?<aud>aGRXUWlPaU)|(?<b64>aU5qUWlP)|(?<crit>amNtbDBJanBi)|(?<cty>amRIa2lPaU)|(?<epk>bGNHc2lPbn)|(?<enc>bGJtTWlPaU)|(?<jku>cWEzVWlPaU)|(?<jwk>cWQyc2lPb)|(?<iss>cGMzTWlPaU)|(?<iv>cGRpSTZJ)|(?<kid>cmFXUWlP)|(?<key_ops>clpYbGZiM0J6SWpwY)|(?<kty>cmRIa2lPaUp)|(?<nonce>dWIyNWpaU0k2)|(?<p2c>d01tTWlP)|(?<p2s>d01uTWlPaU)|(?<ppt>d2NIUWlPaU)|(?<sub>emRXSWlPaU)|(?<svt>emRuUWlP)|(?<tag>MFlXY2lPaU)|(?<typ>MGVYQWlPaUp)|(?<url>MWNtd2l)|(?<use>MWMyVWlPaUp)|(?<ver>MlpYSWlPaU)|(?<version>MlpYSnphVzl1SWpv)|(?<x>NElqb2)|(?<x5c>NE5XTWlP)|(?<x5t>NE5YUWlPaU)|(?<x5ts256>NE5YUWpVekkxTmlJNkl)|(?<x5u>NE5YVWlPaU)|(?<zip>NmFYQWlPaU))[a-zA-Z0-9\\/\\\\_+\\-\\r\\n]{40,4096}={0,2}", flags: "i", groups: 34, secretGroup: null, entropy: 2.0, keywords: ["zxlk"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#jwt-base64" },
148  { id: "kraken-access-token", label: "KRAKEN_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:kraken)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9\\/=_\\+\\-]{80,90})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["kraken"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#kraken-access-token" },
149  { id: "kubernetes-secret-yaml", label: "KUBERNETES_SECRET_YAML", regex: "(?:\\bkind:[ \\t]{0,4096}[\"']?\\bsecret\\b[\"']?[\\s\\S]{0,200}?\\bdata:[\\s\\S]{0,100}?\\s{1,4096}([\\w.-]{1,4096}:(?:[ \\t]{0,4096}(?:\\||>[-+]?)\\s{1,4096})?[ \\t]{0,4096}(?:[\"']?[a-z0-9+/]{10,4096}={0,3}[\"']?|\\{\\{[ \\t\\w\"|$:=,.-]{1,4096}}}|\"\"|''))|\\bdata:[\\s\\S]{0,100}?\\s{1,4096}([\\w.-]{1,4096}:(?:[ \\t]{0,4096}(?:\\||>[-+]?)\\s{1,4096})?[ \\t]{0,4096}(?:[\"']?[a-z0-9+/]{10,4096}={0,3}[\"']?|\\{\\{[ \\t\\w\"|$:=,.-]{1,4096}}}|\"\"|''))[\\s\\S]{0,200}?\\bkind:[ \\t]{0,4096}[\"']?\\bsecret\\b[\"']?)", flags: "i", groups: 2, secretGroup: null, entropy: null, keywords: ["secret"], allow: ["(kind:[\\s\\S]+\\n---\\n[\\s\\S]+\\bdata:|data:[\\s\\S]+\\n---\\n[\\s\\S]+\\bkind:)"], kind: "generic", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#kubernetes-secret-yaml" },
150  { id: "kucoin-access-token", label: "KUCOIN_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:kucoin)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{24})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["kucoin"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#kucoin-access-token" },
151  { id: "kucoin-secret-key", label: "KUCOIN_SECRET_KEY", regex: "[\\w.-]{0,50}?(?:kucoin)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["kucoin"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#kucoin-secret-key" },
152  { id: "launchdarkly-access-token", label: "LAUNCHDARKLY_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:launchdarkly)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["launchdarkly"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#launchdarkly-access-token" },
153  { id: "linear-api-key", label: "LINEAR_API_KEY", regex: "lin_api_[a-z0-9]{40}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["lin_api_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#linear-api-key" },
154  { id: "linear-client-secret", label: "LINEAR_CLIENT_SECRET", regex: "[\\w.-]{0,50}?(?:linear)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["linear"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#linear-client-secret" },
155  { id: "linkedin-client-id", label: "LINKEDIN_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:linked[_-]?in)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{14})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["linkedin", "linked_in", "linked-in"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#linkedin-client-id" },
156  { id: "linkedin-client-secret", label: "LINKEDIN_CLIENT_SECRET", regex: "[\\w.-]{0,50}?(?:linked[_-]?in)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{16})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["linkedin", "linked_in", "linked-in"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#linkedin-client-secret" },
157  { id: "lob-api-key", label: "LOB_API_KEY", regex: "[\\w.-]{0,50}?(?:lob)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}((live|test)_[a-f0-9]{35})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: null, entropy: null, keywords: ["test_", "live_"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#lob-api-key" },
158  { id: "lob-pub-api-key", label: "LOB_PUB_API_KEY", regex: "[\\w.-]{0,50}?(?:lob)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}((test|live)_pub_[a-f0-9]{31})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: null, entropy: null, keywords: ["test_pub", "live_pub", "_pub"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#lob-pub-api-key" },
159  { id: "looker-client-id", label: "LOOKER_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:looker)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{20})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["looker"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#looker-client-id" },
160  { id: "looker-client-secret", label: "LOOKER_CLIENT_SECRET", regex: "[\\w.-]{0,50}?(?:looker)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{24})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["looker"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#looker-client-secret" },
161  { id: "mailchimp-api-key", label: "MAILCHIMP_API_KEY", regex: "[\\w.-]{0,50}?(?:MailchimpSDK.initialize|mailchimp)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{32}-us\\d\\d)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["mailchimp"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#mailchimp-api-key" },
162  { id: "mailgun-private-api-token", label: "MAILGUN_PRIVATE_API_TOKEN", regex: "[\\w.-]{0,50}?(?:mailgun)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(key-[a-f0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["mailgun"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#mailgun-private-api-token" },
163  { id: "mailgun-pub-key", label: "MAILGUN_PUB_KEY", regex: "[\\w.-]{0,50}?(?:mailgun)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(pubkey-[a-f0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["mailgun"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#mailgun-pub-key" },
164  { id: "mailgun-signing-key", label: "MAILGUN_SIGNING_KEY", regex: "[\\w.-]{0,50}?(?:mailgun)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-h0-9]{32}-[a-h0-9]{8}-[a-h0-9]{8})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["mailgun"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#mailgun-signing-key" },
165  { id: "mapbox-api-token", label: "MAPBOX_API_TOKEN", regex: "[\\w.-]{0,50}?(?:mapbox)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(pk\\.[a-z0-9]{60}\\.[a-z0-9]{22})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["mapbox"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#mapbox-api-token" },
166  { id: "mattermost-access-token", label: "MATTERMOST_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:mattermost)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{26})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["mattermost"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#mattermost-access-token" },
167  { id: "maxmind-license-key", label: "MAXMIND_LICENSE_KEY", regex: "\\b([A-Za-z0-9]{6}_[A-Za-z0-9]{29}_mmk)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["_mmk"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#maxmind-license-key" },
168  { id: "messagebird-api-token", label: "MESSAGEBIRD_API_TOKEN", regex: "[\\w.-]{0,50}?(?:message[_-]?bird)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{25})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["messagebird", "message-bird", "message_bird"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#messagebird-api-token" },
169  { id: "messagebird-client-id", label: "MESSAGEBIRD_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:message[_-]?bird)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["messagebird", "message-bird", "message_bird"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#messagebird-client-id" },
170  { id: "microsoft-teams-webhook", label: "MICROSOFT_TEAMS_WEBHOOK", regex: "https://[a-z0-9]{1,4096}\\.webhook\\.office\\.com/webhookb2/[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}@[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}/IncomingWebhook/[a-z0-9]{32}/[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}", flags: "i", groups: 3, secretGroup: null, entropy: null, keywords: ["webhook.office.com", "webhookb2", "incomingwebhook"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#microsoft-teams-webhook" },
171  { id: "netlify-access-token", label: "NETLIFY_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:netlify)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{40,46})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["netlify"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#netlify-access-token" },
172  { id: "new-relic-browser-api-token", label: "NEW_RELIC_BROWSER_API_TOKEN", regex: "[\\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(NRJS-[a-f0-9]{19})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["nrjs-"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#new-relic-browser-api-token" },
173  { id: "new-relic-insert-key", label: "NEW_RELIC_INSERT_KEY", regex: "[\\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(NRII-[a-z0-9-]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["nrii-"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#new-relic-insert-key" },
174  { id: "new-relic-user-api-id", label: "NEW_RELIC_USER_API_ID", regex: "[\\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["new-relic", "newrelic", "new_relic"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#new-relic-user-api-id" },
175  { id: "new-relic-user-api-key", label: "NEW_RELIC_USER_API_KEY", regex: "[\\w.-]{0,50}?(?:new-relic|newrelic|new_relic)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(NRAK-[a-z0-9]{27})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["nrak"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#new-relic-user-api-key" },
176  { id: "notion-api-token", label: "NOTION_API_TOKEN", regex: "\\b(ntn_[0-9]{11}[A-Za-z0-9]{32}[A-Za-z0-9]{3})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["ntn_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#notion-api-token" },
177  { id: "npm-access-token", label: "NPM_ACCESS_TOKEN", regex: "\\b(npm_[a-z0-9]{36})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["npm_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#npm-access-token" },
178  { id: "nuget-config-password", label: "NUGET_CONFIG_PASSWORD", regex: "<add key=\\\"(?:(?:ClearText)?Password)\\\"\\s{0,4096}value=\\\"(.{8,4096})\\\"\\s{0,4096}/>", flags: "i", groups: 1, secretGroup: null, entropy: 1.0, keywords: ["<add key="], allow: ["33f!!lloppa", "hal\\+9ooo_da!sY", "^\\%\\S.*\\%$"], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#nuget-config-password" },
179  { id: "nytimes-access-token", label: "NYTIMES_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:nytimes|new-york-times,|newyorktimes)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9=_\\-]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["nytimes", "new-york-times", "newyorktimes"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#nytimes-access-token" },
180  { id: "octopus-deploy-api-key", label: "OCTOPUS_DEPLOY_API_KEY", regex: "\\b(API-[A-Z0-9]{26})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["api-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#octopus-deploy-api-key" },
181  { id: "okta-access-token", label: "OKTA_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:[\\w.-]{0,50}?(?:(?:[Oo]kta|OKTA))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3})(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(00[\\w=\\-]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["okta"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#okta-access-token" },
182  { id: "openai-api-key", label: "OPENAI_API_KEY", regex: "\\b(sk-(?:proj|svcacct|admin)-(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})T3BlbkFJ(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})\\b|sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["t3blbkfj"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#openai-api-key" },
183  { id: "openshift-user-token", label: "OPENSHIFT_USER_TOKEN", regex: "\\b(sha256~[\\w-]{43})(?:[^\\w-]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.5, keywords: ["sha256~"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#openshift-user-token" },
184  { id: "perplexity-api-key", label: "PERPLEXITY_API_KEY", regex: "\\b(pplx-[a-zA-Z0-9]{48})(?:[\\x60'\"\\s;]|\\\\[nr]|$|\\b)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["pplx-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#perplexity-api-key" },
185  { id: "plaid-api-token", label: "PLAID_API_TOKEN", regex: "[\\w.-]{0,50}?(?:plaid)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(access-(?:sandbox|development|production)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["plaid"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#plaid-api-token" },
186  { id: "plaid-client-id", label: "PLAID_CLIENT_ID", regex: "[\\w.-]{0,50}?(?:plaid)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{24})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.5, keywords: ["plaid"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#plaid-client-id" },
187  { id: "plaid-secret-key", label: "PLAID_SECRET_KEY", regex: "[\\w.-]{0,50}?(?:plaid)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{30})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.5, keywords: ["plaid"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#plaid-secret-key" },
188  { id: "planetscale-api-token", label: "PLANETSCALE_API_TOKEN", regex: "\\b(pscale_tkn_[\\w=\\.-]{32,64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["pscale_tkn_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#planetscale-api-token" },
189  { id: "planetscale-oauth-token", label: "PLANETSCALE_OAUTH_TOKEN", regex: "\\b(pscale_oauth_[\\w=\\.-]{32,64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["pscale_oauth_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#planetscale-oauth-token" },
190  { id: "planetscale-password", label: "PLANETSCALE_PASSWORD", regex: "\\b(pscale_pw_[\\w=\\.-]{32,64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["pscale_pw_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#planetscale-password" },
191  { id: "postman-api-token", label: "POSTMAN_API_TOKEN", regex: "\\b(PMAK-[a-f0-9]{24}\\-[a-f0-9]{34})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["pmak-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#postman-api-token" },
192  { id: "prefect-api-token", label: "PREFECT_API_TOKEN", regex: "\\b(pnu_[a-zA-Z0-9]{36})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["pnu_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#prefect-api-token" },
193  { id: "privateai-api-token", label: "PRIVATEAI_API_TOKEN", regex: "[\\w.-]{0,50}?(?:[\\w.-]{0,50}?(?:private[_-]?ai)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3})(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{32})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["privateai", "private_ai", "private-ai"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#privateai-api-token" },
194  { id: "pulumi-api-token", label: "PULUMI_API_TOKEN", regex: "\\b(pul-[a-f0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["pul-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#pulumi-api-token" },
195  { id: "pypi-upload-token", label: "PYPI_UPLOAD_TOKEN", regex: "pypi-AgEIcHlwaS5vcmc[\\w-]{50,1000}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["pypi-ageichlwas5vcmc"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#pypi-upload-token" },
196  { id: "rapidapi-access-token", label: "RAPIDAPI_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:rapidapi)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9_-]{50})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["rapidapi"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#rapidapi-access-token" },
197  { id: "readme-api-token", label: "README_API_TOKEN", regex: "\\b(rdme_[a-z0-9]{70})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["rdme_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#readme-api-token" },
198  { id: "rubygems-api-token", label: "RUBYGEMS_API_TOKEN", regex: "\\b(rubygems_[a-f0-9]{48})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["rubygems_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#rubygems-api-token" },
199  { id: "scalingo-api-token", label: "SCALINGO_API_TOKEN", regex: "\\b(tk-us-[\\w-]{48})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["tk-us-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#scalingo-api-token" },
200  { id: "sendbird-access-id", label: "SENDBIRD_ACCESS_ID", regex: "[\\w.-]{0,50}?(?:sendbird)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["sendbird"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sendbird-access-id" },
201  { id: "sendbird-access-token", label: "SENDBIRD_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:sendbird)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["sendbird"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sendbird-access-token" },
202  { id: "sendgrid-api-token", label: "SENDGRID_API_TOKEN", regex: "\\b(SG\\.[a-z0-9=_\\-\\.]{66})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["sg."], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sendgrid-api-token" },
203  { id: "sendinblue-api-token", label: "SENDINBLUE_API_TOKEN", regex: "\\b(xkeysib-[a-f0-9]{64}\\-[a-z0-9]{16})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["xkeysib-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sendinblue-api-token" },
204  { id: "sentry-access-token", label: "SENTRY_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:sentry)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["sentry"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sentry-access-token" },
205  { id: "sentry-org-token", label: "SENTRY_ORG_TOKEN", regex: "\\bsntrys_eyJpYXQiO[a-zA-Z0-9+/]{10,200}(?:LCJyZWdpb25fdXJs|InJlZ2lvbl91cmwi|cmVnaW9uX3VybCI6)[a-zA-Z0-9+/]{10,200}={0,2}_[a-zA-Z0-9+/]{43}(?:[^a-zA-Z0-9+/]|$)", flags: "i", groups: 0, secretGroup: null, entropy: 4.5, keywords: ["sntrys_eyjpyxqio"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sentry-org-token" },
206  { id: "sentry-user-token", label: "SENTRY_USER_TOKEN", regex: "\\b(sntryu_[a-f0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.5, keywords: ["sntryu_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sentry-user-token" },
207  { id: "settlemint-application-access-token", label: "SETTLEMINT_APPLICATION_ACCESS_TOKEN", regex: "\\b(sm_aat_[a-zA-Z0-9]{16})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["sm_aat"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#settlemint-application-access-token" },
208  { id: "settlemint-personal-access-token", label: "SETTLEMINT_PERSONAL_ACCESS_TOKEN", regex: "\\b(sm_pat_[a-zA-Z0-9]{16})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["sm_pat"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#settlemint-personal-access-token" },
209  { id: "settlemint-service-access-token", label: "SETTLEMINT_SERVICE_ACCESS_TOKEN", regex: "\\b(sm_sat_[a-zA-Z0-9]{16})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["sm_sat"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#settlemint-service-access-token" },
210  { id: "shippo-api-token", label: "SHIPPO_API_TOKEN", regex: "\\b(shippo_(?:live|test)_[a-fA-F0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["shippo_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#shippo-api-token" },
211  { id: "shopify-access-token", label: "SHOPIFY_ACCESS_TOKEN", regex: "shpat_[a-fA-F0-9]{32}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["shpat_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#shopify-access-token" },
212  { id: "shopify-custom-access-token", label: "SHOPIFY_CUSTOM_ACCESS_TOKEN", regex: "shpca_[a-fA-F0-9]{32}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["shpca_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#shopify-custom-access-token" },
213  { id: "shopify-private-app-access-token", label: "SHOPIFY_PRIVATE_APP_ACCESS_TOKEN", regex: "shppa_[a-fA-F0-9]{32}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["shppa_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#shopify-private-app-access-token" },
214  { id: "shopify-shared-secret", label: "SHOPIFY_SHARED_SECRET", regex: "shpss_[a-fA-F0-9]{32}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["shpss_"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#shopify-shared-secret" },
215  { id: "sidekiq-secret", label: "SIDEKIQ_SECRET", regex: "[\\w.-]{0,50}?(?:BUNDLE_ENTERPRISE__CONTRIBSYS__COM|BUNDLE_GEMS__CONTRIBSYS__COM)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-f0-9]{8}:[a-f0-9]{8})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["bundle_enterprise__contribsys__com", "bundle_gems__contribsys__com"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sidekiq-secret" },
216  { id: "sidekiq-sensitive-url", label: "SIDEKIQ_SENSITIVE_URL", regex: "\\bhttps?://([a-f0-9]{8}:[a-f0-9]{8})@(?:gems.contribsys.com|enterprise.contribsys.com)(?:[\\/|\\#|\\?|:]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["gems.contribsys.com", "enterprise.contribsys.com"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sidekiq-sensitive-url" },
217  { id: "slack-app-token", label: "SLACK_APP_TOKEN", regex: "xapp-\\d-[A-Z0-9]{1,4096}-\\d{1,4096}-[a-z0-9]{1,4096}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["xapp"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-app-token" },
218  { id: "slack-bot-token", label: "SLACK_BOT_TOKEN", regex: "xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]{0,4096}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["xoxb"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-bot-token" },
219  { id: "slack-config-access-token", label: "SLACK_CONFIG_ACCESS_TOKEN", regex: "xoxe.xox[bp]-\\d-[A-Z0-9]{163,166}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["xoxe.xoxb-", "xoxe.xoxp-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-config-access-token" },
220  { id: "slack-config-refresh-token", label: "SLACK_CONFIG_REFRESH_TOKEN", regex: "xoxe-\\d-[A-Z0-9]{146}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["xoxe-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-config-refresh-token" },
221  { id: "slack-legacy-bot-token", label: "SLACK_LEGACY_BOT_TOKEN", regex: "xoxb-[0-9]{8,14}-[a-zA-Z0-9]{18,26}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["xoxb"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-legacy-bot-token" },
222  { id: "slack-legacy-token", label: "SLACK_LEGACY_TOKEN", regex: "xox[os]-\\d{1,4096}-\\d{1,4096}-\\d{1,4096}-[a-fA-F\\d]{1,4096}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["xoxo", "xoxs"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-legacy-token" },
223  { id: "slack-legacy-workspace-token", label: "SLACK_LEGACY_WORKSPACE_TOKEN", regex: "xox[ar]-(?:\\d-)?[0-9a-zA-Z]{8,48}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["xoxa", "xoxr"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-legacy-workspace-token" },
224  { id: "slack-user-token", label: "SLACK_USER_TOKEN", regex: "xox[pe](?:-[0-9]{10,13}){3}-[a-zA-Z0-9-]{28,34}", flags: "i", groups: 0, secretGroup: null, entropy: 2.0, keywords: ["xoxp-", "xoxe-"], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-user-token" },
225  { id: "slack-webhook-url", label: "SLACK_WEBHOOK_URL", regex: "(?:https?://)?hooks.slack.com/(?:services|workflows|triggers)/[A-Za-z0-9+/]{43,56}", flags: "i", groups: 0, secretGroup: null, entropy: null, keywords: ["hooks.slack.com"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#slack-webhook-url" },
226  { id: "snyk-api-token", label: "SNYK_API_TOKEN", regex: "[\\w.-]{0,50}?(?:snyk[_.-]?(?:(?:api|oauth)[_.-]?)?(?:key|token))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["snyk"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#snyk-api-token" },
227  { id: "sonar-api-token", label: "SONAR_API_TOKEN", regex: "[\\w.-]{0,50}?(?:sonar[_.-]?(login|token))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}((?:squ_|sqp_|sqa_)?[a-z0-9=_\\-]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: 2, entropy: null, keywords: ["sonar"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sonar-api-token" },
228  { id: "sourcegraph-access-token", label: "SOURCEGRAPH_ACCESS_TOKEN", regex: "\\b(\\b(sgp_(?:[a-fA-F0-9]{16}|local)_[a-fA-F0-9]{40}|sgp_[a-fA-F0-9]{40}|[a-fA-F0-9]{40})\\b)(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 2, secretGroup: null, entropy: 3.0, keywords: ["sgp_", "sourcegraph"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sourcegraph-access-token" },
229  { id: "square-access-token", label: "SQUARE_ACCESS_TOKEN", regex: "\\b((?:EAAA|sq0atp-)[\\w-]{22,60})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["sq0atp-", "eaaa"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#square-access-token" },
230  { id: "squarespace-access-token", label: "SQUARESPACE_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:squarespace)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["squarespace"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#squarespace-access-token" },
231  { id: "stripe-access-token", label: "STRIPE_ACCESS_TOKEN", regex: "\\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 2.0, keywords: ["sk_test", "sk_live", "sk_prod", "rk_test", "rk_live", "rk_prod"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#stripe-access-token" },
232  { id: "sumologic-access-id", label: "SUMOLOGIC_ACCESS_ID", regex: "[\\w.-]{0,50}?(?:[\\w.-]{0,50}?(?:(?:[Ss]umo|SUMO))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3})(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(su[a-zA-Z0-9]{12})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["sumo"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sumologic-access-id" },
233  { id: "sumologic-access-token", label: "SUMOLOGIC_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:(?:[Ss]umo|SUMO))(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{64})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.0, keywords: ["sumo"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#sumologic-access-token" },
234  { id: "telegram-bot-api-token", label: "TELEGRAM_BOT_API_TOKEN", regex: "[\\w.-]{0,50}?(?:telegr)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9]{5,16}:(?:A)[a-z0-9_\\-]{34})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["telegr"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#telegram-bot-api-token" },
235  { id: "travisci-access-token", label: "TRAVISCI_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:travis)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{22})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["travis"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#travisci-access-token" },
236  { id: "twilio-api-key", label: "TWILIO_API_KEY", regex: "SK[0-9a-fA-F]{32}", flags: "i", groups: 0, secretGroup: null, entropy: 3.0, keywords: ["sk"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#twilio-api-key" },
237  { id: "twitch-api-token", label: "TWITCH_API_TOKEN", regex: "[\\w.-]{0,50}?(?:twitch)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{30})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["twitch"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#twitch-api-token" },
238  { id: "twitter-access-secret", label: "TWITTER_ACCESS_SECRET", regex: "[\\w.-]{0,50}?(?:twitter)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{45})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["twitter"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#twitter-access-secret" },
239  { id: "twitter-access-token", label: "TWITTER_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:twitter)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([0-9]{15,25}-[a-zA-Z0-9]{20,40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["twitter"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#twitter-access-token" },
240  { id: "twitter-api-key", label: "TWITTER_API_KEY", regex: "[\\w.-]{0,50}?(?:twitter)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{25})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["twitter"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#twitter-api-key" },
241  { id: "twitter-api-secret", label: "TWITTER_API_SECRET", regex: "[\\w.-]{0,50}?(?:twitter)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{50})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["twitter"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#twitter-api-secret" },
242  { id: "twitter-bearer-token", label: "TWITTER_BEARER_TOKEN", regex: "[\\w.-]{0,50}?(?:twitter)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(A{22}[a-zA-Z0-9%]{80,100})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["twitter"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#twitter-bearer-token" },
243  { id: "typeform-api-token", label: "TYPEFORM_API_TOKEN", regex: "[\\w.-]{0,50}?(?:typeform)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(tfp_[a-z0-9\\-_\\.=]{59})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["tfp_"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#typeform-api-token" },
244  { id: "vault-batch-token", label: "VAULT_BATCH_TOKEN", regex: "\\b(hvb\\.[\\w-]{138,300})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 4.0, keywords: ["hvb."], allow: [], kind: "prefix", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#vault-batch-token" },
245  { id: "vault-service-token", label: "VAULT_SERVICE_TOKEN", regex: "\\b((?:hvs\\.[\\w-]{90,120}|s\\.(?:[a-z0-9]{24})))(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: 3.5, keywords: ["hvs.", "s."], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#vault-service-token" },
246  { id: "yandex-access-token", label: "YANDEX_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:yandex)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(t1\\.[A-Z0-9a-z_-]{1,4096}[=]{0,2}\\.[A-Z0-9a-z_-]{86}[=]{0,2})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["yandex"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#yandex-access-token" },
247  { id: "yandex-api-key", label: "YANDEX_API_KEY", regex: "[\\w.-]{0,50}?(?:yandex)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(AQVN[A-Za-z0-9_\\-]{35,38})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["yandex"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#yandex-api-key" },
248  { id: "yandex-aws-access-token", label: "YANDEX_AWS_ACCESS_TOKEN", regex: "[\\w.-]{0,50}?(?:yandex)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}(YC[a-zA-Z0-9_\\-]{38})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["yandex"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#yandex-aws-access-token" },
249  { id: "zendesk-secret-key", label: "ZENDESK_SECRET_KEY", regex: "[\\w.-]{0,50}?(?:zendesk)(?:[ \\t\\w.-]{0,20})[\\s'\"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)[\\x60'\"\\s=]{0,5}([a-z0-9]{40})(?:[\\x60'\"\\s;]|\\\\[nr]|$)", flags: "i", groups: 1, secretGroup: null, entropy: null, keywords: ["zendesk"], allow: [], kind: "vendor-keyword", source: "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml#zendesk-secret-key" },
250];
251
252/** gitleaks' own global allowlist, minus its path patterns (rows carry no path). */
253export const GLOBAL_ALLOW_REGEXES: readonly string[] = [
254  "^true|false|null$",
255  "^(?:a+|b+|c+|d+|e+|f+|g+|h+|i+|j+|k+|l+|m+|n+|o+|p+|q+|r+|s+|t+|u+|v+|w+|x+|y+|z+|\\*+|\\.+)$",
256  "^\\$(?:\\d+|{\\d+})$",
257];
258
259export const GLOBAL_STOPWORDS: readonly string[] = [
260  "014df517-39d1-4453-b7b3-9930c563627c",
261  "abcdefghijklmnopqrstuvwxyz",
262];
263
264export const CORPUS_SHA256 = "e163e53b9e7e8a8511e77271e2b323ed057759542a6d988258afe3a1fa329caf";
265
types/index.d.ts 34 lines
1declare module "claude-code" {
2  interface PluginState {
3    "prompt-redact": {
4      /** Per-session salt: a placeholder minted under one salt means nothing under another. */
5      salt: string;
6      /** Placeholder -> real value. Session-scoped; never written to disk or $.store. */
7      vault: Record<string, string>;
8      /** Label -> how many values of that label were hidden this session. */
9      counts: Record<string, number>;
10      /** How many placeholders were restored into Edit/Write/NotebookEdit arguments. */
11      restored: number;
12      /** Scanning on/off for this session (/redact on|off). */
13      enabled: boolean;
14      /** Aggressiveness preset. */
15      tier: "strict" | "balanced" | "precision";
16      /** User allowlist: candidate values matching are never redacted. */
17      allow: string[];
18      /** User force-redact list: values matching are always redacted. */
19      deny: string[];
20      /** Rule ids switched off for this session; a disabled rule never fires. */
21      disabled: string[];
22      /** Whether the opaque-placeholder notice already landed on this prompt. */
23      noticeShown: boolean;
24      /**
25       * How many runs of each interception hook reached this code this session,
26       * by event name. Runs the engine or an outer plugin skipped are absent:
27       * the platform's skip notice is written outside this process, so a count
28       * is an observation, never proof of coverage.
29       */
30      hookFires: Record<string, number>;
31    };
32  }
33}
34