Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it. Reach L0: no network, no processes, no…

Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it, and puts the real value back only inside Edit, Write and NotebookEdit arguments so file edits still work.
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.
❯ ./register.ts hooks: session.start, prompt.submit, session.append, tool.call{tool=Edit|Write|NotebookEdit}, prompt.section{name=env_info_simple}, command.run{command=redact} ❯ ./register.ts calls: $.command.register, $.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
Reach L0, draws and remembers.
Threat model for redact (reach L0, draws and remembers)
1. Reads: the text of every prompt and every stored row (prompts, tool results, Claude's blocks, attachments, notices) and the arguments of Edit, Write and NotebookEdit calls, in memory; state keys redact.vault, redact.salt, redact.counts, redact.restored; no files, 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; an unknown one passes through unchanged. A row that makes the scanner throw is withheld from the model, never stored raw
prompt.submit: the typed prompt is scanned and each hit becomes [REDACTED:LABEL#hash] before the engine queues it.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.tool.call on Edit, Write and NotebookEdit: placeholders in the arguments are swapped back for the real value, so an edit to a line that held a key still matches the file. Bash never gets the real value.prompt.section: one paragraph is appended to the environment section telling Claude the placeholders are opaque./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: 24 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.
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 scanned | Secret-rule hits | Real test keys and vectors | Fake fixtures | False positives |
|---|---|---|---|---|
| 42,836 | 68 | 60 | 8 | 0 |
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.
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.
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.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.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.key=value detection needs the value to look like a secret (length and entropy): password=hunter2 is caught, token=abc is not.Grep for a placeholder finds nothing. Search by the line around it.claude -p, the SDK, the VS Code panel or cloud sessions. The redact: hid ... line arrives as ui_log there. /redact works everywhere.Disable it in /plugin. It leaves nothing: no store keys, no files.
hooks/register.ts 129 lines1import type { EngineInterface, PluginOptions, Register, SessionAppendInput } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import { describe, fnv1a, redactBlocks, redactText, restore, tally, withhold, 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. Never ask the person for the',
14 'hidden value, never try to reconstruct it, and never rely on a placeholder in a shell command.',
15].join(' ')
16
17const DOORS: Record<string, string> = {
18 'prompt': 'your prompt',
19 'command': 'a command result',
20 'response': "Claude's reply",
21 'tool-result': 'a tool result',
22 'tool-message': 'a tool message',
23 'delivery': 'a delivered message',
24 'attachment': 'an attachment',
25 'hook-context': 'hook context',
26 'note': 'a plugin note',
27 'compaction': 'the compaction summary',
28 'notice': 'a notice',
29}
30
31function settings(options: PluginOptions): Settings {
32 const off = typeof options.off === 'string' ? options.off.split(',').map(s => s.trim()).filter(Boolean) : []
33 return { pii: options.pii === true, quiet: options.quiet === true, off: new Set(off) }
34}
35
36async function ensureSalt($: EngineInterface): Promise<string> {
37 const have = await read($, salt)
38 if (have) return have
39 const fresh = fnv1a(String(Math.random()) + String(Date.now())) + fnv1a(String(Math.random()) + String(performance.now()))
40 await update($, salt, () => fresh)
41 return fresh
42}
43
44async function remember($: EngineInterface, pairs: ReadonlyArray<readonly [string, string]>, found: Record<string, number>, cfg: Settings, place: string): Promise<void> {
45 await update($, vault, v => ({ ...v, ...Object.fromEntries(pairs) }))
46 await update($, counts, c => {
47 const total: Record<string, number> = { ...c }
48 for (const [label, n] of Object.entries(found)) total[label] = (total[label] ?? 0) + n
49 return total
50 })
51 if (!cfg.quiet) $.ui.log(`hid ${describe(found)} in ${place}`)
52}
53
54function where(e: SessionAppendInput): string {
55 const agent = e.agentId ? ' (subagent)' : ''
56 return (DOORS[e.door] ?? e.door) + agent
57}
58
59export const register: Register = (on, options) => {
60 const cfg = settings(options)
61
62 on('session.start', async ($, e, next) => {
63 const started = await next(e)
64 await $.command.register({ name: 'redact', description: 'Show what redact hid in this session' })
65 return started
66 })
67
68 // The typed prompt is scrubbed before the turn starts, so the on-screen echo
69 // and the command args hold the placeholder. The engine's queue record is
70 // written earlier in the headless path and is out of reach (see README).
71 on('prompt.submit', async ($, e, next) => {
72 const s = await ensureSalt($)
73 const r = redactText(e.text, s, cfg)
74 if (r.hits.length === 0) return next(e)
75 await remember($, r.pairs, tally(r.hits), cfg, 'your prompt')
76 return next({ ...e, text: r.text })
77 })
78
79 on('session.append', async ($, e, next) => {
80 const s = await ensureSalt($)
81 const r = redactBlocks(e.message.content, s, cfg)
82 if (r.hits.length === 0) return next(e)
83 await remember($, r.pairs, tally(r.hits), cfg, where(e))
84 return next({ ...e, message: { ...e.message, content: r.blocks } })
85 }).catch(($, e, next) => {
86 if (next.called) return next(e)
87 $.ui.log(`scanning failed, so this row was withheld from the model (${where(e)})`)
88 return next({ ...e, message: { ...e.message, content: withhold(e.message.content) } })
89 })
90
91 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
92 const v = await read($, vault)
93 if (e.tool === 'Edit') {
94 const o = restore(e.old_string, v)
95 const n = restore(e.new_string, v)
96 if (o.n + n.n === 0) return next(e)
97 await update($, restored, k => k + o.n + n.n)
98 return next({ ...e, old_string: o.text, new_string: n.text })
99 }
100 if (e.tool === 'Write') {
101 const c = restore(e.content, v)
102 if (c.n === 0) return next(e)
103 await update($, restored, k => k + c.n)
104 return next({ ...e, content: c.text })
105 }
106 if (e.tool === 'NotebookEdit') {
107 const c = restore(e.new_source, v)
108 if (c.n === 0) return next(e)
109 await update($, restored, k => k + c.n)
110 return next({ ...e, new_source: c.text })
111 }
112 return next(e)
113 })
114
115 on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => {
116 const r = await next(e)
117 return { text: r.text ? `${r.text}\n\n${NOTE}` : NOTE }
118 })
119
120 on('command.run', { command: 'redact' }, async $ => {
121 const c = await read($, counts)
122 const k = await read($, restored)
123 const total = Object.values(c).reduce((a, b) => a + b, 0)
124 const rules = cfg.pii ? 'secrets and PII' : 'secrets only (set pii to true for email, phone, cards, national ids)'
125 if (total === 0) return { text: `nothing hidden yet this session. Scanning ${rules}.` }
126 return { text: `hid ${total} values this session (${describe(c)}); restored ${k} placeholders inside Edit, Write and NotebookEdit arguments. Scanning ${rules}.` }
127 })
128}
129hooks/scrub.ts 99 lines1import 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}
99hooks/rules.ts 682 lines1export type RuleKind = 'secret' | 'pii'
2
3export type Rule = {
4 id: string
5 label: string
6 kind: RuleKind
7 pattern: RegExp
8 group?: number
9 verify?: (value: string) => boolean
10 // sees the text and the match start, for context a regex without lookbehind cannot check
11 before?: (text: string, at: number) => boolean
12 source: string
13}
14
15export type Hit = { id: string; label: string; kind: RuleKind; start: number; end: number; value: string }
16
17const GITLEAKS = 'https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml'
18const GITHUB_PATTERNS = 'https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns'
19
20function entropy(s: string): number {
21 if (s.length === 0) return 0
22 const counts = new Map<string, number>()
23 for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1)
24 let bits = 0
25 for (const n of counts.values()) {
26 const p = n / s.length
27 bits -= p * Math.log2(p)
28 }
29 return bits
30}
31
32function luhn(digits: string): boolean {
33 let sum = 0
34 let double = false
35 for (let i = digits.length - 1; i >= 0; i--) {
36 let d = digits.charCodeAt(i) - 48
37 if (double) {
38 d *= 2
39 if (d > 9) d -= 9
40 }
41 sum += d
42 double = !double
43 }
44 return digits.length > 0 && sum % 10 === 0
45}
46
47const VERHOEFF_D = [
48 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
49 [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
50 [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
51 [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
52 [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
53 [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
54 [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
55 [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
56 [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
57 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
58]
59const VERHOEFF_P = [
60 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
61 [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
62 [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
63 [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
64 [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
65 [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
66 [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
67 [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
68]
69
70function verhoeff(digits: string): boolean {
71 let c = 0
72 for (let i = 0; i < digits.length; i++) {
73 const d = digits.charCodeAt(digits.length - 1 - i) - 48
74 c = VERHOEFF_D[c]![VERHOEFF_P[i % 8]![d]!]!
75 }
76 return digits.length > 0 && c === 0
77}
78
79function ibanValid(raw: string): boolean {
80 const iban = raw.replace(/ /g, '')
81 if (iban.length < 15 || iban.length > 34) return false
82 const moved = iban.slice(4) + iban.slice(0, 4)
83 let rem = 0
84 for (const ch of moved) {
85 const code = ch.charCodeAt(0)
86 const n = code >= 65 ? code - 55 : code - 48
87 rem = n > 9 ? (rem * 100 + n) % 97 : (rem * 10 + n) % 97
88 }
89 return rem === 1
90}
91
92const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
93
94function base64urlDecode(s: string): string | null {
95 let acc = 0
96 let bits = 0
97 let out = ''
98 for (const ch of s.replace(/=+$/, '')) {
99 let v = B64URL.indexOf(ch)
100 if (ch === '+') v = 62
101 else if (ch === '/') v = 63
102 if (v < 0) return null
103 acc = ((acc << 6) | v) & 0xffffff
104 bits += 6
105 if (bits >= 8) {
106 bits -= 8
107 out += String.fromCharCode((acc >> bits) & 0xff)
108 }
109 }
110 return out
111}
112
113function jwtHeaderHasAlg(token: string): boolean {
114 const decoded = base64urlDecode(token.slice(0, token.indexOf('.')))
115 if (decoded === null) return false
116 try {
117 const header = JSON.parse(decoded)
118 return header !== null && typeof header === 'object' && typeof header.alg === 'string'
119 } catch {
120 return false
121 }
122}
123
124const NOT_SECRETS = new Set([
125 'true', 'false', 'null', 'none', 'nil', 'undefined', 'changeme', 'password', 'passw0rd',
126 'secret', 'token', 'placeholder', 'redacted', 'example', 'default', 'required', 'optional',
127])
128
129function plausibleSecret(v: string): boolean {
130 const lower = v.toLowerCase()
131 if (NOT_SECRETS.has(lower)) return false
132 if (/^\$\{[^}]*\}$|^\$\(?[A-Za-z_]\w*\)?$|^%[A-Za-z_]\w*%$|^\{\{.*\}\}$|^<.*>$/.test(v)) return false
133 if (v.includes('${') || v.includes('{{')) return false
134 if (/^(?:\/|\.{1,2}\/|~\/|[A-Za-z]:\\|\.\w)/.test(v)) return false
135 if (/^(?:https?|file):\/\//i.test(lower)) return false
136 if (/^(?:process\.env|os\.environ|env\.|import\.meta\.env)/.test(v)) return false
137 if (/^(.)\1*$/.test(v)) return false
138 if (/x{4,}|\*{3,}|\.{3}|your[_-]?|example|placeholder|changeme|redacted|dummy|sample|insert[_-]?/i.test(v)) return false
139 return true
140}
141
142function notFiller(v: string): boolean {
143 return entropy(v) >= 2.5 && !/x{6,}|X{6,}|0{8,}|EXAMPLE/.test(v)
144}
145
146// template and build-time slots: {{secret}}, <token>, __API_KEY__, %s, and a bare `Name=` cut off before its value
147function placeholderShaped(v: string): boolean {
148 return /^(?:\{\{|<|__|%)|(?:\}\}|>|__)$|^[A-Za-z_]+=$/.test(v)
149}
150
151function quotedSecret(v: string): boolean {
152 // lowercase words joined by - _ . with no digit read as enum values ('same-origin', 'github_webhook')
153 return plausibleSecret(v) && !placeholderShaped(v) && entropy(v) >= 2.0 && !/^[a-z]+(?:[-_.][a-z]*)+$/.test(v)
154}
155
156function codeIdentifier(v: string): boolean {
157 const digits = v.replace(/\D/g, '').length
158 const upper = v.replace(/[^A-Z]/g, '').length
159 const letters = v.replace(/[^A-Za-z]/g, '').length
160 // word humps (Uint8Array, ToStringUtf8) keep capitals sparse; random strings are about half capitals
161 return /^[A-Za-z][A-Za-z0-9]*$/.test(v) && digits <= 2 && upper > 0 && upper * 3 <= letters
162}
163
164function codeExpression(v: string): boolean {
165 if (v.includes('?.') || codeIdentifier(v)) return true
166 // a member chain (jose.base64url.decode, result.Payload.Data.ToStringUtf8) whose parts read as names, not
167 // a dotted token whose parts are digit-heavy
168 const parts = v.split('.')
169 return parts.length > 1 && parts.every((p) => /^[A-Za-z_$][\w$]*$/.test(p) && p.replace(/\D/g, '').length <= 2)
170}
171
172function bareSecret(v: string): boolean {
173 if (!plausibleSecret(v) || placeholderShaped(v) || entropy(v) < 3.0) return false
174 // a code identifier or member chain with no digit (getToken, process.env.KEY, my-secret-name) is not a value
175 if (!/\d/.test(v) && /^[A-Za-z_$][\w$]*(?:[.\-][A-Za-z_$][\w$]*)*$/.test(v)) return false
176 return !codeExpression(v)
177}
178
179function keywordStart(text: string, at: number): boolean {
180 if (at === 0) return true
181 const prev = text.charAt(at - 1)
182 if (prev === ':') return false
183 if (!/[A-Za-z0-9]/.test(prev) || /\\[nrt]$/.test(text.slice(Math.max(0, at - 2), at))) return true
184 // inside a word only a camelCase hump counts (dbPassword, clientSecret), never the auth of OAuth
185 return /^[A-Z][a-z]/.test(text.slice(at, at + 2)) && !(/o/i.test(prev) && /^auth/i.test(text.slice(at, at + 4)))
186}
187
188function dbUrlHasRealPassword(url: string): boolean {
189 const password = /^[^:]+:\/\/[^:@\/]*:([^@\/]*)@/.exec(url)?.[1] ?? ''
190 return (
191 password.length > 0 &&
192 plausibleSecret(password) &&
193 !/^(?:pass|pwd|secret|password|%[sd]|\{\{.*\}\}|<.*>)$/i.test(password)
194 )
195}
196
197function notTemplated(text: string, at: number): boolean {
198 return text.slice(Math.max(0, at - 2), at) !== '{{'
199}
200
201const FILE_TLDS = new Set([
202 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'pdf', 'js', 'mjs', 'ts', 'tsx', 'jsx', 'css', 'json',
203 'html', 'md', 'txt', 'yaml', 'yml', 'xml', 'zip', 'gz', 'py', 'rb', 'go', 'rs', 'java', 'lock', 'map',
204])
205
206function emailPlausible(v: string): boolean {
207 const at = v.lastIndexOf('@')
208 const local = v.slice(0, at)
209 const tld = v.slice(v.lastIndexOf('.') + 1).toLowerCase()
210 return !local.endsWith('.') && !local.includes('..') && !FILE_TLDS.has(tld)
211}
212
213function digitsOf(v: string): string {
214 return v.replace(/\D/g, '')
215}
216
217function phonePlausible(v: string): boolean {
218 const d = digitsOf(v)
219 if (d.length < 10 || d.length > 15) return false
220 if (/^(\d)\1+$/.test(d)) return false
221 if (/^\d+$/.test(v) && v.startsWith('1') && (v.length === 10 || v.length === 13)) return false
222 if (/^\d{4}[-.\/]\d{2}[-.\/]\d{2}/.test(v)) return false
223 if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(v)) return false
224 return true
225}
226
227function cardPlausible(v: string): boolean {
228 const d = digitsOf(v)
229 if (d.length < 13 || d.length > 19) return false
230 if (/ /.test(v) && /-/.test(v)) return false
231 if (/^(\d)\1+$/.test(d)) return false
232 return luhn(d)
233}
234
235function ssnPlausible(v: string): boolean {
236 return !['078-05-1120', '219-09-9999', '123-45-6789'].includes(v)
237}
238
239export const RULES: readonly Rule[] = [
240 {
241 id: 'aws-access-key',
242 label: 'AWS_KEY',
243 kind: 'secret',
244 pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g,
245 verify: notFiller,
246 source: GITLEAKS,
247 },
248 {
249 id: 'aws-secret-key',
250 label: 'AWS_SECRET',
251 kind: 'secret',
252 pattern: /(?:aws[_.-]?secret[_.-]?(?:access[_.-]?)?key|secret[_.-]?access[_.-]?key)["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]?([A-Za-z0-9\/+]{40})(?![A-Za-z0-9\/+=])/gi,
253 group: 1,
254 verify: (v) => entropy(v) >= 3.5 && !/EXAMPLE/.test(v),
255 source: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html',
256 },
257 {
258 id: 'github-token',
259 label: 'GITHUB_TOKEN',
260 kind: 'secret',
261 pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{82,255})\b/g,
262 verify: notFiller,
263 source: GITLEAKS,
264 },
265 {
266 id: 'gitlab-pat',
267 label: 'GITLAB_TOKEN',
268 kind: 'secret',
269 pattern: /\bglpat-[A-Za-z0-9_-]{20,128}(?:\.[A-Za-z0-9_-]{2,128}){0,3}(?![\w-])/g,
270 verify: notFiller,
271 source: GITLEAKS,
272 },
273 {
274 id: 'slack-token',
275 label: 'SLACK_TOKEN',
276 kind: 'secret',
277 pattern: /\bxox[abprs]-[0-9]{8,14}-[A-Za-z0-9-]{10,250}(?![\w-])/g,
278 verify: notFiller,
279 source: GITLEAKS,
280 },
281 {
282 id: 'slack-webhook',
283 label: 'SLACK_WEBHOOK',
284 kind: 'secret',
285 pattern: /(?:https?:\/\/)?hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9+\/]{43,56}(?![A-Za-z0-9+\/])/g,
286 verify: notFiller,
287 source: GITLEAKS,
288 },
289 {
290 id: 'stripe-live-key',
291 label: 'STRIPE_KEY',
292 kind: 'secret',
293 pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,247}\b/g,
294 verify: notFiller,
295 source: GITLEAKS,
296 },
297 {
298 id: 'sendgrid-key',
299 label: 'SENDGRID_KEY',
300 kind: 'secret',
301 pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![\w-])/g,
302 verify: notFiller,
303 source: GITLEAKS,
304 },
305 {
306 id: 'twilio-key',
307 label: 'TWILIO_KEY',
308 kind: 'secret',
309 pattern: /\bSK[0-9a-fA-F]{32}\b/g,
310 verify: notFiller,
311 source: GITLEAKS,
312 },
313 {
314 id: 'google-api-key',
315 label: 'GOOGLE_API_KEY',
316 kind: 'secret',
317 pattern: /\bAIza[0-9A-Za-z_-]{35}(?![\w-])/g,
318 verify: notFiller,
319 source: GITLEAKS,
320 },
321 {
322 id: 'google-oauth-secret',
323 label: 'GOOGLE_OAUTH_SECRET',
324 kind: 'secret',
325 pattern: /\bGOCSPX-[A-Za-z0-9_-]{28}(?![\w-])/g,
326 verify: notFiller,
327 source: GITHUB_PATTERNS,
328 },
329 {
330 id: 'anthropic-key',
331 label: 'ANTHROPIC_KEY',
332 kind: 'secret',
333 pattern: /\bsk-ant-[a-z]{3,8}\d{2}-[A-Za-z0-9_-]{32,200}(?![\w-])/g,
334 verify: notFiller,
335 source: GITLEAKS,
336 },
337 {
338 id: 'openai-key',
339 label: 'OPENAI_KEY',
340 kind: 'secret',
341 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,
342 verify: notFiller,
343 source: GITLEAKS,
344 },
345 {
346 id: 'huggingface-token',
347 label: 'HF_TOKEN',
348 kind: 'secret',
349 pattern: /\bhf_[A-Za-z]{34}\b/g,
350 verify: notFiller,
351 source: GITLEAKS,
352 },
353 {
354 id: 'npm-token',
355 label: 'NPM_TOKEN',
356 kind: 'secret',
357 pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
358 verify: notFiller,
359 source: GITLEAKS,
360 },
361 {
362 id: 'pypi-token',
363 label: 'PYPI_TOKEN',
364 kind: 'secret',
365 pattern: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,1000}(?![\w-])/g,
366 source: GITLEAKS,
367 },
368 {
369 id: 'azure-storage-key',
370 label: 'AZURE_STORAGE_KEY',
371 kind: 'secret',
372 pattern: /(?:AccountKey|azure[_.-]?storage[_.-]?(?:account[_.-]?)?key|storage[_.-]?account[_.-]?key)["'`]?[ \t]{0,5}[=:][ \t]{0,5}["'`]?([A-Za-z0-9+\/]{86}==)/gi,
373 group: 1,
374 verify: notFiller,
375 source: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string',
376 },
377 {
378 id: 'jwt',
379 label: 'JWT',
380 kind: 'secret',
381 pattern: /\beyJ[A-Za-z0-9_-]{10,4096}\.ey[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{0,4096}(?![\w-])/g,
382 verify: jwtHeaderHasAlg,
383 source: 'https://datatracker.ietf.org/doc/html/rfc7519',
384 },
385 {
386 id: 'private-key',
387 label: 'PRIVATE_KEY',
388 kind: 'secret',
389 // the body never holds five dashes, so a BEGIN without an END stops at the next marker instead of scanning 16 KB
390 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,
391 source: GITLEAKS,
392 },
393 {
394 id: 'private-key-truncated',
395 label: 'PRIVATE_KEY',
396 kind: 'secret',
397 // a key cut off before its END line; separators may be real or JSON-escaped newlines, lines may be indented.
398 // The base64 class has no newline, backslash or space, so each line can split only one way.
399 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,
400 source: 'https://www.rfc-editor.org/rfc/rfc7468',
401 },
402 {
403 id: 'db-connection-url',
404 label: 'DB_URL',
405 kind: 'secret',
406 pattern: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:@\/'"`<>]{0,128}:[^\s@\/'"`<>]{1,256}@[^\s\/'"`<>?#@(){}\[\],;]{1,256}(?:[\/?#][^\s'"`<>(){}\[\],;]{0,1024})?/gi,
407 verify: dbUrlHasRealPassword,
408 before: notTemplated,
409 source: GITHUB_PATTERNS,
410 },
411 {
412 id: 'auth-header',
413 label: 'AUTH_HEADER',
414 kind: 'secret',
415 pattern: /\bAuthorization["']?[ \t]{0,5}[:=][ \t]{0,5}["']?(?:Bearer|Basic|Token)[ \t]{1,5}([A-Za-z0-9._~+\/=-]{8,4096})/gi,
416 group: 1,
417 verify: plausibleSecret,
418 source: 'https://www.rfc-editor.org/rfc/rfc9110#name-authorization',
419 },
420 {
421 id: 'generic-secret-quoted',
422 label: 'SECRET',
423 kind: 'secret',
424 pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]([^\s"'`]{8,256})["'`]/gi,
425 group: 1,
426 verify: quotedSecret,
427 before: keywordStart,
428 source: GITLEAKS,
429 },
430 {
431 id: 'generic-secret',
432 label: 'SECRET',
433 kind: 'secret',
434 // a literal \n, \r or \t (backslash and letter) ends the value, as in a dotenv string inside source code
435 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,
436 group: 1,
437 verify: bareSecret,
438 before: keywordStart,
439 source: GITLEAKS,
440 },
441 {
442 id: 'email',
443 label: 'EMAIL',
444 kind: 'pii',
445 // a colon or slash before the local part means URL userinfo (user:token@host), not an address
446 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,
447 group: 1,
448 verify: emailPlausible,
449 source: 'https://www.rfc-editor.org/rfc/rfc5322#section-3.4.1',
450 },
451 {
452 id: 'payment-card',
453 label: 'CARD',
454 kind: 'pii',
455 pattern: /(?:^|[^\w.+\/-])([2-6](?:[ -]?\d){12,18})(?![\w-]|[ -]\d)/gm,
456 group: 1,
457 verify: cardPlausible,
458 source: 'https://en.wikipedia.org/wiki/Luhn_algorithm',
459 },
460 {
461 id: 'aadhaar',
462 label: 'AADHAAR',
463 kind: 'pii',
464 pattern: /(?:^|[^\w.+\/-])([2-9]\d{3}([ -]?)\d{4}\2\d{4})(?![\w-]|[ -]\d)/gm,
465 group: 1,
466 verify: (v) => verhoeff(digitsOf(v)),
467 source: 'https://uidai.gov.in/en/my-aadhaar/about-your-aadhaar.html',
468 },
469 {
470 id: 'us-ssn',
471 label: 'SSN',
472 kind: 'pii',
473 pattern: /(?:^|[^\w.\/-])((?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4})(?![\w-]|\.\d)/gm,
474 group: 1,
475 verify: ssnPlausible,
476 source: 'https://www.ssa.gov/employer/randomization.html',
477 },
478 {
479 id: 'iban',
480 label: 'IBAN',
481 kind: 'pii',
482 pattern: /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?\b/g,
483 verify: ibanValid,
484 source: 'https://en.wikipedia.org/wiki/International_Bank_Account_Number',
485 },
486 {
487 id: 'india-pan',
488 label: 'PAN',
489 kind: 'pii',
490 pattern: /\b[A-Z]{3}[ABCFGHLJPT][A-Z]\d{4}[A-Z]\b/g,
491 source: 'https://en.wikipedia.org/wiki/Permanent_account_number',
492 },
493 {
494 id: 'phone',
495 label: 'PHONE',
496 kind: 'pii',
497 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,
498 group: 1,
499 verify: phonePlausible,
500 source: 'https://www.itu.int/rec/T-REC-E.164',
501 },
502]
503
504const PLACEHOLDER = /\[REDACTED:[A-Z0-9_]{1,64}#[0-9a-fA-F]{1,64}\]/g
505
506type Compiled = { re: RegExp; indexed: boolean }
507const compiled = new Map<RegExp, Compiled>()
508
509function compile(pattern: RegExp): Compiled {
510 let c = compiled.get(pattern)
511 if (c) return c
512 const flags = pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'
513 try {
514 c = { re: new RegExp(pattern.source, flags.includes('d') ? flags : flags + 'd'), indexed: true }
515 } catch {
516 c = { re: new RegExp(pattern.source, flags), indexed: false }
517 }
518 compiled.set(pattern, c)
519 return c
520}
521
522type Ranked = Hit & { order: number }
523
524// Rules anchored on a fixed prefix or marker. Only these run on decoded base64: the generic and PII rules
525// would fire on arbitrary decoded prose.
526const WRAPPABLE = new Set([
527 'aws-access-key', 'github-token', 'gitlab-pat', 'slack-token', 'slack-webhook', 'stripe-live-key', 'sendgrid-key',
528 'twilio-key', 'google-api-key', 'google-oauth-secret', 'anthropic-key', 'openai-key', 'huggingface-token',
529 'npm-token', 'pypi-token', 'private-key', 'private-key-truncated', 'db-connection-url', 'jwt',
530])
531const B64_MIN = 24
532const B64_MAX = 8192
533const B64_RUNS = 200
534
535const B64_VALUE = new Int8Array(128).fill(-1)
536for (let i = 0; i < 64; i++) B64_VALUE[B64URL.charCodeAt(i)] = i
537B64_VALUE[43] = 62
538B64_VALUE[47] = 63
539
540function b64Value(code: number): number {
541 return code < 128 ? B64_VALUE[code]! : -1
542}
543
544// Maximal runs of the base64 alphabet (standard or url-safe, never mixed) with optional padding.
545// Padded or standard runs must be a multiple of 4 long; unpadded url-safe runs only need a valid tail.
546function base64Runs(text: string): [number, number][] {
547 const runs: [number, number][] = []
548 let i = 0
549 while (i < text.length && runs.length < B64_RUNS) {
550 if (b64Value(text.charCodeAt(i)) < 0) {
551 i++
552 continue
553 }
554 let j = i
555 let std = false
556 let url = false
557 for (; j < text.length; j++) {
558 const c = text.charCodeAt(j)
559 if (b64Value(c) < 0) break
560 if (c === 43 || c === 47) std = true
561 else if (c === 45 || c === 95) url = true
562 }
563 let k = j
564 while (k < text.length && k - j < 2 && text.charCodeAt(k) === 61) k++
565 const body = j - i
566 const total = k - i
567 const shaped = k > j ? total % 4 === 0 : body % 4 === 0 || (!std && body % 4 !== 1)
568 if (total >= B64_MIN && total <= B64_MAX && !(std && url) && shaped) runs.push([i, k])
569 i = k
570 }
571 return runs
572}
573
574// Decodes text[start, end) to a byte string, or null once more than a tenth of the bytes are not printable ASCII.
575function decodePrintable(text: string, start: number, end: number): string | null {
576 let stop = end
577 while (stop > start && text.charCodeAt(stop - 1) === 61) stop--
578 const maxBad = Math.floor(((stop - start) * 3) / 40)
579 let bad = 0
580 let acc = 0
581 let bits = 0
582 let out = ''
583 for (let i = start; i < stop; i++) {
584 acc = ((acc << 6) | b64Value(text.charCodeAt(i))) & 0xffffff
585 bits += 6
586 if (bits >= 8) {
587 bits -= 8
588 const byte = (acc >> bits) & 0xff
589 if (!(byte >= 0x20 && byte <= 0x7e) && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d && ++bad > maxBad) return null
590 out += String.fromCharCode(byte)
591 }
592 }
593 return out
594}
595
596function firstWrappedRule(decoded: string, off: ReadonlySet<string> | undefined): number {
597 for (let order = 0; order < RULES.length; order++) {
598 const rule = RULES[order]!
599 if (!WRAPPABLE.has(rule.id) || off?.has(rule.id) || off?.has(`${rule.id}-base64`)) continue
600 const { re } = compile(rule.pattern)
601 const g = rule.group ?? 0
602 re.lastIndex = 0
603 for (let m = re.exec(decoded); m !== null; m = re.exec(decoded)) {
604 const raw = m[g]
605 if (raw && (!rule.before || rule.before(decoded, m.index)) && (!rule.verify || rule.verify(raw))) {
606 re.lastIndex = 0
607 return order
608 }
609 re.lastIndex = m.index + 1
610 }
611 re.lastIndex = 0
612 }
613 return -1
614}
615
616export function scan(text: string, opts: { pii?: boolean; off?: ReadonlySet<string> } = {}): Hit[] {
617 const spans: [number, number][] = []
618 PLACEHOLDER.lastIndex = 0
619 let masked = text
620 for (let m = PLACEHOLDER.exec(text); m !== null; m = PLACEHOLDER.exec(text)) {
621 spans.push([m.index, m.index + m[0].length])
622 }
623 if (spans.length > 0) {
624 // spaces keep every offset stable while no rule can match through a placeholder
625 let out = ''
626 let at = 0
627 for (const [s, e] of spans) {
628 out += text.slice(at, s) + ' '.repeat(e - s)
629 at = e
630 }
631 masked = out + text.slice(at)
632 }
633
634 const found: Ranked[] = []
635 RULES.forEach((rule, order) => {
636 if (rule.kind === 'pii' && !opts.pii) return
637 if (opts.off?.has(rule.id)) return
638 const { re, indexed } = compile(rule.pattern)
639 const g = rule.group ?? 0
640 re.lastIndex = 0
641 for (let m = re.exec(masked); m !== null; m = re.exec(masked)) {
642 if (m[0] === '') {
643 re.lastIndex++
644 continue
645 }
646 const raw = m[g]
647 if (raw === undefined || raw === '') continue
648 const start = indexed ? (m.indices?.[g]?.[0] ?? -1) : m.index + m[0].lastIndexOf(raw)
649 if (start < 0) continue
650 const end = start + raw.length
651 const value = text.slice(start, end)
652 const inPlaceholder = spans.some(([s, e]) => start < e && end > s)
653 if (inPlaceholder || (rule.before && !rule.before(masked, m.index)) || (rule.verify && !rule.verify(value))) {
654 re.lastIndex = m.index + 1
655 continue
656 }
657 found.push({ id: rule.id, label: rule.label, kind: rule.kind, start, end, value, order })
658 }
659 re.lastIndex = 0
660 })
661
662 // one bounded decode pass: a base64 run whose decoded text trips a prefix rule is redacted whole
663 for (const [start, end] of base64Runs(masked)) {
664 const decoded = decodePrintable(masked, start, end)
665 if (decoded === null) continue
666 const order = firstWrappedRule(decoded, opts.off)
667 if (order < 0) continue
668 const rule = RULES[order]!
669 found.push({ id: `${rule.id}-base64`, label: rule.label, kind: rule.kind, start, end, value: text.slice(start, end), order })
670 }
671
672 found.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.order - b.order)
673 const hits: Hit[] = []
674 let lastEnd = -1
675 for (const h of found) {
676 if (h.start < lastEnd) continue
677 hits.push({ id: h.id, label: h.label, kind: h.kind, start: h.start, end: h.end, value: h.value })
678 lastEnd = h.end
679 }
680 return hits
681}
682types/index.d.ts 11 lines1declare 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