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

<img src="../../brand/pixel-icons/svg/cloakroom.svg" width="64" height="64" alt="">
Check your secrets at the door. Claude gets a ticket; the real value only comes back where you allow it.
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:
@-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]..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..env. Web fetches, MCP tools, subagent prompts, messages to other sessions and questions shown to you keep the ticket.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./cloakroom audit), so you know what to rotate. The findings are drawn in a pane on your screen, never sent to Claude.It CANNOT:
hooks in settings.json). They receive tool inputs and outputs on their own path.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..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.curl http://localhost:3000 is held like any other network call, because a local port can forward anywhere../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./cloakroom audit finds.$STRIPE_SECRET_KEY in commands instead of the value avoids both. Cloakroom tells Claude to prefer that.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
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.
/plugin install cloakroom --marketplace modmyclaude/claude-code-mods
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.
.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..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./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.CLAUDE_CONFIG_DIR, HOME and USERPROFILE (to find transcripts). Writes none.pk_) and documentation examples are left alone on purpose.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.[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.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.show puts the value in the band. Another mod that draws around the band can read it there.This mod was built from prompts/cloakroom.md.
More mods: the full catalogue.
hooks/register.tsx 730 lines1// 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}
730hooks/audit.ts 119 lines1// /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}
119hooks/clean.ts 33 lines1// 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}
33hooks/dotenv.ts 92 lines1// .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}
92hooks/egress.ts 362 lines1// 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}
362hooks/scrub.ts 146 lines1// 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}
146hooks/vault.ts 439 lines1// 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}
439hooks/detect.ts 725 lines1// 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