SLOPSHOPPER

secret-box

Claude can use a secret without seeing it: a masked box saves keys to .env and gives Claude a placeholder, every key a tool prints comes back as a placeholder…

newpanebandguardtoastprompt
A shopper browsing a rack in a slop shop
README

secret-box

A Claude Code mod that lets Claude use a secret without ever seeing it. When Claude needs an API key, it calls a request_secret tool, a masked box opens, you paste the key, and the mod writes it to .env. Claude gets a placeholder like [REDACTED-SECRET-3f9a1c2e] instead of the value, and the placeholder turns into the real key whenever a tool runs. Any key a tool prints comes back to Claude as a placeholder, and committing .env is refused.

secret-box: on the left Claude calls secret-box - request_secret for STRIPE_SECRET_KEY and waits; on the right the secret box pane asks for STRIPE_SECRET_KEY with the reason, and the value field shows only dots; below, the four steps: Claude calls a tool, you paste into a masked box, the mod writes .env and .gitignore, Claude gets a placeholder that turns into the key when a tool runs

A key that reaches the conversation is in the transcript for good. It is sent to the model on every turn after, kept in the session file on disk, and replayed into every fork of the session. You can't take it back afterwards, so the only fix is to never send it. Keys get in two ways: you paste one, or a tool prints one (a cat .env, a config file, an API answer). secret-box closes both, and still lets Claude run the code that needs the key.

  • A tool to ask with. request_secret takes a variable name and a one-line reason. Its description tells Claude to use it instead of asking you to paste.
  • A masked box. A pane opens beside the transcript with the field already focused. It only ever draws dots and a character count.
  • A placeholder to use. The mod writes NAME=value to .env (and makes sure .gitignore covers it), then tells Claude the name and a placeholder. Claude can put the placeholder in a command or a file: curl -u [REDACTED-SECRET-3f9a1c2e]: https://api.stripe.com/v1/balance runs with the real key.
  • Placeholders on the way out. Every tool result is scanned before Claude sees it. About 70 vendor key shapes (sk_live_, ghp_, AKIA, xoxb- and more), any key shaped like a short lowercase prefix and a long random body (imp_ and 40 hex), passwords on CLI flags and in connection strings, base64 that decodes to a key, high-entropy tokens, every .env value with a secret name, and every value the mod already knows come back as placeholders. cat .env still shows PORT=3000.
  • Placeholders on the way in. A key you paste into the prompt by mistake is swapped for a placeholder before Claude reads it.
  • Never committed. git add .env and a git commit that would carry a .env file are refused, because a pushed key can't be taken back.

Using a key without seeing it: Claude writes the placeholder into a Bash command, the shell runs it with the real key, and Claude reads back length 27 and first 8 characters sk_test_

On the way out: cat .env shows PORT=3000 and placeholders for STRIPE_SECRET_KEY and DB_PASSWORD, echo of the password comes back as the placeholder, and a git commit with .env staged is refused

Install

claude plugin marketplace add ray-amjad/claude-code-mods
claude plugin install secret-box@claude-code-mods

Or inside Claude Code: /plugin marketplace add ray-amjad/claude-code-mods, then /plugin install secret-box@claude-code-mods.

Usage

Ask for something that needs a secret, such as billing.js needs the Stripe secret key, get it set up. Claude calls secret-box - request_secret.

  • Paste the value into the box. It shows dots and a character count.
  • Enter saves it. A toast says where it went, and Claude gets the placeholder.
  • Esc cancels. Claude is told you cancelled and nothing is written.

Then ask Claude to use it: check the key works. It writes the placeholder into a command and the real key is used. A tool row says secret-box: hid 1 value(s) whenever something was swapped on the way out.

If you'd rather not wait for Claude to think of the tool, say use the secret box to ask me for STRIPE_SECRET_KEY.

Try it on the example

The example folder is a tiny app whose billing.js reads STRIPE_SECRET_KEY, with a .env.example beside it.

cp -R example /tmp/shop && cd /tmp/shop && git init
claude

