SLOPSHOPPER

cloakroom

Check your secrets at the door. Claude gets a ticket; the real value only comes back where you allow it.

newpanebandguardcommandtoast
★ 1v0.1.5MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/cloakroom
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cloakroom
│ ┃ cloakroom-audit ✕ › fix the failing auth test and add an audit log call │ ┃ Run /cloakroom audit to check your past │ ┃ transcripts. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Read(/work/app/src/auth.ts) │ ⎿ Denied by cloakroom: [cloakroom: result withheld, redacti │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cloakroom │ ⎿ cloakroom: cloakroom: no tickets yet this session. Secrets that │ ⎿ cloakroom: /cloakroom lists tickets; /cloakroom show <ticket> sh │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cloakroom-audit
Run /cloakroom audit to check your past transcripts.
README

<img src="../../brand/pixel-icons/svg/cloakroom.svg" width="64" height="64" alt="">

Cloakroom

Check your secrets at the door. Claude gets a ticket; the real value only comes back where you allow it.

What it can and cannot do

Read this first. Cloakroom lowers the chance that a secret ends up somewhere it should not. It does not make that impossible, and it never claims to prevent exfiltration.

It CAN:

  • Keep secrets out of the model request, the saved transcript, screen shares and exports (all but Claude Code's own permission box, see the CANNOT list), at every door it hooks: tool results (every tool, MCP included), your prompt and pastes, @-mentioned files, CLAUDE.md and the first message's context, the system prompt's sections, reminders and settings-hook output, skill text, every row the conversation stores, messages to and from other agents and sessions, and records sent to your own OpenTelemetry collector. A secret becomes a ticket such as [secret:STRIPE_SECRET_KEY#k3x9qa].
  • Learn the secret-named values in your project's .env files when the session starts (those long and varied enough to be a real secret, see Limits), then hide them wherever they show up later, including their base64, URL-encoded and JSON-escaped forms. That catches an echo or printenv that no pattern would.
  • Turn tickets back into real values only inside Write, Edit and NotebookEdit, and inside a Bash command made only of local file tools (see "Which Bash commands get the real value"), so Claude can still write your .env. Web fetches, MCP tools, subagent prompts, messages to other sessions and questions shown to you keep the ticket.
  • Hold any other Bash command that carries a known secret until you press Allow once: one that names a network program (curl, wget, nc, ssh, scp, rsync to a remote, git push, bash's /dev/tcp, a remote URL beside the value), and one it cannot vouch for at all (sh -c, bash -c, eval, source, node, python, npm, make, find, xargs to a shell, a script, a variable used as the program). A hold needs a positive reason to pass, never the absence of a known threat.
  • Find secrets already written into your past Claude Code transcripts (/cloakroom audit), so you know what to rotate. The findings are drawn in a pane on your screen, never sent to Claude.

It CANNOT:

  • Stop a malicious mod installed beside it. A mod whose hooks run before cloakroom's sees values before they are hidden; one whose tool hooks run after it sees restored values.
  • See into your settings hooks (hooks in settings.json). They receive tool inputs and outputs on their own path.
  • Stop a secret that never enters the conversation. printenv | curl ..., a $STRIPE_SECRET_KEY reference, or a script that reads .env itself all pass: cloakroom only holds a command when it can see the value in it. That is also the recommended way to use a secret in a command, and Claude is told so.
  • Tell where a local program's output goes once it is on disk. A value cloakroom wrote into .env with echo can be read and sent by the next command, which it checks on its own when the value is literal in it, and cannot when the command reads the file.
  • Vouch for a local port. A command that hands a value to curl http://localhost:3000 is held like any other network call, because a local port can forward anywhere.
  • Tell a program by anything but its name. ./echo or /tmp/cat is taken for the real echo or cat; a script that borrows a local tool's name is the same case as a script that reads .env itself.
  • Read images, PDFs or other media. A key in a screenshot reaches Claude.
  • Undo exposure that already happened. Rotate anything /cloakroom audit finds.
  • Keep a restored value off your screen when Claude Code asks permission. The permission dialog shows a Bash command or file content after the ticket was swapped back. In auto mode, the permission classifier reads it too, and that is a model request. Writing $STRIPE_SECRET_KEY in commands instead of the value avoids both. Cloakroom tells Claude to prefer that.
  • Remember anything across a restart. Tickets live in the mod's memory only, so a ticket from before a restart or reload is refused in tools rather than written as text. Paste the value again, or use its environment variable.

What the validator sees it reach (claude plugin validate --strict):

hooks: config.set{key="cloakroom.enabled"}, session.start, tool.call, prompt.submit, prompt.attachment, prompt.context, prompt.compose, skill.prompt, session.append, session.send, session.receive, telemetry.log{to=collector}, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=cloakroom-audit}, command.run{command=cloakroom}
answers its own command: command.run{command=cloakroom}
gating hook without .catch: config.set{key="cloakroom.enabled"}
gating hook with .catch: tool.call, prompt.submit, session.append, session.send, session.receive, telemetry.log{to=collector}
calls: $.clock.after (via show), $.clock.now, $.command.register, $.env.get (via runAudit), $.fs.list (via listDir), $.fs.read (via runAudit, seed), $.prompt.submit, $.session.surfaces (via audit, show), $.ui.invalidate (via redraw, rescanAll, rescanCached), $.ui.open (via audit), $.ui.resolve, $.ui.toast
env writes: nothing
env reads: CLAUDE_CONFIG_DIR, HOME, USERPROFILE
hooks telemetry for: collector

Which Bash commands get the real value

A real value is swapped into a Bash command only when every program on the line is on this list, and nothing on the line points at the network:

echo printf cat tee head tail wc uniq cut tr rev fold paste column grep egrep fgrep cmp diff comm jq test [ true false cp mv mkdir rmdir touch ls stat chmod chown ln readlink realpath basename dirname pwd cd date sleep base64 xxd od hexdump md5sum sha1sum sha256sum shasum, plus the shell's own export unset set local declare typeset readonly shift return break continue exit read wait umask, and if for while case and the other shell keywords. Heredoc bodies are data. sudo, env, timeout, nice and nohup are looked through to the program they run.

Everything else is held with the red band, for example sh -c '...', eval, node -e, python -c, npm run, make, find, xargs ... sh, ./script.sh, $PROGRAM ..., sed, awk and sort (all three can run other programs), and any curl, wget, nc, ssh, git push or remote URL. The band names the program, Allow once runs that exact command one time, and Claude is told to prefer the variable ($STRIPE_SECRET_KEY), which passes untouched because it carries no value.

Install

/plugin install cloakroom --marketplace modmyclaude/claude-code-mods

Use it

There is nothing to turn on. From the next session, a secret that would reach Claude arrives as a ticket instead:

STRIPE_SECRET_KEY=[secret:STRIPE_SECRET_KEY#k3x9qa]

The same value keeps the same ticket for the whole session. The random part is drawn fresh each session and never computed from the value. When you paste a key into a prompt, a short notice says it was swapped. Claude reads one added line in its instructions: tickets turn back into real values only in Write, Edit, NotebookEdit and Bash, and it should prefer environment variables in commands.

The red band. When a Bash command carries a secret and cloakroom cannot vouch for it, Claude is told the call was held, and a band appears above the prompt:

held: STRIPE_SECRET_KEY#k3x9qa was about to leave via curl to paste.example
Claude was told to wait. Allow once lets this exact command run one time.
[ Deny ]  [ Allow once ]

or, for a command that is not made of local file tools:

held: STRIPE_SECRET_KEY#k3x9qa is in a command cloakroom cannot vouch for: sh is not on its local-only list

Shell syntax it cannot follow is named as the feature it is, such as process substitution <( ) can run a program.

The band never takes the keyboard by itself. Click it, or press ctrl+x tab, and the focus starts on Deny (d also denies). Allow once has no key, so a stray keystroke cannot press it; click it or move to it on purpose. Allow once lets that exact command run one time within 10 minutes and tells Claude it may run it again. Deny leaves it refused. While a hold waits for you, it is drawn on its own (other mods' bands come back once you answer), so nothing another mod draws can hide Deny.

Commands:

  • /cloakroom lists this session's tickets: the name, the kind, where it was found, and how often it was hidden and restored. It never prints a value. Like any command's output, the list is a transcript row that Claude reads; Claude already sees the tickets themselves.
  • /cloakroom show <ticket> draws the value in the band above the prompt for 30 seconds, with a Hide button. It goes to your screen only, never into the transcript or to Claude. Control characters in a value show as U+FFFD.
  • /cloakroom forget <ticket> stops hiding that value for the rest of the session (for a false positive). Its ticket stops working in tools.
  • /cloakroom audit checks your past transcripts and lists each exposed secret by kind and date, with a preview such as sk_live_...9f2, in a pane on your screen only. Claude is told only that the results are in the pane: not a preview, name, date or file. Values under 16 characters show only their length. Run it again to continue past the time budget.

show, forget and audit run only when you type them (in the terminal, the desktop app or Remote Control). Another plugin, a peer session, a channel or a prompt-injected Claude running them is refused. The plain list runs from anywhere.

Settings

  • On: on by default. Off, cloakroom hides nothing, restores nothing, holds nothing and reads no file: every door passes what Claude Code sends as it is, so a secret that reaches Claude while it is off reaches it as written. A ticket Claude already holds stays its ticket text (written as text into a file, say); the real value never comes back while it is off. What was hidden before stays hidden. Switching off drops any held command, Allow once and shown value. Switching on reads the .env files (if the session started off) and has the cached prompt parts checked again; switching reloads the mod, so tickets from before it are refused in tools, as after a restart. /cloakroom says it is off. /config or the modmyclaude hub flips it, live; another mod, or a /config sent over Remote Control, can switch it on but not off.

What it can touch

  • Files read: your project's .env and .env.* files at session start, at the root and up to two folder levels down (apps/web/.env), skipping node_modules, .git, build output and hidden folders, at most 120 folders. An @-mentioned file is not read by cloakroom: it is scrubbed as the attachment Claude Code makes of it, its line count kept, so #L10-20 still names the same lines. Your transcripts under ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects), only when you run /cloakroom audit, newest first, at most 4 MiB per file and about 6 seconds and 96 MB per run.
  • Files written: none: no store, no settings, no plugin state, no copy of any file. A real value is never written to disk by cloakroom.
  • Memory: the ticket-to-value map and the last audit's results, in the mod's own memory, gone when the session ends or the mod reloads.
  • Shown to Claude: the plain /cloakroom list and the one-line answers of show, forget and audit, as any command's output is. Never a value, and never the audit's findings.
  • Environment: reads CLAUDE_CONFIG_DIR, HOME and USERPROFILE (to find transcripts). Writes none.
  • Prompts sent: one, only after you press Allow once, telling Claude it may run the held command again.
  • Telemetry: rewrites records bound for your own OpenTelemetry collector when one is configured. It never touches Anthropic's own analytics.
  • Network, processes, model calls: none.

Limits

  • Detection is pattern-based: known key formats (AWS, GitHub, Anthropic, OpenAI, Stripe, Slack, Google, Twilio, Resend, SendGrid, Mailgun, npm, PyPI, GitLab, Hugging Face and more), private key blocks, JWTs, Bearer and Basic headers, passwords in connection URLs, and values written next to a secret-like name. A secret in an unknown format with no telling name can be missed. Stripe publishable keys (pk_) and documentation examples are left alone on purpose.
  • Only a value that is at least 8 characters, varied enough (about 3 bits of entropy per character), not a filesystem path and not a common default (postgres, password, admin, changeme, localhost and the like) is learned and hidden everywhere it appears. A short or common-default value is not learned and is only caught by shape: DB_PASSWORD=hunter2 is hidden where it sits next to its name, but the bare word hunter2 later in a log is not, and POSTGRES_PASSWORD=postgres is left alone so postgres in image names, lockfiles and package lists stays readable. A path next to a password-like name (PWD, OLDPWD, PASSWORD_STORE_DIR) is never treated as a secret.
  • Every pattern runs in linear time. A test sends 1 MB and 5 MB of text built to stall pattern matchers through a real tool call, best of three runs each, and requires the 5 MB run to take under 8 times as long as the 1 MB one (linear is about 5 times, quadratic 25), with a 15-second backstop (alone, 5 MB measured about 0.6 seconds on a laptop). Text over 16 MB, text whose check overruns 4 seconds, or a value nested too deep to walk is withheld rather than passed on unchecked.
  • If a check fails, the text is withheld, not sent. A tool result becomes [cloakroom: result withheld, redaction failed], a prompt is not sent (it says why), an attachment (an @-mentioned file among them) is withheld, and a call that could not be checked does not run.
  • The band (holds and show) draws in the terminal and the desktop app. In VS Code and on mobile there is no band: a held command stays refused (run it yourself), and show says it needs one of the other two. So does audit, whose pane is drawn only there too.
  • Some things are written by Claude Code itself, outside the doors cloakroom hooks: a large tool output it saves to its tool-results folder, and its own prompt history (what the up arrow recalls), which may keep a pasted key as you typed it.
  • A row may flash on screen, or reach an SDK stream or Remote Control, just before cloakroom rewrites it. Neither the model nor the transcript file reads that form. Tool results are rewritten before they are drawn.
  • Context another mod attaches to a tool result cannot be changed at that point (Claude Code requires it word for word). It is scrubbed when it reaches Claude, as an attachment and as a stored row.
  • show puts the value in the band. Another mod that draws around the band can read it there.

Built from a prompt

This mod was built from prompts/cloakroom.md.

More mods: the full catalogue.

Source 8 files
hooks/register.tsx 730 lines
1// cloakroom: check your secrets at the door. Claude gets a ticket; the real
2// value only comes back where you allow it.
3//
4// The doors (each one scrubs before the text is stored or sent):
5//   tool.call          tool results, every tool; tickets restored in the inputs
6//                      of Write, Edit, NotebookEdit and Bash only; Bash held
7//                      when it would carry a value off the machine
8//   prompt.submit      what you type or paste
9//   prompt.attachment  reminders, hook output, nested CLAUDE.md, mentioned files
10//   prompt.context     CLAUDE.md and the first message's context blocks
11//   prompt.compose     the system prompt's sections, plus one line on tickets
12//   skill.prompt       skill text
13//   session.append     every row the conversation keeps (the transcript)
14//   session.send       messages to other agents and sessions
15//   session.receive    deliveries from them
16//   telemetry.log      records bound for an OpenTelemetry collector
17// Every gating hook fails closed: when the scrub throws or overruns, the text
18// is withheld or the call refused, never passed on unscanned.
19
20import type { EngineInterface, FsEntry, PromptComposeSection, Register, Timer, ToolCallResult } from 'claude-code'
21
22import { auditTranscript, findingLines, newAudit, type AuditState } from './audit'
23import { clean } from './clean'
24import { isEnvFileName, isSecretName, isWorthHiding, parseDotenv, SKIP_DIRS } from './dotenv'
25import { classifyEgress, leaksValue, localProof } from './egress'
26import { labels, restoreDeep, scrubBlocks, scrubDeep, WITHHELD, withheldBlocks } from './scrub'
27import { Vault, type Entry } from './vault'
28
29type Dollar = EngineInterface
30
31/** Tools that run on this machine, where a ticket becomes the real value again. */
32const RESTORE = new Set(['Write', 'Edit', 'NotebookEdit', 'Bash'])
33/** Local lookups whose inputs are paths and patterns: left as written. */
34const LOCAL_READS = new Set(['Read', 'Glob', 'Grep', 'LSP', 'ToolSearch'])
35
36// The switch (`enabled`, which /config and the modmyclaude hub flip): off, cloakroom hides
37// nothing, restores nothing, holds nothing and reads no file; every door passes what the
38// engine sends as it is. The engine reloads the mod with the new value; the config.set hook
39// below is the same switch at once.
40let isOn = true
41const OFF_TEXT = 'Cloakroom is off. /mods or /config turns it on.'
42
43const PERMIT_MS = 10 * 60_000
44const REVEAL_MS = 30_000
45const AUDIT_MS = 6_000
46const AUDIT_BYTES = 96 * 1024 * 1024
47const FS_READ_MAX = 4 * 1024 * 1024
48const MAX_HOLDS = 5
49const AUDIT_PANE = 'cloakroom-audit'
50
51const LINE: PromptComposeSection = {
52  id: 'cloakroom:tickets',
53  scope: 'session',
54  text:
55    'Secrets in this session are hidden as [secret:NAME#id] tickets, which turn back into the real value only inside Write, Edit, NotebookEdit and Bash; prefer environment variables such as $STRIPE_SECRET_KEY in commands over literal values.',
56}
57
58const USAGE =
59  '/cloakroom lists tickets; /cloakroom show <ticket> shows a value to you alone; ' +
60  '/cloakroom forget <ticket> stops hiding it; /cloakroom audit checks past transcripts.'
61
62/** Where a conversation row came in, as /cloakroom names a ticket's source. */
63const SOURCES: Record<string, string> = {
64  prompt: 'your prompt',
65  command: 'a command',
66  response: "Claude's reply",
67  'tool-result': 'tool output',
68  'tool-message': 'tool output',
69  delivery: 'a delivery',
70  attachment: 'an attachment',
71  'hook-context': 'hook output',
72  note: 'a plugin note',
73  compaction: 'a compaction summary',
74  notice: 'a notice',
75}
76
77/** Runs typed by the person: the terminal, Remote Control, and the desktop app (which arrives as sdk). */
78const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
79/** Subcommands that reveal, read transcripts, or lower the protection. */
80const PERSON_ONLY = new Set(['show', 'audit', 'forget'])
81
82/** A held Bash command: what it carries and, as a clause after the labels, why it waits. */
83type Hold = { command: string; labels: string; count: number; reason: string }
84
85/** What one load of the mod holds, in its own memory only (never $.state or $.store). */
86type Room = {
87  vault: Vault
88  /** Bash commands waiting on the person, oldest first. */
89  holds: Hold[]
90  /** Commands the person allowed once, by the text Claude sent, until when. */
91  permits: Map<string, number>
92  reveal: Entry | undefined
93  revealTimer: Timer | undefined
94  audit: AuditState
95  /** The last audit's lines, drawn in its pane only: command text is a transcript row Claude reads. */
96  auditView: string[] | undefined
97  /** The project whose .env files are still to be read: the session started with cloakroom off. */
98  unseeded: string | undefined
99}
100
101const trimEnd = (path: string) => path.replace(/[\\/]+$/, '')
102const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
103
104
105/** Prompt parts the engine caches are asked again once the vault learns a value. */
106function rescanCached($: Dollar, room: Room): void {
107  if (!room.vault.takeGrown()) return
108  try {
109    $.ui.invalidate('prompt.attachment')
110    $.ui.invalidate('prompt.context')
111  } catch {
112    // a re-entered call cannot reach $: the next growth asks again
113  }
114}
115
116/** Switched on: prompt parts the engine cached while cloakroom was off are asked again, to be checked. */
117function rescanAll($: Dollar): void {
118  try {
119    $.ui.invalidate('prompt.attachment')
120    $.ui.invalidate('prompt.context')
121  } catch {
122    // nothing cached
123  }
124}
125
126function redraw($: Dollar): void {
127  try {
128    $.ui.invalidate('ui.render')
129  } catch {
130    // nothing drawing
131  }
132}
133
134// ------------------------------------------------------- session start
135
136async function listDir($: Dollar, dir: string): Promise<FsEntry[]> {
137  return $.fs.list(dir).catch(() => [])
138}
139
140/** How far below the project root .env files are looked for (`apps/web/.env`), and in how many folders. */
141const SEED_DEPTH = 2
142const SEED_DIRS = 120
143
144/** Learns secret-named values from the project's .env files, down to apps/web/.env. */
145async function seed($: Dollar, room: Room, cwd: string): Promise<void> {
146  const root = trimEnd(cwd)
147  const files: string[] = []
148  let level = [root]
149  let listed = 0
150  for (let depth = 0; depth <= SEED_DEPTH && level.length > 0; depth++) {
151    const deeper: string[] = []
152    for (const dir of level) {
153      if (listed++ >= SEED_DIRS) break
154      for (const f of await listDir($, dir)) {
155        if (f.kind !== 'dir' && isEnvFileName(f.name)) files.push(`${dir}/${f.name}`)
156        else if (f.kind === 'dir' && !f.name.startsWith('.') && !SKIP_DIRS.has(f.name)) deeper.push(`${dir}/${f.name}`)
157      }
158    }
159    level = deeper
160  }
161  for (const path of files) {
162    const text = await $.fs.read(path).catch(() => undefined) // missing or unreadable: skip it
163    if (text === undefined) continue
164    const shown = path.slice(root.length + 1)
165    for (const { name, value } of parseDotenv(text)) {
166      if (isSecretName(name) && isWorthHiding(name, value)) room.vault.seed(value, name, shown)
167    }
168  }
169  room.vault.takeGrown()
170}
171
172// ------------------------------------------------------- tool calls
173
174/**
175 * Holds a Bash command that carries a real value unless the command is proven
176 * local (every program on cloakroom's local-only list) and nothing on it points
177 * at the network. A hold needs a positive reason to pass, never the absence of
178 * a known threat: `sh -c`, `eval`, an interpreter, a package
179 * runner or a variable as the program is held too, and Allow once lets the
180 * person run that exact command one time. Returns the deny text, or undefined.
181 */
182async function holdIfLeaving($: Dollar, room: Room, sent: string, restored: string): Promise<string | undefined> {
183  const found = room.vault.find(restored, 'a Bash command')
184  if (found.length === 0) return undefined
185  const egress = classifyEgress(restored, found)
186  const proof = localProof(restored)
187  if (!egress && proof.ok) return undefined
188  const now = await $.clock.now()
189  const until = room.permits.get(sent)
190  if (until !== undefined) {
191    room.permits.delete(sent)
192    if (until > now) return undefined // the person pressed Allow once: this one run passes
193  }
194  // The program and the host come from Claude's command: drawn only once cleaned, never a value.
195  const names = clean(labels(found.map(f => f.entry)))
196  const count = new Set(found.map(f => f.entry.label)).size
197  const values = found.map(f => restored.slice(f.start, f.end))
198  let reason: string
199  let toClaude: string
200  if (egress) {
201    const via = clean(egress.via)
202    const to = clean(egress.to)
203    reason = `${count === 1 ? 'was' : 'were'} about to leave via ${via} to ${to}`
204    toClaude = `${names} would leave this machine via ${via} to ${to}.`
205  } else {
206    // Here proof.ok is false (a proven, non-leaving command returned above); the fallback only narrows the type.
207    const word = proof.ok ? 'a program' : proof.program
208    const program = leaksValue(word, values) ? 'a program in the command' : clean(word)
209    const feature = proof.ok || proof.feature === undefined ? undefined : clean(proof.feature)
210    reason = `${count === 1 ? 'is' : 'are'} in a command cloakroom cannot vouch for: ${feature ?? `${program} is not on its local-only list`}`
211    toClaude =
212      `${names} ${count === 1 ? 'is' : 'are'} in it and ${feature ?? `${program} is not on cloakroom's local-only list`} ` +
213      '(a real value goes only into echo, printf, cat and other local file tools). ' +
214      'Prefer the environment variable instead of the value.'
215  }
216  const same = room.holds.findIndex(hold => hold.command === sent)
217  if (same >= 0) room.holds.splice(same, 1)
218  room.holds.push({ command: sent, labels: names, count, reason })
219  if (room.holds.length > MAX_HOLDS) room.holds.shift()
220  redraw($)
221  $.ui.toast(`cloakroom held a command: ${names} ${reason}`)
222  return (
223    `cloakroom held this command: ${toClaude} ` +
224    'The person decides in the red band above the prompt (Allow once or Deny); wait for them before running it again.'
225  )
226}
227
228/** Whether `text` is one of the strings inside `value` (a few levels down). */
229function holdsString(value: unknown, text: string, depth = 0): boolean {
230  if (value === text) return true
231  if (depth > 4 || value === null || typeof value !== 'object') return false
232  return Object.values(value).some(item => holdsString(item, text, depth + 1))
233}
234
235/** The result as the model will read it: the same object when clean, a fresh one when anything was hidden. */
236function scrubResult(vault: Vault, ran: ToolCallResult, tool: string): ToolCallResult {
237  const source = `${tool} output`
238  if (ran.deny !== undefined) {
239    const s = vault.scrub(ran.deny, source)
240    return s.changed ? { deny: s.text } : ran
241  }
242  if (ran.isError) {
243    const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : JSON.stringify(ran.result ?? ''))
244    const s = vault.scrub(text, source)
245    const r = scrubDeep(vault, ran.result, source)
246    // An error has no result shape to keep: the model reads the scrubbed text as the error.
247    return s.changed || r.changed ? { deny: s.text } : ran
248  }
249  const r = scrubDeep(vault, ran.result, source)
250  // core's `text` is mapped from the result; scan it too only when the result
251  // looked clean and the text is not one of the strings just scanned (Bash's
252  // stdout usually is), to catch a value that reached the model's text alone.
253  const isTextDirty =
254    !r.changed && ran.text !== undefined && !holdsString(ran.result, ran.text) && vault.scrub(ran.text, source).changed
255  if (!r.changed && !isTextDirty) return ran
256  // Never `ran` itself, nor its `ref`: core would reuse its own unscrubbed
257  // messages. A fresh `{ result }` is mapped for the model from what we hand it.
258  // Context a hook beneath attached must be kept word for word (the engine
259  // refuses an answer that changes it); it reaches the model as an attachment,
260  // which prompt.attachment and session.append scrub.
261  return { result: r.value, ...(ran.context ? { context: ran.context } : {}) }
262}
263
264// ------------------------------------------------------- /cloakroom
265
266function listText(room: Room): string {
267  const entries = room.vault.entries()
268  if (entries.length === 0) {
269    return `cloakroom: no tickets yet this session. Secrets that reach Claude are swapped for [secret:NAME#id] tickets.\n${USAGE}`
270  }
271  const lines = entries.slice(0, 100).map(entry => {
272    const used = `hidden ${entry.hidden}x, restored ${entry.restored}x`
273    return `- ${entry.label}: ${entry.kind}, from ${entry.source}; ${used}`
274  })
275  if (entries.length > 100) lines.push(`- ...and ${entries.length - 100} more`)
276  return `cloakroom: ${plural(entries.length, 'ticket')} this session (values are held in memory only, never shown here):\n${lines.join('\n')}\n${USAGE}`
277}
278
279async function show($: Dollar, room: Room, arg: string): Promise<string> {
280  const entry = arg ? room.vault.lookup(arg) : undefined
281  if (!entry) return `cloakroom: no ticket ${arg || '(none given)'} this session. /cloakroom lists them.`
282  const surfaces = await $.session.surfaces()
283  if (!surfaces.some(s => s === 'terminal' || s === 'desktop')) {
284    return 'cloakroom: show draws the value in the band above the prompt, which only the terminal and the desktop app have.'
285  }
286  room.reveal = entry
287  room.revealTimer?.cancel()
288  room.revealTimer = $.clock.after(REVEAL_MS, () => {
289    if (room.reveal === entry) room.reveal = undefined
290    redraw($)
291  })
292  redraw($)
293  return `cloakroom: showing ${entry.label} in the band above the prompt for ${REVEAL_MS / 1000} seconds. It is drawn on your screen only; Claude is not sent the value.`
294}
295
296function forget(room: Room, arg: string): string {
297  const entry = arg ? room.vault.lookup(arg) : undefined
298  if (!entry) return `cloakroom: no ticket ${arg || '(none given)'} this session. /cloakroom lists them.`
299  room.vault.forget(entry)
300  if (room.reveal === entry) room.reveal = undefined
301  return `cloakroom: forgot ${entry.label}. Its value is no longer hidden this session, and the ticket no longer works in tools.`
302}
303
304type Transcript = { path: string; size: number; mtimeMs: number }
305
306/** projects/<project>/<session>.jsonl, and subagents under <session>/subagents/. */
307async function listTranscripts($: Dollar, projects: string): Promise<Transcript[]> {
308  const files: Transcript[] = []
309  for (const project of (await listDir($, projects)).filter(d => d.kind === 'dir').slice(0, 2000)) {
310    const dir = `${projects}/${project.name}`
311    for (const entry of await listDir($, dir)) {
312      if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) {
313        files.push({ path: `${dir}/${entry.name}`, size: entry.size, mtimeMs: entry.mtimeMs })
314      } else if (entry.kind === 'dir') {
315        for (const sub of await listDir($, `${dir}/${entry.name}/subagents`)) {
316          if (sub.kind === 'file' && sub.name.endsWith('.jsonl')) {
317            files.push({ path: `${dir}/${entry.name}/subagents/${sub.name}`, size: sub.size, mtimeMs: sub.mtimeMs })
318          }
319        }
320      }
321    }
322    if (files.length >= 20_000) break
323  }
324  return files.sort((a, b) => b.mtimeMs - a.mtimeMs)
325}
326
327/** Reads past transcripts, newest first, within a time and size budget; read-only. */
328async function runAudit($: Dollar, room: Room): Promise<string[]> {
329  const config = await $.env.get('CLAUDE_CONFIG_DIR')
330  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
331  const base = config ? trimEnd(config) : home ? `${trimEnd(home)}/.claude` : undefined
332  if (!base) return ['cloakroom audit: cannot find your Claude Code folder (HOME is not set).']
333  const shownBase = home && base.startsWith(trimEnd(home)) ? `~${base.slice(trimEnd(home).length)}` : base
334  const files = await listTranscripts($, `${base}/projects`)
335  if (files.length === 0) return [`cloakroom audit: no transcripts found in ${shownBase}/projects.`]
336
337  const { audit: tally } = room
338  const started = performance.now()
339  let bytes = 0
340  let read = 0
341  let isCut = false
342  for (const file of files) {
343    if (tally.done.has(file.path)) continue
344    if (performance.now() - started > AUDIT_MS || bytes > AUDIT_BYTES) {
345      isCut = true
346      break
347    }
348    tally.done.add(file.path)
349    if (file.size > FS_READ_MAX) {
350      tally.tooBig++
351      continue
352    }
353    const text = await $.fs.read(file.path).catch(() => undefined)
354    if (text === undefined) {
355      tally.unreadable++
356      continue
357    }
358    auditTranscript(tally, room.vault, text, file.path, new Date(file.mtimeMs).toISOString().slice(0, 10))
359    bytes += text.length
360    read++
361  }
362  const covered = files.filter(f => tally.done.has(f.path)).length
363  const lines = [
364    `cloakroom audit (read-only): ${covered} of ${plural(files.length, 'transcript')} in ${shownBase}/projects checked, newest first (${read} read just now).`,
365  ]
366  if (tally.tooBig > 0 || tally.unreadable > 0) {
367    lines.push(`Not checked: ${tally.tooBig} over 4 MiB (the most one file read can take) and ${tally.unreadable} unreadable.`)
368  }
369  const found = findingLines(tally)
370  if (found.length === 0) {
371    lines.push('No exposed secrets found in what was checked.')
372  } else {
373    lines.push('Rotate these: they were already written into past transcripts on this machine (previews only, never the value):')
374    lines.push(...found)
375  }
376  if (isCut) lines.push('Run /cloakroom audit again to check older transcripts.')
377  return lines
378}
379
380/**
381 * The audit's findings go to a pane on the person's screen, never into the command's text:
382 * that is a transcript row Claude reads, and even a masked preview is a key's fingerprint.
383 */
384async function audit($: Dollar, room: Room): Promise<string> {
385  const surfaces = await $.session.surfaces()
386  if (!surfaces.some(s => s === 'terminal' || s === 'desktop')) {
387    return 'cloakroom audit: the results draw in a pane, which only the terminal and the desktop app have.'
388  }
389  room.auditView = await runAudit($, room)
390  const opened = await $.ui.open({ id: AUDIT_PANE, title: 'Cloakroom audit', focus: true, closeOnEscape: true })
391  redraw($)
392  return opened.isPlaced
393    ? 'cloakroom audit: the results are in the Cloakroom audit pane, on your screen only. Claude is not sent them.'
394    : 'cloakroom audit: the results are ready, but the pane could not be drawn here. Run it again in a wider window.'
395}
396
397/** The command's output: file names, previews and echoed arguments are cleaned by the caller. */
398async function answer($: Dollar, room: Room, args: string, origin: string): Promise<string> {
399  const [verb = '', ...rest] = args.trim().split(/\s+/)
400  const action = verb.toLowerCase()
401  const arg = rest.join(' ')
402  // show reveals a value, audit reads transcripts, forget stops hiding one:
403  // only for the person typing, never another plugin, a peer, a relay or
404  // a prompt-injected model running the command.
405  if (PERSON_ONLY.has(action) && !PERSON_ORIGINS.has(origin)) {
406    return `cloakroom: /cloakroom ${action} runs only when you type it yourself; this run came from ${origin}.`
407  }
408  switch (action) {
409    case '':
410    case 'list':
411      return listText(room)
412    case 'show':
413      return show($, room, arg)
414    case 'forget':
415      return forget(room, arg)
416    case 'audit':
417      return audit($, room)
418    default:
419      return `cloakroom: unknown command "${verb}". ${USAGE}`
420  }
421}
422
423// ------------------------------------------------------- the hooks
424
425export const register: Register = (on, options) => {
426  isOn = options.enabled !== false
427  const room: Room = {
428    vault: new Vault(),
429    holds: [],
430    permits: new Map(),
431    reveal: undefined,
432    revealTimer: undefined,
433    audit: newAudit(),
434    auditView: undefined,
435    unseeded: undefined,
436  }
437  const { vault } = room
438
439  // Off at once: holds, Allow once permits and a shown value are dropped, so none comes
440  // back stale; what was hidden stays hidden (nothing is asked again to undo it). A ticket
441  // Claude uses while off stays the ticket's text: restoring it with no check on the way
442  // out would be the leak this mod is for. On at once: .env files are read if the session
443  // started off, and the prompt parts cached while off are asked again.
444  on('config.set', { key: 'cloakroom.enabled' }, async ($, e, next) => {
445    // Off only by the person in the menu or the hub on their press. Not another mod (one that
446    // lets Claude change settings must not be a way around the cloakroom), and not a /config
447    // over Remote Control, which the engine says is not attestably the person's hand.
448    const isPersons = e.origin.kind === 'composer' || (e.origin.kind === 'plugin' && e.origin.name === 'modmyclaude-hub')
449    if (e.value === false && !isPersons) {
450      return { deny: 'cloakroom stays on: you switch it off in the /config menu on this machine, or in the modmyclaude hub' }
451    }
452    const set = await next(e)
453    if (set.deny === undefined && typeof set.value === 'boolean') {
454      if (set.value) {
455        const cwd = room.unseeded
456        room.unseeded = undefined
457        // Read before the doors open; a value that cannot be learned is still caught by its shape.
458        if (cwd !== undefined) await seed($, room, cwd).catch(() => undefined)
459        isOn = true
460        rescanAll($)
461      } else {
462        isOn = false
463        room.holds.length = 0
464        room.permits.clear()
465        room.reveal = undefined
466        room.revealTimer?.cancel()
467        room.revealTimer = undefined
468      }
469      redraw($)
470    }
471    return set
472  })
473
474  on('session.start', async ($, e, next) => {
475    try {
476      await $.command.register({
477        name: 'cloakroom',
478        description: "List this session's secret tickets, or show, forget or audit",
479        argumentHint: '[show <ticket> | forget <ticket> | audit]',
480        immediate: true,
481      })
482    } catch {
483      // a host that takes no commands: the doors still work
484    }
485    // Off, no file is read: the .env files wait until it is switched on.
486    if (isOn) await seed($, room, e.cwd)
487    else room.unseeded = e.cwd
488    return next(e)
489  })
490
491  on('tool.call', async ($, e, next) => {
492    if (!isOn) return next(e)
493    const tool = String(e.tool)
494    const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...args } = e as Record<string, unknown>
495    let call = e
496    if (RESTORE.has(tool)) {
497      // Bash: the command alone (its description is drawn on screen as written).
498      const target: Record<string, unknown> = tool === 'Bash' ? { command: args.command } : args
499      const r = restoreDeep(vault, target)
500      if (r.unknown.length > 0) {
501        return {
502          deny:
503            `cloakroom does not know ${[...new Set(r.unknown)].join(', ')} (tickets do not survive a restart or reload), ` +
504            'so nothing was run or written. Ask the person to paste the value again, or use its environment variable.',
505        }
506      }
507      if (tool === 'Bash' && typeof r.value.command === 'string' && typeof args.command === 'string') {
508        const held = await holdIfLeaving($, room, args.command, r.value.command)
509        if (held !== undefined) return { deny: held }
510      }
511      if (r.changed) call = { ...e, ...r.value } as typeof e
512    } else if (!LOCAL_READS.has(tool)) {
513      // Web fetches, MCP tools, subagents, messages, questions: a raw value
514      // that reached Claude some other way leaves only as its ticket.
515      const s = scrubDeep(vault, args, `${tool} input`)
516      if (s.changed) call = { ...e, ...s.value } as typeof e
517    }
518    const ran = await next(call)
519    const out = scrubResult(vault, ran, tool)
520    rescanCached($, room)
521    return out
522  }).catch(($, e, next) =>
523    // Off, a failure beneath is the engine's, passed on as if cloakroom were not here.
524    !isOn ? next(e) : next.called ? { deny: WITHHELD } : { deny: 'cloakroom: the secret check failed, so this call did not run.' },
525  )
526
527  on('prompt.submit', async ($, e, next) => {
528    if (!isOn) return next(e)
529    const s = vault.scrub(e.text, 'your prompt')
530    const context = e.context?.map(c => vault.scrub(c, 'prompt context'))
531    if (!s.changed && !context?.some(c => c.changed)) return next(e)
532    if (s.changed) $.ui.toast(clean(`cloakroom hid ${labels(s.entries)} in your prompt; Claude sees the ticket.`))
533    rescanCached($, room)
534    return next({ ...e, text: s.text, ...(context ? { context: context.map(c => c.text) } : {}) })
535  }).catch(($, e, next) =>
536    next.called ? next(e) : { drop: 'cloakroom could not check this prompt for secrets, so it was not sent.' },
537  )
538
539  on('prompt.attachment', async ($, e, next) => {
540    if (!isOn) return next(e)
541    const r = await next(e)
542    if (r.text === null) return r
543    // A file the person @-mentioned, or one Claude Code shows edited, keeps its line count, so a
544    // `#L10-20` reference in the prompt still names the lines it did.
545    const isFile = e.type === 'file' || e.type === 'edited_text_file'
546    const s = vault.scrub(r.text, `${e.type} attachment`, { keepLines: isFile })
547    return s.changed ? { text: s.text } : r
548  }).catch(($, e, next) => (!isOn ? next(e) : { text: WITHHELD }))
549
550  on('prompt.context', async ($, e, next) => {
551    if (!isOn) return next(e)
552    const r = await next(e)
553    let changed = false
554    const blocks = r.blocks.map(b => {
555      const s = vault.scrub(b.text, `${b.name} context`)
556      if (!s.changed) return b
557      changed = true
558      return { ...b, text: s.text }
559    })
560    // A rewritten claudeMd text stands for the files behind it, which the
561    // engine then treats as unknown to the hooks above: their contents are
562    // not passed on.
563    return changed ? { blocks } : r
564  }).catch(async ($, e, next) => {
565    if (!isOn) return next(e)
566    // Withhold every block but the date rather than send one unscanned.
567    const r = await next(e).catch(() => ({ blocks: [] }))
568    return { blocks: r.blocks.map(b => (b.name === 'currentDate' ? b : { ...b, text: WITHHELD })) }
569  })
570
571  on('prompt.compose', async ($, e, next) => {
572    if (!isOn) return next(e)
573    const r = await next(e)
574    const sections = r.sections.map(section => {
575      if (section.id === LINE.id) return section
576      const s = vault.scrub(section.text, `${section.id} section`)
577      return s.changed ? { ...section, text: s.text } : section
578    })
579    if (!sections.some(section => section.id === LINE.id)) sections.push(LINE)
580    return { sections }
581  }).catch(async ($, e, next) => {
582    if (!isOn) return next(e)
583    const r = await next(e).catch(() => ({ sections: [] }))
584    return { sections: [...r.sections.filter(s => s.scope === 'shared'), LINE] }
585  })
586
587  on('skill.prompt', async ($, e, next) => {
588    if (!isOn) return next(e)
589    const r = await next(e)
590    const s = vault.scrub(r.text, `${e.skill} skill`)
591    return s.changed ? { text: s.text } : r
592  }).catch(($, e, next) => (!isOn ? next(e) : { text: WITHHELD }))
593
594  on('session.append', async ($, e, next) => {
595    if (!isOn) return next(e)
596    const r = scrubBlocks(vault, e.message.content, SOURCES[e.door] ?? 'the conversation')
597    if (!r.changed) return next(e)
598    return next({ ...e, message: { ...e.message, content: r.value } })
599  }).catch(($, e, next) =>
600    next.called ? next(e) : next({ ...e, message: { ...e.message, content: withheldBlocks(e.message.content) } }),
601  )
602
603  on('session.send', async ($, e, next) => {
604    if (!isOn) return next(e)
605    const s = vault.scrub(e.text, 'an outgoing message')
606    return next(s.changed ? { ...e, text: s.text } : e)
607  }).catch(($, e, next) =>
608    next.called ? next(e) : { isDelivered: false, reason: 'cloakroom could not check this message for secrets, so it was not sent.' },
609  )
610
611  on('session.receive', async ($, e, next) => {
612    if (!isOn) return next(e)
613    const s = vault.scrub(e.text, 'a delivery')
614    return next(s.changed ? { ...e, text: s.text } : e)
615  }).catch(($, e, next) => (next.called ? next(e) : { consumed: 'cloakroom could not check this delivery for secrets' }))
616
617  on('telemetry.log', { to: 'collector' }, async ($, e, next) => {
618    if (!isOn) return next(e)
619    let changed = false
620    const attributes: Record<string, string | number | boolean | readonly string[]> = {}
621    for (const [key, value] of Object.entries(e.attributes)) {
622      const r = scrubDeep(vault, value, 'telemetry')
623      if (r.changed) changed = true
624      attributes[key] = r.value
625    }
626    return next(changed ? { ...e, attributes } : e)
627  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'cloakroom could not check this record for secrets' }))
628
629  // The band: a held command (red), or a value the person asked to see, drawn
630  // above whatever the other mods and the engine put there. It never takes the
631  // keyboard on its own. Once the person focuses it, the ring starts on Deny;
632  // Allow once has no hotkey, so no stray key can press it.
633  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
634    if (!isOn || e.props.hasSurvey) return next(e)
635    const hold = room.holds[0]
636    const shown = room.reveal
637    if (!hold && !shown) return next(e)
638    const { Box, Button, Text } = $.ui.resolve(e)
639    // A pending hold renders alone: what another mod draws beneath could
640    // make the engine refuse the whole band and take Deny and Allow once with it.
641    const below = hold ? null : await next(e)
642
643    // Each button acts on the hold it was drawn for, even if the queue moved since.
644    const decide = (isAllowed: boolean) => async () => {
645      if (!isOn) return // a press drawn before the switch went off
646      for (let i = room.holds.length - 1; i >= 0; i--) {
647        if (room.holds[i]!.command === hold!.command) room.holds.splice(i, 1)
648      }
649      redraw($)
650      if (!isAllowed) return
651      room.permits.set(hold!.command, (await $.clock.now()) + PERMIT_MS)
652      await $.prompt.submit({
653        text:
654          `cloakroom: the person pressed Allow once for the held command (${hold!.labels} ${hold!.reason}). ` +
655          'Run that exact command again; it will pass one time.',
656      })
657    }
658
659    const mine = hold ? (
660      <Box key="cloakroom" flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
661        <Text color="error" bold>
662          held: {hold.labels} {hold.reason}
663        </Text>
664        <Text dimColor>
665          Claude was told to wait. Allow once lets this exact command run one time
666          {room.holds.length > 1 ? ` (${room.holds.length - 1} more held after this one)` : ''}.
667        </Text>
668        <Box gap={2}>
669          <Button key="deny" label="Deny" hotkey="d" autoFocus onPress={decide(false)} />
670          <Button key="allow" label="Allow once" onPress={decide(true)} />
671        </Box>
672      </Box>
673    ) : (
674      <Box key="cloakroom" flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
675        <Text bold>
676          {clean(shown!.label)} ({clean(shown!.kind)}) - on this screen only, never sent to Claude
677        </Text>
678        <Text color="warning">{clean(shown!.value, { keepNewlines: true })}</Text>
679        <Button
680          key="hide"
681          label="Hide"
682          autoFocus
683          onPress={() => {
684            room.reveal = undefined
685            room.revealTimer?.cancel()
686            redraw($)
687          }}
688        />
689      </Box>
690    )
691    return (
692      <Box flexDirection="column">
693        {mine}
694        {below}
695      </Box>
696    )
697  })
698
699  // The audit's findings, on the person's screen only. File names and previews are untrusted: clean() each line.
700  on('ui.render', { component: 'Pane', requestId: AUDIT_PANE }, ($, e) => {
701    const { Box, Text } = $.ui.resolve(e)
702    if (!isOn) {
703      return (
704        <Box flexDirection="column">
705          <Text key="off" dimColor>
706            {OFF_TEXT}
707          </Text>
708        </Box>
709      )
710    }
711    const lines = room.auditView ?? ['Run /cloakroom audit to check your past transcripts.']
712    return (
713      <Box flexDirection="column" paddingX={1}>
714        {lines.map((line, i) => (
715          <Text key={`line-${i}`}>{clean(line)}</Text>
716        ))}
717      </Box>
718    )
719  })
720
721  // Every word of the output passes clean(): file names, previews and echoed arguments are untrusted.
722  on('command.run', { command: 'cloakroom' }, async ($, e) => {
723    if (!isOn) {
724      $.ui.toast(OFF_TEXT)
725      return {}
726    }
727    return { text: clean(await answer($, room, e.args, e.origin.kind), { keepNewlines: true }) }
728  }).catch(() => ({ text: 'cloakroom: that command failed, and nothing was shown.' }))
729}
730
hooks/audit.ts 119 lines
1// /cloakroom audit: which secrets were already written into past transcripts.
2//
3// Read-only. Values found here are never learned into the vault and never
4// printed: a finding shows its kind, the format's public prefix and at most
5// its last three characters (only for values of 16 characters or more).
6
7import { detect } from './detect'
8import { isMediaPayload } from './scrub'
9import type { Vault } from './vault'
10
11export type Finding = {
12  kind: string
13  name: string
14  preview: string
15  first: string
16  last: string
17  files: Set<string>
18}
19
20export type AuditState = {
21  /** Transcripts already read this session (a later run goes on from here). */
22  done: Set<string>
23  /** Keyed by the raw value, in memory only, so one secret is one line. */
24  findings: Map<string, Finding>
25  tooBig: number
26  unreadable: number
27}
28
29export function newAudit(): AuditState {
30  return { done: new Set(), findings: new Map(), tooBig: 0, unreadable: 0 }
31}
32
33/** The format's public prefix of a value (`sk_live_`), when a detector knows it. */
34function prefixOf(value: string): string | undefined {
35  return detect(value).find(hit => hit.start === 0 && hit.end === value.length)?.prefix
36}
37
38/** A preview that identifies a secret to its owner without giving it away. */
39export function mask(value: string, prefix = prefixOf(value)): string {
40  const lines = value.split('\n').length
41  if (value.startsWith('-----BEGIN')) {
42    const head = value.slice(0, value.indexOf('-----', 11) + 5)
43    return `${head} (${lines} lines)`
44  }
45  const trimmed = value.trimEnd()
46  const tail = trimmed.length >= 16 ? trimmed.slice(-3) : ''
47  if (prefix && value.startsWith(prefix) && value.length - prefix.length >= 8) {
48    return tail ? `${prefix}...${tail}` : `${prefix}...`
49  }
50  return tail ? `...${tail} (${trimmed.length} chars)` : `(${trimmed.length} chars)`
51}
52
53function record(state: AuditState, value: string, kind: string, name: string, file: string, date: string, prefix?: string): void {
54  const known = state.findings.get(value)
55  if (known) {
56    known.files.add(file)
57    if (date < known.first) known.first = date
58    if (date > known.last) known.last = date
59    return
60  }
61  state.findings.set(value, { kind, name, preview: mask(value, prefix), first: date, last: date, files: new Set([file]) })
62}
63
64function walkStrings(value: unknown, visit: (s: string) => void, depth = 0): void {
65  if (depth > 64) return
66  if (typeof value === 'string') {
67    visit(value)
68    return
69  }
70  if (Array.isArray(value)) {
71    for (const item of value) walkStrings(item, visit, depth + 1)
72    return
73  }
74  if (value !== null && typeof value === 'object') {
75    const owner = value as Record<string, unknown>
76    for (const [key, item] of Object.entries(owner)) {
77      if (!isMediaPayload(key, owner)) walkStrings(item, visit, depth + 1)
78    }
79  }
80}
81
82/** Adds the secrets in one transcript (JSON lines) to the findings. */
83export function auditTranscript(state: AuditState, vault: Vault, text: string, file: string, fileDate: string): void {
84  for (const line of text.split('\n')) {
85    if (line.trim() === '') continue
86    let row: unknown
87    try {
88      row = JSON.parse(line)
89    } catch {
90      row = line // a torn last line: scan it as text
91    }
92    const stamp = (row as { timestamp?: unknown } | null)?.timestamp
93    const date = typeof stamp === 'string' && /^\d{4}-\d{2}-\d{2}/.test(stamp) ? stamp.slice(0, 10) : fileDate
94    walkStrings(row, s => {
95      if (s.length < 8) return
96      for (const found of vault.scanKnown(s)) {
97        const e = found.entry
98        record(state, e.value, e.kind, e.label.split('#')[0]!, file, date, e.prefix)
99      }
100      for (const hit of detect(s)) {
101        const value = s.slice(hit.start, hit.end)
102        if (!vault.isIgnored(value)) record(state, value, hit.kind, hit.name, file, date, hit.prefix)
103      }
104    })
105  }
106}
107
108/** The findings as lines, most recently exposed first. */
109export function findingLines(state: AuditState, limit = 50): string[] {
110  const all = [...state.findings.values()].sort((a, b) => (a.last < b.last ? 1 : a.last > b.last ? -1 : 0))
111  const lines = all.slice(0, limit).map(f => {
112    const when = f.first === f.last ? f.first : `${f.first} to ${f.last}`
113    const where = f.files.size === 1 ? '1 transcript' : `${f.files.size} transcripts`
114    return `- ${f.kind}: ${f.preview} (${f.name}) - ${where}, ${when}`
115  })
116  if (all.length > limit) lines.push(`- ...and ${all.length - limit} more`)
117  return lines
118}
119
hooks/clean.ts 33 lines
1// One gate for every untrusted string cloakroom draws or prints: the program
2// and host in a held command, a revealed value, file names, previews.
3//
4// A drawn tree holding a control character is refused whole by the engine,
5// and the band composes the bands beneath it, so one stray ESC would blank
6// the hold (and every other mod's band) right when it matters. Bidi overrides
7// and zero-width characters are dropped too: they can make a host read as
8// another one.
9
10const ANSI = [
11  /\x1b\[[0-?]{0,64}[ -/]{0,16}[@-~]/g, // CSI: ESC [ ... final byte
12  /\x9b[0-?]{0,64}[ -/]{0,16}[@-~]/g, // the same, as one C1 character
13  /\x1b\][^\x07\x1b]{0,4096}(?:\x07|\x1b\\)/g, // OSC: ESC ] ... BEL or ST
14  /\x1b[@-Z\\-_]/g, // two-character escapes
15]
16/** Bidi embeddings, overrides and isolates; zero-width spaces and joiners; BOM. */
17const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g
18/** C0 (all but newline here), DEL and C1. */
19const CONTROL = /[\x00-\x09\x0b-\x1f\x7f-\x9f]/g
20
21/**
22 * The text with ANSI sequences removed, invisible direction and width marks
23 * dropped, tabs as two spaces, and every other control character as U+FFFD.
24 * Newlines are kept only when `keepNewlines` (a multi-line key, a list).
25 */
26export function clean(text: string, options: { keepNewlines?: boolean } = {}): string {
27  let out = text.replace(/\r\n/g, '\n')
28  for (const re of ANSI) out = out.replace(re, '')
29  out = out.replace(INVISIBLE, '').replace(/\t/g, '  ')
30  if (!options.keepNewlines) out = out.replace(/\n/g, ' ')
31  return out.replace(CONTROL, '\uFFFD')
32}
33
hooks/dotenv.ts 92 lines
1// .env files: which ones to read at session start, how to parse them, and
2// which of their values are worth hiding everywhere for the session.
3
4import { isLearnable, isPlaceholder } from './detect'
5
6/** `.env`, `.env.local`, `.env.production`: the names cloakroom reads. */
7export function isEnvFileName(name: string): boolean {
8  return name === '.env' || (name.startsWith('.env.') && name.length > 5)
9}
10
11/** Folders never worth listing for .env files (big, generated or vendored). */
12export const SKIP_DIRS = new Set([
13  'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', 'target', 'vendor', '.venv', 'venv',
14  '__pycache__', '.next', '.nuxt', '.turbo', '.cache', 'coverage', '.idea', '.vscode', 'Pods', '.terraform',
15])
16
17/** The secret-looking variable names the spec names, matched anywhere in the name. */
18const SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIAL|DSN|PRIVATE/i
19const PASSWORD_NAME = /PASSWORD|PASS|PWD/i
20
21export function isSecretName(name: string): boolean {
22  return SECRET_NAME.test(name)
23}
24
25/**
26 * Whether a .env value is worth hiding everywhere it appears this session: a
27 * learnable value (see isLearnable: not a path under a non-secret name, a short value, a common default
28 * like `postgres` or a low-entropy word), not a placeholder, and not a bare
29 * number under a non-password name. A weak one is still hidden where it sits
30 * next to its name (`DB_PASSWORD=hunter2`), by shape.
31 */
32export function isWorthHiding(name: string, value: string): boolean {
33  if (!isLearnable(value, name) || isPlaceholder(value)) return false
34  if (/^\d+$/.test(value) && !PASSWORD_NAME.test(name)) return false // TOKEN_TTL=86400000
35  return true
36}
37
38export type EnvPair = { name: string; value: string }
39
40const LINE = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]{0,127})[ \t]*=[ \t]*(.*)$/
41
42/**
43 * Parses dotenv text: `KEY=value`, `export KEY=value`, single, double and
44 * backtick quotes (double quotes unescape `\n`, and may span lines, as a PEM
45 * key often does), and ` # comments` after unquoted values.
46 */
47export function parseDotenv(text: string): EnvPair[] {
48  const lines = text.split(/\r?\n/)
49  const out: EnvPair[] = []
50  for (let i = 0; i < lines.length; i++) {
51    const m = LINE.exec(lines[i] ?? '')
52    if (!m) continue
53    const name = m[1]!
54    let rest = m[2] ?? ''
55    const quote = rest[0]
56    if (quote === '"' || quote === "'" || quote === '`') {
57      let body = rest.slice(1)
58      let close = closingQuote(body, quote)
59      // A quoted value may run over several lines (up to 200).
60      let extra = 0
61      while (close < 0 && i + 1 < lines.length && extra < 200) {
62        i++
63        extra++
64        body += '\n' + (lines[i] ?? '')
65        close = closingQuote(body, quote)
66      }
67      if (close < 0) continue // unterminated: not a value we can trust
68      let value = body.slice(0, close)
69      if (quote === '"') {
70        value = value.replace(/\\([nrt"\\])/g, (_, c: string) => ({ n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' })[c] ?? c)
71      }
72      out.push({ name, value })
73      continue
74    }
75    const hash = rest.search(/[ \t]#/)
76    if (hash >= 0) rest = rest.slice(0, hash)
77    out.push({ name, value: rest.trim() })
78  }
79  return out
80}
81
82function closingQuote(body: string, quote: string): number {
83  for (let i = 0; i < body.length; i++) {
84    if (quote === '"' && body[i] === '\\') {
85      i++
86      continue
87    }
88    if (body[i] === quote) return i
89  }
90  return -1
91}
92
hooks/egress.ts 362 lines
1// Should this shell command get a real value, and would it carry one off the machine?
2//
3// Pure and table-tested. Both checks read the command AFTER tickets were swapped
4// back for real values, plus where those values sit in it. Two gates, both must
5// pass before a value is restored into Bash:
6//   localProof: every program on the line is on a short local-only list (a
7//     positive reason to restore; a wrapper, interpreter, package runner or a
8//     variable as the program is not proven and is held);
9//   classifyEgress: nothing on the line points at the network (defence in depth
10//     for the local list: `echo v > /dev/tcp/...`, a remote URL beside the value).
11// Neither is a sandbox: a command that sends a secret by a route they do not
12// see (an environment variable, a script file) passes.
13
14export type Egress = { via: string; to: string }
15type Span = { start: number; end: number }
16
17/** Programs whose job is talking to another machine. */
18const NET = new Set([
19  'curl', 'wget', 'nc', 'ncat', 'netcat', 'socat', 'telnet', 'ssh', 'scp', 'sftp', 'ftp', 'rsync',
20  'http', 'https', 'xh', 'aria2c', 'gh',
21])
22/** git subcommands that reach a remote. */
23const GIT_NET = new Set(['push', 'fetch', 'pull', 'clone', 'ls-remote', 'remote', 'send-email', 'request-pull', 'submodule'])
24/** Words that run the next word as the command. */
25const WRAPPERS = new Set(['sudo', 'doas', 'env', 'exec', 'command', 'builtin', 'nohup', 'time', 'nice', 'stdbuf', 'timeout', 'xargs', 'busybox', 'then', 'do', 'else', '!'])
26
27const URL_RE = /\bhttps?:\/\/[^\s'"<>|;&)`\\]{1,2048}/gi
28
29function isLocalHost(host: string): boolean {
30  const h = host.toLowerCase().replace(/^\[|\]$/g, '')
31  return h === 'localhost' || h.endsWith('.localhost') || h === '::1' || h === '0.0.0.0' || /^127\.\d+\.\d+\.\d+$/.test(h)
32}
33
34function hostOf(url: string): string {
35  try {
36    return new URL(url).hostname || 'an unknown host'
37  } catch {
38    const m = /^[a-z]+:\/\/(?:[^@/]*@)?([^/:?#]+)/i.exec(url)
39    return m?.[1] ?? 'an unknown host'
40  }
41}
42
43type Segment = { start: number; end: number }
44
45/**
46 * Splits a command line at `| || && ; & ( ) $( \``, and newlines, outside
47 * single and double quotes; a `$(` or backtick inside double quotes still
48 * starts a command, as the shell runs it.
49 */
50export function segments(command: string): Segment[] {
51  const out: Segment[] = []
52  let start = 0
53  let quote: '"' | "'" | null = null
54  const cut = (at: number, skip: number) => {
55    if (at > start) out.push({ start, end: at })
56    start = at + skip
57  }
58  for (let i = 0; i < command.length; i++) {
59    const c = command[i]
60    if (quote === "'") {
61      if (c === "'") quote = null
62      continue
63    }
64    if (c === '\\') {
65      i++
66      continue
67    }
68    if (quote === '"') {
69      if (c === '"') quote = null
70      else if (c === '`') cut(i, 1)
71      else if (c === '$' && command[i + 1] === '(') {
72        cut(i, 2)
73        i++
74      }
75      continue
76    }
77    if (c === '"' || c === "'") quote = c
78    else if (c === '|' || c === '&' || c === ';' || c === '\n' || c === '(' || c === ')' || c === '`' || c === '{' || c === '}') cut(i, 1)
79    else if (c === '$' && command[i + 1] === '(') {
80      cut(i, 2)
81      i++
82    }
83  }
84  cut(command.length, 0)
85  return out
86}
87
88/** The words of one simple command, as a shell splits them: quotes join, backslash escapes. */
89function words(text: string): string[] {
90  const out: string[] = []
91  let word = ''
92  let isWord = false
93  let quote: string | null = null
94  for (let i = 0; i < text.length; i++) {
95    const c = text[i]!
96    if (quote !== null) {
97      if (c === quote) quote = null
98      else word += c
99      continue
100    }
101    if (c === '"' || c === "'") {
102      quote = c
103      isWord = true
104    } else if (c === '\\' && i + 1 < text.length) {
105      word += text[++i]
106      isWord = true
107    } else if (/\s/.test(c)) {
108      if (isWord) out.push(word)
109      word = ''
110      isWord = false
111    } else {
112      word += c
113      isWord = true
114    }
115  }
116  if (isWord) out.push(word)
117  return out
118}
119
120/** Wrapper flags that take the next word as their value (`sudo -u me`, `nice -n 5`). */
121const FLAG_WITH_VALUE = new Set(['-u', '-g', '-n', '-C', '-D', '-p', '-U', '-r', '-t', '-S', '-s', '-k', '-h', '-I', '-L', '-P'])
122
123/** The program a simple command runs, past `FOO=bar`, `sudo -u x`, `env`, `timeout 5`. */
124export function commandOf(text: string): { name: string; args: string[] } | undefined {
125  const ws = words(text)
126  let i = 0
127  while (i < ws.length) {
128    const w = ws[i]!
129    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
130      i++
131      continue
132    }
133    const base = (w.split('/').pop() ?? w).toLowerCase()
134    if (WRAPPERS.has(base)) {
135      i++
136      // the wrapper's own flags, their values and numbers (sudo -u me, timeout 30, nice -n 5)
137      while (i < ws.length && /^(?:-\S*|\d+(?:\.\d+)?[smhd]?)$/.test(ws[i]!)) {
138        i += FLAG_WITH_VALUE.has(ws[i]!) ? 2 : 1
139      }
140      continue
141    }
142    return { name: base, args: ws.slice(i + 1) }
143  }
144  return undefined
145}
146
147/** Where a network program is pointed: a URL's host, `user@host:`, or its first operand. */
148function targetOf(name: string, args: string[], text: string): string {
149  URL_RE.lastIndex = 0
150  const url = URL_RE.exec(text)
151  if (url) return hostOf(url[0])
152  if (name === 'gh') return 'github.com'
153  for (const a of args) {
154    if (a.startsWith('-')) continue
155    const remote = /^(?:[^@\s/]+@)?([A-Za-z0-9.-]+\.[A-Za-z]{2,}|localhost|\d+\.\d+\.\d+\.\d+)(?::|$)/.exec(a)
156    if (remote) return remote[1]!
157  }
158  for (const a of args) if (!a.startsWith('-')) return a.slice(0, 80)
159  return 'an unknown host'
160}
161
162/** git's subcommand and the words after it, past `-c key=value`, `-C dir` and other options. */
163function gitSub(args: string[]): { sub: string; rest: string[] } | undefined {
164  for (let i = 0; i < args.length; i++) {
165    const a = args[i]!
166    if (a === '-c' || a === '-C' || a === '--git-dir' || a === '--work-tree') {
167      i++
168      continue
169    }
170    if (!a.startsWith('-')) return { sub: a, rest: args.slice(i + 1) }
171  }
172  return undefined
173}
174
175/**
176 * Programs whose own work stays on this machine: text and file tools that run
177 * no other program (so not `sort`, whose --compress-program runs one). A real value is restored into a Bash command only when every
178 * program on the line is one of these (see localProof). Shells, interpreters,
179 * package runners, `find`, `xargs`'s target and anything unknown are left out on
180 * purpose: they can run or send whatever they are handed.
181 */
182export const LOCAL = new Set([
183  'echo', 'printf', 'cat', 'tee', 'head', 'tail', 'wc', 'uniq', 'cut', 'tr', 'rev', 'fold', 'paste', 'column',
184  'grep', 'egrep', 'fgrep', 'cmp', 'diff', 'comm', 'jq',
185  'test', '[', '[[', 'true', 'false', ':', 'cp', 'mv', 'mkdir', 'rmdir', 'touch', 'ls', 'stat', 'chmod', 'chown', 'ln',
186  'readlink', 'realpath', 'basename', 'dirname', 'pwd', 'cd', 'date', 'sleep',
187  'base64', 'xxd', 'od', 'hexdump', 'md5sum', 'sha1sum', 'sha256sum', 'shasum',
188  'export', 'unset', 'set', 'local', 'declare', 'typeset', 'readonly', 'shift', 'return', 'break', 'continue', 'exit', 'read', 'wait', 'umask',
189])
190/** Shell words that structure a line and run nothing themselves; their bodies are checked as their own segments. */
191const KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'in', 'select', 'function'])
192
193/** `feature`, when the word in the program's place is shell syntax, says which, in words a person knows. */
194export type LocalProof = { ok: true } | { ok: false; program: string; feature?: string }
195
196/**
197 * Shell syntax standing where a program would (a `<` or `>` that a segment
198 * starts with): named as the feature it is, since `<` is no program anyone
199 * recognises. `<(` and `>(` are process substitution; any other is a redirect
200 * the segmenting cannot follow, such as one after a `( )` group.
201 */
202function featureOf(command: string, seg: Span, name: string): string | undefined {
203  const symbol = /^(?:\d*|&)[<>]+/.exec(name)?.[0]
204  if (symbol === undefined) return undefined
205  // `<(` / `>(`: the segment ends at the parenthesis, right after the arrow (`> >(cat)` writes into one).
206  const arrow = command[seg.end - 1]
207  if (command[seg.end] === '(' && (arrow === '<' || arrow === '>')) {
208    return `process substitution ${arrow}( ) can run a program`
209  }
210  return `a redirect (${symbol}) there is past what cloakroom can check`
211}
212
213/**
214 * Whether every program `command` runs is on the local-only list, so a real
215 * value may be restored into it. Heredoc bodies are data, not commands. A
216 * line with no program (assignments alone, a comment) is local. The first
217 * program that is not proven local is named, so the hold can say why.
218 */
219export function localProof(command: string): LocalProof {
220  const data = heredocBodies(command)
221  for (const seg of segments(command)) {
222    if (data.some(d => d.start <= seg.start && seg.start < d.end)) continue
223    const text = command.slice(seg.start, seg.end)
224    const cmd = commandOf(text)
225    if (!cmd) continue
226    if (cmd.name.startsWith('#')) continue // a comment to the end of the segment
227    if (KEYWORDS.has(cmd.name)) {
228      // `for x in a b`, `if test -f x`: the words after a keyword may be a command of their own.
229      if (cmd.name === 'if' || cmd.name === 'elif' || cmd.name === 'while' || cmd.name === 'until') {
230        const inner = localProof(cmd.args.join(' '))
231        if (!inner.ok) return inner
232      }
233      continue
234    }
235    if (!LOCAL.has(cmd.name)) {
236      const feature = featureOf(command, seg, cmd.name)
237      return feature === undefined ? { ok: false, program: cmd.name } : { ok: false, program: cmd.name, feature }
238    }
239  }
240  return { ok: true }
241}
242
243/**
244 * The spans of heredoc bodies (`<<EOF` to the line that is `EOF`): data the
245 * shell never runs. Only a `<<` outside quotes and not escaped opens one: a
246 * quoted `'<<EOF'` is a plain word, and the lines after it are commands.
247 */
248function heredocBodies(command: string): Span[] {
249  const out: Span[] = []
250  const open = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/
251  let quote: '"' | "'" | null = null
252  for (let i = 0; i < command.length; i++) {
253    const c = command[i]
254    if (c === '\\') {
255      i++
256      continue
257    }
258    if (quote !== null) {
259      if (c === quote) quote = null
260      continue
261    }
262    if (c === '"' || c === "'") {
263      quote = c
264      continue
265    }
266    if (c !== '<' || command[i + 1] !== '<') continue
267    const m = open.exec(command.slice(i))
268    if (!m) continue
269    const word = m[2]!
270    const lineEnd = command.indexOf('\n', i)
271    if (lineEnd < 0) break
272    const lines = command.slice(lineEnd + 1).split('\n')
273    let end = command.length
274    let at = lineEnd + 1
275    for (const line of lines) {
276      if (line.replace(/^\t+/, '') === word) {
277        end = at + line.length // the terminator line is part of the heredoc, not a command
278        break
279      }
280      at += line.length + 1
281    }
282    out.push({ start: lineEnd + 1, end })
283    i = end // the next heredoc, if any, starts after this body
284    quote = null
285  }
286  return out
287}
288
289/**
290 * What the hold names must never be the secret: a target or program word
291 * that overlaps a value is replaced. The band, the toast and Claude's deny
292 * text show only these and the tickets' labels.
293 */
294function named(egress: Egress, values: readonly string[]): Egress {
295  return {
296    via: leaksValue(egress.via, values) ? 'a program in the command' : egress.via,
297    to: leaksValue(egress.to, values) ? 'a host named in the command' : egress.to,
298  }
299}
300
301/** Whether a word drawn on screen would show a secret value, or a telling piece of one. */
302export function leaksValue(word: string, values: readonly string[]): boolean {
303  return values.some(v => v.length > 0 && (word.includes(v) || (word.length >= 4 && v.includes(word))))
304}
305
306/**
307 * Whether `command`, which holds secret values at `secrets`, would send them
308 * off this machine: a URL that carries one; a network program (curl, wget,
309 * nc, ssh, scp, rsync to a remote, git push and kin) anywhere in the command
310 * line, pipes included; bash's /dev/tcp; or any non-local URL beside them.
311 * Null when it would not, or when it carries no value at all.
312 */
313export function classifyEgress(command: string, secrets: readonly Span[]): Egress | null {
314  if (secrets.length === 0) return null
315  const found = findEgress(command, secrets)
316  return found && named(found, secrets.map(s => command.slice(s.start, s.end)))
317}
318
319function findEgress(command: string, secrets: readonly Span[]): Egress | null {
320  const urls: { start: number; end: number; host: string }[] = []
321  URL_RE.lastIndex = 0
322  for (let m = URL_RE.exec(command); m !== null; m = URL_RE.exec(command)) {
323    urls.push({ start: m.index, end: m.index + m[0].length, host: hostOf(m[0]) })
324  }
325
326  // 1. A value inside a URL leaves with the request, whatever runs it.
327  for (const u of urls) {
328    if (isLocalHost(u.host)) continue
329    if (secrets.some(s => s.start < u.end && s.end > u.start)) {
330      const seg = segments(command).find(g => g.start <= u.start && u.start < g.end)
331      const via = seg ? commandOf(command.slice(seg.start, seg.end))?.name : undefined
332      return { via: via ?? 'a URL', to: u.host }
333    }
334  }
335
336  // 2. A network program anywhere on the line: `echo $V | curl -d @- host` sends V.
337  for (const seg of segments(command)) {
338    const text = command.slice(seg.start, seg.end)
339    const cmd = commandOf(text)
340    if (!cmd) continue
341    if (cmd.name === 'git') {
342      const git = gitSub(cmd.args)
343      if (git && GIT_NET.has(git.sub)) return { via: `git ${git.sub}`, to: targetOf('git', git.rest, text) }
344      continue
345    }
346    if (!NET.has(cmd.name)) continue
347    if (cmd.name === 'rsync' && !cmd.args.some(a => /^[^-/][^/]*:|^rsync:\/\//.test(a))) continue // local copy
348    const to = targetOf(cmd.name, cmd.args, text)
349    if (isLocalHost(to)) continue // this machine talking to itself
350    return { via: cmd.name, to }
351  }
352
353  // 3. bash's own network: > /dev/tcp/host/port
354  const tcp = /\/dev\/(?:tcp|udp)\/([^/\s]+)\//.exec(command)
355  if (tcp) return { via: '/dev/tcp', to: tcp[1]! }
356
357  // 4. A script or program handed a remote URL alongside the value.
358  const remote = urls.find(u => !isLocalHost(u.host))
359  if (remote) return { via: 'a URL in the command', to: remote.host }
360  return null
361}
362
hooks/scrub.ts 146 lines
1// Scrubbing whole values: tool results, tool inputs, conversation rows.
2// Every function returns the same object when nothing changed, so a caller
3// can tell "clean" from "rewritten" by identity, and builds fresh objects
4// when something did.
5
6import type { Entry, Vault } from './vault'
7import { ScanError } from './vault'
8
9/** What the model reads in place of a text cloakroom could not scan. */
10export const WITHHELD = '[cloakroom: result withheld, redaction failed]'
11
12const MAX_DEPTH = 64
13
14export type Deep<T> = { value: T; changed: boolean; entries: Entry[] }
15
16/** Media payloads: base64 bytes are not text, and rewriting them would corrupt the file. */
17export function isMediaPayload(key: string, owner: Record<string, unknown>): boolean {
18  if (key === 'base64') return true
19  if (key !== 'data') return false
20  const kind = typeof owner.type === 'string' ? owner.type : ''
21  const mime = typeof owner.mimeType === 'string' ? owner.mimeType : typeof owner.media_type === 'string' ? owner.media_type : ''
22  return /^(?:image|audio|document)$/.test(kind) || /^(?:image|audio|video)\/|pdf$/.test(mime)
23}
24
25/**
26 * Every string inside `value` scrubbed, at any depth (to 64), keys kept.
27 * Throws ScanError for a value nested too deep to walk: the caller withholds it.
28 */
29export function scrubDeep<T>(vault: Vault, value: T, source: string, depth = 0): Deep<T> {
30  if (depth > MAX_DEPTH) throw new ScanError('a value nested too deep to scan')
31  if (typeof value === 'string') {
32    const s = vault.scrub(value, source)
33    return { value: s.text as T, changed: s.changed, entries: s.entries }
34  }
35  if (Array.isArray(value)) {
36    let changed = false
37    const entries: Entry[] = []
38    const items = value.map(item => {
39      const r = scrubDeep(vault, item, source, depth + 1)
40      if (r.changed) changed = true
41      entries.push(...r.entries)
42      return r.value
43    })
44    return { value: (changed ? items : value) as T, changed, entries }
45  }
46  if (value !== null && typeof value === 'object') {
47    const owner = value as Record<string, unknown>
48    let changed = false
49    const entries: Entry[] = []
50    const copy: Record<string, unknown> = {}
51    for (const [key, item] of Object.entries(owner)) {
52      if (isMediaPayload(key, owner)) {
53        copy[key] = item
54        continue
55      }
56      const r = scrubDeep(vault, item, source, depth + 1)
57      if (r.changed) changed = true
58      entries.push(...r.entries)
59      copy[key] = r.value
60    }
61    return { value: (changed ? copy : value) as T, changed, entries }
62  }
63  return { value, changed: false, entries: [] }
64}
65
66/** Every string inside `value` restored from tickets; unknown tickets collected. */
67export function restoreDeep<T>(vault: Vault, value: T, depth = 0): { value: T; unknown: string[]; entries: Entry[]; changed: boolean } {
68  if (depth > MAX_DEPTH) throw new ScanError('a value nested too deep to restore')
69  if (typeof value === 'string') {
70    const r = vault.restore(value)
71    return { value: r.text as T, unknown: r.unknown, entries: r.entries, changed: r.text !== value }
72  }
73  if (Array.isArray(value)) {
74    const unknown: string[] = []
75    const entries: Entry[] = []
76    let changed = false
77    const items = value.map(item => {
78      const r = restoreDeep(vault, item, depth + 1)
79      unknown.push(...r.unknown)
80      entries.push(...r.entries)
81      if (r.changed) changed = true
82      return r.value
83    })
84    return { value: (changed ? items : value) as T, unknown, entries, changed }
85  }
86  if (value !== null && typeof value === 'object') {
87    const unknown: string[] = []
88    const entries: Entry[] = []
89    let changed = false
90    const copy: Record<string, unknown> = {}
91    for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
92      const r = restoreDeep(vault, item, depth + 1)
93      unknown.push(...r.unknown)
94      entries.push(...r.entries)
95      if (r.changed) changed = true
96      copy[key] = r.value
97    }
98    return { value: (changed ? copy : value) as T, unknown, entries, changed }
99  }
100  return { value, unknown: [], entries: [], changed: false }
101}
102
103type Block = { type: string; [field: string]: unknown }
104
105/**
106 * A conversation row's blocks scrubbed: text blocks and tool_result content
107 * (a string or its own text blocks). Thinking, tool_use and media blocks are
108 * left: the engine puts those back as they were whatever a hook answers.
109 */
110export function scrubBlocks(vault: Vault, blocks: readonly Block[], source: string): Deep<Block[]> {
111  let changed = false
112  const entries: Entry[] = []
113  const out = blocks.map(block => {
114    if (block.type === 'text' && typeof block.text === 'string') {
115      const s = vault.scrub(block.text, source)
116      if (!s.changed) return block
117      changed = true
118      entries.push(...s.entries)
119      return { ...block, text: s.text }
120    }
121    if (block.type === 'tool_result') {
122      const r = scrubDeep(vault, block.content, source)
123      if (!r.changed) return block
124      changed = true
125      entries.push(...r.entries)
126      return { ...block, content: r.value }
127    }
128    return block
129  })
130  return { value: changed ? out : (blocks as Block[]), changed, entries }
131}
132
133/** The blocks with every text the model could read replaced by WITHHELD (the fail-closed answer). */
134export function withheldBlocks(blocks: readonly Block[]): Block[] {
135  return blocks.map(block => {
136    if (block.type === 'text') return { ...block, text: WITHHELD }
137    if (block.type === 'tool_result') return { ...block, content: WITHHELD }
138    return block
139  })
140}
141
142/** `NAME#id, OTHER#id` for messages: labels only, never values. */
143export function labels(entries: readonly Entry[]): string {
144  return [...new Set(entries.map(e => e.label))].join(', ')
145}
146
hooks/vault.ts 439 lines
1// The vault: ticket <-> value, in this module's memory and nowhere else.
2//
3// Not $.store (a file on disk), not $.state (any plugin may read it), not the
4// transcript. A reload of the mod or the end of the session forgets it, and
5// tickets from before then are refused in tools (see restore()).
6//
7// Tickets are `[secret:NAME#xxxxxx]`: NAME comes from where the value was found
8// (its variable name, or its format), the six characters from
9// crypto.getRandomValues. Nothing in a ticket is computed from the value, and
10// the same value keeps the same ticket for the life of the vault.
11
12import { detect, isLearnable, type Hit } from './detect'
13
14export type Entry = {
15  ticket: string
16  /** `NAME#xxxxxx`, the ticket without its brackets. */
17  label: string
18  kind: string
19  /** Where cloakroom first met it: `.env.local`, `Bash output`, `your prompt`. */
20  source: string
21  value: string
22  prefix?: string
23  /** Seeded from a .env file: kept when learned entries are evicted. */
24  isSeeded: boolean
25  hidden: number
26  restored: number
27}
28
29export type Found = { start: number; end: number; entry: Entry }
30
31/** Thrown when text cannot be scanned in full; every caller withholds the text. */
32export class ScanError extends Error {
33  override name = 'ScanError'
34}
35
36/** Longest string scanned in one piece. Longer text is withheld, never passed unscanned. */
37export const MAX_SCAN_CHARS = 16 * 1024 * 1024
38/** Wall-clock cap for one scan; past it the text is withheld. */
39export const SCAN_BUDGET_MS = 4000
40const MAX_ENTRIES = 2000
41
42export const TICKET = /\[secret:[A-Z0-9_]{1,48}#[a-z2-7]{6}\]/g
43const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'
44
45function randomId(): string {
46  const bytes = crypto.getRandomValues(new Uint8Array(6))
47  let id = ''
48  for (const b of bytes) id += ID_ALPHABET[b & 31] // 256 is a multiple of 32: uniform
49  return id
50}
51
52export function sanitizeName(name: string): string {
53  return (
54    name
55      .toUpperCase()
56      .replace(/[^A-Z0-9_]+/g, '_')
57      .replace(/^_+|_+$/g, '')
58      .slice(0, 48) || 'SECRET'
59  )
60}
61
62// ------------------------------------------------------- encoded forms
63
64const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
65
66function base64(bytes: Uint8Array): string {
67  let out = ''
68  for (let i = 0; i < bytes.length; i += 3) {
69    const a = bytes[i] ?? 0
70    const b = bytes[i + 1]
71    const c = bytes[i + 2]
72    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
73    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
74    out += b === undefined ? '=' : B64[(n >> 6) & 63]!
75    out += c === undefined ? '=' : B64[n & 63]!
76  }
77  return out
78}
79
80/**
81 * The spellings a value takes on the way through tools: JSON-escaped (once and
82 * twice, as transcripts nest JSON), URL-encoded, and base64 at each of the
83 * three byte alignments it can sit at inside a longer base64 run.
84 */
85export function encodedForms(value: string): string[] {
86  const forms = new Set<string>()
87  const json = JSON.stringify(value).slice(1, -1)
88  forms.add(json)
89  forms.add(JSON.stringify(json).slice(1, -1))
90  try {
91    forms.add(encodeURIComponent(value))
92  } catch {
93    // a lone surrogate: no URL form
94  }
95  const bytes = new TextEncoder().encode(value)
96  forms.add(base64(bytes))
97  for (let shift = 0; shift < 3; shift++) {
98    if (shift > 0 && bytes.length < 12) break
99    const padded = new Uint8Array(shift + bytes.length)
100    padded.set(bytes, shift)
101    const enc = base64(padded)
102    // Characters that mix in a neighbour's bits differ by context: keep the
103    // ones the value's bytes alone decide.
104    const core = enc.slice(Math.ceil((shift * 8) / 6), Math.floor(((shift + bytes.length) * 8) / 6))
105    if (core.length >= 12) {
106      forms.add(core)
107      forms.add(core.replace(/\+/g, '-').replace(/\//g, '_'))
108    }
109  }
110  forms.delete(value)
111  return [...forms].filter(form => form.length >= 8)
112}
113
114// ------------------------------------------------------- Aho-Corasick
115
116/** Many needles, one linear pass: the longest needle ending at each position. */
117class Matcher {
118  private readonly next: Map<number, number>[] = [new Map()]
119  private readonly fail: number[] = [0]
120  private readonly outLen: number[] = [0]
121  private readonly outEntry: (Entry | undefined)[] = [undefined]
122
123  add(needle: string, entry: Entry): void {
124    let s = 0
125    for (let i = 0; i < needle.length; i++) {
126      const c = needle.charCodeAt(i)
127      let t = this.next[s]!.get(c)
128      if (t === undefined) {
129        t = this.next.length
130        this.next.push(new Map())
131        this.fail.push(0)
132        this.outLen.push(0)
133        this.outEntry.push(undefined)
134        this.next[s]!.set(c, t)
135      }
136      s = t
137    }
138    if (needle.length > this.outLen[s]!) {
139      this.outLen[s] = needle.length
140      this.outEntry[s] = entry
141    }
142  }
143
144  build(): void {
145    const queue: number[] = []
146    for (const t of this.next[0]!.values()) queue.push(t)
147    for (let qi = 0; qi < queue.length; qi++) {
148      const s = queue[qi]!
149      for (const [c, t] of this.next[s]!) {
150        let f = this.fail[s]!
151        while (f !== 0 && !this.next[f]!.has(c)) f = this.fail[f]!
152        const g = this.next[f]!.get(c)
153        this.fail[t] = g !== undefined && g !== t ? g : 0
154        // A node's own needle is the longest ending here; else its suffix's.
155        if (this.outLen[t] === 0) {
156          this.outLen[t] = this.outLen[this.fail[t]!]!
157          this.outEntry[t] = this.outEntry[this.fail[t]!]
158        }
159        queue.push(t)
160      }
161    }
162  }
163
164  /**
165   * Spans of known values, in order, overlapping ones merged as they are found
166   * (each span is pushed and popped at most once, so a text of nothing but
167   * matches still costs one pass). `isExact` is false on a merged span.
168   */
169  scan(text: string, offset: number): KnownSpan[] {
170    const out: KnownSpan[] = []
171    let s = 0
172    for (let i = 0; i < text.length; i++) {
173      const c = text.charCodeAt(i)
174      while (s !== 0 && !this.next[s]!.has(c)) s = this.fail[s]!
175      s = this.next[s]!.get(c) ?? 0
176      const len = this.outLen[s]!
177      if (len === 0) continue
178      let start = i + 1 - len
179      const entry = this.outEntry[s]!
180      let isExact = true
181      // Ends only grow, so an overlap is always with the spans just pushed. A
182      // span inside this match is absorbed; one reaching further back makes a
183      // union that is no single value.
184      while (out.length > 0 && out[out.length - 1]!.end - offset > start) {
185        const last = out.pop()!
186        if (last.start - offset < start) {
187          start = last.start - offset
188          isExact = false
189        }
190      }
191      out.push({ start: start + offset, end: i + 1 + offset, entry, isExact })
192    }
193    return out
194  }
195}
196
197type KnownSpan = Found & { isExact: boolean }
198
199type Candidate = { start: number; end: number; len: number; rank: number; entry?: Entry; isExact: boolean; hit?: Hit }
200
201/** Longer wins; on a tie, the more specific detector (lower rank; known values first). */
202function better(a: Candidate, b: Candidate): boolean {
203  return a.len > b.len || (a.len === b.len && a.rank < b.rank)
204}
205
206// ------------------------------------------------------- the vault
207
208export type ScrubResult = { text: string; changed: boolean; entries: Entry[] }
209
210export class Vault {
211  private readonly byValue = new Map<string, Entry>()
212  private readonly byTicket = new Map<string, Entry>()
213  /** Values the person told cloakroom to stop hiding (/cloakroom forget). */
214  private readonly ignored = new Set<string>()
215  private matcher: Matcher | undefined
216  /** Set when a value was learned since the last takeGrown(). */
217  private grown = false
218
219  get size(): number {
220    return this.byValue.size
221  }
222
223  entries(): Entry[] {
224    return [...this.byValue.values()]
225  }
226
227  /** The entry a ticket names: `[secret:A#x]`, `A#x`, or just `x`. */
228  lookup(text: string): Entry | undefined {
229    const raw = text.trim()
230    const label = raw.replace(/^\[secret:/, '').replace(/\]$/, '')
231    const direct = this.byTicket.get(`[secret:${label}]`)
232    if (direct) return direct
233    const id = label.split('#').pop() ?? ''
234    if (!/^[a-z2-7]{6}$/.test(id)) return undefined
235    for (const entry of this.byValue.values()) if (entry.label.endsWith(`#${id}`)) return entry
236    return undefined
237  }
238
239  /** True once since a value joined the vault (so cached prompt parts get rescanned). */
240  takeGrown(): boolean {
241    const was = this.grown
242    this.grown = false
243    return was
244  }
245
246  /** The value's entry, minting its ticket the first time it is seen. */
247  ticketFor(value: string, name: string, kind: string, source: string, extra: { prefix?: string; isSeeded?: boolean } = {}): Entry {
248    const known = this.byValue.get(value)
249    if (known) {
250      if (extra.isSeeded) known.isSeeded = true
251      return known
252    }
253    let label: string
254    do label = `${sanitizeName(name)}#${randomId()}`
255    while (this.byTicket.has(`[secret:${label}]`))
256    const entry: Entry = {
257      ticket: `[secret:${label}]`,
258      label,
259      kind,
260      source,
261      value,
262      ...(extra.prefix ? { prefix: extra.prefix } : {}),
263      isSeeded: extra.isSeeded === true,
264      hidden: 0,
265      restored: 0,
266    }
267    this.byValue.set(value, entry)
268    this.byTicket.set(entry.ticket, entry)
269    this.ignored.delete(value)
270    this.evict()
271    if (isLearnable(value, label.split('#')[0])) {
272      this.matcher = undefined
273      this.grown = true
274    }
275    return entry
276  }
277
278  /** Learns a value from a .env file. */
279  seed(value: string, name: string, source: string): Entry {
280    return this.ticketFor(value, name, 'From a .env file', source, { isSeeded: true })
281  }
282
283  /** Stops hiding a value this session; its ticket stops working in tools. */
284  forget(entry: Entry): void {
285    this.byValue.delete(entry.value)
286    this.byTicket.delete(entry.ticket)
287    this.ignored.add(entry.value)
288    this.matcher = undefined
289  }
290
291  isIgnored(value: string): boolean {
292    return this.ignored.has(value)
293  }
294
295  /** A hostile text full of fresh "keys" must not grow the vault without end. */
296  private evict(): void {
297    if (this.byValue.size <= MAX_ENTRIES) return
298    for (const entry of this.byValue.values()) {
299      if (entry.isSeeded) continue
300      this.byValue.delete(entry.value)
301      this.byTicket.delete(entry.ticket)
302      this.matcher = undefined
303      if (this.byValue.size <= MAX_ENTRIES) return
304    }
305  }
306
307  private known(): Matcher {
308    if (this.matcher) return this.matcher
309    const matcher = new Matcher()
310    for (const entry of this.byValue.values()) {
311      // A path, a short value or a common default is hidden only where a detector finds it.
312      if (!isLearnable(entry.value, entry.label.split('#')[0])) continue
313      matcher.add(entry.value, entry)
314      for (const form of encodedForms(entry.value)) matcher.add(form, entry)
315    }
316    matcher.build()
317    this.matcher = matcher
318    return matcher
319  }
320
321  /** Known values only, no detectors, nothing learned: for the audit. */
322  scanKnown(text: string): Found[] {
323    return this.byValue.size === 0 ? [] : this.known().scan(text, 0)
324  }
325
326  /**
327   * Every secret in `text`: known values (and their encoded forms) plus
328   * detector hits, merged into non-overlapping spans, each with its entry.
329   * Tickets already in the text are kept as they are: the text between them is
330   * scanned piece by piece, so no span can reach into one. New values are
331   * learned. Throws ScanError when the text cannot be scanned in full.
332   */
333  find(text: string, source: string): Found[] {
334    if (text.length > MAX_SCAN_CHARS) throw new ScanError(`a text of ${text.length} characters is too long to scan`)
335    const started = performance.now()
336    const out: Found[] = []
337    let at = 0
338    if (text.includes('[secret:')) {
339      TICKET.lastIndex = 0
340      for (let m = TICKET.exec(text); m !== null; m = TICKET.exec(text)) {
341        this.findIn(text.slice(at, m.index), at, source, out)
342        at = m.index + m[0].length
343      }
344    }
345    this.findIn(at === 0 ? text : text.slice(at), at, source, out)
346    if (performance.now() - started > SCAN_BUDGET_MS) throw new ScanError('the scan ran out of time')
347    return out
348  }
349
350  private findIn(piece: string, offset: number, source: string, out: Found[]): void {
351    if (piece.length === 0) return
352    const spans: Candidate[] = []
353    if (this.byValue.size > 0) {
354      for (const k of this.known().scan(piece, offset)) {
355        spans.push({ start: k.start, end: k.end, len: k.end - k.start, rank: -1, entry: k.entry, isExact: k.isExact })
356      }
357    }
358    for (const hit of detect(piece)) {
359      if (this.ignored.has(piece.slice(hit.start, hit.end))) continue
360      const start = hit.start + offset
361      const end = hit.end + offset
362      spans.push({ start, end, len: end - start, rank: hit.rank, isExact: true, hit })
363    }
364    if (spans.length === 0) return
365    spans.sort((a, b) => a.start - b.start || b.len - a.len || a.rank - b.rank)
366
367    // Overlapping spans are hidden as one union, so no tail of a secret shows.
368    for (let i = 0; i < spans.length; ) {
369      const first = spans[i]!
370      let end = first.end
371      let best = first
372      let j = i + 1
373      for (; j < spans.length && spans[j]!.start < end; j++) {
374        const span = spans[j]!
375        if (span.end > end) end = span.end
376        if (better(span, best)) best = span
377      }
378      i = j
379      const start = first.start
380      let entry = best.start === start && best.end === end && best.isExact ? best.entry : undefined
381      if (!entry) {
382        const value = piece.slice(start - offset, end - offset)
383        if (this.ignored.has(value)) continue
384        const name = best.entry ? best.entry.label.split('#')[0]! : best.hit!.name
385        const kind = best.entry ? best.entry.kind : best.hit!.kind
386        const prefix = best.hit?.prefix
387        entry = this.ticketFor(value, name, kind, source, prefix ? { prefix } : {})
388      }
389      out.push({ start, end, entry })
390    }
391  }
392
393  /**
394   * `text` with every secret swapped for its ticket. `keepLines` pads a
395   * multi-line secret with its line breaks, so line numbers stay true.
396   */
397  scrub(text: string, source: string, options: { keepLines?: boolean } = {}): ScrubResult {
398    const found = this.find(text, source)
399    if (found.length === 0) return { text, changed: false, entries: [] }
400    const parts: string[] = []
401    const entries: Entry[] = []
402    let at = 0
403    for (const { start, end, entry } of found) {
404      parts.push(text.slice(at, start), entry.ticket)
405      if (options.keepLines) {
406        const breaks = text.slice(start, end).split('\n').length - 1
407        if (breaks > 0) parts.push('\n'.repeat(breaks))
408      }
409      entry.hidden++
410      entries.push(entry)
411      at = end
412    }
413    parts.push(text.slice(at))
414    return { text: parts.join(''), changed: true, entries }
415  }
416
417  /**
418   * `text` with every ticket swapped back for its value. A well-formed ticket
419   * this vault does not hold (from before a restart, or made up) is reported,
420   * and the caller refuses the call rather than write the ticket literally.
421   */
422  restore(text: string): { text: string; unknown: string[]; entries: Entry[] } {
423    if (!text.includes('[secret:')) return { text, unknown: [], entries: [] }
424    const unknown: string[] = []
425    const entries: Entry[] = []
426    const restored = text.replace(TICKET, ticket => {
427      const entry = this.byTicket.get(ticket)
428      if (!entry) {
429        unknown.push(ticket)
430        return ticket
431      }
432      entry.restored++
433      entries.push(entry)
434      return entry.value
435    })
436    return { text: restored, unknown, entries }
437  }
438}
439
hooks/detect.ts 725 lines
1// Pattern detectors for secrets, written from public knowledge of key formats.
2//
3// Linear time is a hard rule here, because the text is often hostile (a web
4// page, a log, a file someone else wrote):
5// - every quantifier is bounded ({m,n}), none is nested;
6// - each pattern starts only at the edge of a run (a lookbehind), so a long run
7//   of matching characters is tried once, not once per character;
8// - a cheap `includes` gate skips a pattern whose anchor text is absent.
9// The worst case is then a small constant times the text length; the 5 MB
10// adversarial test in tests/perf.test.ts holds that to account.
11
12export type Hit = {
13  start: number
14  end: number
15  /** Ticket label base: the variable name, or the format's usual name. */
16  name: string
17  /** A plain-words kind for listings ("Stripe secret key"). */
18  kind: string
19  /** Lower wins when two hits cover the same text. */
20  rank: number
21  /** The public prefix of the format, shown in audit previews (`sk_live_`). */
22  prefix?: string
23}
24
25type Rule = {
26  name: string
27  kind: string
28  /** Anchor texts: the pattern only runs when one of them is in the text. */
29  hints: readonly string[]
30  re: RegExp
31  /** The secret is the last capture group, which ends the match. */
32  tail?: boolean
33  /** The public prefix of a value, for previews. */
34  prefix?: RegExp
35  check?: (value: string) => boolean
36}
37
38// ------------------------------------------------------------- value tests
39
40const LOWER = /[a-z]/
41const UPPER = /[A-Z]/
42const DIGIT = /[0-9]/
43const SYMBOL = /[^A-Za-z0-9]/
44
45/** Shannon entropy in bits per character. */
46export function entropy(value: string): number {
47  if (value.length === 0) return 0
48  const counts = new Map<string, number>()
49  for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1)
50  let bits = 0
51  for (const n of counts.values()) {
52    const p = n / value.length
53    bits -= p * Math.log2(p)
54  }
55  return bits
56}
57
58/** Words people put where a secret will go later, and values that are code, not data. */
59const PLACEHOLDER =
60  /^(?:x{3,}|\*{3,}|\.{3,}|-+|_+|changeme|change[-_ ]?me|replace[-_ ]?me|your[-_ ][\w -]*|<[^>]*>|\$\{[^}]*\}?|\$\([^)]*\)?|\$[A-Za-z_][A-Za-z0-9_]*|\{\{[^}]*\}?\}?|%[A-Za-z_]+%|todo|tbd|none|null|nil|true|false|yes|no|on|off|undefined|placeholder|redacted|hidden|masked|secret|secrets|password|passwd|token|apikey|api_key|string|str|number|int|integer|boolean|bool|object|any|required|optional|empty|dummy|fake|test|testing|sample|default|foo|bar|baz|qux|abc|abcd|1234|12345|123456|enter[-_ ][\w -]*|insert[-_ ][\w -]*)$/i
61
62/**
63 * `process.env.X`, `config.apiKey`, `req.body.password`: a reference in code,
64 * not a value. The first part is a plain word; a token's first part
65 * (`aZ7Lr...9.xyz`) has digits in it.
66 */
67const CODE_REF = /^(?:[A-Za-z_$][A-Za-z_$]{0,30}(?:\.[A-Za-z_$][\w$]{0,63}){1,8}|os\.environ.*|process\.env.*|env\(.*|getenv.*)$/
68
69/** A placeholder, an example, or a run of one character: never a real secret. */
70export function isPlaceholder(value: string): boolean {
71  if (PLACEHOLDER.test(value)) return true
72  if (/example/i.test(value)) return true // AWS's documented AKIA...EXAMPLE and kin
73  if (/^\[secret:/.test(value)) return true
74  if (/^(.)\1+$/.test(value)) return true // "aaaaaaaa"
75  return false
76}
77
78/**
79 * A value that is plainly a filesystem path on its own: `~/x`, `./x`, `../x`,
80 * `C:\x`, two or more `/` between path-like segments (`/Users/dev/app`), or
81 * folders then a file name (`certs/dev.pem`). A run of 20 or more base64
82 * characters is key material, not a folder name, so `/wJ9r...` (1 in 64
83 * random base64 secrets starts with `/`) is never a path by shape alone.
84 */
85export function isPathLike(value: string): boolean {
86  if (/[A-Za-z0-9+=]{20,}/.test(value)) return false
87  if (/^(?:~|\.{1,2})[\\/]|^~$|^[A-Za-z]:\\/.test(value)) return true // ~, . and \ are not base64
88  const parts = value.split('/')
89  const inner = parts.filter((p, i) => p !== '' || (i > 0 && i < parts.length - 1))
90  if (parts.length < 2 || !inner.every(p => /^[\w.@-]+$/.test(p))) return false
91  return parts.length >= 3 || /\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(value)
92}
93
94/** Variables whose value is a path: `PWD`, `TMPDIR`, `*_DIR`, `*_FILE`, `PATH` and `*PATH` lists. */
95const PATH_NAME = /^(?:PWD|OLDPWD|HOME|TMPDIR|PASSWORD_STORE_DIR|GOOGLE_APPLICATION_CREDENTIALS)$|_(?:DIR|HOME|FILE|ROOT)$|PATH$/
96/** A name that says its value is secret (`DB_PWD`, `JWT_SECRET`), unless it names where the secret lives. */
97const SECRET_WORD = /KEY|SECRET|TOKEN|PASS|PWD|CREDENTIAL|PRIVATE|DSN|AUTH/
98const NAMES_A_LOCATION = /^(?:PWD|OLDPWD|GOOGLE_APPLICATION_CREDENTIALS)$|_(?:DIR|FILE|PATH)$/
99
100/**
101 * Whether `value`, held by variable `name` (or by no name), is a path and so
102 * never a secret. Only for a path-valued name (`PWD=/work/app`,
103 * `PASSWORD_STORE_DIR=~/.password-store`) or a plain path, and never for a
104 * secret name: `AWS_SECRET_ACCESS_KEY=/wJ9r...` is the key itself.
105 */
106export function isPathFor(name: string, value: string): boolean {
107  const label = name ? labelOf(name) : ''
108  if (SECRET_WORD.test(label) && !NAMES_A_LOCATION.test(label)) return false
109  if (PATH_NAME.test(label) && /^(?:~|\.{1,2})?[\\/]|^~$|^[A-Za-z]:[\\/]/.test(value)) return true
110  return isPathLike(value)
111}
112
113/** Defaults people leave in dev .env files, compared without case or a trailing `123`/`!`. */
114const COMMON_DEFAULTS = new Set([
115  'postgres', 'postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'rabbitmq', 'docker', 'password', 'passw0rd',
116  'admin', 'administrator', 'root', 'toor', 'guest', 'user', 'secret', 'supersecret', 'mysecret', 'secretkey',
117  'changeme', 'example', 'test', 'testing', 'localhost', 'development', 'production', 'staging', 'default',
118  'true', 'false', 'null', 'undefined', 'letmein', 'welcome', 'qwerty', 'qwertyuiop', 'iloveyou', 'trustno1',
119])
120
121/** Shortest value hidden everywhere it appears; a shorter one is hidden only where a detector finds it. */
122export const MIN_LEARN_CHARS = 8
123/**
124 * Shannon entropy of a value's own characters is at most log2(length), so at
125 * the 8-character floor 3.0 bits means eight distinct characters. Words and
126 * defaults score under it (postgres 2.75, localhost 2.73, supersecret 2.66,
127 * password1 2.95), while a random 12-character token from a 36-symbol
128 * alphabet averages 3.3 (about 1 in 20 falls under 3.0 and is then hidden
129 * only by shape) and longer or richer tokens score higher still.
130 */
131export const MIN_LEARN_ENTROPY = 3.0
132
133/**
134 * Whether a value may be learned: matched everywhere it appears for the rest
135 * of the session, not only where a detector finds it. A path, a short value, a
136 * common default or a low-entropy word would mask unrelated text (the project
137 * path in every build log, `postgres` in every image name), so it is not.
138 */
139export function isLearnable(value: string, name = ''): boolean {
140  if (value.length < MIN_LEARN_CHARS || isPathFor(name, value)) return false
141  if ('01234567890'.includes(value) || '9876543210'.includes(value)) return false // 12345678
142  const word = value.toLowerCase()
143  if (COMMON_DEFAULTS.has(word) || COMMON_DEFAULTS.has(word.replace(/[^a-z]+$/, ''))) return false // Postgres123!
144  return entropy(value) >= MIN_LEARN_ENTROPY - 1e-9 // eight distinct characters score exactly 3.0
145}
146
147/** A value written next to a name may also be code that reads one. */
148function isPlaceholderOrCode(value: string): boolean {
149  return isPlaceholder(value) || CODE_REF.test(value)
150}
151
152/** Upper, lower and digit together: what a random base62 key nearly always has. */
153function mixedClasses(value: string): boolean {
154  return LOWER.test(value) && UPPER.test(value) && DIGIT.test(value)
155}
156
157/** An opaque token, not a word, a placeholder or an environment variable's name. */
158function tokenish(value: string): boolean {
159  if (isPlaceholder(value)) return false
160  if (/^[A-Z0-9_]+$/.test(value) && value.includes('_')) return false // MY_API_TOKEN
161  return DIGIT.test(value) || (LOWER.test(value) && UPPER.test(value))
162}
163
164const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
165const B64_INDEX = new Map([...B64].map((c, i) => [c, i] as const))
166
167/** Decodes standard base64 (padding optional); undefined when it is not base64. */
168export function decodeBase64(text: string): Uint8Array | undefined {
169  const clean = text.replace(/=+$/, '')
170  if (clean.length % 4 === 1) return undefined
171  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
172  let bits = 0
173  let acc = 0
174  let o = 0
175  for (const ch of clean) {
176    const v = B64_INDEX.get(ch)
177    if (v === undefined) return undefined
178    acc = (acc << 6) | v
179    bits += 6
180    if (bits >= 8) {
181      bits -= 8
182      out[o++] = (acc >> bits) & 0xff
183    }
184  }
185  return out.subarray(0, o)
186}
187
188/** Base64 of `user:password`, all printable: what a Basic auth header carries. */
189function basicDecodes(value: string): boolean {
190  const bytes = decodeBase64(value)
191  if (!bytes || bytes.length < 3) return false
192  let colon = -1
193  for (let i = 0; i < bytes.length; i++) {
194    const b = bytes[i] ?? 0
195    if (b < 0x20 || b > 0x7e) return false
196    if (b === 0x3a && colon < 0) colon = i
197  }
198  return colon >= 0 && colon < bytes.length - 1
199}
200
201// ------------------------------------------------------------ vendor rules
202
203// Each rule is the format's public shape: a fixed prefix, then a bounded run.
204const RULES: readonly Rule[] = [
205  {
206    name: 'AWS_ACCESS_KEY_ID',
207    kind: 'AWS access key ID',
208    hints: ['AKIA', 'ASIA', 'ABIA', 'ACCA'],
209    re: /(?<![A-Z0-9])(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}(?![A-Za-z0-9])/g,
210    prefix: /^A[A-Z]{3}/,
211    check: v => !isPlaceholder(v),
212  },
213  {
214    name: 'GITHUB_TOKEN',
215    kind: 'GitHub token',
216    hints: ['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_'],
217    re: /(?<![A-Za-z0-9_])(?:gh[pousr]_[A-Za-z0-9]{36,251}|github_pat_[A-Za-z0-9_]{22,251})(?![A-Za-z0-9_])/g,
218    prefix: /^(?:gh[pousr]_|github_pat_)/,
219  },
220  {
221    name: 'ANTHROPIC_API_KEY',
222    kind: 'Anthropic API key',
223    hints: ['sk-ant-'],
224    re: /(?<![A-Za-z0-9_-])sk-ant-[a-z0-9]{2,12}-[A-Za-z0-9_-]{20,300}/g,
225    prefix: /^sk-ant-[a-z0-9]{2,12}-/,
226  },
227  {
228    name: 'OPENAI_API_KEY',
229    kind: 'OpenAI API key',
230    hints: ['sk-'],
231    re: /(?<![A-Za-z0-9_-])sk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,300}|[A-Za-z0-9]{32,64}(?![A-Za-z0-9_-]))/g,
232    prefix: /^sk-(?:proj-|svcacct-|admin-)?/,
233  },
234  {
235    name: 'STRIPE_SECRET_KEY',
236    kind: 'Stripe secret key',
237    hints: ['k_live_', 'k_test_'],
238    // Secret (sk_) and restricted (rk_) keys. Publishable pk_ keys are public by design.
239    re: /(?<![A-Za-z0-9_])[sr]k_(?:live|test)_[A-Za-z0-9]{10,247}/g,
240    prefix: /^[sr]k_(?:live|test)_/,
241  },
242  {
243    name: 'STRIPE_WEBHOOK_SECRET',
244    kind: 'Stripe webhook secret',
245    hints: ['whsec_'],
246    re: /(?<![A-Za-z0-9_])whsec_[A-Za-z0-9+/=]{20,100}/g,
247    prefix: /^whsec_/,
248  },
249  {
250    name: 'SLACK_TOKEN',
251    kind: 'Slack token',
252    hints: ['xox'],
253    re: /(?<![A-Za-z0-9_-])xox[abposre]-[A-Za-z0-9-]{10,250}/g,
254    prefix: /^xox[a-z]-/,
255  },
256  {
257    name: 'SLACK_APP_TOKEN',
258    kind: 'Slack app token',
259    hints: ['xapp-'],
260    re: /(?<![A-Za-z0-9_-])xapp-[0-9]-[A-Za-z0-9-]{10,250}/g,
261    prefix: /^xapp-/,
262  },
263  {
264    name: 'SLACK_WEBHOOK_URL',
265    kind: 'Slack webhook URL',
266    hints: ['hooks.slack.com'],
267    re: /https:\/\/hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9_/-]{20,250}/g,
268    prefix: /^https:\/\/hooks\.slack\.com\//,
269  },
270  {
271    name: 'GOOGLE_API_KEY',
272    kind: 'Google API key',
273    hints: ['AIza'],
274    re: /(?<![A-Za-z0-9_-])AIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/g,
275    prefix: /^AIza/,
276  },
277  {
278    name: 'GOOGLE_CLIENT_SECRET',
279    kind: 'Google OAuth client secret',
280    hints: ['GOCSPX-'],
281    re: /(?<![A-Za-z0-9_-])GOCSPX-[A-Za-z0-9_-]{28}(?![A-Za-z0-9_-])/g,
282    prefix: /^GOCSPX-/,
283  },
284  {
285    name: 'GOOGLE_OAUTH_TOKEN',
286    kind: 'Google OAuth access token',
287    hints: ['ya29.'],
288    re: /(?<![A-Za-z0-9_-])ya29\.[A-Za-z0-9_-]{20,2048}/g,
289    prefix: /^ya29\./,
290  },
291  {
292    name: 'TWILIO_API_KEY',
293    kind: 'Twilio API key',
294    hints: ['SK'],
295    re: /(?<![A-Za-z0-9])SK[0-9a-f]{32}(?![A-Za-z0-9])/g,
296    prefix: /^SK/,
297  },
298  {
299    name: 'RESEND_API_KEY',
300    kind: 'Resend API key',
301    hints: ['re_'],
302    re: /(?<![A-Za-z0-9_])re_[A-Za-z0-9]{6,24}_[A-Za-z0-9]{16,64}(?![A-Za-z0-9_])/g,
303    prefix: /^re_/,
304    check: mixedClasses, // keeps snake_case names like re_compile_pattern_cache out
305  },
306  {
307    name: 'SENDGRID_API_KEY',
308    kind: 'SendGrid API key',
309    hints: ['SG.'],
310    re: /(?<![A-Za-z0-9_-])SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,
311    prefix: /^SG\./,
312  },
313  {
314    name: 'MAILGUN_API_KEY',
315    kind: 'Mailgun API key',
316    hints: ['key-'],
317    re: /(?<![A-Za-z0-9_-])key-[0-9a-f]{32}(?![A-Za-z0-9])/g,
318    prefix: /^key-/,
319  },
320  {
321    name: 'NPM_TOKEN',
322    kind: 'npm token',
323    hints: ['npm_'],
324    re: /(?<![A-Za-z0-9_])npm_[A-Za-z0-9]{36}(?![A-Za-z0-9_])/g,
325    prefix: /^npm_/,
326  },
327  {
328    name: 'PYPI_TOKEN',
329    kind: 'PyPI token',
330    hints: ['pypi-'],
331    re: /(?<![A-Za-z0-9_-])pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,1000}/g,
332    prefix: /^pypi-/,
333  },
334  {
335    name: 'GITLAB_TOKEN',
336    kind: 'GitLab token',
337    hints: ['glpat-'],
338    re: /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{20,64}/g,
339    prefix: /^glpat-/,
340  },
341  {
342    name: 'HUGGINGFACE_TOKEN',
343    kind: 'Hugging Face token',
344    hints: ['hf_'],
345    re: /(?<![A-Za-z0-9_])hf_[A-Za-z]{34}(?![A-Za-z0-9_])/g,
346    prefix: /^hf_/,
347  },
348  {
349    name: 'DIGITALOCEAN_TOKEN',
350    kind: 'DigitalOcean token',
351    hints: ['_v1_'],
352    re: /(?<![A-Za-z0-9_])do[opr]_v1_[a-f0-9]{64}(?![A-Za-z0-9_])/g,
353    prefix: /^do[opr]_v1_/,
354  },
355  {
356    name: 'SHOPIFY_TOKEN',
357    kind: 'Shopify token',
358    hints: ['shp'],
359    re: /(?<![A-Za-z0-9_])shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}(?![A-Za-z0-9_])/g,
360    prefix: /^shp[a-z]{2}_/,
361  },
362  {
363    name: 'TELEGRAM_BOT_TOKEN',
364    kind: 'Telegram bot token',
365    hints: [':AA'],
366    re: /(?<![0-9])[0-9]{8,10}:AA[A-Za-z0-9_-]{33}(?![A-Za-z0-9_-])/g,
367  },
368  {
369    name: 'SQUARE_TOKEN',
370    kind: 'Square token',
371    hints: ['sq0'],
372    re: /(?<![A-Za-z0-9_-])sq0(?:atp|csp)-[A-Za-z0-9_-]{22,43}(?![A-Za-z0-9_-])/g,
373    prefix: /^sq0[a-z]{3}-/,
374  },
375  {
376    name: 'DATABRICKS_TOKEN',
377    kind: 'Databricks token',
378    hints: ['dapi'],
379    re: /(?<![A-Za-z0-9_])dapi[a-f0-9]{32}(?:-[0-9])?(?![A-Za-z0-9_])/g,
380    prefix: /^dapi/,
381  },
382  {
383    name: 'LINEAR_API_KEY',
384    kind: 'Linear API key',
385    hints: ['lin_api_'],
386    re: /(?<![A-Za-z0-9_])lin_api_[A-Za-z0-9]{40}(?![A-Za-z0-9_])/g,
387    prefix: /^lin_api_/,
388  },
389  {
390    name: 'NOTION_TOKEN',
391    kind: 'Notion token',
392    hints: ['ntn_', 'secret_'],
393    re: /(?<![A-Za-z0-9_])(?:ntn_[A-Za-z0-9]{40,50}|secret_[A-Za-z0-9]{43})(?![A-Za-z0-9_])/g,
394    prefix: /^(?:ntn_|secret_)/,
395    check: v => DIGIT.test(v),
396  },
397  {
398    name: 'SUPABASE_TOKEN',
399    kind: 'Supabase token',
400    hints: ['sbp_'],
401    re: /(?<![A-Za-z0-9_])sbp_[a-f0-9]{40}(?![A-Za-z0-9_])/g,
402    prefix: /^sbp_/,
403  },
404  {
405    name: 'GROQ_API_KEY',
406    kind: 'Groq API key',
407    hints: ['gsk_'],
408    re: /(?<![A-Za-z0-9_])gsk_[A-Za-z0-9]{48,60}(?![A-Za-z0-9_])/g,
409    prefix: /^gsk_/,
410  },
411  {
412    name: 'XAI_API_KEY',
413    kind: 'xAI API key',
414    hints: ['xai-'],
415    re: /(?<![A-Za-z0-9_-])xai-[A-Za-z0-9]{40,100}(?![A-Za-z0-9_-])/g,
416    prefix: /^xai-/,
417  },
418  {
419    name: 'POSTMAN_API_KEY',
420    kind: 'Postman API key',
421    hints: ['PMAK-'],
422    re: /(?<![A-Za-z0-9_-])PMAK-[a-f0-9]{24}-[a-f0-9]{34}(?![A-Za-z0-9_-])/g,
423    prefix: /^PMAK-/,
424  },
425  {
426    name: 'DOPPLER_TOKEN',
427    kind: 'Doppler token',
428    hints: ['dp.'],
429    re: /(?<![A-Za-z0-9_.-])dp\.(?:pt|st|sa|ct|scim|audit)\.[A-Za-z0-9]{40,50}(?![A-Za-z0-9_])/g,
430    prefix: /^dp\.[a-z]+\./,
431  },
432  {
433    name: 'JWT',
434    kind: 'JSON Web Token',
435    hints: ['eyJ'],
436    re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,4096}\.eyJ[A-Za-z0-9_-]{5,8192}\.[A-Za-z0-9_-]{0,4096}/g,
437    prefix: /^eyJ/,
438  },
439  {
440    name: 'BEARER_TOKEN',
441    kind: 'Bearer token',
442    hints: ['earer', 'EARER'],
443    re: /(?<![A-Za-z0-9_])(?:[Bb]earer|BEARER)[ \t]{1,8}([A-Za-z0-9._~+/-]{16,4096}={0,4})/g,
444    tail: true,
445    check: tokenish,
446  },
447  {
448    name: 'BASIC_AUTH',
449    kind: 'Basic auth credentials',
450    hints: ['asic', 'ASIC'],
451    re: /(?<![A-Za-z0-9_])(?:[Bb]asic|BASIC)[ \t]{1,8}([A-Za-z0-9+/]{8,4096}={0,2})(?![A-Za-z0-9+/=])/g,
452    tail: true,
453    check: basicDecodes,
454  },
455]
456
457const RANK_URL = RULES.length
458const RANK_PEM = RULES.length + 1
459const RANK_NAMED = RULES.length + 2
460
461function hasAny(text: string, hints: readonly string[]): boolean {
462  for (const hint of hints) if (text.includes(hint)) return true
463  return false
464}
465
466function runRule(text: string, rule: Rule, rank: number, out: Hit[]): void {
467  const re = rule.re
468  re.lastIndex = 0
469  for (let m = re.exec(text); m !== null; m = re.exec(text)) {
470    const whole = m[0]
471    const value = rule.tail ? (m[m.length - 1] ?? '') : whole
472    const end = m.index + whole.length
473    const start = end - value.length
474    if (value.length === 0 || (rule.check && !rule.check(value))) continue
475    const prefix = rule.prefix?.exec(value)?.[0]
476    out.push({ start, end, name: rule.name, kind: rule.kind, rank, ...(prefix ? { prefix } : {}) })
477  }
478}
479
480// ------------------------------------------------------- private key blocks
481
482const PEM_BEGIN = '-----BEGIN '
483const PEM_END = '-----END '
484/** Base64 body, line breaks, and the `\n` of a key escaped inside JSON. */
485const PEM_BODY = /[A-Za-z0-9+/=\s\\:,-]/
486const PEM_MAX = 64 * 1024
487
488function findPem(text: string, out: Hit[]): void {
489  let pos = text.indexOf(PEM_BEGIN)
490  let nextEnd = -2 // the next END marker at or after pos, found once and reused (linear)
491  while (pos >= 0) {
492    const labelEnd = text.indexOf('-----', pos + PEM_BEGIN.length)
493    const label = labelEnd > 0 && labelEnd - pos < 80 ? text.slice(pos, labelEnd) : ''
494    if (!label.includes('PRIVATE KEY')) {
495      pos = text.indexOf(PEM_BEGIN, pos + 1)
496      continue
497    }
498    if (nextEnd !== -1 && nextEnd < pos) nextEnd = text.indexOf(PEM_END, pos)
499    let stop: number
500    if (nextEnd >= 0 && nextEnd - pos <= PEM_MAX) {
501      const close = text.indexOf('-----', nextEnd + PEM_END.length)
502      stop = close >= 0 && close - nextEnd < 80 ? close + 5 : nextEnd + PEM_END.length
503    } else {
504      // No END marker (output cut short): hide the body as far as it runs.
505      stop = labelEnd + 5
506      const limit = Math.min(text.length, pos + PEM_MAX)
507      while (stop < limit && PEM_BODY.test(text[stop] ?? '')) stop++
508    }
509    out.push({ start: pos, end: stop, name: 'PRIVATE_KEY', kind: 'Private key', rank: RANK_PEM, prefix: label + '-----' })
510    pos = text.indexOf(PEM_BEGIN, stop)
511  }
512}
513
514// ------------------------------------------------------- passwords in URLs
515
516const URL_CRED = /(?<![A-Za-z0-9+.-])([A-Za-z][A-Za-z0-9+.-]{1,30}):\/\/([^\s:/@'"<>`]{0,256}):([^\s/@'"<>`]{1,256})@/g
517
518function findUrlPasswords(text: string, out: Hit[]): void {
519  if (!text.includes('://')) return
520  URL_CRED.lastIndex = 0
521  for (let m = URL_CRED.exec(text); m !== null; m = URL_CRED.exec(text)) {
522    const password = m[3] ?? ''
523    if (isPlaceholder(password)) continue
524    const end = m.index + m[0].length - 1 // just before the "@"
525    const scheme = (m[1] ?? 'url').toUpperCase().replace(/[^A-Z0-9]+/g, '_')
526    out.push({ start: end - password.length, end, name: `${scheme}_PASSWORD`, kind: 'Password in a URL', rank: RANK_URL })
527  }
528}
529
530// ------------------------------------------------- values next to a name
531
532/** How strongly a variable's name says its value is secret. */
533export type NameClass = 'password' | 'strong' | 'weak' | 'auth' | null
534
535/** Splits `apiKey`, `X-API-Key`, `client_secret` into lowercase words. */
536function words(name: string): string[] {
537  return name
538    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
539    .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
540    .toLowerCase()
541    .split(/[^a-z0-9]+/)
542    .filter(Boolean)
543}
544
545const KEY_QUALIFIERS = new Set([
546  'api', 'access', 'secret', 'private', 'signing', 'encryption', 'master', 'license', 'auth',
547  'service', 'account', 'client', 'app', 'admin', 'live', 'webhook', 'session', 'subscription',
548])
549
550export function classifyName(name: string): NameClass {
551  const parts = words(name)
552  const joined = parts.join('')
553  if (/passw(?:or)?d|passphrase/.test(joined) || parts.includes('pass') || parts.includes('pwd')) return 'password'
554  if (/secret(?!ary)|apikey|credential|privatekey|accesskey|authtoken|accesstoken|refreshtoken/.test(joined)) {
555    return 'strong'
556  }
557  if (parts.includes('token') || parts.includes('dsn')) return 'strong'
558  if (parts.includes('key')) return parts.some(p => KEY_QUALIFIERS.has(p)) ? 'strong' : 'weak'
559  if (joined === 'authorization' || joined === 'xauthorization') return 'auth'
560  return null
561}
562
563const AUTH_SCHEMES = /^(?:bearer|basic|token|digest|negotiate|ntlm|hoba|mutual|aws4-hmac-sha256)$/i
564const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
565
566/** Whether `value`, written next to a name of class `cls`, looks like the secret itself. */
567export function namedValueLooksSecret(cls: NameClass, value: string, isQuoted: boolean, name: string): boolean {
568  // `PWD=/Users/dev/app` (a `pwd` name), `PASSWORD_STORE_DIR=~/.password-store`: a path, not the secret.
569  if (cls === null || isPlaceholderOrCode(value) || isPathFor(name, value)) return false
570  const isBareWord = !isQuoted && IDENTIFIER.test(value)
571  switch (cls) {
572    case 'password':
573      // `password: hashedPassword` is code; `password: hunter2` and "pa55" quoted are not.
574      if (value.length < 6) return false
575      return isBareWord ? DIGIT.test(value) : true
576    case 'strong':
577      if (value.length < 8) return false
578      if (/^[a-z][a-z-]*$/.test(value)) return false // a slug: some-config-name
579      if (isBareWord && !DIGIT.test(value)) return false
580      return DIGIT.test(value) || (LOWER.test(value) && UPPER.test(value)) || SYMBOL.test(value)
581    case 'weak':
582      return value.length >= 20 && DIGIT.test(value) && /[A-Za-z]/.test(value) && entropy(value) >= 3.5
583    case 'auth':
584      return value.length >= 12 && !AUTH_SCHEMES.test(value) && tokenish(value)
585  }
586}
587
588/** Upper-snake label for a variable name: `process.env.apiKey` -> `APIKEY`. */
589export function labelOf(name: string): string {
590  const last = name.replace(/^-+/, '').split('.').pop() ?? name
591  const label = last
592    .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
593    .toUpperCase()
594    .replace(/[^A-Z0-9]+/g, '_')
595    .replace(/^_+|_+$/g, '')
596    .slice(0, 48)
597  return label || 'SECRET'
598}
599
600// Phase one finds `name (quote) sep` pairs only; it never reads a value, so a
601// name inside a value (`url: https://x?token=abc`) is still found. Phase two
602// reads a value only after a secret-like name.
603const NAME_SEP = /(?<![A-Za-z0-9_.$-])(-{0,2}[A-Za-z_$][A-Za-z0-9_.$-]{0,63})(?:\\?["'])?[ \t]{0,8}(?:=>|:=|=|:)[ \t]{0,8}/g
604
605const VALUE_MAX = 512
606
607/** A character an unquoted value may hold: not space, quote, bracket or separator. */
608function isValueChar(code: number): boolean {
609  if (code <= 0x20 || code === 0x7f || code === 0xa0 || code === 0x2028 || code === 0x2029 || code === 0xfeff) {
610    return false
611  }
612  // " ' ` , ; & < > ( ) { } [ ] \
613  return !(
614    code === 0x22 || code === 0x27 || code === 0x60 || code === 0x2c || code === 0x3b || code === 0x26 ||
615    code === 0x3c || code === 0x3e || code === 0x28 || code === 0x29 || code === 0x7b || code === 0x7d ||
616    code === 0x5b || code === 0x5d || code === 0x5c
617  )
618}
619
620/**
621 * Reads values for one text. Unquoted values share one cache of the current
622 * run of value characters, and starts only move forward, so every character
623 * is scanned at most once however many names point into the same run.
624 */
625function valueReader(text: string) {
626  let runFrom = -1
627  let runTo = -1
628  let runIsWhole = false
629  const unquotedEnd = (q: number): number => {
630    if (q < runFrom || q > runTo) {
631      runFrom = q
632      runTo = q
633      runIsWhole = false
634    }
635    const cap = Math.min(text.length, q + VALUE_MAX)
636    if (runIsWhole || runTo >= cap) return Math.min(runTo, cap)
637    let i = runTo
638    while (i < cap && isValueChar(text.charCodeAt(i))) i++
639    runTo = i
640    runIsWhole = i < cap || i === text.length
641    return i
642  }
643  /** The value starting at `q`: its span and whether it was quoted. */
644  return (q: number): { start: number; end: number; isQuoted: boolean } | undefined => {
645    let at = q
646    const first = text[at]
647    if (first === '\\' && (text[at + 1] === '"' || text[at + 1] === "'")) {
648      at += 2 // JSON-escaped quotes: the value runs to the next backslash
649    } else if (first === '"' || first === "'") {
650      // A quoted value may hold spaces; it ends at the same quote, within reach.
651      const limit = Math.min(text.length, at + 1 + VALUE_MAX)
652      let i = at + 1
653      while (i < limit) {
654        const c = text[i]
655        if (c === first) return i > at + 1 ? { start: at + 1, end: i, isQuoted: true } : undefined
656        if (c === '\\' || c === '\n' || c === '\r') break
657        i++
658      }
659      at += 1 // unterminated: read it as an unquoted value
660    } else if (first === '`') {
661      at += 1
662    }
663    const end = unquotedEnd(at)
664    return end > at ? { start: at, end, isQuoted: false } : undefined
665  }
666}
667
668function findNamed(text: string, out: Hit[]): void {
669  const readValue = valueReader(text)
670  let hiddenTo = 0 // names inside a value already hidden are not read again
671  NAME_SEP.lastIndex = 0
672  for (let m = NAME_SEP.exec(text); m !== null; m = NAME_SEP.exec(text)) {
673    if (m.index < hiddenTo) continue
674    const rawName = m[1] ?? ''
675    const cls = classifyName(rawName)
676    if (cls === null) continue
677    const span = readValue(m.index + m[0].length)
678    if (!span) continue
679    let { end } = span
680    if (!span.isQuoted) {
681      while (end > span.start && text[end - 1] === '.') end-- // a sentence's full stop
682    }
683    const value = text.slice(span.start, end)
684    if (!namedValueLooksSecret(cls, value, span.isQuoted, rawName)) continue
685    out.push({ start: span.start, end, name: labelOf(rawName), kind: 'Secret named in text', rank: RANK_NAMED })
686    hiddenTo = end
687  }
688}
689
690// --password s3cret, --api-key "abc": a flag, a space, the value.
691const FLAG =
692  /(?<![A-Za-z0-9_-])--(password|passwd|pass|token|api[-_]?key|secret|access[-_]token|auth[-_]token|client[-_]secret)[ \t]{1,4}(?:"([^"\\\r\n]{1,512})"|'([^'\\\r\n]{1,512})'|([^\s"'`;&|<>()]{1,512}))/g
693
694function findFlags(text: string, out: Hit[]): void {
695  FLAG.lastIndex = 0
696  for (let m = FLAG.exec(text); m !== null; m = FLAG.exec(text)) {
697    const quoted = m[2] ?? m[3]
698    const value = quoted ?? m[4] ?? ''
699    const isQuoted = quoted !== undefined
700    const end = m.index + m[0].length - (isQuoted ? 1 : 0)
701    const name = m[1] ?? 'secret'
702    if (!isQuoted && value.startsWith('-')) continue // the next flag, not a value
703    if (!namedValueLooksSecret(classifyName(name), value, isQuoted, name)) continue
704    out.push({ start: end - value.length, end, name: labelOf(name), kind: 'Secret named in text', rank: RANK_NAMED })
705  }
706}
707
708// ------------------------------------------------------------- everything
709
710/** Every detector's hits in `text`, unordered and possibly overlapping. */
711export function detect(text: string): Hit[] {
712  const out: Hit[] = []
713  RULES.forEach((rule, rank) => {
714    if (hasAny(text, rule.hints)) runRule(text, rule, rank, out)
715  })
716  if (text.includes(PEM_BEGIN)) findPem(text, out)
717  findUrlPasswords(text, out)
718  if (text.includes('=') || text.includes(':')) findNamed(text, out)
719  if (text.includes('--')) findFlags(text, out)
720  return out
721}
722
723/** The detector kinds, for tests and the README. */
724export const RULE_NAMES: readonly string[] = RULES.map(rule => rule.name)
725