SLOPSHOPPER

redact

Redacts secrets and PII from every row Claude Code stores and from the CLAUDE.md and memory context, before the model reads it and before the transcript keeps…

newguardcommandprompt
v0.2.0MITupdated 2026-10-15aecheman27-gif/claude-mods/redact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact
› fix the failing auth test and add an audit log call ● redact: scanning failed, so this row was withheld from the model (a tool result) ● redact: scanning failed, so this row was withheld from the model (a tool result) ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /redact ⎿ redact: nothing hidden yet this session. Scanning secrets only (set pii to true for email, phone, cards, national ids) ● redact: scanning failed, so this row was withheld from the model (a tool result) ● redact: scanning failed, so this row was withheld from the model (a tool result) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

redact

Redacts secrets and PII from every row Claude Code stores and from the CLAUDE.md and memory context, before the model reads it and before the transcript keeps it, and puts the real value back only inside Edit, Write and NotebookEdit arguments for a file that already holds that value, so editing a file that contains a secret still works.

Built on Claude Code 2.1.288. Proven on 2.1.288 (stages: validate, load, typecheck, test, command, isolation, plus a live interactive session with a real file write). Requires Claude Code 2.1.287 or later. Version 0.2.0 (the CLAUDE.md scan, the restore rule, the refusals and the added rules) is validated and tested on 2.1.296, not yet run in a live session.

What it can reach

❯ ./register.ts hooks: session.start, prompt.submit, session.append, prompt.context, prompt.section{name=memory}, tool.call{tool=Edit|Write|NotebookEdit}, tool.call{tool=Bash|PowerShell|Monitor}, prompt.section{name=env_info_simple}, command.run{command=redact} ❯ ./register.ts calls: $.command.register, $.fs.read, $.state.get, $.state.set, $.ui.log ❯ ./register.ts state writes: redact.counts, redact.restored, redact.salt, redact.vault ❯ ./register.ts state reads: redact.counts, redact.restored, redact.salt, redact.vault

Draws and remembers, and reads one file: the one an Edit, Write or NotebookEdit call names, and only when its arguments hold a placeholder. No network, no processes, no file writes.