Accept the trust prompt (mods don't load in an untrusted folder). Tell Claude billing.js needs the Stripe secret key, paste a fake key such as sk_test_123, and press Enter. Then ask prove the key is in .env without showing it to me, and cat .env to see the placeholder.

Settings

Set them with /plugin configure secret-box@claude-code-mods, or under pluginConfigs in your user settings.

| Option | Default | What it does | | :- | :- | :- | | secrets | true | Hide secrets in tool output and prompts | | pii | false | Hide email addresses and public IP addresses | | hideEmails | true | With pii on, also hide emails; turn off to hide only IPs | | redactPrivateIps | false | With pii on, also hide private and reserved IPs recognized by the detector | | restoreInToolInputs | true | Turn placeholders back into real values before a tool runs | | restoreTools | Bash,Write,Edit,MultiEdit,NotebookEdit | The tools that may receive a real value. Tools that send data off the machine are left out on purpose | | notify | true | A count on the tool row, a toast on a prompt | | minEntropy | 3.6 | Bits per character for the high-entropy test | | minLength | 24 | Shortest token the high-entropy test looks at | | contextMinEntropy | 3.0 | The floor when API_KEY=, "token": or Bearer sits in front | | allow | empty | Exact strings never to hide, comma separated | | allowEmails | empty | Addresses or @domain.com never to hide | | allowPrefixes | empty | Extra id prefixes to treat as public | | denyPrefixes | empty | Prefixes to test even though they look like a public id |

Stripe object ids (price_, prod_, cus_, pk_live_ and so on) are public and are never hidden. Neither are git SHAs, file paths or names written in code.

How it works

  • session.start registers the tool with isDeferred: false, so Claude sees it on every turn, and reads .env in the working directory to learn which values are secret.
  • The box is a pane opened with $.ui.open and an Input. The element has no password mode, so the mod masks it itself: the field draws • repeated, and onInput works out what was typed, pasted or deleted and keeps the real value in a module variable. A hook may only run for 10 seconds of its own time, but time inside a mods API call doesn't count, so it waits for you in short $.process.run(['sleep', '0.3']) calls.
  • On Enter the mod writes .env with $.fs and hands the value to the vault, which answers with the placeholder. The vault maps each value to a random placeholder that stays stable within the session, and lives in memory only.
  • The redaction is transcript-redactor, unchanged, in hooks/redact.ts and hooks/detect.ts. register.jsx calls its register and adds only what secret-box needs, so the two can't drift. Its hooks hide keys in your prompt, CLAUDE.md, tool results, @ files, the engine's own attachments and subagent tasks, and put real values back into Bash, Write and Edit. A value restored into a command is hidden again if the command prints it.
  • Before a tool reads a .env file, a tool.call hook reads it too and gives every value whose name says secret (KEY, TOKEN, SECRET, PASSWORD and the like) to the vault. So even a weak value the detector would miss, like a word password, comes back as a placeholder.
  • A tool.call hook on Bash refuses git add of a .env file and runs git diff --cached --name-only before any git commit. A prompt.mention hook refuses @.env, which would attach the raw file.
  • Every hook that guards has a .catch that fails closed: if a scan breaks, the output is withheld or the call is refused.

Limits

  • It hides what Claude reads, not what Claude does. With a placeholder Claude can still run a command that transforms the key (base64, rev) or sends it somewhere, and the transformed output isn't recognized. This stops keys from landing in the transcript by accident. It is not a sandbox against a model that is trying to leak one.
  • In auto mode, Claude Code's classifier blocks cat .env and source .env as credential access before secret-box sees them. Using the placeholder from the box still works. In default mode, approve the read and it comes back masked.
  • Terminals under 144 columns. Claude Code won't place a pane the user didn't open, so the box draws in the band above the prompt instead, and you press Ctrl+X then Tab to type in it. Wider, or with fullscreen rendering, it docks beside the transcript.
  • The masking is the mod's. A paste can show for one frame before it is redrawn as dots. Editing in the middle of the field with the arrow keys isn't tracked.
  • No restore when publishing. A command that runs git commit or gh issue, gh pr (and the like) gets no real values, so a commit message keeps the placeholder. A step that needs the key has to be its own command.
  • Files Claude writes get the real value. A placeholder in a file Claude writes with Write or Edit lands on disk as the key.
  • A .env value under 8 characters is hidden in .env reads, but not wherever else it turns up.
  • The vault is memory only. Placeholders from an earlier session mean nothing in a new one. The mod relearns .env at the start of each session, so those values are hidden again.
  • Only the working directory's .env is learned at session start. Other .env files are masked by name when they are read.
  • claude -p has no one to type, so the tool answers that it needs an interactive session.
  • If your CLAUDE.md says secrets go somewhere else (a vault, a secrets manager), Claude follows that and may not offer the box. Ask for it by name.
  • Mods only load in a folder you have trusted. In a brand new repo, accept the trust prompt first.

Develop

claude --plugin-dir ./secret-box
claude plugin validate .
claude plugin test .
node --experimental-strip-types tests/detect.test.mts

tests/secret-box.test.ts covers the guard, .env values and the round trip from placeholder to value. tests/detect.test.mts is transcript-redactor's detector test: 174 values that must be hidden and 114 that must be kept.

Tested with Claude Code 2.1.296.

Original redactor contributions

Random placeholder tags were contributed by Ben Ellis (@benissimo). The hideEmails option was contributed by Cody Harris (@VibeCodyH). Both implementations are adapted to this mod's current vault and hooks.

To keep emails readable while hiding public IPs, set pii: true and hideEmails: false. PII hiding remains off by default. The restored IP detector recognizes IPv4 and full-form IPv6; compressed IPv6 is not comprehensively covered.

Source 3 files
hooks/register.jsx 270 lines
1// secret-box: Claude can use a secret without ever seeing it.
2//
3// Pattern: guard what you can't undo. A secret in the conversation is in the
4// transcript for good, so it must never get there:
5//   - In: Claude calls request_secret, you paste the value into a masked box,
6//     the mod writes it to .env and hands Claude a placeholder.
7//   - Out: every secret a tool would show Claude (a `cat .env`, an API answer,
8//     a key you paste) is swapped for a placeholder like [REDACTED-SECRET-1a2b3c4d].
9//   - Use: a placeholder in a tool's input becomes the real value as the tool
10//     runs, so `curl -H "Authorization: Bearer [REDACTED-SECRET-...]"` works.
11//   - Never: committing .env is refused, since a pushed key can't be taken back.
12//
13// The redaction is transcript-redactor, unchanged (./redact.ts, ./detect.ts):
14// its hooks do the In, Out and Use. This file adds the box, teaches the vault
15// the values in .env, and guards commits. Nothing is written but .env.
16
17import { register as redactor, LIVE_TAG_TOOLS, brand } from './redact.ts'
18import { keepSecret } from './detect.ts'
19
20const TOOL = 'mcp__secret-box__request_secret'
21const PANE = 'secret-box'
22const DOT = '•'
23
24// The one request being collected right now. The real value lives only here,
25// in the mod's memory, until it is saved.
26let pending = null // { name, reason, value, answer, isInBand }
27
28// A .env key whose name says it holds a secret
29const SECRET_NAME = /KEY|TOKEN|SECRET|PASS|PWD|CREDENTIAL|AUTH|PRIVATE|DSN|SALT|SIGNING|WEBHOOK/i
30const ASSIGNMENT = /^(\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*)(.*)$/gm
31
32export function register(on, options) {
33  // request_secret answers with a live placeholder: keep it live
34  LIVE_TAG_TOOLS.add(TOOL)
35  brand.name = 'secret-box'
36  redactor(on, options || {})
37
38  // 1. Give Claude the tool, and teach the vault the secrets already in .env
39  on('session.start', async ($, e, next) => {
40    await $.tool.register({
41      name: 'request_secret',
42      description:
43        'Ask the user for a secret (API key, token, password) without it ever entering the conversation. ' +
44        'The user pastes it into a masked box and the mod saves it to the project .env. You get back the ' +
45        'variable name and a placeholder like [REDACTED-SECRET-1a2b3c4d]. Write that placeholder in a command ' +
46        'or file and it becomes the real value when the tool runs. Use this INSTEAD of asking the user to ' +
47        'paste a secret into chat.',
48      inputSchema: {
49        type: 'object',
50        properties: {
51          name: { type: 'string', description: 'Environment variable name, e.g. STRIPE_SECRET_KEY' },
52          reason: { type: 'string', description: 'One short line telling the user why it is needed' },
53        },
54        required: ['name', 'reason'],
55      },
56      isDeferred: false,
57    })
58    await learnDotenv($, ['.env'])
59    return next(e)
60  })
61
62  // 2. The guard: .env may be read (its secrets come back hidden), never committed
63  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
64    const why = await commitBlockReason($, String(e.command || ''))
65    return why ? { deny: `secret-box: ${why} A pushed key can't be taken back.` } : next(e)
66  }).catch(() => ({ deny: 'secret-box: the .env guard failed, so this call is blocked to be safe.' }))
67
68  // 3. A call that reads a .env file: learn its secret values before it runs,
69  //    so the redactor hides them in the output even when they look weak
70  //    (a word password). Reading .env is fine; its secrets come back hidden.
71  on('tool.call', { tool: /^(Bash|Read|Grep|Edit|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
72    const files = envFiles(e)
73    if (files.length > 0) await learnDotenv($, files)
74    return next(e)
75  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-box: could not check this .env file, so the call is blocked to be safe.' }))
76
77  // @.env would attach the raw file to your prompt
78  on('prompt.mention', { path: /(^|\/)\.env(\.[\w.-]+)?$/ }, async ($, e, next) =>
79    isEnvFile(e.path) ? { deny: 'secret-box: ask Claude to read .env instead; its secrets come back hidden' } : next(e))
80    .catch(() => ({ deny: 'secret-box: the .env guard failed' }))
81
82  // 4. The box. Claude calls the tool: open it, wait for you, save, answer.
83  on('tool.call', { tool: TOOL }, async ($, e, next) => {
84    const name = String(e.name || '').trim()
85    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
86      return { result: `"${name}" is not a valid variable name. Use letters, digits and underscores.` }
87    }
88    // A plain -p run has no one to type into the box
89    if ((await $.session.surfaces()).length === 0) {
90      return { result: `secret-box needs an interactive session to collect ${name}. Ask the user to run Claude Code interactively.` }
91    }
92    if (pending) return { result: `secret-box is already waiting for ${pending.name}. Ask for one secret at a time.` }
93
94    // `answer` stays undefined until Enter (the value) or Escape (empty)
95    pending = { name, reason: String(e.reason || ''), value: '', answer: undefined, isInBand: false }
96    const opened = await $.ui.open({ id: PANE, title: 'secret-box', focus: true, closeOnEscape: true, holdToasts: true, rows: 7 })
97    // A narrow terminal (under 144 columns) keeps the pane undrawn: use the band above the prompt instead
98    if (!opened.isPlaced) {
99      pending.isInBand = true
100      await $.ui.close({ id: PANE })
101      $.ui.invalidate('ui.render')
102      $.ui.toast(`Claude wants ${name}: press ctrl+x then tab to type it in the box above the prompt`, { timeoutMs: 10000 })
103    }
104
105    // A hook's own run time is capped at 10 s, but time inside a mods API call
106    // is not counted, so wait for the person in short `sleep` processes.
107    const deadline = (await $.clock.now()) + 10 * 60_000
108    while (pending.answer === undefined && !next.signal.aborted && (await $.clock.now()) < deadline) {
109      await $.process.run(['sleep', '0.3'])
110    }
111    const value = pending.answer
112    pending = null
113    await $.ui.close({ id: PANE })
114    $.ui.invalidate('ui.render')
115    if (!value) return { result: `The user cancelled. ${name} was not saved.` }
116
117    try {
118      const where = await saveToDotenv($, await $.session.cwd(), name, value)
119      const tag = keepSecret(value)
120      $.ui.toast(`${name} saved to ${where}`)
121      // The ONLY things Claude learns: the name, the place, and a placeholder
122      return { result: `The user entered ${name}. It was saved to ${where}. You can't see the value. To use it in a command or a file, write ${tag}: it becomes the real value when the tool runs and is hidden again in the output. Code should read it from the environment.` }
123    } catch (err) {
124      return { result: `Saving ${name} failed: ${String(err && err.message || err).slice(0, 300)}` }
125    }
126  }).catch(($, e, next) => {
127    // If the hook itself fails (or times out), drop the request and tell Claude plainly
128    pending = null
129    $.ui.log('request_secret failed: ' + JSON.stringify(next.error).slice(0, 500))
130    return { result: 'secret-box failed before anything was saved. Nothing was written.' }
131  })
132
133  // Draw the box, in our pane or (narrow terminal) in the band above the prompt
134  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
135    if (e.requestId !== PANE || !pending) return next(e)
136    return drawBox($, e, pending)
137  })
138  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139    if (!pending || !pending.isInBand) return next(e)
140    return drawBox($, e, pending)
141  })
142
143  // Escape (or any close) on the pane before Enter means cancel
144  on('ui.close', async ($, e, next) => {
145    if (e.id === PANE && pending && !pending.isInBand) pending.answer = ''
146    return next(e)
147  }).catch(($, e, next) => next(e))
148}
149
150// .env, .env.local, .env.production ... but not .env.example and friends
151function isEnvFile(path) {
152  const base = String(path || '').split('/').pop()
153  return /^\.env(\.[\w.-]+)?$/.test(base) && !/\.(example|sample|template|dist)$/.test(base)
154}
155
156// The .env files a tool call reads or names, as written in the call
157function envFiles(e) {
158  const direct = [e.file_path, e.notebook_path, e.path].filter(isEnvFile)
159  const named = String(e.command || '').match(/(?:^|[^\w.\/-])((?:[\w.\/-]*\/)?\.env(?:\.[\w.-]+)?)(?![\w.-])/g) || []
160  return [...direct, ...named.map(m => m.replace(/^[^\w.\/-]/, '')).filter(isEnvFile)]
161}
162
163// Read .env files and hand every secret-named value to the vault, so it is
164// hidden wherever it turns up. Never throws: a file it can't read teaches it
165// nothing, and the redactor still scans the output.
166async function learnDotenv($, files) {
167  let cwd = ''
168  try { cwd = await $.session.cwd() } catch {}
169  for (const f of files) {
170    try {
171      const path = f.startsWith('/') ? f : f.startsWith('~/') ? f : cwd + '/' + f
172      if (path.startsWith('~/') || !(await $.fs.exists(path))) continue
173      for (const m of (await $.fs.read(path)).matchAll(ASSIGNMENT)) {
174        const value = m[3].trim().replace(/^["']|["']$/g, '')
175        if (value.length >= 6 && SECRET_NAME.test(m[2])) keepSecret(value)
176      }
177    } catch {}
178  }
179}
180
181// The repositories a command's git calls run in: each `git -C <dir>`, and the
182// session's own repository for a plain `git`
183function gitDirs(cmd) {
184  const dirs = new Set()
185  for (const m of cmd.matchAll(/\bgit((?:[ \t]+-c[ \t]+\S+|[ \t]+-C[ \t]+(?:"[^"]*"|'[^']*'|\S+))*)/g)) {
186    const c = m[1].match(/-C[ \t]+("[^"]*"|'[^']*'|\S+)/g)
187    if (!c) dirs.add('')
188    else dirs.add(c.at(-1).replace(/^-C[ \t]+/, '').replace(/^["']|["']$/g, ''))
189  }
190  return [...dirs]
191}
192
193// Why a Bash command must be blocked: it stages or commits a .env file
194async function commitBlockReason($, cmd) {
195  if (/\bgit\b[^;&|]*\badd\b/.test(cmd) && /\.env\b(?!\.(example|sample|template|dist))/.test(cmd)) {
196    return 'staging a .env file is blocked.'
197  }
198  if (!/\bgit\b[^;&|]*\bcommit\b/.test(cmd)) return ''
199  // The index is read before the command runs. If the same command stages
200  // (git add ... && git commit, or commit -a), count what it could stage too:
201  // every changed or untracked file git does not ignore.
202  const stages = /\bgit\b[^;&|]*\b(add|stage)\b/.test(cmd) || /\bcommit\b[^;&|]*\s(-a|--all|-\w*a\w*)\b/.test(cmd)
203  for (const dir of gitDirs(cmd)) {
204    const git = (...args) => $.process.run(['git', ...(dir ? ['-C', dir] : []), ...args])
205    const checks = [git('diff', '--cached', '--name-only')]
206    if (stages) checks.push(git('ls-files', '--modified', '--others', '--exclude-standard'))
207    for (const r of await Promise.all(checks)) {
208      if (r.exitCode !== 0) return `could not check what this commit includes${dir ? ' in ' + dir : ''}, so it is blocked.`
209      const env = r.stdout.split('\n').find(isEnvFile)
210      if (env) {
211        return stages
212          ? `this command could stage and commit ${env}. Add it to .gitignore, or stage named files in a separate step.`
213          : 'this commit would include a .env file. Unstage it with git rm --cached.'
214      }
215    }
216  }
217  return ''
218}
219
220// The field shows dots; the real text is kept in `p.value`, never drawn.
221function drawBox($, e, p) {
222  const { Box, Text, Input } = $.ui.resolve(e)
223  return (
224    <Box flexDirection="column" paddingX={1}>
225      <Text bold>Claude is asking for <Text color="yellow">{p.name}</Text></Text>
226      {p.reason ? <Text dimColor>{p.reason}</Text> : null}
227      <Input
228        key="secret"
229        label="value "
230        placeholder="paste it here, it stays masked"
231        value={DOT.repeat(p.value.length)}
232        submitLabel="save"
233        autoFocus
234        onInput={text => {
235          // Everything up to the first non-dot is the part we already hold;
236          // the rest is what was just typed or pasted. Backspace shortens the dots.
237          let kept = 0
238          while (kept < text.length && text[kept] === DOT) kept++
239          p.value = p.value.slice(0, Math.min(kept, p.value.length)) + text.slice(kept)
240          $.ui.invalidate('ui.render')
241        }}
242        onSubmit={() => { p.answer = p.value }}
243      />
244      <Text dimColor>{p.value.length ? `${p.value.length} characters · ` : ''}Enter saves · {p.isInBand ? 'ctrl+x tab to focus, Enter on empty cancels' : 'Esc cancels'} · Claude never sees the value</Text>
245    </Box>
246  )
247}
248
249async function saveToDotenv($, cwd, name, value) {
250  const path = cwd + '/.env'
251  const old = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
252  const line = `${name}=${quote(value)}`
253  const lines = old.split('\n').filter((l, i, all) => !(i === all.length - 1 && l === ''))
254  const at = lines.findIndex(l => l.startsWith(name + '='))
255  if (at >= 0) lines[at] = line
256  else lines.push(line)
257  await $.fs.write(path, lines.join('\n') + '\n')
258
259  const gi = cwd + '/.gitignore'
260  const ignore = (await $.fs.exists(gi)) ? await $.fs.read(gi) : ''
261  const isIgnored = ignore.split('\n').some(l => ['.env', '.env*', '*.env', '/.env'].includes(l.trim()))
262  if (!isIgnored) await $.fs.write(gi, ignore + (ignore && !ignore.endsWith('\n') ? '\n' : '') + '.env\n')
263  return '.env' + (isIgnored ? '' : ' (and added .env to .gitignore)')
264}
265
266// Plain values stay bare; anything with spaces, quotes or # gets double quotes.
267function quote(v) {
268  return /^[A-Za-z0-9_\-.\/:+=@]*$/.test(v) ? v : '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"'
269}
270
hooks/redact.ts 252 lines
1import type { Register, PromptContextBlock } from 'claude-code'
2import {
3  makeConfig,
4  scrubSecrets,
5  scrubPii,
6  restore,
7  mint,
8  walk,
9  collect,
10  escapeLiteralTags,
11  type WalkState,
12} from './detect.ts'
13
14// transcript-redactor: keeps secrets (and, if asked, email addresses) out of
15// the session transcript. Everything that decides what a secret is lives in
16// ./detect.ts; this file only wires it to the events where text enters the
17// conversation, and to the tools where a placeholder must become real again.
18//
19// Every hook fails closed. A hook that throws or runs past its budget is
20// skipped by the engine, and for a redactor that would mean the raw text goes
21// through. So each one has a `.catch` that withholds instead.
22
23// The scan is linear, but a string past this size could still run past the
24// hook's 10 s budget. It is withheld rather than passed on unscanned.
25const SCAN_CAP = 16_000_000
26
27const WITHHELD =
28  'transcript-redactor could not scan this result in time, so it was withheld. Narrow the output (head, grep, offset) and retry.'
29const BLOCK_WITHHELD = '[transcript-redactor withheld this text: the scan failed]'
30
31// Tools whose answer carries live placeholders, so they are not turned into
32// quoted (LITERAL) ones: the subagent tools, whose answer holds placeholders
33// the subagent used. A mod built on this one can add its own tool here.
34export const LIVE_TAG_TOOLS = new Set(['Agent', 'Task'])
35
36// The name on the toast and the tool row. A mod built on this one sets its own.
37export const brand = { name: 'transcript-redactor' }
38
39// Attachments that carry text from outside the engine's own prose: a file,
40// a diff of a file, a queued prompt. These get the full scrub, emails too.
41const OUTSIDE_TEXT = new Set(['file', 'edited_text_file', 'queued_command'])
42
43type Budget = { budget: { remainingMs: number } }
44
45// Throws before a scan that cannot finish: the `.catch` then withholds.
46function guard(text: string, next: Budget): void {
47  if (text.length > SCAN_CAP) throw new Error('transcript-redactor: text too large to scan')
48  const needMs = 300 + text.length / 4000
49  if (next.budget.remainingMs < needMs) throw new Error('transcript-redactor: out of time')
50}
51
52// A Bash command that writes text into git history or posts it to GitHub.
53// Its placeholders stay placeholders: a commit message or an issue body that
54// quotes one must not carry the real key off the machine. The whole command
55// is left as written, so a step that needs the key goes in its own call.
56const PUBLISHES =
57  /\bgit\b(?:[ \t]+-[cC][ \t]+\S+|[ \t]+--?[\w-]+(?:=\S+)?)*[ \t]+(?:commit|tag|notes|send-email)\b|\bgh[ \t]+(?:issue|pr|release|gist|discussion|api|repo[ \t]+(?:create|edit))\b/
58
59// A restored value goes into Bash as shell source. One holding any of these
60// (quotes, $, backticks, separators, globs) could run as code there, so it is
61// never put into a command. Most keys are letters, digits and -_.+/=:
62const SHELL_ACTIVE = /[\s$`"'\\;&|<>()*?[\]{}#~]|[\x00-\x1f\x7f]/
63
64// @-mentioned files the engine attaches as media or a page reference, never
65// as text. When one is too large for $.fs.read it holds no text to check.
66const MEDIA_FILE = /\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic|pdf)$/i
67
68function looksBinary(text: string): boolean {
69  const head = text.slice(0, 8192)
70  return head.includes('\u0000') || (head.match(/�/g)?.length ?? 0) > 8
71}
72
73export const register: Register = (on, options) => {
74  const cfg = makeConfig(options as Record<string, unknown>)
75
76  const scrub = (text: string): string => {
77    let out = text
78    if (cfg.secrets) out = scrubSecrets(out, cfg)
79    if (cfg.pii) out = scrubPii(out, cfg)
80    return out
81  }
82  const secretsOnly = (text: string): string => (cfg.secrets ? scrubSecrets(text, cfg) : text)
83
84  // Counts the values hidden, not the placeholders that stand for quoted text.
85  const countHidden = (tags: Set<string>): number => [...tags].filter((t) => !t.startsWith('[REDACTED-LITERAL-')).length
86
87  // 1. What the user types. A pasted key never reaches the model as itself,
88  //    and neither does the context a hook above attached to the prompt.
89  on('prompt.submit', async ($, e, next) => {
90    const { value, tags } = collect(() => {
91      guard(e.text, next)
92      return { text: scrub(e.text), context: e.context?.map((c) => (guard(c, next), scrub(c))) }
93    })
94    const n = countHidden(tags)
95    if (n > 0 && cfg.notify) $.ui.toast(`${brand.name}: hid ${n} value(s) from the prompt`)
96    if (tags.size === 0) return next(e)
97    return next({ ...e, text: value.text, ...(value.context ? { context: value.context } : {}) })
98  }).catch(($, e, next) => (next.called ? next(e) : { drop: 'transcript-redactor failed to scan this prompt, so it was not sent.' }))
99
100  // 2. Every tool call. On the way in, a placeholder becomes its real value,
101  //    but only for a tool that runs on this machine. On the way out, the
102  //    result, the text the model reads and any attached context are scanned.
103  on('tool.call', async ($, e, next) => {
104    const command = (e as { command?: unknown }).command
105    const publishes = e.tool === 'Bash' && typeof command === 'string' && PUBLISHES.test(command)
106    const isLocal = cfg.restore && cfg.restoreTools.has(e.tool) && !publishes
107    // The real values this call was handed. Whatever the tool prints of them
108    // comes back hidden, even bare and short, where no detector would fire.
109    const handed = new Set<string>()
110    const restoreTracked = (s: string): string =>
111      restore(s) === s
112        ? s
113        : s.replace(/\[REDACTED-SECRET-[0-9a-f]{8}\]/g, (t) => {
114            const v = restore(t)
115            if (v !== t) handed.add(v)
116            return v
117          })
118    const rehideHanded = (text: string): string => {
119      let out = text
120      for (const v of [...handed].sort((a, b) => b.length - a.length)) if (v.length >= 4 && out.includes(v)) out = out.split(v).join(mint('SECRET', v))
121      return out
122    }
123    const input = isLocal ? (walk({ ...e }, restoreTracked) as typeof e) : e
124    if (e.tool === 'Bash' && [...handed].some((v) => SHELL_ACTIVE.test(v))) {
125      return {
126        deny: `${brand.name}: a secret in this command holds shell characters, so its real value was not put into Bash, where it could run as code. Write it to a file with the Write tool and have the command read that file instead.`,
127      }
128    }
129    const r = await next(input)
130    if (r.deny !== undefined) {
131      // A refusal from beneath may quote the input it refused, real values
132      // and all, and the model reads it as the tool's answer.
133      const d = collect(() => (guard(r.deny, next), rehideHanded(scrub(escapeLiteralTags(r.deny)))))
134      return d.tags.size === 0 ? r : { deny: d.value }
135    }
136
137    const escape = !LIVE_TAG_TOOLS.has(e.tool)
138    const st: WalkState = { incomplete: false }
139    // The result and the text often hold the same string: scan it once.
140    const memo = new Map<string, string>()
141    const clean = (s: string): string => {
142      const done = memo.get(s)
143      if (done !== undefined) return done
144      guard(s, next)
145      const out = rehideHanded(scrub(escape ? escapeLiteralTags(s) : s))
146      memo.set(s, out)
147      return out
148    }
149    const { value, tags } = collect(() => ({
150      result: walk(r.result, clean, st),
151      text: r.text === undefined ? undefined : clean(r.text),
152      context: r.context?.map(clean),
153    }))
154    if (tags.size === 0) return r
155    // Something was hidden, and part of the result could not be rewritten:
156    // that part may hold the same secret, so the whole result is withheld.
157    if (st.incomplete) return { deny: 'transcript-redactor: a secret sits in a part of this result it cannot rewrite (too deep, or not plain data). Narrow the call and retry.' }
158
159    const n = countHidden(tags)
160    if (cfg.notify && n > 0) $.ui.notice(e.tool_use_id, `${brand.name}: hid ${n} value(s)`)
161    const context = value.context ? { context: value.context } : {}
162    if (r.isError) return { isError: true as const, result: value.result, text: value.text, ...context }
163    return { result: value.result, ...context } as typeof r
164  }).catch(async ($, e, next) => {
165    // Nothing ran yet: refuse the call. The tool answered: withhold it.
166    if (!next.called) return { deny: WITHHELD }
167    const r = await next(e)
168    if (r.deny !== undefined) return { deny: WITHHELD }
169    if (r.isError) return { isError: true as const, result: WITHHELD, text: WITHHELD }
170    return { deny: WITHHELD }
171  })
172
173  // 3. The blocks attached to the first message (CLAUDE.md and friends).
174  //    Secrets only: the email in there is the user's own, and the engine
175  //    puts it there so the model knows who it is talking to.
176  on('prompt.context', async ($, e, next) => {
177    const r = await next(e)
178    if (!cfg.secrets) return r
179    const blocks: PromptContextBlock[] = r.blocks.map((b) => {
180      guard(b.text, next)
181      return { ...b, text: secretsOnly(b.text) }
182    })
183    // The raw files behind a rewritten CLAUDE.md would carry the key on to
184    // the hooks above, so they are left out once any block changed.
185    if (blocks.every((b, i) => b.text === r.blocks[i].text)) return r
186    return { blocks }
187  }).catch(async ($, e, next) => {
188    // Keep the blocks, so the model sees that something was held back.
189    const r = await next(e)
190    return { blocks: r.blocks.map((b) => ({ ...b, text: BLOCK_WITHHELD })) }
191  })
192
193  // 4. What the engine injects on its own after the first message: a file
194  //    named with @, a diff of a file that changed on disk, a nested
195  //    CLAUDE.md, a settings hook's context. None of it passes tool.call.
196  on('prompt.attachment', async ($, e, next) => {
197    const r = await next(e)
198    if (r.text == null) return r
199    const text = r.text
200    guard(text, next)
201    const isOutside = e.origin.kind !== 'engine' || OUTSIDE_TEXT.has(e.type)
202    const isFile = e.type === 'file' || e.type === 'edited_text_file'
203    const out = isOutside ? scrub(isFile ? escapeLiteralTags(text) : text) : secretsOnly(text)
204    return out === text ? r : { text: out }
205  }).catch(() => ({ text: BLOCK_WITHHELD }))
206
207  // 5. A file named with @ is read with no tool call, and its raw text is
208  //    stored in the transcript whatever the model is shown. So a file that
209  //    holds a secret is refused here, before it is read, and the model is
210  //    told to use the Read tool, whose result is scrubbed.
211  on('prompt.mention', async ($, e, next) => {
212    if (!cfg.secrets) return next(e)
213    let text: string
214    try {
215      const read = await $.fs.read(e.path)
216      if (typeof read !== 'string') return next(e)
217      text = read
218    } catch {
219      // Missing: the engine attaches nothing. An image or a PDF too large to
220      // read is attached as media, not text. A text file that is present
221      // but unreadable (too large, say) cannot be checked, so it is refused.
222      if (!(await $.fs.exists(e.path))) return next(e)
223      if (MEDIA_FILE.test(e.path)) return next(e)
224      return { deny: 'transcript-redactor: could not check this file for secrets; ask Claude to Read it instead' }
225    }
226    if (looksBinary(text)) return next(e)
227    guard(text, next)
228    const { tags } = collect(() => scrubSecrets(text, cfg))
229    if (tags.size > 0) return { deny: 'transcript-redactor: this file holds a secret; ask Claude to Read it instead, so the value is replaced by a placeholder' }
230    return next(e)
231  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'transcript-redactor: could not check this file for secrets' }))
232
233  // 6. A subagent's task. The model only ever holds placeholders, and the
234  //    Agent tool gets no restore, so this is a second layer: a value that
235  //    slipped into the prompt some other way still reaches the subagent as
236  //    a placeholder. A workflow agent's prompt cannot be rewritten, only
237  //    refused, so one that holds a secret is not started.
238  on('agent.spawn', async ($, e, next) => {
239    if (!cfg.secrets) return next(e)
240    if (e.workflow) {
241      guard(e.prompt, next)
242      const { tags } = collect(() => secretsOnly(e.prompt))
243      if (tags.size === 0) return next(e)
244      return { deny: "transcript-redactor: this workflow agent's prompt holds a secret, and a workflow prompt cannot be rewritten. Pass the value by a file or an env var instead." }
245    }
246    guard(e.prompt, next)
247    const prompt = secretsOnly(e.prompt)
248    const description = secretsOnly(e.description)
249    return prompt === e.prompt && description === e.description ? next(e) : next({ ...e, prompt, description })
250  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'transcript-redactor failed to scan this task, so the subagent was not started.' }))
251}
252
hooks/detect.ts 1600 lines
1// The pure half of transcript-redactor: the config, the vault, the detectors,
2// and the walkers. Nothing in here touches `$` or registers a hook, so another
3// mod can vendor this file as it is and the tests can run it under plain node.
4//
5// The value is not deleted. It is put in a vault that lives in this module,
6// in memory, for the session, and the transcript gets a placeholder in its
7// place: `[REDACTED-SECRET-1a2b3c4d]`. Within one session the same value always
8// mints the same placeholder, so the model can still tell one customer from
9// another, and the placeholder is swapped back for the real value on the way
10// into a tool that runs on this machine, so a `Bash` command or a `Write` still
11// carries the real key.
12//
13// Nothing is written to disk. The vault dies with the session.
14
15export type Kind = 'SECRET' | 'EMAIL' | 'IP' | 'LITERAL'
16
17export type Config = {
18  secrets: boolean
19  pii: boolean
20  emails: boolean
21  privateIps: boolean
22  restore: boolean
23  restoreTools: ReadonlySet<string>
24  notify: boolean
25  minEntropy: number
26  minLength: number
27  contextMinEntropy: number
28  allow: ReadonlySet<string>
29  allowEmails: readonly string[]
30  allowPrefix: readonly string[]
31  denyPrefix: readonly string[]
32}
33
34// The tools that run on this machine. A placeholder is only swapped back for
35// its real value in these: a tool that hands its input to another model or to
36// the network (Agent, WebFetch, WebSearch, SendMessage, an MCP tool) gets the
37// placeholder as written.
38export const DEFAULT_RESTORE_TOOLS = 'Bash,Write,Edit,MultiEdit,NotebookEdit'
39
40// ---------------------------------------------------------------- the vault
41
42const byValue = new Map<string, string>()
43const byTag = new Map<string, string>()
44let hidden = 0
45let minted: Set<string> | null = null
46
47const TAG = /\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]/g
48const WHOLE_TAG = /^\[REDACTED-(?:SECRET|EMAIL|IP|LITERAL)-[0-9a-f]{8}\]$/
49
50function randomHex8(): string {
51  const bytes = new Uint8Array(4)
52  const crypto = (globalThis as { crypto?: { getRandomValues?: (values: Uint8Array) => Uint8Array } }).crypto
53  if (crypto?.getRandomValues) crypto.getRandomValues(bytes)
54  else for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256)
55  return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
56}
57
58// Swaps a value for its placeholder and remembers both. A value that already
59// holds a placeholder from an earlier pass (a key inside a URL password) is
60// stored with that placeholder put back, so one restore gives the real text.
61export function mint(kind: Kind, raw: string): string {
62  hidden++
63  const value = kind === 'LITERAL' ? raw : restore(unpark(raw))
64  const seen = byValue.get(value)
65  if (seen) {
66    if (kind === 'SECRET') remember(value)
67    minted?.add(seen)
68    return seen
69  }
70  let tag: string
71  do tag = `[REDACTED-${kind}-${randomHex8()}]`
72  while (byTag.has(tag))
73  byValue.set(value, tag)
74  byTag.set(tag, value)
75  if (kind === 'SECRET') remember(value)
76  minted?.add(tag)
77  return tag
78}
79
80// A secret is hidden again wherever it turns up later: across the session
81// when it is distinctive enough (echoable), and within the text being
82// scanned right now when it is shorter but still not a plain word.
83function remember(value: string): void {
84  const echoes = echoable(value)
85  if (echoes && !echo.has(value)) {
86    echo.add(value)
87    echoIndex = null
88  }
89  if (local && (echoes || localWorthy(value))) local.add(value)
90}
91
92// One pass, on purpose: a LITERAL placeholder gives back the placeholder text
93// it stands for, and that text must not then turn into the secret.
94export function restore(text: string): string {
95  if (byTag.size === 0 || !text.includes('[REDACTED-')) return text
96  return text.replace(TAG, (tag) => byTag.get(tag) ?? tag)
97}
98
99// Hides a value the caller knows is secret wherever it turns up for the rest
100// of the session, even one too short or too plain for the echo rule (a word
101// password from .env). Values under PREFIX characters can't be indexed.
102export function keepSecret(value: string): string {
103  const tag = mint('SECRET', value)
104  if (value.length >= PREFIX && !echo.has(value)) {
105    echo.add(value)
106    echoIndex = null
107  }
108  return tag
109}
110
111// How many times a value has been hidden this session. A hook reads it before
112// and after a scrub to learn whether anything changed.
113export function hiddenCount(): number {
114  return hidden
115}
116
117// Runs `fn` and hands back every placeholder it minted or reused, so a hook
118// can count the values it hid without counting one value twice.
119export function collect<T>(fn: () => T): { value: T; tags: Set<string> } {
120  const outer = minted
121  const tags = new Set<string>()
122  minted = tags
123  try {
124    return { value: fn(), tags }
125  } finally {
126    minted = outer
127    if (outer) for (const t of tags) outer.add(t)
128  }
129}
130
131// A placeholder read back from a file or a log is quoted text, not a handle.
132// Left alone it would turn into the real secret the next time the model
133// copied that text into a command. So a live placeholder found in a tool's
134// output is swapped for a LITERAL one, which restores to the placeholder text
135// and nothing more. Run this before the detectors, so a key the tool printed
136// still comes back as its live placeholder.
137export function escapeLiteralTags(text: string): string {
138  if (byTag.size === 0 || !text.includes('[REDACTED-')) return text
139  return text.replace(TAG, (t) => (byTag.has(t) && !t.startsWith('[REDACTED-LITERAL-') ? mint('LITERAL', t) : t))
140}
141
142// ------------------------------------------------------- known value echoes
143
144// A value hidden once because of where it sat (after `API_KEY=`, inside a
145// URL) is hidden again wherever it turns up, even bare, where no detector
146// would fire on it: a tool that echoes a restored key must not print it.
147const echo = new Set<string>()
148let echoIndex: EchoIndex | null = null
149// The values minted during the current scan, to hide at their other spots.
150let local: Set<string> | null = null
151
152// Short or all-letter values (`postgres`) are left out: hiding every later
153// `postgres` in unrelated output would make the session unreadable.
154function echoable(value: string): boolean {
155  if (countsUp(value)) return false
156  // A slug or a dated name hidden once beside a key name is not worth hiding
157  // everywhere: it would turn every path that holds it into a placeholder.
158  if (value.split(/[^A-Za-z0-9]+/).every((p) => p === '' || isSlugPart(p))) return false
159  if (value.includes('\n')) return value.length >= 40
160  return value.length >= 20 || (value.length >= 10 && /[0-9]/.test(value) && /[A-Za-z]/.test(value))
161}
162
163// A value too short to hide across the session is still hidden at its other
164// spots in the same text, unless it is a plain word or slug (`postgres`
165// hidden once in a URL must not turn every `postgres://` into a placeholder).
166function localWorthy(value: string): boolean {
167  if (value.length < PREFIX || value.length > 400 || value.includes('\n') || countsUp(value)) return false
168  if (value.split(/[^A-Za-z0-9]+/).every((p) => p === '' || isSlugPart(p))) return false
169  return /[0-9]/.test(value) || /[^A-Za-z0-9]/.test(value) || value.length >= 12
170}
171
172// The known values are found with a rolling hash over their first PREFIX
173// characters, so the cost of the scan does not grow with the vault. A regex
174// of every value joined by `|` took 40 s on 200 KB once a long session had
175// hidden a few thousand values.
176const PREFIX = 8
177// Under each prefix the values are kept by length, longest first, so a
178// prefix that thousands of values share costs one set lookup per length,
179// not a walk of the whole list.
180type ByLength = { lengths: number[]; values: Set<string> }
181type EchoIndex = { hashes: Set<number>; byPrefix: Map<string, ByLength>; top: number }
182
183function prefixHash(text: string, at: number): number {
184  let h = 0
185  for (let i = 0; i < PREFIX; i++) h = (Math.imul(h, 31) + text.charCodeAt(at + i)) >>> 0
186  return h
187}
188
189function buildEchoIndex(values: Iterable<string>): EchoIndex {
190  const byPrefix = new Map<string, ByLength>()
191  for (const v of values) {
192    const p = v.slice(0, PREFIX)
193    let entry = byPrefix.get(p)
194    if (!entry) byPrefix.set(p, (entry = { lengths: [], values: new Set() }))
195    if (!entry.lengths.includes(v.length)) entry.lengths.push(v.length)
196    entry.values.add(v)
197  }
198  for (const entry of byPrefix.values()) entry.lengths.sort((a, b) => b - a)
199  const hashes = new Set<number>()
200  for (const p of byPrefix.keys()) hashes.add(prefixHash(p, 0))
201  // 31 to the power PREFIX - 1, to take the oldest character out of the hash.
202  let top = 1
203  for (let i = 0; i < PREFIX - 1; i++) top = Math.imul(top, 31) >>> 0
204  return { hashes, byPrefix, top }
205}
206
207function rehideKnown(text: string): string {
208  if (echo.size === 0 || text.length < PREFIX) return text
209  return rehideWith(text, (echoIndex ??= buildEchoIndex(echo)))
210}
211
212// The values minted in one scan, hidden at their other spots in it. A few
213// values are found faster one by one than by the rolling hash.
214function rehideLocal(text: string, values: Set<string>): string {
215  if (values.size > 64) return rehideWith(text, buildEchoIndex(values))
216  let out = text
217  for (const v of [...values].sort((a, b) => b.length - a.length)) if (out.includes(v)) out = out.split(v).join(mint('SECRET', v))
218  return out
219}
220
221function rehideWith(text: string, ix: EchoIndex): string {
222  if (text.length < PREFIX) return text
223  const parts: string[] = []
224  let last = 0
225  let h = prefixHash(text, 0)
226  for (let i = 0; i + PREFIX <= text.length; ) {
227    if (ix.hashes.has(h)) {
228      const entry = ix.byPrefix.get(text.slice(i, i + PREFIX))
229      let found: string | undefined
230      if (entry) {
231        for (const n of entry.lengths) {
232          const v = text.slice(i, i + n)
233          if (v.length === n && entry.values.has(v)) {
234            found = v
235            break
236          }
237        }
238      }
239      if (found) {
240        parts.push(text.slice(last, i), mint('SECRET', found))
241        i += found.length
242        last = i
243        if (i + PREFIX <= text.length) h = prefixHash(text, i)
244        continue
245      }
246    }
247    if (i + PREFIX >= text.length) break
248    h = (Math.imul((h - Math.imul(text.charCodeAt(i), ix.top)) >>> 0, 31) + text.charCodeAt(i + PREFIX)) >>> 0
249    i++
250  }
251  if (last === 0) return text
252  parts.push(text.slice(last))
253  return parts.join('')
254}
255
256// ------------------------------------------------------------------ parking
257
258// Some spans are known not to be secrets and are big enough to drown the
259// transcript in placeholders: an image in a data: URI, a lockfile hash, a
260// thinking-block signature, a base64 attachment. They are parked behind a
261// marker for the scan and put back after it. The marker is a character the
262// text does not contain; if every candidate is taken, nothing is parked and
263// the scan simply hides more.
264type Parked = { mark: string; spans: string[]; re: RegExp }
265let parked: Parked | null = null
266const MARKS = ['\u0000', '\uE000', '\uE001', '\uFDD0']
267
268function startParking(text: string): Parked | null {
269  const mark = MARKS.find((m) => !text.includes(m))
270  return mark ? { mark, spans: [], re: new RegExp(`${mark}(\\d+)${mark}`, 'g') } : null
271}
272
273function park(span: string): string {
274  if (!parked) return span
275  parked.spans.push(span)
276  return `${parked.mark}${parked.spans.length - 1}${parked.mark}`
277}
278
279function unpark(text: string): string {
280  const p = parked
281  if (!p || !text.includes(p.mark)) return text
282  let out = text
283  for (let i = 0; i < 3 && out.includes(p.mark); i++) out = out.replace(p.re, (_m, n: string) => p.spans[Number(n)] ?? '')
284  return out
285}
286
287// ------------------------------------------------------------- small tools
288
289// Bits per character. Counted in a reused array for plain ASCII, since the
290// scan calls this for every candidate token.
291const COUNTS = new Int32Array(128)
292
293function entropy(text: string): number {
294  let ascii = true
295  for (let i = 0; i < text.length; i++) {
296    const c = text.charCodeAt(i)
297    if (c >= 128) {
298      ascii = false
299      break
300    }
301  }
302  if (!ascii) {
303    const counts = new Map<string, number>()
304    for (const ch of text) counts.set(ch, (counts.get(ch) ?? 0) + 1)
305    let h = 0
306    for (const n of counts.values()) h -= (n / text.length) * Math.log2(n / text.length)
307    return h
308  }
309  COUNTS.fill(0)
310  for (let i = 0; i < text.length; i++) COUNTS[text.charCodeAt(i)]!++
311  let h = 0
312  for (let c = 0; c < 128; c++) {
313    const n = COUNTS[c]!
314    if (n > 0) h -= (n / text.length) * Math.log2(n / text.length)
315  }
316  return h
317}
318
319const B64_TABLE = (() => {
320  const t = new Int16Array(128).fill(-1)
321  const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
322  for (let i = 0; i < abc.length; i++) t[abc.charCodeAt(i)] = i
323  t['-'.charCodeAt(0)] = 62
324  t['_'.charCodeAt(0)] = 63
325  return t
326})()
327
328// Decodes the start of a base64 text into one character per byte. The hooks
329// run with no Buffer and no atob, so this is done by hand.
330function decodeBase64Head(text: string, max = 8192): string {
331  let out = ''
332  let acc = 0
333  let bits = 0
334  for (let i = 0; i < text.length && out.length < max; i++) {
335    const c = text.charCodeAt(i)
336    if (c === 61) break
337    const v = c < 128 ? B64_TABLE[c]! : -1
338    if (v < 0) continue
339    acc = ((acc << 6) | v) & 0xffffff
340    bits += 6
341    if (bits >= 8) {
342      bits -= 8
343      out += String.fromCharCode((acc >> bits) & 0xff)
344    }
345  }
346  return out
347}
348
349// Ranges of the text that the token scan skips. A `soft` range is skipped
350// only when no key name stands in front of the token.
351type Span = [start: number, end: number, soft: boolean]
352
353class Spans {
354  private list: Span[] = []
355  private i = 0
356  add(start: number, end: number, soft = false): void {
357    if (end > start) this.list.push([start, end, soft])
358  }
359  seal(): this {
360    this.list.sort((a, b) => a[0] - b[0])
361    return this
362  }
363  // Offsets are asked in ascending order, so a cursor keeps this linear.
364  at(offset: number): 'hard' | 'soft' | null {
365    const l = this.list
366    while (this.i < l.length && l[this.i]![1] <= offset) this.i++
367    let found: 'hard' | 'soft' | null = null
368    for (let j = this.i; j < l.length && l[j]![0] <= offset; j++) {
369      if (l[j]![1] > offset) {
370        if (!l[j]![2]) return 'hard'
371        found = 'soft'
372      }
373    }
374    return found
375  }
376}
377
378// ------------------------------------------------------------ secret tests
379
380// Escapes that end in a letter or digit and may sit right before a key:
381// quoted-printable `=3D`, URL `%3D`, and the `\n` or `"` of escaped JSON.
382const ESC = String.raw`[%=]3D|\\[nrtfbv]|\\u[0-9A-Fa-f]{4}`
383const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
384
385// A vendor prefix only counts at the start of a word, or right after one of
386// those escapes. Without this, `task-runner-for-the-build` reads as `sk-`.
387// The prefix is written first and the boundary checked behind it, so the
388// regex engine can jump between occurrences of the prefix instead of
389// testing the boundary at every character of the text.
390function word(prefix: string, body: string, edge = 'A-Za-z0-9'): string {
391  return String.raw`${prefix}(?<=(?:^|[^${edge}]|${ESC})${prefix})${body}`
392}
393
394// Shapes that belong to one vendor and mean one thing. These are hidden on
395// sight, whatever their entropy. Each has a hint, a cheap test that the text
396// could hold one, so a 10 MB log pays only for the shapes it might contain.
397// Every quantifier is bounded: an open `{n,}` over a run of millions of
398// characters overflows the regex stack.
399type Shape = { hint: string | RegExp; re: string }
400const VENDOR_SHAPES: readonly Shape[] = [
401  { hint: 'sk-ant-', re: word('sk-ant-', '[A-Za-z0-9_-]{16,4096}') },
402  // A real OpenAI key has a digit or a capital early on; a kebab word does not.
403  { hint: 'sk-', re: word('sk-', '(?=[a-z_-]{0,200}[0-9A-Z])[A-Za-z0-9_-]{20,4096}') },
404  { hint: /gh[pousr]_/, re: word('gh[pousr]_', '[A-Za-z0-9]{20,4096}') },
405  { hint: 'github_pat_', re: word('github_pat_', '[A-Za-z0-9_]{20,4096}') },
406  { hint: 'xox', re: word('xox[baprse]-', '[A-Za-z0-9-]{10,4096}') },
407  { hint: 'xapp-', re: word('xapp-', '[0-9]-[A-Za-z0-9-]{10,4096}') },
408  { hint: /A[KS]IA/, re: word('A(?:KIA|SIA)', '[0-9A-Z]{16}(?![A-Za-z0-9])') },
409  { hint: /[sr]k_(?:live|test)_/, re: word('[sr]k_(?:live|test)_', '[A-Za-z0-9]{16,4096}') },
410  { hint: 'whsec_', re: word('whsec_', '[A-Za-z0-9]{16,4096}') },
411  { hint: 'AIza', re: word('AIza', '[0-9A-Za-z_-]{30,4096}') },
412  { hint: 'ya29.', re: word(String.raw`ya29\.`, '[A-Za-z0-9_-]{20,4096}') },
413  { hint: 'npm_', re: word('npm_', '[A-Za-z0-9]{30,4096}') },
414  { hint: 'dop_v1_', re: word('dop_v1_', '[a-f0-9]{40,4096}') },
415  { hint: 'glpat-', re: word('glpat-', '[A-Za-z0-9_-]{16,4096}') },
416  { hint: 'shpat_', re: word('shpat_', '[a-f0-9]{32,4096}') },
417  { hint: 'SG.', re: word(String.raw`SG\.`, String.raw`[A-Za-z0-9_-]{16,4096}\.[A-Za-z0-9_-]{16,4096}`) },
418  { hint: 'hooks.slack.com', re: String.raw`https://hooks\.slack\.com/services/[A-Za-z0-9/+_-]{16,4096}` },
419  { hint: '/api/webhooks/', re: String.raw`https://discord(?:app)?\.com/api/webhooks/[0-9]{1,30}/[A-Za-z0-9_-]{16,4096}` },
420  // Prefixes built from words, which the name test would otherwise excuse.
421  { hint: 'vercel_blob_rw_', re: word('vercel_blob_rw_', '[A-Za-z0-9]{16}_[A-Za-z0-9]{30}') },
422  { hint: 'tskey-', re: word('tskey-', '[a-z]{1,20}-[A-Za-z0-9_-]{20,4096}') },
423  { hint: 'pscale_', re: word('pscale_', '(?:tkn|pw|oauth)_[A-Za-z0-9_-]{32,4096}') },
424  { hint: 'pdl_', re: word('pdl_', '(?:live|sdbx)_apikey_[A-Za-z0-9_-]{20,4096}') },
425  { hint: 'pypi-AgE', re: word('pypi-AgE', '[A-Za-z0-9_-]{50,4096}') },
426  { hint: 'sntrys_eyJ', re: word('sntrys_eyJ', '[A-Za-z0-9+/=_-]{50,4096}') },
427  { hint: 'pcsk_', re: word('pcsk_', '[A-Za-z0-9_-]{40,4096}') },
428  { hint: 'HRKU-AA', re: word('HRKU-AA', '[A-Za-z0-9_-]{40,4096}') },
429  { hint: 'dckr_', re: word('dckr_', '(?:pat|oat)_[A-Za-z0-9_-]{20,4096}') },
430  { hint: 'figd_', re: word('figd_', '[A-Za-z0-9_-]{30,4096}') },
431  { hint: 'GOCSPX-', re: word('GOCSPX-', '[A-Za-z0-9_-]{20,4096}') },
432  { hint: /gl(?:rt|dt|ptt|cbt|imt|ft|soat|oas|agent)-/, re: word('gl(?:rt|dt|ptt|cbt|imt|ft|soat|oas|agent)-', '[A-Za-z0-9_-]{20,4096}') },
433  { hint: 'bb_live_', re: word('bb_live_', '[A-Za-z0-9_-]{20,4096}') },
434  { hint: 'sb_secret_', re: word('sb_secret_', '[A-Za-z0-9_-]{20,4096}') },
435  { hint: 'ATATT3', re: word('ATATT3', '[A-Za-z0-9_=-]{100,4096}') },
436  // Keys whose secret half sits after a `.`, `:` or `/`.
437  { hint: 'pat', re: word('pat', String.raw`[A-Za-z0-9]{14}\.[a-f0-9]{64}`) },
438  { hint: /[st]k\.eyJ/, re: word(String.raw`[st]k\.eyJ`, String.raw`[A-Za-z0-9_-]{10,4096}\.[A-Za-z0-9_-]{20,4096}`) },
439  { hint: 'st.', re: word(String.raw`st\.`, String.raw`(?:[0-9a-f]{24}|${UUID})\.[0-9a-f]{32}\.[0-9a-f]{32}`) },
440  { hint: /\/[0-9]{8}[0-9/]*:[0-9a-f]{32}/, re: String.raw`(?<![0-9])[0-9]/[0-9]{8,30}(?:/[0-9]{8,30})?:[0-9a-f]{32}` },
441  { hint: /-[0-9a-f]{12}:[0-9a-f]{32}/, re: String.raw`(?<![0-9A-Fa-f])${UUID}:[0-9a-f]{32}` },
442  // Hugging Face tokens are letters only, so no entropy rule can see them.
443  { hint: /(?:hf|api_org)_/, re: word('(?:hf|api_org)_', '[A-Za-z0-9]{34}(?![A-Za-z0-9_])') },
444  { hint: 'ops_eyJ', re: word('ops_eyJ', '[A-Za-z0-9+/=_-]{50,4096}') },
445  { hint: /fm[12][ar]?_/, re: word('fm[12][ar]?_', '[A-Za-z0-9+/=,_-]{50,4096}') },
446  { hint: 'sig=', re: String.raw`(?<=[?&]sig=)[A-Za-z0-9%+/]{30,4096}` },
447  { hint: 'AAAAAAAAAAAAAAAAAAAAA', re: word('AAAAAAAAAAAAAAAAAAAAA', '[A-Za-z0-9]{0,200}%[A-Za-z0-9%]{30,4096}') },
448  { hint: 'Q~', re: String.raw`[A-Za-z0-9_~.-]{3}[0-9][A-Za-z0-9_~.-]Q~[A-Za-z0-9_~.-]{31,34}` },
449  // Lowercase and hex shapes that one vendor owns.
450  { hint: 'e2b_', re: word('e2b_', String.raw`[a-f0-9]{40}(?![\w-])`) },
451  { hint: 'lsv2_', re: word('lsv2_', String.raw`(?:pt|sk)_[a-f0-9]{32}_[a-f0-9]{10}(?![\w-])`) },
452  { hint: /[0-9a-f]-us[0-9]/, re: String.raw`(?:(?<=${ESC})|(?<![A-Za-z0-9]))[a-f0-9]{32}-us[0-9]{1,2}(?![\w-])` },
453  { hint: /-[a-f0-9]{8}-[a-f0-9]{8}(?![\w-])/, re: String.raw`(?:(?<=${ESC})|(?<![A-Za-z0-9]))[a-f0-9]{32}-[a-f0-9]{8}-[a-f0-9]{8}(?![\w-])` },
454  { hint: /xa[ap]t-/, re: word('xa[ap]t-', String.raw`${UUID}(?![\w-])`) },
455  { hint: 'SK', re: word('SK', String.raw`[0-9a-f]{32}(?![\w-])`) },
456  { hint: 'tvly-', re: word('tvly-', '(?:dev-|prod-)?[A-Za-z0-9]{32,4096}') },
457  { hint: 'tr_', re: word('tr_', '(?:dev|prod|stg|pat)_[A-Za-z0-9]{20,4096}', 'A-Za-z0-9_') },
458  { hint: ':AA', re: String.raw`(?<![0-9])[0-9]{8,10}:AA[A-Za-z0-9_-]{33}(?![A-Za-z0-9_-])` },
459  { hint: /a[ks]-/, re: word('a[ks]-', '[A-Za-z0-9]{22}(?![A-Za-z0-9_-])', 'A-Za-z0-9_-') },
460  // Keys that reuse a Stripe id prefix: Resend `re_`, Stripe client secrets.
461  { hint: 're_', re: word('re_', '[A-Za-z0-9]{8}_[A-Za-z0-9]{24}(?![A-Za-z0-9_])', 'A-Za-z0-9_') },
462  { hint: '_secret_', re: word('(?:pi|seti)_', '[A-Za-z0-9]{14,64}_secret_[A-Za-z0-9]{16,4096}', 'A-Za-z0-9_') },
463  // A Klaviyo private key is `pk_` and 34 alnum. Stripe and Clerk publishable
464  // keys always carry `live_` or `test_` after `pk_`, so they do not match.
465  { hint: 'pk_', re: word('pk_', '[A-Za-z0-9]{34}(?![A-Za-z0-9_])', 'A-Za-z0-9_') },
466  // An age identity (SOPS reads it from keys.txt) is upper case and digits.
467  { hint: 'AGE-SECRET-KEY-1', re: word('AGE-SECRET-KEY-1', '[0-9A-Z]{58}') },
468  { hint: 'AGE-PLUGIN-', re: word('AGE-PLUGIN-[A-Z0-9]{1,40}-1', '[0-9A-Z]{20,4096}') },
469  // Tokens that are not mixed case, so no entropy rule sees them alone.
470  { hint: /NR(?:AK|II|JS|AA|RA)-/, re: word('NR(?:AK|II|JS|AA|RA)-', '[A-Za-z0-9_-]{27,40}') },
471  { hint: 'dt0c01.', re: word(String.raw`dt0c01\.`, String.raw`[A-Z0-9]{24}\.[A-Z0-9]{64}`) },
472  { hint: /pat-(?:na|eu|ap)[0-9]-/, re: word('pat-(?:na|eu|ap)[0-9]-', String.raw`${UUID}(?![\w-])`) },
473  { hint: /(?:sdk|api)-[0-9a-f]{8}-/, re: word('(?:sdk|api)-', String.raw`${UUID}(?![\w-])`) },
474  { hint: 'lin_oauth_', re: word('lin_oauth_', '[a-f0-9]{64}') },
475  { hint: 'shippo_', re: word('shippo_(?:live|test)_', '[a-f0-9]{40}') },
476  { hint: 'dapi', re: word('dapi', String.raw`[a-f0-9]{32}(?:-[0-9]{1,2})?(?![\w-])`) },
477  { hint: ':fx', re: String.raw`(?<![0-9a-f-])${UUID}:fx(?!\w)` },
478  { hint: 'access_token$', re: String.raw`access_token\$(?:production|sandbox)\$[a-z0-9]{16}\$[a-f0-9]{32}` },
479  // A Twilio auth token is plain hex; it is known by the account SID beside it.
480  { hint: /AC[0-9a-f]{32}["']?[ \t]*[,:]/, re: String.raw`(?<=\bAC[0-9a-f]{32}["']?[ \t]*[,:][ \t]*["']?)[0-9a-f]{32}(?![0-9A-Za-z])` },
481]
482
483// Each shape also takes in the rest of its token, so a match that stops at a
484// `-` or `_` inside a key cannot leave the tail of the key on screen.
485const VENDOR = VENDOR_SHAPES.map((s) => ({ hint: s.hint, re: new RegExp(`(?:${s.re})[A-Za-z0-9_-]{0,4096}`, 'g') }))
486
487function scrubVendors(text: string, cfg: Config): string {
488  let out = text
489  for (const { hint, re } of VENDOR) {
490    if (typeof hint === 'string' ? !out.includes(hint) : !hint.test(out)) continue
491    out = out.replace(re, (m) => (cfg.allow.has(m) || m.includes('[REDACTED-') ? m : mint('SECRET', m)))
492  }
493  return out
494}
495
496// A private key block, up to its END line (never past the next BEGIN, so a
497// file of BEGIN lines is not scanned again for each one). When the END line
498// is missing (`head key.pem`), the base64 lines that follow the BEGIN line
499// are still taken, and only those.
500const PRIVATE_KEY =
501  /-----BEGIN[^\n-]{0,40}PRIVATE KEY(?: BLOCK)?-----(?:(?:(?!-----BEGIN)[\s\S]){0,65536}?-----END[^\n-]{0,40}PRIVATE KEY(?: BLOCK)?-----|(?:\r?\n(?:[A-Za-z][A-Za-z-]{0,40}: [^\n]*|[ \t]*))*(?:\r?\n[A-Za-z0-9+/=]{4}[A-Za-z0-9+/=]*(?=\r?\n|$))+)/g
502const PUTTY_PRIVATE = /(Private-Lines: \d+\r?\n)((?:[A-Za-z0-9+/=]{1,80}(?:\r?\n|$))+)/g
503
504// A run of dot-separated parts, where a JWT may hide. It has one starting
505// point per run, so a long run is scanned once. A JWT regex of its own went
506// quadratic on base64 JSON, where every row starts with `eyJ`.
507const DOTTED = /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_-]+)+/g
508
509// The password inside a connection string: postgres://user:REDACTED@host.
510// The user may be empty (redis://:pw@host), and the password may hold `/` or
511// `@`: it ends at the `@` that is followed by a host.
512const URL_PASSWORD =
513  /\b([a-z][a-z0-9+.-]{1,20}:\/\/[^\s/@:[\]]{0,80}):(?!\d{1,5}(?:[/?#]|$))([^\s"'`<>]{3,200}?)@(?=[A-Za-z0-9.-]{1,253}(?::\d{1,5})?(?:[/?#\s"'`<>]|$))/gi
514
515// A token used alone as the userinfo: `https://<token>@dev.azure.com/...`,
516// the documented clone form for Azure DevOps, GitHub and Bitbucket tokens.
517const URL_USERINFO = /\b([a-z][a-z0-9+.-]{1,20}:\/\/)([A-Za-z0-9_-]{20,200})@(?=[A-Za-z0-9.-]{1,253}(?::\d{1,5})?[/:])/g
518
519// A credential in a URL query: ?api_key=, &sig=, ?accessToken=. The value may
520// be percent-encoded, so it is decoded before it is judged.
521const URL_KEYWORD =
522  '(?:[Aa]pi[_-]?[Kk]ey|API[_-]?KEY|[Aa]pikey|[Kk]ey|KEY|[Tt]oken|TOKEN|[Ss]ecret|SECRET|[Ss]ig|SIG|[Ss]ignature|SIGNATURE|[Pp]assword|PASSWORD|[Pp]asswd|[Pp]wd|PWD|[Aa]uth|AUTH|[Cc]redential|CREDENTIAL)'
523const URL_PARAM = new RegExp(
524  String.raw`([?&;](?:[A-Za-z0-9._-]*[_.-]|[a-z0-9]+(?=[A-Z]))?${URL_KEYWORD}=)((?:[A-Za-z0-9+_~-]|%[0-9A-Fa-f]{2}){8,400}={0,2})(?=[&#\s"'<>)\]]|$)`,
525  'g',
526)
527
528// Values after a password-like key, judged by the key and not by entropy:
529// a human password (`Summer2024!`, `hunter2hunter2`) has too little.
530const PW_WORD = String.raw`(?:password|passwd|passphrase|secret|secret[_-]?key|client[_-]?secret|api[_-]?key|apikey|private[_-]?key|secret[_-]?access[_-]?key|access[_-]?key|auth[_-]?token)`
531// Short password names (`pass`, `pw`, `pwd`, `passcode`). They count only as
532// the whole key or after a separator, so `compass` and `bypass` are left.
533const SHORT_PW = String.raw`(?:pass(?:[_-]?code)?|pwd?)`
534const SHORT_KEY = new RegExp(String.raw`(?:^|[_.-])${SHORT_PW}$`, 'i')
535// A short key (`pass:`) with a number or a short word after it is a count
536// or a status, not a password.
537const shortKeyStatus = (key: string, v: string): boolean => SHORT_KEY.test(key) && (/^[0-9]+$/.test(v) || /^[a-z]{1,8}$/.test(v))
538const STRONG_QUOTED = new RegExp(
539  String.raw`((?:^|[\s{,;(\[])(\\?["']?)((?:[A-Za-z0-9._-]*[_.-]|pg)?${PW_WORD}|(?:[A-Za-z0-9._-]*[_.-])?${SHORT_PW})\2[ \t]*[:=][ \t]*)(\\?["'])([^\n]{3,200}?)\4`,
540  'gim',
541)
542// A YAML list item (`- password: x`, compose `- DB_PASS=x`) counts too.
543const STRONG_LINE = new RegExp(
544  String.raw`^([ \t]*(?:-[ \t]+)?(?:export[ \t]+)?([A-Za-z0-9._-]*${PW_WORD}|(?:[A-Za-z0-9._-]*[_.-])?${SHORT_PW})[ \t]*[:=][ \t]*)([^\s"'#;][^\s"';]{2,199})[ \t]*$`,
545  'gim',
546)
547// `DB_PASS=`: upper case with a separator only, so prose `Pass:` is left.
548const ENV_PASS = /^([ \t]*(?:-[ \t]+)?(?:export[ \t]+)?()[A-Z0-9_]*_PASS[ \t]*[:=][ \t]*)([^\s"'#;][^\s"';]{2,199})[ \t]*$/gm
549// A Gmail app password written unquoted: four groups of four letters.
550const APP_PASSWORD = new RegExp(
551  String.raw`^([ \t]*(?:-[ \t]+)?(?:export[ \t]+)?[A-Za-z0-9._-]*${PW_WORD}[ \t]*[:=][ \t]*)([a-z]{4}(?: [a-z]{4}){3})[ \t]*$`,
552  'gim',
553)
554// The line after a YAML block indicator: `password: |` then the value.
555const BLOCK_PASSWORD = new RegExp(
556  String.raw`^([ \t]*(?:-[ \t]+)?["']?[A-Za-z0-9._-]*${PW_WORD}["']?[ \t]*:[ \t]*[|>][-+]?[0-9]?[ \t]*\r?\n[ \t]+)(\S[^\n]{2,199}?)[ \t]*(?=\r?$)`,
557  'gim',
558)
559// SQL: `CREATE USER x WITH PASSWORD '...'`, MySQL `IDENTIFIED BY '...'`.
560const SQL_PASSWORD =
561  /(\b(?:(?:with|encrypted|unencrypted)[ \t]+password|identified[ \t]+(?:with[ \t]+\S+[ \t]+)?by(?:[ \t]+password)?)[ \t]+)'([^'\n]{3,200})'/gi
562// Terraform: the default of a variable whose name says it is a password.
563const HCL_DEFAULT = new RegExp(String.raw`(\bvariable[ \t]+"[\w-]*${PW_WORD}"[ \t]*\{[^{}]{0,400}?\bdefault[ \t]*=[ \t]*")([^"\n]{3,200})"`, 'gi')
564// Terraform state: the result of a random_password (usually a database
565// master password), and an output that sits under a password-like name.
566const TF_RESULT = /("result"\s*:\s*")((?:[^"\\\n]|\\.){3,400})"/g
567const TF_OUTPUT = new RegExp(
568  String.raw`("([\w.-]{1,100})"\s*:\s*\{\s*(?:"(?:sensitive|type)"\s*:\s*[^,{}]{1,40},\s*){0,4}"value"\s*:\s*")((?:[^"\\\n]|\\.){3,400})"`,
569  'g',
570)
571// A name and its value as sibling fields: a HAR header or form field, a
572// Kubernetes env entry. The name decides; the value sits apart from it.
573const JSON_NAME_VALUE = /("name"\s*:\s*"([^"\n]{1,100})"\s*,\s*"value"\s*:\s*")((?:[^"\\\n]|\\.){3,400})"/g
574const JSON_VALUE_NAME = /("value"\s*:\s*")((?:[^"\\\n]|\\.){3,400})("\s*,\s*"name"\s*:\s*"([^"\n]{1,100})")/g
575const YAML_NAME_VALUE =
576  /^([ \t]*)(-[ \t]+name:[ \t]*["']?([\w.-]{1,100})["']?[ \t]*\r?\n\1[ \t]+value:[ \t]*(["']?))([^\n"']{3,200}?)\4[ \t]*(?=\r?$)/gm
577const PW_NAME = new RegExp(String.raw`^[\w.-]*${PW_WORD}$|^(?:[\w.-]*[_.-])?${SHORT_PW}$`, 'i')
578// SQL: an INSERT whose column list names a key or a password.
579const SQL_INSERT = /\bINSERT[ \t]+INTO[ \t]+[\w."`[\]]{1,100}[ \t]*\(([^()\n]{1,1000})\)\s*VALUES\s*/gi
580// `PGPASSWORD=x psql` is the one common env name with no separator.
581const KV_PASSWORD = /((?:^|[\s;&?,])-{0,2}(?:[A-Za-z0-9._-]*[_.-]|pg)?(?:password|passwd)=|[;&?]pwd=)([^\s"'&;,]{3,200})/gim
582
583// A generated password in quotes, with no name beside it: upper, lower,
584// digit and a symbol from the Terraform and password-manager sets, no space,
585// no path or regex characters. The token scan cuts such a value at each
586// symbol, so it is judged whole here.
587// The opening quote must follow a space, `=`, `:`, `(`, `,`, `[` or `{`, so
588// the text between two strings (`"0"=20cellpadding=3D"`) is not taken.
589const QUOTED_VALUE = /(?<=^|[\s:=(,[{])(["'])([^\s"'\\/.,;`]{12,128})\1/gm
590const PW_SYMBOL = /[!#$%&*()=+[\]{}<>:?@^~]/
591
592function looksGenerated(v: string): boolean {
593  if (!/[A-Z]/.test(v) || !/[a-z]/.test(v) || !/[0-9]/.test(v) || !PW_SYMBOL.test(v)) return false
594  if (/\$\{|\{\{|%\(|^\^|\$$|[A-Za-z]-[A-Za-z]\]|=>|::|\(\)|<\/?[a-z]|=[0-9A-F]{2}|[0-9]{6}/.test(v)) return false
595  // Real words in it: code, a format, a shortcut (`Ctrl+Alt+F12`), a payee
596  // with an order number (`Merchant#a12345`).
597  const words = v.match(/[A-Za-z]{4,}/g)?.filter((w) => WORDY.test(w)) ?? []
598  if (words.length >= 2 || words.some((w) => w.length >= 6)) return false
599  // Mostly letters and digits: a run of symbols is binary or markup.
600  if ((v.match(/[A-Za-z0-9]/g)?.length ?? 0) * 5 < v.length * 3) return false
601  return entropy(v) >= 3.5
602}
603
604// Things that stand in for a value without being one: a variable, a call, a
605// template, a placeholder, a type, the key's own name, a path.
606// A call has to close (`getSecret("x")`), and a YAML tag is a word
607// (`!Ref DbPassword`), so a generated password that starts `ab(` or `!` is
608// not taken for one.
609const REFERENCE =
610  /^[A-Za-z_$][\w$.]*\(.*\)[,;]?$|^\$|^\{\{|^<.*>$|^%\(|^!!?[A-Za-z][\w:]*(?:[ \t]|$)|^\*+$|^(?:true|false|null|none|nil|undefined|required|optional|string|str|bool|int|number)$|^(?:process\.env|os\.environ|env\.|config\.|settings\.)|^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/i
611const SELF_REF =
612  /^[{`(]?(?:[a-z][A-Za-z0-9]*)?(?:Password|Passwd|Secret|Key|Token|Pwd)[A-Za-z0-9]*[)},`]*$|^(?:password|passwd|secret|apikey|api_key|pwd|token)[),}]*,?$/
613const PLACEHOLDER = /^(?:x{3,}|\.{3,}|…|(?:your|my|<)[_ -]?[a-z_ -]*(?:key|password|secret|token)[a-z_ -]*>?)$/i
614
615function isStandIn(v: string): boolean {
616  return (
617    REFERENCE.test(v) ||
618    SELF_REF.test(v) ||
619    PLACEHOLDER.test(v) ||
620    /^(?:[/~]|\.\.?\/)/.test(v) ||
621    /^\{[\w$.]+\}$/.test(v) ||
622    // A test fixture made of words: `test-internal-key`, `fake_secret`.
623    (/^(?:[a-z]+[_-])*(?:test|fake|dummy|mock|sample|example|placeholder|changeme)(?:[_-][a-z]+)*$/.test(v) && /[_-]/.test(v)) ||
624    // `password: pw,` in an object literal is code, not a value.
625    /^[A-Za-z_$][\w$]*,$/.test(v)
626  )
627}
628
629// Credentials on a command line: `--password x`, `curl -u user:pw`,
630// `mysql -pPW`, a .pgpass line, a .netrc entry, `gh secret set --body`.
631const CLI_FLAG =
632  /((?:^|\s)--?(?:password|passwd|pass|api-key|apikey|api_key|token|client-secret|auth-token|access-token)(?:=|[ \t]+)(["']?))(?![$<{%])([^\s"'=-][^\s"']{2,200})\2/g
633const CLI_USERPASS =
634  /(\b(?:curl|wget|http|https|xh|httpie|lftp|ftp)\b[^\n|;&]*?\s(?:-u|-U|--user|--proxy-user)(?:[ \t]+|=)["']?[^\s:"'$]+:)(?!\$)([^\s"'`]{3,200})/g
635const MYSQL_P =
636  /(\b(?:mysql(?:dump|admin|import|sh|check|slap|binlog)?|mariadb(?:-dump|-admin)?)\b[^\n|;&]*?\s-p)(?!\$)([^\s`-][^\s`]{2,200})/g
637const PGPASS =
638  /^((?:\*|[A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\]):(?:\d{1,5}|\*):(?![0-9]+:)[A-Za-z0-9_$*.-]+:[A-Za-z0-9_$*.@-]+:)(?!\/\/)(\S{3,200})$/gm
639const NETRC =
640  /((?:^|\n)[ \t]*(?:machine[ \t]+\S+|default)(?:\s+(?:login|account|port)[ \t]+\S+)*\s+password[ \t]+)(\S{3,200})(?=[ \t]*(?:\r?\n|$)|\s+(?:machine|default|login|account|macdef)\b)/g
641const GH_SECRET = /(\bgh[ \t]+secret[ \t]+set\b[^\n;&|]*?[ \t](?:-b|--body)(?:[ \t]+|=)(["']?))([^\s"']{3,400})(?=\2)/g
642// Short password flags that only mean a password for one command:
643// `sshpass -p`, `docker login -p`, `mongosh -p`, `7z -pPW`, `sqlcmd -P`,
644// `zip -P`, `redis-cli -a`, `ldapsearch -w`. Each is anchored at its command.
645const CMD_PW_TOOLS: [cmd: string, flag: string][] = [
646  [String.raw`sshpass|(?:docker|podman|nerdctl|helm[ \t]+registry)[ \t]+login|mongo(?:sh|dump|restore|import|export)?`, String.raw`-p[ \t]*`],
647  [String.raw`7za?|7zz`, '-p'],
648  [String.raw`sqlcmd|zip`, String.raw`-P[ \t]+`],
649  [String.raw`redis-cli|valkey-cli`, String.raw`-a[ \t]+`],
650  [String.raw`ldap[a-z]+`, String.raw`-w[ \t]+`],
651]
652const CMD_PW = CMD_PW_TOOLS.map(
653  ([cmd, flag]) => new RegExp(String.raw`(\b(?:${cmd})\b[^\n|;&]*?[ \t]${flag}(["']?))(?![$-])([^\s"']{3,200})\2`, 'g'),
654)
655const CMD_PW_GATE = /sshpass|login|mongo|7z|sqlcmd|zip|redis-cli|valkey-cli|ldap/
656// `openssl ... -passout pass:PW`, `smbclient -U user%PW`, `htpasswd -b file
657// user PW`, `rabbitmqctl add_user user PW`.
658const OPENSSL_PASS = /(-pass(?:in|out)?[ \t]+pass:)([^\s"']{3,200})/g
659const SMB_USER = /(\bsmbclient\b[^\n|;&]*?[ \t]-U[ \t]+["']?[^\s%"']+%)([^\s"']{3,200})/g
660const HTPASSWD = /(\bhtpasswd[ \t]+-[a-zA-Z]*b[a-zA-Z]*[ \t]+(?:-[a-zA-Z]+[ \t]+)*\S+[ \t]+\S+[ \t]+)(["']?)([^\s"']{3,200})\2/g
661const NET_USER = /(\bnet[ \t]+user[ \t]+(?:"[^"\n]{1,64}"|\S+)[ \t]+)(["']?)((?!\/)[^\s"'*]{3,200})\2/gi
662const RABBIT_USER = /(\brabbitmqctl[ \t]+(?:add_user|change_password)[ \t]+\S+[ \t]+)(["']?)([^\s"']{3,200})\2/g
663
664// Cookies: every value in a Cookie header, curl -b, or cookies.txt.
665const COOKIE = /((?:^|["'\s])(?:(?:set-)?cookie["']?[ \t]*:|-b|--cookie)[ \t]*["']?)([^"'\n]*)/gim
666const COOKIE_PAIR = /(^|;[ \t]*)([^=;\s]+=)([^;\s]{16}[^;\s]*)/g
667const COOKIE_ATTR = /^(?:path|domain|expires|max-age|samesite|priority)=$/i
668const COOKIE_VALUE = /^(?:s%3A)?([A-Za-z0-9_+%-]{16}[A-Za-z0-9_+%-]*)(?:\.[A-Za-z0-9_+%/-]{16}[A-Za-z0-9_+%/-]*)?={0,2}$/i
669const COOKIES_TXT =
670  /^((?:#HttpOnly_)?\.?[\w.-]+\t(?:TRUE|FALSE)\t\S+\t(?:TRUE|FALSE)\t\d+\t[^\t\n]+\t)([^\t\r\n]{16}[^\t\r\n]*)$/gm
671const HAR_COOKIE = /("name"\s*:\s*"[^"]*(?:sess|sid|token|auth)[^"]*"\s*,\s*"value"\s*:\s*")([^"]{16}[^"]*)"/gi
672
673// Basic auth and Docker `auth`: base64 of `user:password`, often short.
674const BASIC_AUTH = /((?:["']auth["'][ \t]*:[ \t]*["']|\b[Bb]asic[ \t]+))([A-Za-z0-9+/]{8,200}={0,2})(?![A-Za-z0-9+/=])/g
675// A Kubernetes bootstrap token: `abcdef.0123456789abcdef`.
676const BOOTSTRAP = /\b([a-z0-9]{6})\.([a-z0-9]{16})\b/g
677
678// Inline media in a data: URI. Only media types: a key put in a JSON or text
679// data URI is still scanned. The length floor stops a short key placed after
680// a fake image prefix from being excused.
681const DATA_URI =
682  /data:(?:(?:image|font|audio|video)\/[\w.+-]+|application\/(?:x-)?font-[\w.+-]+|application\/vnd\.ms-fontobject)(?:;[\w-]+=[\w.-]+)*;base64,[A-Za-z0-9+/]{64}[A-Za-z0-9+/]*={0,2}/g
683
684// A base64 blob: one long run, or three or more wrapped lines (a PEM bundle,
685// a MIME part). A trailing short line is only taken when a blank line, a
686// `--boundary` or the end of the text follows it.
687const B64_LINE = String.raw`[A-Za-z0-9+/]{40}[A-Za-z0-9+/]*={0,2}\r?\n`
688const BLOB = new RegExp(
689  String.raw`(?<![A-Za-z0-9+/=])(?:(?:${B64_LINE}){3}(?:${B64_LINE})*(?:[A-Za-z0-9+/]+={0,2}(?=\r?\n[\r\n-]|\r?\n?$))?|[A-Za-z0-9+/]{256}[A-Za-z0-9+/]*={0,2})`,
690  'g',
691)
692const BLOB_NAME =
693  /(?:key|token|secret|password|passwd|credential|auth|private|signature|session|cookie|dsn|cert[_-]?key)[\w.-]*["'\]\s]{0,4}[:=]{1,2}\s*["'`]?\s*$/i
694const BLOB_INNER = /PRIVATE KEY|OPENSSH|"private_key"|"client_secret"|"refresh_token"|"password"|"auths"/
695
696// A Claude thinking-block signature, and a redacted thinking block: opaque
697// to everyone and useless as a credential. Only the value is parked; the
698// thinking text beside it is still scanned.
699const THINKING_SIG =
700  /("type"\s*:\s*"thinking"\s*,\s*"thinking"\s*:\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*,\s*"signature"\s*:\s*")([A-Za-z0-9+/]{200}[A-Za-z0-9+/]*={0,2})(?=")/g
701const REDACTED_THINKING = /("type"\s*:\s*"redacted_thinking"\s*,\s*"data"\s*:\s*")([A-Za-z0-9+/]{100}[A-Za-z0-9+/]*={0,2})(?=")/g
702
703// A lockfile or SRI integrity hash: exact digest lengths only, so a key
704// glued after `sha256-` is still judged.
705const SRI = /\bsha(?:1-[A-Za-z0-9+/]{27}=|256-[A-Za-z0-9+/]{43}=|384-[A-Za-z0-9+/]{64}|512-[A-Za-z0-9+/]{86}==)(?![A-Za-z0-9+/=])/g
706
707// Public font files: the file name is a hash, not a secret. Path characters
708// only, so a query string on the same URL is still scanned.
709const PUBLIC_URL = /https:\/\/fonts\.gstatic\.com\/[A-Za-z0-9_/.-]+/g
710
711// Mail: an optional `cat -n`, Read or `grep -n` prefix on each line.
712const PFX = String.raw`(?:[ \t]*\d+\t|[^\s:]+:\d+[:-])?`
713const PFX_RE = new RegExp(`^${PFX}`)
714const CTE = new RegExp(String.raw`^${PFX}Content-Transfer-Encoding:[ \t]*base64[ \t]*\r?$`, 'gim')
715const CONTENT_TYPE = new RegExp(String.raw`^${PFX}Content-Type:[ \t]*(?:\r?\n${PFX}[ \t]+)?([^;\s]+)`, 'im')
716const MAIL_HEADER_NAMES =
717  'DKIM-Signature|X-Google-DKIM-Signature|ARC-Seal|ARC-Message-Signature|ARC-Authentication-Results|Authentication-Results|Message-I[Dd]|References|In-Reply-To|Feedback-ID|X-SG-EID|X-SG-ID|X-MSFBL|X-Gm-Message-State|X-Google-Smtp-Source|X-Gm-Gg|X-Gm-Features|X-Received|Received-SPF|Return-Path|Received'
718const MAIL_HEADER = new RegExp(String.raw`^${PFX}(?:${MAIL_HEADER_NAMES}):[^\n]*(?:\r?\n${PFX}[ \t][^\n]*){0,12}`, 'gm')
719const HEADER_LINE = new RegExp(String.raw`^${PFX}[A-Z][A-Za-z0-9-]*:`)
720const ENCODED_WORD = /=\?[\w-]+\?[Bb]\?[A-Za-z0-9+/=]{4}[A-Za-z0-9+/=]*\?=/g
721const BOUNDARY = /Content-Type:[ \t]*multipart\/[^\n]*?(?:\r?\n[ \t][^\n]*?)?\bboundary="?([^"\s;]+)"?/gi
722
723// The generic scan. A slash-bearing base64 run (an AWS secret, `openssl rand
724// -base64`) is judged whole first; anything else is cut into candidate
725// tokens. `/` and `.` are left out of a candidate on purpose: with them in,
726// every long file path becomes one. A token never starts inside a `%XX`
727// escape, and `=` only counts as padding when nothing follows it.
728const CAND_BODY = String.raw`[A-Za-z0-9+_-]{12,200}(?:={1,2}(?![A-Za-z0-9=]))?`
729const CANDIDATE_SRC = String.raw`(?<=%[0-9A-Fa-f]{2})(?<!%25)${CAND_BODY}|(?<=%25[0-9A-Fa-f]{2})${CAND_BODY}|(?<!%[0-9A-Fa-f]?|%25[0-9A-Fa-f]?)${CAND_BODY}`
730const SLASHED_SRC = String.raw`(?<![A-Za-z0-9+/_.%~-])[A-Za-z0-9+/]{20,300}={0,2}(?![A-Za-z0-9+/=_.%~-])`
731const CANDIDATE = new RegExp(CANDIDATE_SRC, 'g')
732const SCAN = new RegExp(`(${SLASHED_SRC})|${CANDIDATE_SRC}`, 'g')
733
734// A name to the left of the candidate that says the candidate is a key:
735// `API_KEY=`, `"token":`, an escaped `\"token\":`, `&quot;token&quot;:`,
736// `Authorization: Bearer `, `Authorization: <any scheme> `, `--api-key `, a
737// table cell, a YAML block, a Go header map `X-Api-Key:[`, a Python header
738// tuple `('X-Api-Key', '`. `PAT` is a personal access token, not `PATH`.
739const NAME_WORDS =
740  'key|token|secret|password|passwd|pwd|credential|auth|bearer|private|signature|session|cookie|dsn|salt|nonce|otp|sess(?:ion)?[_-]?id|(?<![A-Za-z])sid|(?<![A-Za-z])sig|(?<![A-Za-z])pat(?![a-z])'
741const Q = String.raw`(?:\\{0,3}["'${'`'}]|&(?:quot|apos|#0*3[49]|#x0*2[27]);|\\u00(?:22|27))`
742const NAMED = new RegExp(
743  '(?:' +
744    String.raw`(?:${NAME_WORDS})(?:(?<=key)[_-]?id)?(?:${Q}|\]|\s){0,6}[:=]{1,2}(?:${Q}|\s){0,4}\[?` +
745    String.raw`|(?:${NAME_WORDS})[\w-]{0,20}${Q}[ \t]*,[ \t]*${Q}` +
746    String.raw`|\bbearer[ \t]+["']?` +
747    String.raw`|authorization["']?[ \t]*[:=][ \t]*["']?(?:[A-Za-z][\w-]{0,31}[ \t]+)?` +
748    String.raw`|(?:^|\s)--?(?![a-z_-]*(?:public|pub|cache|idempotency|signing|recv)[_-]?key)[a-z_-]*(?:key|token|secret|password|passwd|pw|auth)[ \t]+["']?` +
749    String.raw`|\|[ \t]*[\w.-]*(?:key|token|secret|password)[ \t]*\|[ \t]*` +
750    String.raw`|(?:key|token|secret|password|passwd|pwd|credential|auth)[\w-]*["']?[ \t]*:[ \t]*[>|][-+1-9]{0,2}[ \t]*\r?\n[ \t]+` +
751    ')$',
752  'i',
753)
754
755// A sibling field name that makes its value a key: the value is then
756// judged as if the name stood right before it.
757const KEY_NAME = new RegExp(String.raw`(?:${NAME_WORDS}|authorization)`, 'i')
758
759const HEX_ONLY = /^[0-9a-f]+$/i
760const PREFIXED_KEY = /^[a-z]{2,12}(?:_v[0-9])?[_-]([a-z0-9]{32,})$/
761const DIGITS_ONLY = /^[0-9]+$/
762const LETTERS_ONLY = /^[A-Za-z]+$/
763
764// Public ids with a shape rather than a prefix: a YouTube channel or
765// playlist, a Monzo transaction, a Vercel deployment, team or project, an
766// OpenAI org, a Claude Code session, environment or server tool call. Exact
767// lengths only, and the Anthropic ids start `01`, so any other token after
768// the same prefix is still judged.
769const PUBLIC_SHAPE =
770  /^(?:UC[A-Za-z0-9_-]{22}|PL[A-Za-z0-9_-]{16,32}|(?:tx|mm|acc|pot|merch|user)_0000[A-Za-z0-9]{18}|dpl_[A-Za-z0-9]{24,32}|org-[A-Za-z0-9]{24}|team_[A-Za-z0-9]{24}|prj_[A-Za-z0-9]{28}|(?:session|env|srvtoolu)_01[A-Za-z0-9]{22})$/
771
772// Prefixes that name a PUBLIC object id, not a key. Stripe hands these out in
773// dashboards, invoices and source code; hiding them makes a session useless
774// and protects nothing. `pk_live_` is Stripe's publishable key, also public.
775const PUBLIC_PREFIX =
776  /^(?:price|prod|cus|sub|sched|in|ch|pi|cs|py|re|txn|il|si|seti|evt|acct|promo|coupon|plan|card|ba|src|dp|du|iv|ii|rcpt|file|link|pm|tok|pk|test|toolu|msg|req|run|wf)_/i
777// A public id is the prefix, maybe `live_` or `test_`, then one alnum body.
778// `prod_api_key_<random>` is not that shape, so it is judged like any token.
779const PUBLIC_ID_BODY = /^(?:(?:live|test)_)?[A-Za-z0-9]+$/
780// A WooCommerce consumer secret reuses Stripe's `cs_`; Stripe's never is
781// 40 lowercase hex.
782// A Klaviyo private key reuses `pk_`, with no `live_` or `test_` after it.
783const NOT_PUBLIC = /^cs_[a-f0-9]{40}$|^pk_(?!live_|test_)[A-Za-z0-9]{34}$/
784// Generic prefixes a key name in front may override (`API_KEY=test_...`).
785const SOFT_PUBLIC = /^(?:test|tok)_/i
786
787// A name written in code (`archived-modules-marker`, `handleCheckoutSession`,
788// `lesson_article_outline_open_v1`) reads as high entropy but is a word list.
789// A key is not built out of words.
790const WORD_PART = /^(?:[a-z]+[0-9]{0,3}|[A-Z][a-z]+[0-9]{0,3}|[A-Z]{2,})$/
791const ALNUM_ONLY = /^[A-Za-z0-9]+$/
792const SEGMENTS = /[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+/g
793const WORDY = /^[A-Za-z]?[a-z]{2,}$/
794const SLASH_WORD = /^(?:[a-z]+[0-9]{0,3}|[A-Z][a-z]+[0-9]{0,3})$/
795const STRICT_CAMEL = /^(?:[a-z]{2,}|[A-Z][a-z]{2,})(?:[A-Z][a-z]{2,})+[0-9]{0,3}$/
796
797// `playerEventBatchV1Schema` splits into six segments of which four are
798// words; a random key splits into many segments of which almost none are.
799// A camel name has a capital in it: an all-lowercase token with a digit run
800// in the middle is a random value more often than a name.
801function isCamelName(token: string): boolean {
802  if (!ALNUM_ONLY.test(token) || !/[A-Z]/.test(token)) return false
803  const segs = token.match(SEGMENTS)
804  if (!segs || segs.join('') !== token) return false
805  const words = segs.filter((seg) => WORDY.test(seg)).length
806  return words >= 2 && words >= segs.length - 2
807}
808
809// Every character used once AND mostly in alphabet order: a charset constant.
810// The order test matters: a random 24-character key has all distinct
811// characters about 1% of the time.
812function isAlphabet(token: string): boolean {
813  if (token.length < 20) return false
814  let runs = 0
815  for (let i = 1; i < token.length; i++) if (token.charCodeAt(i) === token.charCodeAt(i - 1) + 1) runs++
816  return runs * 2 >= token.length && new Set(token).size === token.length
817}
818
819// Half or more of the characters follow the one before in code order:
820// `0123456789abcdef`, the base64 alphabet. A sample in docs, never a key.
821function countsUp(s: string): boolean {
822  let runs = 0
823  for (let i = 1; i < s.length; i++) if (s.charCodeAt(i) === s.charCodeAt(i - 1) + 1) runs++
824  return runs * 2 >= s.length
825}
826
827function isMixed(s: string): boolean {
828  return /[a-z]/.test(s) && /[A-Z]/.test(s) && /[0-9]/.test(s)
829}
830
831// One word, or a number of up to four digits (a year, a month, a day).
832function isSlugPart(part: string): boolean {
833  return WORD_PART.test(part) || /^[0-9]{1,4}$/.test(part)
834}
835
836// A part made only of words, short acronyms and short numbers: `expenseID`,
837// `2026`, `v2Final`. It names something; it is not a random remainder.
838function isWordish(part: string): boolean {
839  const segs = part.match(SEGMENTS)
840  if (!segs || segs.join('') !== part) return false
841  return segs.every((seg) => WORDY.test(seg) || /^[A-Z]{2,4}$/.test(seg) || /^[0-9]{1,4}$/.test(seg))
842}
843
844function looksLikeName(token: string, named: boolean): boolean {
845  if (isCamelName(token)) return true
846  // A name beside the token outweighs the alphabet rule.
847  if (!named && isAlphabet(token)) return true
848  const parts = token.split(/[_-]/)
849  if (parts.length < 2) return false
850  // Beside a key's name, one key-like part makes the whole token a key:
851  // `django-insecure-<random>`, `live-secret-<random>`.
852  if (named && parts.some((p) => p.length >= 16 && /[0-9]/.test(p) && /[A-Za-z]/.test(p) && entropy(p) >= 3.0)) return false
853  // A long random remainder outweighs any words in front of it
854  // (`vercel_blob_rw_<random>`, `dckr_pat_<random>`). Mixed case is asked
855  // for, so a hex cache key or a timestamp is not a remainder.
856  const residue = parts.filter((p) => !WORD_PART.test(p) && !isCamelName(p) && !isWordish(p)).join('')
857  // Words plus one long number (a LinkedIn post slug, a Zoom recording, an
858  // Amazon ref tag): the number is an id, not part of a random remainder.
859  // Only the parts that are neither words, numbers nor date stamps count,
860  // and camel parts are not excused here, since a random string can pass
861  // the camel test.
862  const longNumber = !named && parts.some((p) => /^[A-Z]{0,4}[0-9]{6,}$/.test(p))
863  const randomPart = longNumber ? parts.filter((p) => !WORD_PART.test(p) && !/^[0-9]{1,4}$/.test(p) && !DIGITS_ONLY.test(p) && !DATE_STAMP.test(p)).join('') : ''
864  if ((longNumber ? (randomPart.length >= 8 ? 16 : 0) : residue.length) >= 16 && isMixed(residue)) return false
865  // Words, dates and short numbers only (`2025-08-26-lesson`): a slug.
866  if (parts.every(isSlugPart)) return true
867  // Three or more separated parts, two of them plain words: a naming
868  // convention (`META_Conv_Lookalike-Customers_FreeTrial_2024Q1`), not a key.
869  if (parts.length >= 3 && parts.filter((p) => /^[A-Za-z]{4,}$/.test(p)).length >= 2) return true
870  return longNumber && randomPart.length < 8
871}
872const DATE_STAMP = /^[A-Z]{0,4}(?:19|20)[0-9]{6}$/
873
874function isPublicId(token: string, named: boolean, cfg: Config): boolean {
875  if (PUBLIC_SHAPE.test(token)) return true
876  if (cfg.allowPrefix.some((p) => token.startsWith(p))) return true
877  if (!PUBLIC_PREFIX.test(token) || NOT_PUBLIC.test(token)) return false
878  if (cfg.denyPrefix.some((p) => token.startsWith(p))) return false
879  if (named && SOFT_PUBLIC.test(token)) return false
880  return PUBLIC_ID_BODY.test(token.replace(PUBLIC_PREFIX, ''))
881}
882
883// A letters-only key (no digit at all) splits into many short camel
884// segments; a camel name of the same length splits into a few long ones.
885function lettersLookRandom(token: string, named: boolean, h: number): boolean {
886  if (!LETTERS_ONLY.test(token) || !/[a-z]/.test(token) || !/[A-Z]/.test(token)) return false
887  const segs = token.match(SEGMENTS) ?? []
888  if (named && token.length >= 20 && h >= 3.6 && segs.length * 4 >= token.length) return true
889  return token.length >= 24 && h >= 4.0 && segs.length * 3 >= token.length
890}
891
892// Claude Code transcripts and most analytics put a `sessionId` UUID on every
893// line. A UUID beside that name is an identifier, not a credential; a real
894// session cookie is random text, and the cookie rules hide it on their own.
895const SESSION_UUID = new RegExp(`^${UUID}$`, 'i')
896const SESSION_ID_NAME = new RegExp(String.raw`sess(?:ion)?[_-]?id(?:${Q}|\s){0,6}[:=]{1,2}(?:${Q}|\s){0,4}$`, 'i')
897
898function looksSecret(token: string, before: string, cfg: Config): boolean {
899  if (token.startsWith('REDACTED-')) return false
900  if (cfg.allow.has(token) || DIGITS_ONLY.test(token)) return false
901  const named = NAMED.test(before)
902  if (isPublicId(token, named, cfg)) return false
903  // `invoice+statements+acct_<id>`: a mail sender address made of words and
904  // a public id. Each `+` part has to be one, so base64 is still judged.
905  if (token.includes('+') && token.split('+').every((p) => p === '' || WORD_PART.test(p) || isPublicId(p, named, cfg))) return false
906  if (named && SESSION_UUID.test(token) && SESSION_ID_NAME.test(before)) return false
907  if (looksLikeName(token, named)) return false
908
909  const h = entropy(token)
910
911  // A bare hex run is a git SHA or a checksum far more often than it is a
912  // key, so hex needs a name beside it before it is hidden.
913  if (HEX_ONLY.test(token)) return named && token.length >= 24 && h >= cfg.contextMinEntropy
914
915  // A short lowercase prefix and a long random body (imp_ and 40 hex): a
916  // vendor key no rule names. A git SHA has no prefix, so it is not caught here.
917  const prefixed = token.match(PREFIXED_KEY)
918  if (prefixed && /[0-9]/.test(prefixed[1]!) && /[a-z]/.test(prefixed[1]!) && entropy(prefixed[1]!) >= cfg.contextMinEntropy) return true
919
920  if (isMixed(token) && token.length >= cfg.minLength && h >= cfg.minEntropy) return true
921  if (lettersLookRandom(token, named, h)) return true
922  // Next to a key's name the bar is lower, but the token still has to look
923  // like a key: letters and digits together, not a word.
924  const alnum = /[0-9]/.test(token) && /[A-Za-z]/.test(token)
925  return named && alnum && token.length >= 16 && h >= cfg.contextMinEntropy
926}
927
928// A slash-bearing base64 run, judged on the parts that are not words, so a
929// path like `uploads/2026/10/12/Report2Final` stays a path.
930function slashedSecret(run: string, named: boolean, cfg: Config): boolean {
931  if (cfg.allow.has(run)) return false
932  const body = run.replace(/=+$/, '').replace(/^\/+/, '')
933  if (!body.includes('/') || !isMixed(body) || countsUp(body)) return false
934  const parts = body.split('/').filter(Boolean)
935  const isWord = (p: string): boolean => SLASH_WORD.test(p) || STRICT_CAMEL.test(p) || DIGITS_ONLY.test(p)
936  const words = parts.filter(isWord).length
937  if (words * 2 >= parts.length) return false
938  if (!isMixed(parts.filter((p) => !isWord(p)).join(''))) return false
939  const h = entropy(body)
940  return (body.length >= 32 && h >= 4.3) || (named && body.length >= 16 && h >= 3.6)
941}
942
943// -------------------------------------------------------------- the passes
944
945// Mints `value` unless it is already a placeholder, allowed, or a stand-in.
946function pairOut(cfg: Config, head: string, value: string, tail = ''): string {
947  if (WHOLE_TAG.test(value) || cfg.allow.has(value) || isStandIn(value)) return head + value + tail
948  return head + mint('SECRET', value) + tail
949}
950
951function parkOpaque(text: string): string {
952  let out = text
953  if (out.includes('"signature"')) out = out.replace(THINKING_SIG, (_m, head: string, sig: string) => head + park(sig))
954  if (out.includes('redacted_thinking')) out = out.replace(REDACTED_THINKING, (_m, head: string, data: string) => head + park(data))
955  if (out.includes('sha')) out = out.replace(SRI, (m) => park(m))
956  return out
957}
958
959function scrubKeyBlocks(text: string, cfg: Config): string {
960  let out = text
961  if (out.includes('PRIVATE KEY')) out = out.replace(PRIVATE_KEY, (m) => (cfg.allow.has(m) ? m : mint('SECRET', m)))
962  if (out.includes('Private-Lines:')) out = out.replace(PUTTY_PRIVATE, (_m, head: string, body: string) => head + mint('SECRET', body))
963  return out
964}
965
966// Walks the dot-separated parts of a run: a part holding `eyJ` and the two
967// parts after it are a JWT, when each is long enough.
968function scrubJwtChain(chain: string, cfg: Config): string {
969  if (!chain.includes('eyJ')) return chain
970  const segs = chain.split('.')
971  const out: string[] = []
972  let k = 0
973  while (k < segs.length) {
974    const i = k + 2 < segs.length ? segs[k]!.indexOf('eyJ') : -1
975    if (i >= 0 && segs[k]!.length - i >= 11 && segs[k + 1]!.length >= 8 && segs[k + 2]!.length >= 8) {
976      const jwt = `${segs[k]!.slice(i)}.${segs[k + 1]}.${segs[k + 2]}`
977      out.push(segs[k]!.slice(0, i) + (cfg.allow.has(jwt) ? jwt : mint('SECRET', jwt)))
978      k += 3
979    } else {
980      out.push(segs[k]!)
981      k += 1
982    }
983  }
984  return out.join('.')
985}
986
987// `postgres://user:REDACTED@host` in docs: the word itself stands in for one.
988const URL_PASSWORD_WORD = /^(?:password|passwd|pass|pw|pwd|secret|<[^>]*>)$/i
989
990function scrubUrls(text: string, cfg: Config): string {
991  let out = text
992  if (out.includes('://')) {
993    out = out.replace(URL_PASSWORD, (m, head: string, password: string) =>
994      WHOLE_TAG.test(password) || cfg.allow.has(password) || URL_PASSWORD_WORD.test(password) ? m : `${head}:${mint('SECRET', password)}@`,
995    )
996    out = out.replace(URL_USERINFO, (m, head: string, user: string) => {
997      if (cfg.allow.has(user) || !/[0-9]/.test(user) || !/[A-Za-z]/.test(user) || looksLikeName(user, true)) return m
998      return entropy(user) >= cfg.contextMinEntropy ? `${head}${mint('SECRET', user)}@` : m
999    })
1000  }
1001  if (out.includes('=')) {
1002    out = out.replace(URL_PARAM, (m, head: string, value: string) => (urlParamSecret(value, cfg) ? head + mint('SECRET', value) : m))
1003  }
1004  return out
1005}
1006
1007function urlParamSecret(raw: string, cfg: Config): boolean {
1008  if (cfg.allow.has(raw)) return false
1009  let v = raw
1010  try {
1011    v = decodeURIComponent(raw)
1012  } catch {
1013    // A broken escape: judge it as it stands.
1014  }
1015  if (cfg.allow.has(v) || v.length < 12) return false
1016  if ([/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(v)).length < 2) return false
1017  if (looksLikeName(v, true)) return false
1018  return entropy(v) >= cfg.contextMinEntropy
1019}
1020
1021function scrubCommandLines(text: string, cfg: Config): string {
1022  let out = text
1023  if (/-(?:pass|api|token|client|auth|access)/.test(out)) {
1024    out = out.replace(CLI_FLAG, (m, head: string, quote: string, v: string) => {
1025      if (/^[A-Z][A-Z0-9_]*$/.test(v) || /^[A-Za-z_]+=/.test(v) || /^[A-Za-z]+(?:[-_][A-Za-z]+)+$/.test(v)) return m
1026      return pairOut(cfg, head, v, quote)
1027    })
1028  }
1029  if (/\s-(?:u|U|-user|-proxy-user)\b/.test(out)) {
1030    out = out.replace(CLI_USERPASS, (m, head: string, v: string) => (DIGITS_ONLY.test(v) ? m : pairOut(cfg, head, v)))
1031  }
1032  if (/mysql|mariadb/.test(out)) out = out.replace(MYSQL_P, (_m, head: string, v: string) => pairOut(cfg, head, v))
1033  if (out.includes(':')) out = out.replace(PGPASS, (_m, head: string, v: string) => pairOut(cfg, head, v))
1034  if (out.includes('password')) out = out.replace(NETRC, (_m, head: string, v: string) => pairOut(cfg, head, v))
1035  if (out.includes('gh')) out = out.replace(GH_SECRET, (_m, head: string, _q: string, v: string) => pairOut(cfg, head, v))
1036  if (CMD_PW_GATE.test(out)) {
1037    for (const re of CMD_PW) out = out.replace(re, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1038  }
1039  if (out.includes('pass:')) out = out.replace(OPENSSL_PASS, (_m, head: string, v: string) => pairOut(cfg, head, v))
1040  if (out.includes('smbclient')) out = out.replace(SMB_USER, (_m, head: string, v: string) => pairOut(cfg, head, v))
1041  if (out.includes('htpasswd')) out = out.replace(HTPASSWD, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1042  if (/net[ \t]+user/i.test(out)) out = out.replace(NET_USER, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1043  if (out.includes('rabbitmqctl')) out = out.replace(RABBIT_USER, (_m, head: string, q: string, v: string) => pairOut(cfg, head, v, q))
1044  return out
1045}
1046
1047function scrubAssignments(text: string, cfg: Config): string {
1048  let out = text
1049  // A value under a key that says it is public is left: a publishable key.
1050  const value = (head: string, key: string, v: string, tail = ''): string => {
1051    if (/public|publishable/i.test(key)) return head + v + tail
1052    if (PUBLIC_PREFIX.test(v) && !/secret|private/i.test(key) && isPublicId(v, false, cfg)) return head + v + tail
1053    if (shortKeyStatus(key, v)) return head + v + tail
1054    return pairOut(cfg, head, v, tail)
1055  }
1056  // A value whose name sits in a sibling field.
1057  const sibling = (head: string, name: string, v: string, tail = ''): string => {
1058    if (PW_NAME.test(name)) return value(head, name, v, tail)
1059    if (KEY_NAME.test(name) && !/public|publishable/i.test(name) && looksSecret(v, 'key=', cfg)) return head + mint('SECRET', v) + tail
1060    return head + v + tail
1061  }
1062  if (/pass|secret|key|token|pw/i.test(out)) {
1063    out = out.replace(STRONG_QUOTED, (_m, head: string, _q: string, key: string, quote: string, v: string) => value(head + quote, key, v, quote))
1064    out = out.replace(APP_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head, v))
1065    out = out.replace(STRONG_LINE, (_m, head: string, key: string, v: string) => value(head, key, v))
1066    out = out.replace(ENV_PASS, (_m, head: string, _k: string, v: string) => value(head, '', v))
1067    out = out.replace(BLOCK_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head, v))
1068    out = out.replace(KV_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head, v))
1069  }
1070  if (PW_SYMBOL.test(out)) {
1071    out = out.replace(QUOTED_VALUE, (m, q: string, v: string, offset: number, whole: string) => {
1072      if (!looksGenerated(v) || isStandIn(v) || cfg.allow.has(v)) return m
1073      // A MIME boundary is random by design and public.
1074      if (/boundary=$/i.test(whole.slice(Math.max(0, offset - 9), offset))) return m
1075      return q + mint('SECRET', v) + q
1076    })
1077  }
1078  if (/password|identified/i.test(out)) out = out.replace(SQL_PASSWORD, (_m, head: string, v: string) => pairOut(cfg, head + "'", v, "'"))
1079  if (out.includes('variable')) out = out.replace(HCL_DEFAULT, (_m, head: string, v: string) => pairOut(cfg, head, v, '"'))
1080  if (out.includes('"value"')) {
1081    out = out.replace(JSON_NAME_VALUE, (_m, head: string, name: string, v: string) => sibling(head, name, v, '"'))
1082    out = out.replace(JSON_VALUE_NAME, (_m, head: string, v: string, tail: string, name: string) => sibling(head, name, v, tail))
1083    out = out.replace(TF_OUTPUT, (_m, head: string, name: string, v: string) => (PW_NAME.test(name) ? value(head, name, v, '"') : _m))
1084  }
1085  if (/"random_(?:password|string)"/.test(out)) {
1086    out = out.replace(TF_RESULT, (m, head: string, v: string) => (WHOLE_TAG.test(v) || cfg.allow.has(v) ? m : `${head}${mint('SECRET', v)}"`))
1087  }
1088  if (/-[ \t]+name:/.test(out)) {
1089    out = out.replace(YAML_NAME_VALUE, (_m, indent: string, head: string, name: string, q: string, v: string) => sibling(indent + head, name, v, q))
1090  }
1091  if (/INSERT[ \t]+INTO/i.test(out)) out = scrubSqlInserts(out, cfg)
1092  return out
1093}
1094
1095// The VALUES rows after an INSERT whose columns name a key or a password:
1096// the value in that column is hidden. Bounded to 100 rows and 20,000
1097// characters per INSERT. Quoted values only; a number is never a key.
1098function scrubSqlInserts(text: string, cfg: Config): string {
1099  const ranges: [number, number][] = []
1100  for (const m of text.matchAll(SQL_INSERT)) {
1101    const names = m[1]!.split(',').map((c) => c.trim().replace(/^[`"[]|[`"\]]$/g, ''))
1102    const kinds = names.map((n) => (PW_NAME.test(n) ? 'pw' : KEY_NAME.test(n) && !/(?:^|_)id$|public/i.test(n) ? 'key' : ''))
1103    if (!kinds.some(Boolean)) continue
1104    const limit = Math.min(text.length, m.index! + m[0].length + 20_000)
1105    let i = m.index! + m[0].length
1106    const skipSpace = (): void => {
1107      while (i < limit && /\s/.test(text[i]!)) i++
1108    }
1109    for (let row = 0; row < 100 && text[i] === '('; row++) {
1110      i++
1111      for (let col = 0; i < limit && text[i] !== ')'; col++) {
1112        skipSpace()
1113        if (text[i] === "'") {
1114          const from = ++i
1115          while (i < limit && !(text[i] === "'" && text[i + 1] !== "'")) i += text[i] === "'" ? 2 : 1
1116          const v = text.slice(from, i)
1117          i++
1118          const kind = kinds[col]
1119          if (kind && v.length >= 3 && !cfg.allow.has(v) && (kind === 'pw' ? !isStandIn(v) : looksSecret(v, 'key=', cfg))) ranges.push([from, from + v.length])
1120        } else {
1121          while (i < limit && text[i] !== ',' && text[i] !== ')') i++
1122        }
1123        skipSpace()
1124        if (text[i] === ',') i++
1125        else break
1126      }
1127      if (text[i] !== ')') break
1128      i++
1129      skipSpace()
1130      if (text[i] !== ',') break
1131      i++
1132      skipSpace()
1133    }
1134  }
1135  if (ranges.length === 0) return text
1136  const parts: string[] = []
1137  let last = 0
1138  for (const [from, to] of ranges) {
1139    if (from < last) continue
1140    parts.push(text.slice(last, from), mint('SECRET', text.slice(from, to)))
1141    last = to
1142  }
1143  parts.push(text.slice(last))
1144  return parts.join('')
1145}
1146
1147function cookieSecret(raw: string, cfg: Config): boolean {
1148  if (cfg.allow.has(raw) || raw.includes('[REDACTED-')) return false
1149  const m = raw.match(COOKIE_VALUE)
1150  if (!m) return false
1151  const body = m[1]!
1152  if (looksLikeName(body, true)) return false
1153  return /[0-9]/.test(body) && /[A-Za-z]/.test(body) && entropy(body) >= cfg.contextMinEntropy
1154}
1155
1156function scrubCookies(text: string, cfg: Config): string {
1157  let out = text
1158  if (/cookie|-b\s/i.test(out)) {
1159    out = out.replace(COOKIE, (_m, head: string, header: string) =>
1160      head +
1161      header.replace(COOKIE_PAIR, (pair, sep: string, name: string, v: string) =>
1162        !COOKIE_ATTR.test(name) && cookieSecret(v, cfg) ? sep + name + mint('SECRET', v) : pair,
1163      ),
1164    )
1165  }
1166  if (out.includes('\t')) out = out.replace(COOKIES_TXT, (m, head: string, v: string) => (cookieSecret(v, cfg) ? head + mint('SECRET', v) : m))
1167  if (out.includes('"value"')) out = out.replace(HAR_COOKIE, (m, head: string, v: string) => (cookieSecret(v, cfg) ? `${head}${mint('SECRET', v)}"` : m))
1168  return out
1169}
1170
1171function scrubAuthBlobs(text: string, cfg: Config): string {
1172  let out = text
1173  if (/asic|"auth"|'auth'/.test(out)) {
1174    out = out.replace(BASIC_AUTH, (m, head: string, v: string) => {
1175      if (v.length % 4 !== 0 || cfg.allow.has(v)) return m
1176      return /^[\x21-\x7e]+:[\x20-\x7e]+$/.test(decodeBase64Head(v)) ? head + mint('SECRET', v) : m
1177    })
1178  }
1179  if (out.includes('.')) {
1180    out = out.replace(BOOTSTRAP, (m, id: string, secret: string, offset: number, whole: string) => {
1181      if (!/[0-9]/.test(secret) || !/[a-z]/.test(secret) || countsUp(secret)) return m
1182      return NAMED.test(whole.slice(Math.max(0, offset - 64), offset)) ? `${id}.${mint('SECRET', secret)}` : m
1183    })
1184  }
1185  return out
1186}
1187
1188// The decoded start of a base64 text is text, not media: almost no control
1189// bytes. An image or random bytes fail this at once, and a PDF or a zip
1190// (docx, xlsx) is known by its first bytes, so the full decode below only
1191// runs on encoded text.
1192function decodesToText(b64: string): boolean {
1193  const head = decodeBase64Head(b64, 512)
1194  if (head.length < 8 || head.startsWith('%PDF') || head.startsWith('PK\x03\x04')) return false
1195  let control = 0
1196  for (let i = 0; i < head.length; i++) {
1197    const c = head.charCodeAt(i)
1198    if ((c < 32 && c !== 9 && c !== 10 && c !== 13) || c === 127) control++
1199  }
1200  return control * 50 <= head.length