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…

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.
❯ ./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
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.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).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.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.prompt.section: one paragraph is appended to the environment section telling Claude the placeholders are opaque, where they are restored and what is refused./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.
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.
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.
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..env) work as before./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.Bash, PowerShell, Monitor). Change a line holding a hidden value with Edit.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.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 190 lines1import 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}
190hooks/scrub.ts 127 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}
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}
127hooks/rules.ts 770 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 // 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}
770types/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