Threat model for redact (draws, remembers, reads the file an edit with a placeholder targets)
1. Reads:    the text of every prompt and every stored row (prompts, tool results, Claude's blocks, attachments, notices), the context blocks of the first message (CLAUDE.md, @-imports, auto-memory) and the system prompt's memory section, the arguments of Edit, Write and NotebookEdit calls and the command of Bash, PowerShell and Monitor calls, in memory; the file an Edit, Write or NotebookEdit call names when its arguments hold a placeholder (to check it already holds the value); state keys redact.vault, redact.salt, redact.counts, redact.restored; no env vars, no settings
2. Runs:     nothing
3. Sends:    nothing leaves the machine; no network call, no model call
4. Persists: placeholder-to-value pairs, the salt and counters in $.state for this session only (reset by /clear, /resume, /branch; gone at exit); nothing in $.store, no files
5. Hostile input: a crafted row can only change what gets redacted; the rule set is fixed in source and nothing reaches a process, a file, the network or the model. A crafted placeholder in a tool argument restores only a value this session hid itself, and only into a file that already holds that value, so a hidden value never lands anywhere it was not already on disk; an unknown placeholder is refused, and so is a shell command naming any placeholder. A row or context block that makes the scanner throw is withheld from the model, never sent raw, and a restore check that throws refuses the call

How it works

  1. prompt.submit: the typed prompt is scanned and each hit becomes [REDACTED:LABEL#hash] before the engine queues it.
  2. session.append: every row the conversation stores is scanned the same way: tool results (file reads, shell output, MCP results), Claude's own blocks, attachments such as nested memory, notices. The rewritten row is what the model reads and what the session file keeps.
  3. prompt.context and prompt.section (memory): the CLAUDE.md files, their @ imports and auto-memory ride the first user message as a context block, not as a stored row, so they are scanned here. A rewritten CLAUDE.md block leaves the engine's list of the files behind it unknown to hooks above, by design (see DECISIONS.md).
  4. tool.call on Edit, Write and NotebookEdit: a placeholder is swapped back for the real value only when the target file already holds that value (as written, JSON-escaped in a notebook, or with CRLF line ends), so an edit to a line that held a key still matches the file and keeps the key. The call is refused, with a reason Claude reads, when it would carry a hidden value into a file that does not hold it (a new script, a temp file for curl -d @file, a memory note, a notebook) or when it uses a placeholder this session does not know (one from before /clear, /resume or a restart) that the file does not already hold as text.
  5. tool.call on Bash, PowerShell and Monitor: a command that contains a placeholder is refused. A shell never gets the real value, and in -replace, sed or grep the brackets act as a character class and rewrite the wrong text; Claude is told to use Edit.
  6. prompt.section: one paragraph is appended to the environment section telling Claude the placeholders are opaque, where they are restored and what is refused.
  7. /redact: the session total by label and how many placeholders were restored.

The hash is FNV-1a over a per-session salt plus the value, so the same secret gets the same placeholder across rows, edits and compaction summaries, and a placeholder from another session means nothing here.

Detection is deterministic: 30 secret rules and 7 PII rules, regex plus checksums (Luhn, Verhoeff, IBAN mod-97, JWT header decode), no model call. The list with sources is in RULES.md.

False positives on ordinary code

A redactor that fires on normal work gets uninstalled in a week, so the number to defend is the false-positive count, not the catch count. tools/fp-corpus.mjs shallow-fetches 11 public repositories at pinned commits (lockfiles, Go sums, Terraform, Markdown, minified JS, JWT and PEM test vectors, a dotenv test suite) and scans every text file with the secret rules:

Files scannedSecret-rule hitsReal test keys and vectorsFake fixturesFalse positives
42,836686080

Every hit was opened at its line and labelled by hand; the labels live in tools/fp-corpus-labels.json, so a rerun that produces a new hit shows it as unreviewed instead of counting it. Rerun it yourself:

node tools/fp-corpus.mjs

The full per-hit list is in tools/fp-corpus-results.md at the repo root. The 60 real-shaped hits are complete private keys and signed JWTs published as test vectors, and demo database URLs with a password; a redactor should hide those. The PII rules, off by default, hit 650 times on the same corpus (mostly email addresses in docs), which is why they are opt-in.

my-claude-mods: the corpus and its tools live in the upstream repository and measured the original 24 secret rules. The six rules added in this copy (generic-secret-escaped, xml-config-secret, xml-element-secret, odbc-password, flag-secret, sqlcmd-password) and the narrowed generic rules (environment variable names, backticked code names and cut-off vendor token prefixes are no longer hidden) are covered by the unit cases in tests/rules.test.ts, not by a corpus run.

Install

One session: claude --plugin-dir ./plugins/redact. To keep it:

claude plugin marketplace add karanb192/claude-code-redact
claude plugin install redact@claude-code-redact

then /reload-plugins in an open session. Installed copies are cached by version: bump version before reinstalling.

Options

Stored under pluginConfigs in settings; a change reloads the mod.

  • pii (boolean, default false): also hide email, phone, payment cards, US SSN, Aadhaar, PAN and IBAN. Secrets are always hidden.
  • quiet (boolean, default false): no redact: hid ... line in the transcript. /redact still reports totals.
  • off (string, default empty): comma-separated rule ids to skip, for example generic-secret,email. Ids are in RULES.md.

Limitations

  • Three host bookkeeping records are stored as made and can hold a raw value. The engine lets a mod rewrite the conversation rows the model reads, not the records around them. (a) The toolUseResult record of a Write, Edit or NotebookEdit call keeps the file content as written, so a value restored into a file lands there in clear. (b) A slash command's args stamp keeps the typed arguments. (c) In headless claude -p only, the queue-operation record keeps the typed prompt, because it is written before prompt.submit runs; an interactive session writes no such record. None of the three is sent to the model.
  • Claude cannot put a hidden value into a file that does not already hold it. A key you type in the prompt, or one a tool printed, is never written into a new file, a script, a notebook or a memory note; the call is refused and Claude is told to leave the value out or ask you to add it. Edits to a file that already contains the secret (a config file, an .env) work as before.
  • Placeholders do not survive /clear, /resume or a restart. The values live in this session's state; a placeholder left in the transcript from before is refused in an edit (Claude is told to re-read the file) rather than written into the file as text.
  • A shell command that contains a placeholder is refused (Bash, PowerShell, Monitor). Change a line holding a hidden value with Edit.
  • Where an organization manages Claude Code, CLAUDE.md and memory are not scanned. There prompt.context and prompt.section hooks run only for plugins the organization lists in managed settings, so a user-installed redact still scans every stored row but not the CLAUDE.md and memory context. claude plugin validate says so.
  • tool_use blocks are not rewritable. The engine puts the model's own tool call blocks back as made; a mod may rewrite text blocks and tool results only. The model only ever sees placeholders, so a raw value in a tool call means it came from text the rules missed.
  • A secret the rules do not know stays visible. Generic key=value detection needs the value to look like a secret (length and entropy): password=hunter2 is caught, token=abc is not.
  • A secret split across two rows, or shown partially (the last four characters), is not caught.
  • Images are not scanned. A screenshot of a key goes through.
  • Claude cannot search for a secret by value. Grep for a placeholder finds nothing. Search by the line around it.
  • Plugins loaded ahead of this one see the row before it is rewritten.
  • The screen may show a row just before its rewrite. The model and the session file never see that form.
  • Nothing is drawn in claude -p, the SDK, the VS Code panel or cloud sessions. The redact: hid ... line arrives as ui_log there. /redact works everywhere.
  • A pattern guard is not a permission rule. A determined prompt can describe a secret in words the rules do not match.

Uninstall

Disable it in /plugin. It leaves nothing: no store keys, no files.

Source 4 files
hooks/register.ts 190 lines
1import type { EngineInterface, PluginOptions, PromptContextResult, Register, SessionAppendInput } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import { WITHHELD, describe, fnv1a, redactBlocks, redactText, refused, restore, shaped, tally, withhold, type Refusal, type Settings } from './scrub'
4
5const vault = atom({ plugin: 'redact', key: 'vault' } as const, {} as Record<string, string>)
6const salt = atom({ plugin: 'redact', key: 'salt' } as const, '')
7const counts = atom({ plugin: 'redact', key: 'counts' } as const, {} as Record<string, number>)
8const restored = atom({ plugin: 'redact', key: 'restored' } as const, 0)
9
10const NOTE = [
11  'Redaction notice: strings shaped like [REDACTED:LABEL#hex] are placeholders a local redactor inserted',
12  'in place of secrets and personal data before you saw them. The real value is restored only when a',
13  'placeholder is passed verbatim in Edit, Write or NotebookEdit arguments for a file that already holds',
14  'that value; a call that would carry it into any other file, or that uses a placeholder from an earlier',
15  'session, is refused. Never ask the person for the hidden value, never try to reconstruct it, and never',
16  'put a placeholder in a shell command (such calls are refused): change a line that holds one with Edit.',
17].join(' ')
18
19const RESTORE_FAILED = 'redact: the placeholder check failed, so this call was blocked rather than run with placeholders or a hidden value.'
20
21function refusal(r: Refusal, path: string): string {
22  if (r.isKnown) {
23    return `redact: ${r.placeholder} stands for a hidden value that ${path} does not already hold, so the call was refused. A hidden value is put back only into a file that already contains it (an edit that keeps an existing secret), never carried into a new file, script or note. Leave that value out, or ask the person to add it themselves.`
24  }
25  return `redact: ${r.placeholder} is a placeholder from an earlier session (redact forgets its values on /clear, /resume and restart), or one it never made, so it would land in ${path} as literal text. Re-read the source file to get a current placeholder, then retry.`
26}
27
28// The context blocks with every text scanned. A rewritten claudeMd text leaves the files behind it unknown
29// from here up (the engine drops the list), which is the price of sending only text this hook has scanned:
30// a changed list instead would have the engine render the text again, out of this hook's sight.
31async function scrubContext($: EngineInterface, r: PromptContextResult, cfg: Settings): Promise<PromptContextResult> {
32  const s = await ensureSalt($)
33  const blocks = r.blocks.map(b => ({ b, red: redactText(b.text, s, cfg) }))
34  const hits = blocks.flatMap(x => x.red.hits)
35  if (hits.length === 0) return r
36  await remember($, blocks.flatMap(x => x.red.pairs), tally(hits), cfg, 'CLAUDE.md and memory')
37  return { blocks: blocks.map(x => (x.red.hits.length ? { ...x.b, text: x.red.text } : x.b)) }
38}
39
40const DOORS: Record<string, string> = {
41  'prompt': 'your prompt',
42  'command': 'a command result',
43  'response': "Claude's reply",
44  'tool-result': 'a tool result',
45  'tool-message': 'a tool message',
46  'delivery': 'a delivered message',
47  'attachment': 'an attachment',
48  'hook-context': 'hook context',
49  'note': 'a plugin note',
50  'compaction': 'the compaction summary',
51  'notice': 'a notice',
52}
53
54function settings(options: PluginOptions): Settings {
55  const off = typeof options.off === 'string' ? options.off.split(',').map(s => s.trim()).filter(Boolean) : []
56  return { pii: options.pii === true, quiet: options.quiet === true, off: new Set(off) }
57}
58
59async function ensureSalt($: EngineInterface): Promise<string> {
60  const have = await read($, salt)
61  if (have) return have
62  const fresh = fnv1a(String(Math.random()) + String(Date.now())) + fnv1a(String(Math.random()) + String(performance.now()))
63  await update($, salt, () => fresh)
64  return fresh
65}
66
67async function remember($: EngineInterface, pairs: ReadonlyArray<readonly [string, string]>, found: Record<string, number>, cfg: Settings, place: string): Promise<void> {
68  await update($, vault, v => ({ ...v, ...Object.fromEntries(pairs) }))
69  await update($, counts, c => {
70    const total: Record<string, number> = { ...c }
71    for (const [label, n] of Object.entries(found)) total[label] = (total[label] ?? 0) + n
72    return total
73  })
74  if (!cfg.quiet) $.ui.log(`hid ${describe(found)} in ${place}`)
75}
76
77function where(e: SessionAppendInput): string {
78  const agent = e.agentId ? ' (subagent)' : ''
79  return (DOORS[e.door] ?? e.door) + agent
80}
81
82export const register: Register = (on, options) => {
83  const cfg = settings(options)
84
85  on('session.start', async ($, e, next) => {
86    const started = await next(e)
87    await $.command.register({ name: 'redact', description: 'Show what redact hid in this session' })
88    return started
89  })
90
91  // The typed prompt is scrubbed before the turn starts, so the on-screen echo
92  // and the command args hold the placeholder. The engine's queue record is
93  // written earlier in the headless path and is out of reach (see README).
94  on('prompt.submit', async ($, e, next) => {
95    const s = await ensureSalt($)
96    const r = redactText(e.text, s, cfg)
97    if (r.hits.length === 0) return next(e)
98    await remember($, r.pairs, tally(r.hits), cfg, 'your prompt')
99    return next({ ...e, text: r.text })
100  })
101
102  on('session.append', async ($, e, next) => {
103    const s = await ensureSalt($)
104    const r = redactBlocks(e.message.content, s, cfg)
105    if (r.hits.length === 0) return next(e)
106    await remember($, r.pairs, tally(r.hits), cfg, where(e))
107    return next({ ...e, message: { ...e.message, content: r.blocks } })
108  }).catch(($, e, next) => {
109    if (next.called) return next(e)
110    $.ui.log(`scanning failed, so this row was withheld from the model (${where(e)})`)
111    return next({ ...e, message: { ...e.message, content: withhold(e.message.content) } })
112  })
113
114  // CLAUDE.md, its @-imports and auto-memory ride the first user message as context blocks, never as a
115  // stored row, so session.append never sees them: they are scanned here, on the way up.
116  on('prompt.context', async ($, e, next) => scrubContext($, await next(e), cfg)).catch(async ($, e, next) => {
117    const r = await next(e)
118    $.ui.log('scanning failed, so CLAUDE.md and memory were withheld from the model')
119    return { blocks: r.blocks.map(b => ({ ...b, text: WITHHELD })) }
120  })
121
122  on('prompt.section', { name: 'memory' }, async ($, e, next) => {
123    const r = await next(e)
124    if (!r.text) return r
125    const red = redactText(r.text, await ensureSalt($), cfg)
126    if (red.hits.length === 0) return r
127    await remember($, red.pairs, tally(red.hits), cfg, 'the memory section')
128    return { text: red.text }
129  }).catch(async ($, e, next) => {
130    await next(e)
131    return { text: WITHHELD }
132  })
133
134  // A value is put back only into a file that already holds it, so a placeholder never carries a hidden
135  // value somewhere new; one the vault does not know is refused rather than written as literal text.
136  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
137    const args = e.tool === 'Edit' ? [e.old_string, e.new_string] : e.tool === 'Write' ? [e.content] : e.tool === 'NotebookEdit' ? [e.new_source] : []
138    if (!args.some(a => shaped(a) !== undefined)) return next(e)
139    const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
140    const v = await read($, vault)
141    const file = await $.fs.read(path).catch(() => '') // a new file holds nothing
142    const bad = refused(args, v, file)
143    if (bad) return { deny: refusal(bad, path) }
144    if (e.tool === 'Edit') {
145      const o = restore(e.old_string, v)
146      const n = restore(e.new_string, v)
147      if (o.n + n.n === 0) return next(e)
148      await update($, restored, k => k + o.n + n.n)
149      return next({ ...e, old_string: o.text, new_string: n.text })
150    }
151    if (e.tool === 'Write') {
152      const c = restore(e.content, v)
153      if (c.n === 0) return next(e)
154      await update($, restored, k => k + c.n)
155      return next({ ...e, content: c.text })
156    }
157    if (e.tool === 'NotebookEdit') {
158      const c = restore(e.new_source, v)
159      if (c.n === 0) return next(e)
160      await update($, restored, k => k + c.n)
161      return next({ ...e, new_source: c.text })
162    }
163    return next(e)
164  }).catch(($, e, next) => (next.called ? next(e) : { deny: RESTORE_FAILED }))
165
166  // A shell never gets the real value, and in -replace, sed or grep the brackets read as a character class
167  // that rewrites the wrong text, so a command naming a placeholder is refused, never run.
168  on('tool.call', { tool: ['Bash', 'PowerShell', 'Monitor'] }, async ($, e, next) => {
169    const p = shaped(String(e.command ?? ''))
170    if (p === undefined) return next(e)
171    return {
172      deny: `redact: this command contains the placeholder ${p}. A shell never gets the hidden value, and in -replace, sed or grep its brackets act as a character class and change the wrong text. To change a line that holds a hidden value, use Edit with the placeholder in old_string and new_string.`,
173    }
174  }).catch(($, e, next) => (next.called ? next(e) : { deny: RESTORE_FAILED }))
175
176  on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => {
177    const r = await next(e)
178    return { text: r.text ? `${r.text}\n\n${NOTE}` : NOTE }
179  })
180
181  on('command.run', { command: 'redact' }, async $ => {
182    const c = await read($, counts)
183    const k = await read($, restored)
184    const total = Object.values(c).reduce((a, b) => a + b, 0)
185    const rules = cfg.pii ? 'secrets and PII' : 'secrets only (set pii to true for email, phone, cards, national ids)'
186    if (total === 0) return { text: `nothing hidden yet this session. Scanning ${rules}.` }
187    return { text: `hid ${total} values this session (${describe(c)}); restored ${k} placeholders inside Edit, Write and NotebookEdit arguments. Scanning ${rules}.` }
188  })
189}
190
hooks/scrub.ts 127 lines
1import type { ApiContentBlock } from 'claude-code'
2import { scan, type Hit } from './rules'
3
4export type Settings = { pii: boolean; quiet: boolean; off: ReadonlySet<string> }
5
6export const PLACEHOLDER = /\[REDACTED:[A-Z0-9_]+#[0-9a-f]{8}\]/g
7
8export function fnv1a(s: string): string {
9  let h = 0x811c9dc5
10  for (let i = 0; i < s.length; i++) {
11    h ^= s.charCodeAt(i)
12    h = Math.imul(h, 0x01000193) >>> 0
13  }
14  return h.toString(16).padStart(8, '0')
15}
16
17export function placeholder(hit: Hit, salt: string): string {
18  return `[REDACTED:${hit.label}#${fnv1a(salt + hit.value)}]`
19}
20
21export type Redacted = { text: string; hits: Hit[]; pairs: Array<[string, string]> }
22
23export function redactText(text: string, salt: string, cfg: Settings): Redacted {
24  const hits = scan(text, { pii: cfg.pii, off: cfg.off })
25  if (hits.length === 0) return { text, hits, pairs: [] }
26  const pairs: Array<[string, string]> = []
27  let out = ''
28  let last = 0
29  for (const h of hits) {
30    const p = placeholder(h, salt)
31    out += text.slice(last, h.start) + p
32    last = h.end
33    pairs.push([p, h.value])
34  }
35  out += text.slice(last)
36  return { text: out, hits, pairs }
37}
38
39export type RedactedBlocks = { blocks: ApiContentBlock[]; hits: Hit[]; pairs: Array<[string, string]> }
40
41export function redactBlocks(blocks: readonly ApiContentBlock[], salt: string, cfg: Settings): RedactedBlocks {
42  const out: ApiContentBlock[] = []
43  const hits: Hit[] = []
44  const pairs: Array<[string, string]> = []
45  for (const block of blocks) {
46    if (block.type === 'text' && typeof block.text === 'string') {
47      const r = redactText(block.text, salt, cfg)
48      hits.push(...r.hits)
49      pairs.push(...r.pairs)
50      out.push(r.hits.length ? { ...block, text: r.text } : block)
51    } else if (block.type === 'tool_result' && typeof block.content === 'string') {
52      const r = redactText(block.content, salt, cfg)
53      hits.push(...r.hits)
54      pairs.push(...r.pairs)
55      out.push(r.hits.length ? { ...block, content: r.text } : block)
56    } else if (block.type === 'tool_result' && Array.isArray(block.content)) {
57      const r = redactBlocks(block.content as ApiContentBlock[], salt, cfg)
58      hits.push(...r.hits)
59      pairs.push(...r.pairs)
60      out.push(r.hits.length ? { ...block, content: r.blocks } : block)
61    } else {
62      out.push(block)
63    }
64  }
65  return { blocks: out, hits, pairs }
66}
67
68export const WITHHELD = '[redact withheld this content: the redactor failed while scanning it]'
69
70// Fail closed: a row the redactor could not scan is replaced, never stored as it came.
71export function withhold(blocks: readonly ApiContentBlock[]): ApiContentBlock[] {
72  return blocks.map(block => {
73    if (block.type === 'text') return { ...block, text: WITHHELD }
74    if (block.type === 'tool_result') return { ...block, content: WITHHELD }
75    return block
76  })
77}
78
79export function tally(hits: readonly Hit[]): Record<string, number> {
80  const t: Record<string, number> = {}
81  for (const h of hits) t[h.label] = (t[h.label] ?? 0) + 1
82  return t
83}
84
85export function describe(t: Record<string, number>): string {
86  return Object.entries(t).sort((a, b) => b[1] - a[1]).map(([label, n]) => `${n} ${label}`).join(', ')
87}
88
89export function restore(text: string, vault: Record<string, string>): { text: string; n: number } {
90  let n = 0
91  const out = text.replace(PLACEHOLDER, p => {
92    const real = vault[p]
93    if (real === undefined) return p
94    n += 1
95    return real
96  })
97  return { text: out, n }
98}
99
100// Anything shaped like a placeholder, a mistyped one included: the shape rules.ts never matches inside.
101export const SHAPED = /\[REDACTED:[A-Z0-9_]{1,64}#[0-9a-fA-F]{1,64}\]/g
102
103export function shaped(text: string): string | undefined {
104  return text.match(new RegExp(SHAPED.source))?.[0]
105}
106
107// as written, JSON-escaped (a notebook's source) or with CRLF line ends
108function holds(file: string, value: string): boolean {
109  return file.includes(value) || file.includes(JSON.stringify(value).slice(1, -1)) || file.includes(value.replace(/\r?\n/g, '\r\n'))
110}
111
112export type Refusal = { placeholder: string; isKnown: boolean }
113
114// The first placeholder a restore into this file must refuse: a known one whose value the file does not
115// already hold, since restoring it would carry a hidden value somewhere new (a script Claude then runs, a
116// file curl uploads, a memory note), or an unknown one (an earlier session's, or mistyped) the file does not
117// already hold as text, since it would land there literally.
118export function refused(texts: readonly string[], vault: Record<string, string>, file: string): Refusal | undefined {
119  for (const text of texts) {
120    for (const m of text.matchAll(SHAPED)) {
121      const real = vault[m[0]]
122      if (real === undefined ? !file.includes(m[0]) : !holds(file, real)) return { placeholder: m[0], isKnown: real !== undefined }
123    }
124  }
125  return undefined
126}
127
hooks/rules.ts 770 lines
1export type RuleKind = 'secret' | 'pii'
2
3export type Rule = {
4  id: string
5  label: string
6  kind: RuleKind
7  pattern: RegExp
8  group?: number
9  // also sees the text and the value's start, for the character that opens it
10  verify?: (value: string, text: string, at: number) => boolean
11  // sees the text and the match start, for context a regex without lookbehind cannot check
12  before?: (text: string, at: number) => boolean
13  source: string
14}
15
16export type Hit = { id: string; label: string; kind: RuleKind; start: number; end: number; value: string }
17
18const GITLEAKS = 'https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml'
19const GITHUB_PATTERNS = 'https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns'
20
21function entropy(s: string): number {
22  if (s.length === 0) return 0
23  const counts = new Map<string, number>()
24  for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1)
25  let bits = 0
26  for (const n of counts.values()) {
27    const p = n / s.length
28    bits -= p * Math.log2(p)
29  }
30  return bits
31}
32
33function luhn(digits: string): boolean {
34  let sum = 0
35  let double = false
36  for (let i = digits.length - 1; i >= 0; i--) {
37    let d = digits.charCodeAt(i) - 48
38    if (double) {
39      d *= 2
40      if (d > 9) d -= 9
41    }
42    sum += d
43    double = !double
44  }
45  return digits.length > 0 && sum % 10 === 0
46}
47
48const VERHOEFF_D = [
49  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
50  [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
51  [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
52  [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
53  [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
54  [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
55  [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
56  [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
57  [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
58  [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
59]
60const VERHOEFF_P = [
61  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
62  [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
63  [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
64  [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
65  [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
66  [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
67  [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
68  [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
69]
70
71function verhoeff(digits: string): boolean {
72  let c = 0
73  for (let i = 0; i < digits.length; i++) {
74    const d = digits.charCodeAt(digits.length - 1 - i) - 48
75    c = VERHOEFF_D[c]![VERHOEFF_P[i % 8]![d]!]!
76  }
77  return digits.length > 0 && c === 0
78}
79
80function ibanValid(raw: string): boolean {
81  const iban = raw.replace(/ /g, '')
82  if (iban.length < 15 || iban.length > 34) return false
83  const moved = iban.slice(4) + iban.slice(0, 4)
84  let rem = 0
85  for (const ch of moved) {
86    const code = ch.charCodeAt(0)
87    const n = code >= 65 ? code - 55 : code - 48
88    rem = n > 9 ? (rem * 100 + n) % 97 : (rem * 10 + n) % 97
89  }
90  return rem === 1
91}
92
93const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
94
95function base64urlDecode(s: string): string | null {
96  let acc = 0
97  let bits = 0
98  let out = ''
99  for (const ch of s.replace(/=+$/, '')) {
100    let v = B64URL.indexOf(ch)
101    if (ch === '+') v = 62
102    else if (ch === '/') v = 63
103    if (v < 0) return null
104    acc = ((acc << 6) | v) & 0xffffff
105    bits += 6
106    if (bits >= 8) {
107      bits -= 8
108      out += String.fromCharCode((acc >> bits) & 0xff)
109    }
110  }
111  return out
112}
113
114function jwtHeaderHasAlg(token: string): boolean {
115  const decoded = base64urlDecode(token.slice(0, token.indexOf('.')))
116  if (decoded === null) return false
117  try {
118    const header = JSON.parse(decoded)
119    return header !== null && typeof header === 'object' && typeof header.alg === 'string'
120  } catch {
121    return false
122  }
123}
124
125const NOT_SECRETS = new Set([
126  'true', 'false', 'null', 'none', 'nil', 'undefined', 'changeme', 'password', 'passw0rd',
127  'secret', 'token', 'placeholder', 'redacted', 'example', 'default', 'required', 'optional',
128])
129
130function plausibleSecret(v: string): boolean {
131  const lower = v.toLowerCase()
132  if (NOT_SECRETS.has(lower)) return false
133  if (/^\$\{[^}]*\}$|^\$\(?[A-Za-z_]\w*\)?$|^\$env:[A-Za-z_]\w*$|^%[A-Za-z_]\w*%$|^\{\{.*\}\}$|^<.*>$/i.test(v)) return false
134  if (v.includes('${') || v.includes('{{') || v.includes('#{')) return false
135  if (/^(?:\/|\.{1,2}\/|~\/|[A-Za-z]:\\|\.\w)/.test(v)) return false
136  if (/^(?:https?|file):\/\//i.test(lower)) return false
137  if (/^(?:process\.env|os\.environ|env\.|import\.meta\.env)/.test(v)) return false
138  if (/^(.)\1*$/.test(v)) return false
139  if (/x{4,}|\*{3,}|\.{3}|your[_-]?|example|placeholder|changeme|redacted|dummy|sample|insert[_-]?/i.test(v)) return false
140  return true
141}
142
143function notFiller(v: string): boolean {
144  return entropy(v) >= 2.5 && !/x{6,}|X{6,}|0{8,}|EXAMPLE/.test(v)
145}
146
147// template and build-time slots: {{secret}}, {Secret}, <token>, __API_KEY__, %s, and a bare `Name=` cut off before its value
148function placeholderShaped(v: string): boolean {
149  return /^(?:\{\{|<|__|%)|(?:\}\}|>|__)$|^[A-Za-z_]+=$|^\{[A-Za-z_][\w.:-]*\}$/.test(v)
150}
151
152// an environment variable's name (GITHUB_TOKEN, DB_PASSWORD) said where its value would go
153function envName(v: string): boolean {
154  return /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(v) && v.replace(/\D/g, '').length <= 2
155}
156
157// The opening of a token a vendor rule above knows in full, cut too short to be that token: a prefix quoted
158// in docs (`pypi-AgE...`, `ghp_`). A whole token is caught by its own rule; a long one that rule misses
159// still reaches the generic rules.
160function vendorPrefix(v: string): boolean {
161  return v.length < 24 && /^(?:A[KS]IA|gh[pousr]_|github_pat_|glpat-|xox[abprs]-|[sr]k_live_|AIza|GOCSPX-|sk-(?:ant|proj|svcacct|admin)-|npm_|pypi-|hf_)/.test(v)
162}
163
164function quotedSecret(v: string, text = '', at = 0): boolean {
165  if (envName(v) || vendorPrefix(v)) return false
166  // a Markdown code span marks code, so a backticked name (`useOAuthFlow`, `client.token`) is not a value
167  if (text.charAt(at - 1) === '`' && codeName(v)) return false
168  // lowercase words joined by - _ . with no digit read as enum values ('same-origin', 'github_webhook')
169  return plausibleSecret(v) && !placeholderShaped(v) && entropy(v) >= 2.0 && !/^[a-z]+(?:[-_.][a-z]*)+$/.test(v)
170}
171
172// a quoted value in a slot that also holds names: config keys, braces, command-line flags
173function namedSlotSecret(v: string): boolean {
174  // a key=value spec (docker's --secret id=npmrc,src=...) is not itself the value
175  return quotedSecret(v) && !codeName(v) && !/^[A-Za-z_][\w.-]*=[^=]/.test(v)
176}
177
178function codeIdentifier(v: string): boolean {
179  const digits = v.replace(/\D/g, '').length
180  const upper = v.replace(/[^A-Z]/g, '').length
181  const letters = v.replace(/[^A-Za-z]/g, '').length
182  // word humps (Uint8Array, ToStringUtf8) keep capitals sparse; random strings are about half capitals
183  return /^[A-Za-z][A-Za-z0-9]*$/.test(v) && digits <= 2 && upper > 0 && upper * 3 <= letters
184}
185
186function codeExpression(v: string): boolean {
187  if (v.includes('?.') || codeIdentifier(v)) return true
188  // a member chain (jose.base64url.decode, result.Payload.Data.ToStringUtf8) whose parts read as names, not
189  // a dotted token whose parts are digit-heavy
190  const parts = v.split('.')
191  return parts.length > 1 && parts.every((p) => /^[A-Za-z_$][\w$]*$/.test(p) && p.replace(/\D/g, '').length <= 2)
192}
193
194function codeName(v: string): boolean {
195  // a code identifier or member chain with no digit (getToken, process.env.KEY, my-secret-name) is not a value
196  if (!/\d/.test(v) && /^[A-Za-z_$][\w$]*(?:[.\-][A-Za-z_$][\w$]*)*$/.test(v)) return true
197  return codeExpression(v)
198}
199
200function bareSecret(v: string): boolean {
201  if (!plausibleSecret(v) || placeholderShaped(v) || entropy(v) < 3.0) return false
202  return !codeName(v) && !envName(v) && !vendorPrefix(v)
203}
204
205function keywordStart(text: string, at: number): boolean {
206  if (at === 0) return true
207  const prev = text.charAt(at - 1)
208  if (prev === ':') return false
209  if (!/[A-Za-z0-9]/.test(prev) || /\\[nrt]$/.test(text.slice(Math.max(0, at - 2), at))) return true
210  // inside a word only a camelCase hump counts (dbPassword, clientSecret), never the auth of OAuth
211  return /^[A-Z][a-z]/.test(text.slice(at, at + 2)) && !(/o/i.test(prev) && /^auth/i.test(text.slice(at, at + 4)))
212}
213
214function dbUrlHasRealPassword(url: string): boolean {
215  const password = /^[^:]+:\/\/[^:@\/]*:([^@\/]*)@/.exec(url)?.[1] ?? ''
216  return (
217    password.length > 0 &&
218    plausibleSecret(password) &&
219    !/^(?:pass|pwd|secret|password|%[sd]|\{\{.*\}\}|<.*>)$/i.test(password)
220  )
221}
222
223function notTemplated(text: string, at: number): boolean {
224  return text.slice(Math.max(0, at - 2), at) !== '{{'
225}
226
227const FILE_TLDS = new Set([
228  'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'pdf', 'js', 'mjs', 'ts', 'tsx', 'jsx', 'css', 'json',
229  'html', 'md', 'txt', 'yaml', 'yml', 'xml', 'zip', 'gz', 'py', 'rb', 'go', 'rs', 'java', 'lock', 'map',
230])
231
232function emailPlausible(v: string): boolean {
233  const at = v.lastIndexOf('@')
234  const local = v.slice(0, at)
235  const tld = v.slice(v.lastIndexOf('.') + 1).toLowerCase()
236  return !local.endsWith('.') && !local.includes('..') && !FILE_TLDS.has(tld)
237}
238
239function digitsOf(v: string): string {
240  return v.replace(/\D/g, '')
241}
242
243function phonePlausible(v: string): boolean {
244  const d = digitsOf(v)
245  if (d.length < 10 || d.length > 15) return false
246  if (/^(\d)\1+$/.test(d)) return false
247  if (/^\d+$/.test(v) && v.startsWith('1') && (v.length === 10 || v.length === 13)) return false
248  if (/^\d{4}[-.\/]\d{2}[-.\/]\d{2}/.test(v)) return false
249  if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(v)) return false
250  return true
251}
252
253function cardPlausible(v: string): boolean {
254  const d = digitsOf(v)
255  if (d.length < 13 || d.length > 19) return false
256  if (/ /.test(v) && /-/.test(v)) return false
257  if (/^(\d)\1+$/.test(d)) return false
258  return luhn(d)
259}
260
261function ssnPlausible(v: string): boolean {
262  return !['078-05-1120', '219-09-9999', '123-45-6789'].includes(v)
263}
264
265export const RULES: readonly Rule[] = [
266  {
267    id: 'aws-access-key',
268    label: 'AWS_KEY',
269    kind: 'secret',
270    pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g,
271    verify: notFiller,
272    source: GITLEAKS,
273  },
274  {
275    id: 'aws-secret-key',
276    label: 'AWS_SECRET',
277    kind: 'secret',
278    pattern: /(?:aws[_.-]?secret[_.-]?(?:access[_.-]?)?key|secret[_.-]?access[_.-]?key)["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]?([A-Za-z0-9\/+]{40})(?![A-Za-z0-9\/+=])/gi,
279    group: 1,
280    verify: (v) => entropy(v) >= 3.5 && !/EXAMPLE/.test(v),
281    source: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html',
282  },
283  {
284    id: 'github-token',
285    label: 'GITHUB_TOKEN',
286    kind: 'secret',
287    pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{82,255})\b/g,
288    verify: notFiller,
289    source: GITLEAKS,
290  },
291  {
292    id: 'gitlab-pat',
293    label: 'GITLAB_TOKEN',
294    kind: 'secret',
295    pattern: /\bglpat-[A-Za-z0-9_-]{20,128}(?:\.[A-Za-z0-9_-]{2,128}){0,3}(?![\w-])/g,
296    verify: notFiller,
297    source: GITLEAKS,
298  },
299  {
300    id: 'slack-token',
301    label: 'SLACK_TOKEN',
302    kind: 'secret',
303    pattern: /\bxox[abprs]-[0-9]{8,14}-[A-Za-z0-9-]{10,250}(?![\w-])/g,
304    verify: notFiller,
305    source: GITLEAKS,
306  },
307  {
308    id: 'slack-webhook',
309    label: 'SLACK_WEBHOOK',
310    kind: 'secret',
311    pattern: /(?:https?:\/\/)?hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9+\/]{43,56}(?![A-Za-z0-9+\/])/g,
312    verify: notFiller,
313    source: GITLEAKS,
314  },
315  {
316    id: 'stripe-live-key',
317    label: 'STRIPE_KEY',
318    kind: 'secret',
319    pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,247}\b/g,
320    verify: notFiller,
321    source: GITLEAKS,
322  },
323  {
324    id: 'sendgrid-key',
325    label: 'SENDGRID_KEY',
326    kind: 'secret',
327    pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![\w-])/g,
328    verify: notFiller,
329    source: GITLEAKS,
330  },
331  {
332    id: 'twilio-key',
333    label: 'TWILIO_KEY',
334    kind: 'secret',
335    pattern: /\bSK[0-9a-fA-F]{32}\b/g,
336    verify: notFiller,
337    source: GITLEAKS,
338  },
339  {
340    id: 'google-api-key',
341    label: 'GOOGLE_API_KEY',
342    kind: 'secret',
343    pattern: /\bAIza[0-9A-Za-z_-]{35}(?![\w-])/g,
344    verify: notFiller,
345    source: GITLEAKS,
346  },
347  {
348    id: 'google-oauth-secret',
349    label: 'GOOGLE_OAUTH_SECRET',
350    kind: 'secret',
351    pattern: /\bGOCSPX-[A-Za-z0-9_-]{28}(?![\w-])/g,
352    verify: notFiller,
353    source: GITHUB_PATTERNS,
354  },
355  {
356    id: 'anthropic-key',
357    label: 'ANTHROPIC_KEY',
358    kind: 'secret',
359    pattern: /\bsk-ant-[a-z]{3,8}\d{2}-[A-Za-z0-9_-]{32,200}(?![\w-])/g,
360    verify: notFiller,
361    source: GITLEAKS,
362  },
363  {
364    id: 'openai-key',
365    label: 'OPENAI_KEY',
366    kind: 'secret',
367    pattern: /\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,250}|[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}|[A-Za-z0-9]{48})(?![\w-])/g,
368    verify: notFiller,
369    source: GITLEAKS,
370  },
371  {
372    id: 'huggingface-token',
373    label: 'HF_TOKEN',
374    kind: 'secret',
375    pattern: /\bhf_[A-Za-z]{34}\b/g,
376    verify: notFiller,
377    source: GITLEAKS,
378  },
379  {
380    id: 'npm-token',
381    label: 'NPM_TOKEN',
382    kind: 'secret',
383    pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
384    verify: notFiller,
385    source: GITLEAKS,
386  },
387  {
388    id: 'pypi-token',
389    label: 'PYPI_TOKEN',
390    kind: 'secret',
391    pattern: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,1000}(?![\w-])/g,
392    source: GITLEAKS,
393  },
394  {
395    id: 'azure-storage-key',
396    label: 'AZURE_STORAGE_KEY',
397    kind: 'secret',
398    pattern: /(?:AccountKey|azure[_.-]?storage[_.-]?(?:account[_.-]?)?key|storage[_.-]?account[_.-]?key)["'`]?[ \t]{0,5}[=:][ \t]{0,5}["'`]?([A-Za-z0-9+\/]{86}==)/gi,
399    group: 1,
400    verify: notFiller,
401    source: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string',
402  },
403  {
404    id: 'jwt',
405    label: 'JWT',
406    kind: 'secret',
407    pattern: /\beyJ[A-Za-z0-9_-]{10,4096}\.ey[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{0,4096}(?![\w-])/g,
408    verify: jwtHeaderHasAlg,
409    source: 'https://datatracker.ietf.org/doc/html/rfc7519',
410  },
411  {
412    id: 'private-key',
413    label: 'PRIVATE_KEY',
414    kind: 'secret',
415    // the body never holds five dashes, so a BEGIN without an END stops at the next marker instead of scanning 16 KB
416    pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----(?:[^-]|-{1,4}(?!-)){16,65536}?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----/g,
417    source: GITLEAKS,
418  },
419  {
420    id: 'private-key-truncated',
421    label: 'PRIVATE_KEY',
422    kind: 'secret',
423    // a key cut off before its END line; separators may be real or JSON-escaped newlines, lines may be indented.
424    // The base64 class has no newline, backslash or space, so each line can split only one way.
425    pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----[ \t]{0,16}(?:(?:\r?\n|\\r\\n|\\n)[ \t]{0,16}[A-Za-z][A-Za-z0-9-]{0,63}:[ \t][^\r\n\\]{0,256}){0,8}(?:(?:\r?\n|\\r\\n|\\n)[ \t]{0,16}(?=\r?\n|\\r\\n|\\n))?(?:(?:\r?\n|\\r\\n|\\n)[ \t]{0,16}[A-Za-z0-9+\/=]{16,128}){2,2048}(?![A-Za-z0-9+\/=])/g,
426    source: 'https://www.rfc-editor.org/rfc/rfc7468',
427  },
428  {
429    id: 'db-connection-url',
430    label: 'DB_URL',
431    kind: 'secret',
432    pattern: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:@\/'"`<>]{0,128}:[^\s@\/'"`<>]{1,256}@[^\s\/'"`<>?#@(){}\[\],;]{1,256}(?:[\/?#][^\s'"`<>(){}\[\],;]{0,1024})?/gi,
433    verify: dbUrlHasRealPassword,
434    before: notTemplated,
435    source: GITHUB_PATTERNS,
436  },
437  {
438    id: 'auth-header',
439    label: 'AUTH_HEADER',
440    kind: 'secret',
441    pattern: /\bAuthorization["']?[ \t]{0,5}[:=][ \t]{0,5}["']?(?:Bearer|Basic|Token)[ \t]{1,5}([A-Za-z0-9._~+\/=-]{8,4096})/gi,
442    group: 1,
443    verify: plausibleSecret,
444    source: 'https://www.rfc-editor.org/rfc/rfc9110#name-authorization',
445  },
446  {
447    id: 'generic-secret-quoted',
448    label: 'SECRET',
449    kind: 'secret',
450    pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]([^\s"'`]{8,256})["'`]/gi,
451    group: 1,
452    verify: quotedSecret,
453    before: keywordStart,
454    source: GITLEAKS,
455  },
456  {
457    id: 'generic-secret',
458    label: 'SECRET',
459    kind: 'secret',
460    // a literal \n, \r or \t (backslash and letter) ends the value, as in a dotenv string inside source code
461    pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}((?:[^\s"'`;,(){}<>\[\]\\]|\\(?![nrt])){8,256})(?!(?!\\[nrt])[^\s"'`;,(){}<>\[\]])/gi,
462    group: 1,
463    verify: bareSecret,
464    before: keywordStart,
465    source: GITLEAKS,
466  },
467  {
468    id: 'generic-secret-escaped',
469    label: 'SECRET',
470    kind: 'secret',
471    // JSON inside a JSON string: a notebook's source lines, an MCP result, `\"password\": \"...\"`
472    pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?(?:\\["'])?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}\\["']((?:[^\s"'`\\]|\\\\){8,256})\\["']/gi,
473    group: 1,
474    verify: quotedSecret,
475    before: keywordStart,
476    source: 'https://www.rfc-editor.org/rfc/rfc8259#section-7',
477  },
478  {
479    id: 'xml-config-secret',
480    label: 'SECRET',
481    kind: 'secret',
482    // .NET appSettings: <add key="DbPassword" value="..."/>, the key ending in a secret's name
483    pattern: /<add[ \t]+key[ \t]*=[ \t]*["'][^"'<>]{0,64}?(?:password|passwd|pwd|secret|token|api[_-]?key|credentials?)["'][ \t]+value[ \t]*=[ \t]*["']([^"'<>\s]{6,256})["']/gi,
484    group: 1,
485    verify: namedSlotSecret,
486    source: 'https://learn.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/appsettings/add-element-for-appsettings',
487  },
488  {
489    id: 'xml-element-secret',
490    label: 'SECRET',
491    kind: 'secret',
492    // <password>...</password>, <DbPassword>, <client_secret>: the element's name ends in a secret's name
493    pattern: /<((?:[\w.-]{1,32}:)?[\w.-]{0,32}?(?:password|passwd|pwd|secret|token|api[_-]?key|credentials?))>([^<>\s]{8,256})<\/\1>/gi,
494    group: 2,
495    verify: namedSlotSecret,
496    source: 'https://www.w3.org/TR/xml/#sec-starttags',
497  },
498  {
499    id: 'odbc-password',
500    label: 'SECRET',
501    kind: 'secret',
502    // an ODBC connection string quotes a value in braces, }} standing for one }: PWD={p;ss}
503    pattern: /(?:pwd|password|passwd)[ \t]{0,5}=[ \t]{0,5}\{((?:[^{}\s"'\[\]()]|\}\}){4,256})\}(?!\})/gi,
504    group: 1,
505    verify: namedSlotSecret,
506    before: keywordStart,
507    source: 'https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqldriverconnect-function',
508  },
509  {
510    id: 'flag-secret',
511    label: 'SECRET',
512    kind: 'secret',
513    // a value given after a space: --password value, PowerShell's -Password "value"
514    pattern: /(?:^|[\s"'`(])(?:--(?:password|passwd|pwd|secret|token|api[_-]?key|client[_-]?secret|access[_-]?token|auth[_-]?token)|-(?:password|passwd|secret|token|api[_-]?key|clientsecret|accesstoken))[ \t]+(["']?)([^\s"'`;|&<>(){}\[\]]{6,256})\1(?![^\s;|&)])/gim,
515    group: 2,
516    verify: namedSlotSecret,
517    source: 'https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html',
518  },
519  {
520    id: 'sqlcmd-password',
521    label: 'SECRET',
522    kind: 'secret',
523    // -P is the password only to sqlcmd, bcp and osql (mysql's -P is a port, grep's a regex), and only capital
524    pattern: /\b(?:[Ss][Qq][Ll][Cc][Mm][Dd]|[Bb][Cc][Pp]|[Oo][Ss][Qq][Ll])(?:\.[Ee][Xx][Ee])?[ \t][^\r\n|;&]{0,256}?[ \t]-P[ \t]*(["']?)([^\s"'`;|&<>(){}\[\]]{4,256})\1(?![^\s;|&)])/g,
525    group: 2,
526    verify: namedSlotSecret,
527    source: 'https://learn.microsoft.com/en-us/sql/tools/sqlcmd/sqlcmd-utility',
528  },
529  {
530    id: 'email',
531    label: 'EMAIL',
532    kind: 'pii',
533    // a colon or slash before the local part means URL userinfo (user:token@host), not an address
534    pattern: /(?:^|mailto:|[^\w.%+\/:-])([A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.){1,8}[A-Za-z]{2,24})\b/gm,
535    group: 1,
536    verify: emailPlausible,
537    source: 'https://www.rfc-editor.org/rfc/rfc5322#section-3.4.1',
538  },
539  {
540    id: 'payment-card',
541    label: 'CARD',
542    kind: 'pii',
543    pattern: /(?:^|[^\w.+\/-])([2-6](?:[ -]?\d){12,18})(?![\w-]|[ -]\d)/gm,
544    group: 1,
545    verify: cardPlausible,
546    source: 'https://en.wikipedia.org/wiki/Luhn_algorithm',
547  },
548  {
549    id: 'aadhaar',
550    label: 'AADHAAR',
551    kind: 'pii',
552    pattern: /(?:^|[^\w.+\/-])([2-9]\d{3}([ -]?)\d{4}\2\d{4})(?![\w-]|[ -]\d)/gm,
553    group: 1,
554    verify: (v) => verhoeff(digitsOf(v)),
555    source: 'https://uidai.gov.in/en/my-aadhaar/about-your-aadhaar.html',
556  },
557  {
558    id: 'us-ssn',
559    label: 'SSN',
560    kind: 'pii',
561    pattern: /(?:^|[^\w.\/-])((?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4})(?![\w-]|\.\d)/gm,
562    group: 1,
563    verify: ssnPlausible,
564    source: 'https://www.ssa.gov/employer/randomization.html',
565  },
566  {
567    id: 'iban',
568    label: 'IBAN',
569    kind: 'pii',
570    pattern: /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?\b/g,
571    verify: ibanValid,
572    source: 'https://en.wikipedia.org/wiki/International_Bank_Account_Number',
573  },
574  {
575    id: 'india-pan',
576    label: 'PAN',
577    kind: 'pii',
578    pattern: /\b[A-Z]{3}[ABCFGHLJPT][A-Z]\d{4}[A-Z]\b/g,
579    source: 'https://en.wikipedia.org/wiki/Permanent_account_number',
580  },
581  {
582    id: 'phone',
583    label: 'PHONE',
584    kind: 'pii',
585    pattern: /(?:^|[^\w+.\/@#-])(\+[1-9]\d{9,14}|\+[1-9]\d{0,2}[ .-]?\(?\d{1,4}\)?(?:[ .-]\d{2,5}){1,4}|\(\d{3}\)[ .-]?\d{3}[ .-]\d{4}|\d{3}([.-])\d{3}\2\d{4}|[6-9]\d{4}[ -]\d{5}|0\d{2,4}[ -]\d{3,4}[ -]\d{3,4})(?![\w-]|[ .)]\d)/gm,
586    group: 1,
587    verify: phonePlausible,
588    source: 'https://www.itu.int/rec/T-REC-E.164',
589  },
590]
591
592const PLACEHOLDER = /\[REDACTED:[A-Z0-9_]{1,64}#[0-9a-fA-F]{1,64}\]/g
593
594type Compiled = { re: RegExp; indexed: boolean }
595const compiled = new Map<RegExp, Compiled>()
596
597function compile(pattern: RegExp): Compiled {
598  let c = compiled.get(pattern)
599  if (c) return c
600  const flags = pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'
601  try {
602    c = { re: new RegExp(pattern.source, flags.includes('d') ? flags : flags + 'd'), indexed: true }
603  } catch {
604    c = { re: new RegExp(pattern.source, flags), indexed: false }
605  }
606  compiled.set(pattern, c)
607  return c
608}
609
610type Ranked = Hit & { order: number }
611
612// Rules anchored on a fixed prefix or marker. Only these run on decoded base64: the generic and PII rules
613// would fire on arbitrary decoded prose.
614const WRAPPABLE = new Set([
615  'aws-access-key', 'github-token', 'gitlab-pat', 'slack-token', 'slack-webhook', 'stripe-live-key', 'sendgrid-key',
616  'twilio-key', 'google-api-key', 'google-oauth-secret', 'anthropic-key', 'openai-key', 'huggingface-token',
617  'npm-token', 'pypi-token', 'private-key', 'private-key-truncated', 'db-connection-url', 'jwt',
618])
619const B64_MIN = 24
620const B64_MAX = 8192
621const B64_RUNS = 200
622
623const B64_VALUE = new Int8Array(128).fill(-1)
624for (let i = 0; i < 64; i++) B64_VALUE[B64URL.charCodeAt(i)] = i
625B64_VALUE[43] = 62
626B64_VALUE[47] = 63
627
628function b64Value(code: number): number {
629  return code < 128 ? B64_VALUE[code]! : -1
630}
631
632// Maximal runs of the base64 alphabet (standard or url-safe, never mixed) with optional padding.
633// Padded or standard runs must be a multiple of 4 long; unpadded url-safe runs only need a valid tail.
634function base64Runs(text: string): [number, number][] {
635  const runs: [number, number][] = []
636  let i = 0
637  while (i < text.length && runs.length < B64_RUNS) {
638    if (b64Value(text.charCodeAt(i)) < 0) {
639      i++
640      continue
641    }
642    let j = i
643    let std = false
644    let url = false
645    for (; j < text.length; j++) {
646      const c = text.charCodeAt(j)
647      if (b64Value(c) < 0) break
648      if (c === 43 || c === 47) std = true
649      else if (c === 45 || c === 95) url = true
650    }
651    let k = j
652    while (k < text.length && k - j < 2 && text.charCodeAt(k) === 61) k++
653    const body = j - i
654    const total = k - i
655    const shaped = k > j ? total % 4 === 0 : body % 4 === 0 || (!std && body % 4 !== 1)
656    if (total >= B64_MIN && total <= B64_MAX && !(std && url) && shaped) runs.push([i, k])
657    i = k
658  }
659  return runs
660}
661
662// Decodes text[start, end) to a byte string, or null once more than a tenth of the bytes are not printable ASCII.
663function decodePrintable(text: string, start: number, end: number): string | null {
664  let stop = end
665  while (stop > start && text.charCodeAt(stop - 1) === 61) stop--
666  const maxBad = Math.floor(((stop - start) * 3) / 40)
667  let bad = 0
668  let acc = 0
669  let bits = 0
670  let out = ''
671  for (let i = start; i < stop; i++) {
672    acc = ((acc << 6) | b64Value(text.charCodeAt(i))) & 0xffffff
673    bits += 6
674    if (bits >= 8) {
675      bits -= 8
676      const byte = (acc >> bits) & 0xff
677      if (!(byte >= 0x20 && byte <= 0x7e) && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d && ++bad > maxBad) return null
678      out += String.fromCharCode(byte)
679    }
680  }
681  return out
682}
683
684function firstWrappedRule(decoded: string, off: ReadonlySet<string> | undefined): number {
685  for (let order = 0; order < RULES.length; order++) {
686    const rule = RULES[order]!
687    if (!WRAPPABLE.has(rule.id) || off?.has(rule.id) || off?.has(`${rule.id}-base64`)) continue
688    const { re } = compile(rule.pattern)
689    const g = rule.group ?? 0
690    re.lastIndex = 0
691    for (let m = re.exec(decoded); m !== null; m = re.exec(decoded)) {
692      const raw = m[g]
693      if (raw && (!rule.before || rule.before(decoded, m.index)) && (!rule.verify || rule.verify(raw, decoded, m.index + m[0].lastIndexOf(raw)))) {
694        re.lastIndex = 0
695        return order
696      }
697      re.lastIndex = m.index + 1
698    }
699    re.lastIndex = 0
700  }
701  return -1
702}
703
704export function scan(text: string, opts: { pii?: boolean; off?: ReadonlySet<string> } = {}): Hit[] {
705  const spans: [number, number][] = []
706  PLACEHOLDER.lastIndex = 0
707  let masked = text
708  for (let m = PLACEHOLDER.exec(text); m !== null; m = PLACEHOLDER.exec(text)) {
709    spans.push([m.index, m.index + m[0].length])
710  }
711  if (spans.length > 0) {
712    // spaces keep every offset stable while no rule can match through a placeholder
713    let out = ''
714    let at = 0
715    for (const [s, e] of spans) {
716      out += text.slice(at, s) + ' '.repeat(e - s)
717      at = e
718    }
719    masked = out + text.slice(at)
720  }
721
722  const found: Ranked[] = []
723  RULES.forEach((rule, order) => {
724    if (rule.kind === 'pii' && !opts.pii) return
725    if (opts.off?.has(rule.id)) return
726    const { re, indexed } = compile(rule.pattern)
727    const g = rule.group ?? 0
728    re.lastIndex = 0
729    for (let m = re.exec(masked); m !== null; m = re.exec(masked)) {
730      if (m[0] === '') {
731        re.lastIndex++
732        continue
733      }
734      const raw = m[g]
735      if (raw === undefined || raw === '') continue
736      const start = indexed ? (m.indices?.[g]?.[0] ?? -1) : m.index + m[0].lastIndexOf(raw)
737      if (start < 0) continue
738      const end = start + raw.length
739      const value = text.slice(start, end)
740      const inPlaceholder = spans.some(([s, e]) => start < e && end > s)
741      if (inPlaceholder || (rule.before && !rule.before(masked, m.index)) || (rule.verify && !rule.verify(value, masked, start))) {
742        re.lastIndex = m.index + 1
743        continue
744      }
745      found.push({ id: rule.id, label: rule.label, kind: rule.kind, start, end, value, order })
746    }
747    re.lastIndex = 0
748  })
749
750  // one bounded decode pass: a base64 run whose decoded text trips a prefix rule is redacted whole
751  for (const [start, end] of base64Runs(masked)) {
752    const decoded = decodePrintable(masked, start, end)
753    if (decoded === null) continue
754    const order = firstWrappedRule(decoded, opts.off)
755    if (order < 0) continue
756    const rule = RULES[order]!
757    found.push({ id: `${rule.id}-base64`, label: rule.label, kind: rule.kind, start, end, value: text.slice(start, end), order })
758  }
759
760  found.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.order - b.order)
761  const hits: Hit[] = []
762  let lastEnd = -1
763  for (const h of found) {
764    if (h.start < lastEnd) continue
765    hits.push({ id: h.id, label: h.label, kind: h.kind, start: h.start, end: h.end, value: h.value })
766    lastEnd = h.end
767  }
768  return hits
769}
770
types/index.d.ts 11 lines
1declare module 'claude-code' {
2  interface PluginState {
3    redact: {
4      vault: Record<string, string>
5      salt: string
6      counts: Record<string, number>
7      restored: number
8    }
9  }
10}
11