Masks passwords, keys, salts and tokens in tool outputs (Read, Bash, any tool_result) before Claude reads them. WordPress-aware.

A Claude Code mod that masks secrets in tool outputs before Claude reads them. The key name stays, the value is replaced by [REDACTED:<NAME>]:
define( 'DB_PASSWORD', 'S3cr3t!P@ss' ); → define( 'DB_PASSWORD', '[REDACTED:DB_PASSWORD]' );
SMTP_PASS=abc123 → SMTP_PASS=[REDACTED:SMTP_PASS]
mysql://wp:REDACTED@db:3306/wp → mysql://wp:REDACTED@db:3306/wp
Built for WordPress agency work (wp-config.php, .env, client dumps and logs), useful anywhere a Read or a cat would otherwise send credentials to the model.
Version française : README.fr.md.
A PostToolUse settings hook can also replace a tool's result (updatedToolOutput), so a shell script could do the regex part. What a mod adds, for this job:
wp-config.php at startup, or from a match ten tool calls ago, are masked in every later output. A settings hook is a fresh process each time and would need a file to remember anything.session.append on tool-result rows covers every tool at once, MCP servers included, without a matcher per tool.file.content or stdout on a typed record, and claude plugin test runs the hooks against the engine without a session..catch handler denies the output if redaction itself throws; a hook that crashes is skipped and the output goes through./secret-redact command.The file on disk is untouched; only what reaches the model (and the transcript) changes.
Requires Claude Code 2.1.287 or later (claude --version).
At the prompt of a terminal session:
/plugin install secret-redact --marketplace samykantari/secret-redact
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). From a shell: claude plugin install secret-redact --marketplace samykantari/secret-redact. In the Claude desktop app, where /plugin does not run: click + next to the prompt box → Plugins → Add marketplace with samykantari/secret-redact → Add plugin.
To try it for one session without installing, from a clone:
claude --plugin-dir ./secret-redact
/plugin then shows 1 mod active · secret-redact. The status line reads 🔒 N masqués · V valeurs · K clés (masked so far · known values · declared keys), and /secret-redact prints the details.
A mod runs with your permissions and outside the sandbox. Review hooks/*.ts before installing, as you would any mod; claude plugin validate ./secret-redact lists every event it hooks and every call it makes without running it.
| Family | Examples |
|---|---|
wp-config.php | DB_PASSWORD, AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY, the four *_SALT |
.env / ini / yaml / shell | any key containing PASSWORD, PASSWD, PASS, SECRET, TOKEN, API_KEY, PRIVATE_KEY, ACCESS_KEY, APP_KEY, ENCRYPTION_KEY, SIGNING_KEY, CLIENT_SECRET, DSN, CREDENTIALS |
| PHP | 'password' => '…', $db_pass = '…' |
| JSON / YAML | "password": "…", "api_key": "…" |
| URLs | scheme://user:PASS@host |
| Headers | Authorization: Bearer …, X-Api-Key: … |
| Known token formats | AWS (AKIA…), GitHub (ghp_…, github_pat_…), Slack (xox…), Stripe (sk_live_…), Google (AIza…), Anthropic, OpenAI, SendGrid, JWT |
| Private keys | -----BEGIN … PRIVATE KEY----- blocks |
Left visible on purpose: DB_NAME, DB_USER, DB_HOST (add them to a list, see below), placeholders (put your unique phrase here, changeme, ${VAR}, <…>), values that are file paths (SSH_KEY_PATH=/home/…), and parameter keys whose name ends in _TTL, _URL, _HEADER, _LENGTH, _ROUNDS, _ID, _NAME, … (TOKEN_TTL=3600, API_KEY_HEADER=X-Api-Key, PASSWORD_RESET_URL=…): a secret-looking prefix does not make them secrets. A key you declare in a list is never filtered this way.
The mod can hide too much, never too little, and it only ever blocks in two cases:
localhost (from DB_HOST) or wordpress (from DB_NAME) would vanish from every output. Such values stay masked next to their key name. To force a value anyway, put it in a list as LABEL=value.stdout is passed through untouched. Every hook runs under Claude Code's 10-second limit; a 1 MB output is redacted in about 150 ms.Three idempotent layers:
session.start — learn values. The mod reads the project's secret-bearing files (wp-config.php, .env*, auth.json, .npmrc, config/database.php, config/parameters.yml, docker-compose*.yml, also under public/, web/, www/, htdocs/, …) and the user lists, extracts the values with the rules above, and from then on masks them wherever they appear: SQL dumps, error logs, curl output, HTML, JSON. Any value a rule matches during the session is learned the same way. Guards: length ≥ 6, no placeholders, longest value wins.tool.call on Read and Bash. After the tool ran, the mod rewrites the typed result (file.content, stdout, stderr) and returns it without ref, so Claude Code maps it for the model and stores the redacted version in the transcript. A context line tells Claude that values exist but are withheld. A .catch fails closed: if redaction throws, the output is denied.session.append on every stored row except the prompt and Claude's reply. Catch-all for every other tool (Grep, Edit, MCP, …) and for what does not go through a tool at all: the output of a ! shell command (which Claude reads and responds to), engine attachments such as CLAUDE.md, settings-hook context, messages from other sessions, compaction summaries. Rewrites text and tool_result blocks before they are stored.Each redaction adds a dim line to the transcript.
Two list files, # lines ignored:
~/.claude/secret-redact.txt — global<project>/.secret-redact — per project, add it to .gitignore. Looked up in the working directory, then in its parents (so a session opened in a subfolder still finds it)| Line | Effect | Holds a secret? |
|---|---|---|
DB_USER= | declares a key name: masked wherever it carries a value (define('DB_USER', …), db_user: …, "DB_USER": …). A name the default rules already cover is reported by /secret-redact and not counted twice | no |
CPANEL_PASS=Tr0ub4dor&3 | declares a value: masked wherever it appears, even bare (dump, log, echo), under that label | yes |
hunter2hunter2 | bare value, label LISTED | yes |
A name is enough as long as the secret shows up next to it; a value is needed to recognise it bare. Since a value masked by its name is learned on the spot, NAME= covers most cases: the first read of the config file teaches the value, which is then masked everywhere. See secret-redact.example.txt.
If Claude tries to read a list, the whole content is withheld: Read returns [REDACTED:SECRET_LIST], and a Bash command that names the file (cat, grep, sed, …) is refused before it runs. Lists are read at session.start: a list created or changed during a session is picked up the next time Claude Code starts (/reload-plugins does not re-read it unless the mod itself reloads). /secret-redact shows the working directory and which lists were found. To scan another secret-bearing file at startup: SECRET_FILES / SUBDIRS in hooks/register.ts.
claude plugin validate ./secret-redact # manifest, hooked events, API calls
claude plugin test ./secret-redact # 26 tests: engine, known values and keys, false positives, hooks, performance
Layer 0 is not covered by claude plugin test (the test kit has no file system): check it with /secret-redact after opening a session in a project.
xxxx xxxx xxxx xxxx xxxx xxxx typed in the admin). Put it in .secret-redact, or add a rule to RULES in hooks/redact.ts.prompt.submit is not hooked.! command output is masked in the stored row, so the transcript shows the masked version to you as well. To read a value yourself, use another terminal.claude -p or cloud sessions, though their hooks still run there when the plugin is loaded.MIT — Samy Kantari.
hooks/register.ts 250 lines1import type { Register } from 'claude-code'
2
3import { KnownSecrets, MASK, extract, redact, summary } from './redact'
4
5/**
6 * secret-redact — masque les secrets dans les sorties d'outils avant que
7 * Claude ne les lise.
8 *
9 * Trois couches :
10 * 0. `session.start` : apprend les VALEURS des secrets du projet (wp-config,
11 * .env, …) et des listes utilisateur, pour les masquer partout ensuite,
12 * quel que soit le contexte (dump SQL, log, HTML, JSON…).
13 * 1. `tool.call` sur Read et Bash : réécrit l'enregistrement typé du résultat
14 * (file.content / stdout / stderr). Les valeurs reconnues sont apprises.
15 * 2. `session.append` sur la porte `tool-result` : filet générique pour tous
16 * les autres outils (Grep, Edit, MCP, …).
17 *
18 * `/secret-redact` affiche l'état : valeurs apprises, sources, total masqué.
19 */
20
21/** Listes utilisateur : une valeur par ligne, ou LABEL=valeur. */
22const LIST_GLOBAL = '.claude/secret-redact.txt' // sous $HOME
23const LIST_PROJECT = '.secret-redact' // sous le cwd
24
25/** Fichiers porteurs de secrets lus au démarrage, relatifs au cwd. */
26const SECRET_FILES = [
27 'wp-config.php',
28 'auth.json',
29 '.npmrc',
30 'config/database.php',
31 'config/parameters.yml',
32 'docker-compose.yml',
33 'docker-compose.override.yml',
34]
35/** Sous-dossiers où chercher aussi `wp-config.php` et `.env*`. */
36const SUBDIRS = ['', 'public', 'public_html', 'web', 'www', 'htdocs', 'wordpress', 'wp', 'app', 'site']
37
38const known = new KnownSecrets()
39let total = 0
40const sources: string[] = []
41/** Diagnostic pour /secret-redact : cwd et chemins de listes examinés. */
42let diagCwd = ''
43const diagLists: string[] = []
44
45/** Dossiers de cwd vers la racine, cwd d'abord (8 niveaux au plus). */
46function parents(cwd: string): string[] {
47 const out: string[] = []
48 let dir = cwd.replace(/\/+$/, '')
49 for (let i = 0; i < 8 && dir; i++) {
50 out.push(dir)
51 const cut = dir.lastIndexOf('/')
52 if (cut <= 0) break
53 dir = dir.slice(0, cut)
54 }
55 return out
56}
57
58const status = ($: { ui: { status: (t?: string) => unknown } }) =>
59 $.ui.status(`🔒 ${total} masqué${total > 1 ? 's' : ''} · ${known.size} valeur${known.size > 1 ? 's' : ''} · ${known.keyCount} clé${known.keyCount > 1 ? 's' : ''}`)
60
61const FAILED = (tool: string) =>
62 `secret-redact : le masquage a échoué, la sortie de ${tool} est retenue par précaution.` +
63 (tool === 'Bash' ? ' La commande a bien été exécutée ; ne la relance pas si elle a des effets de bord.' : '')
64
65const NOTE = (line: string) =>
66 `${line}. Les valeurs existent mais te sont cachées ; ne cherche pas à les obtenir par un autre outil ni à les reconstituer.`
67
68const isListFile = (path: string) => path.endsWith('/' + LIST_PROJECT) || path.endsWith('/' + LIST_GLOBAL) || path === LIST_PROJECT
69/** Mention d'une liste dans une commande shell. */
70const LIST_RE = /(?:^|[\s/'"=])(?:\.secret-redact|secret-redact\.txt)(?=$|[\s'"|;&>)])/
71
72export const register: Register = (on) => {
73 // Couche 0 — apprentissage des valeurs au démarrage.
74 on('session.start', async ($, e, next) => {
75 const cwd = await $.session.cwd()
76 const home = await $.env.get('HOME')
77 sources.length = 0
78 diagLists.length = 0
79 diagCwd = cwd
80
81 const tryList = async (path: string): Promise<boolean> => {
82 if (!(await $.fs.exists(path).catch(() => false))) {
83 diagLists.push(`${path} : absente`)
84 return false
85 }
86 const text = await $.fs.read(path).catch(() => '')
87 const n = known.learnList(text)
88 diagLists.push(`${path} : ${n} entrée${n > 1 ? 's' : ''}`)
89 if (n > 0) sources.push(`${path} (liste, ${n})`)
90 return true
91 }
92 const tryFile = async (path: string) => {
93 if (!(await $.fs.exists(path).catch(() => false))) return
94 const text = await $.fs.read(path).catch(() => '')
95 const n = known.learnAll(extract(text))
96 if (n > 0) sources.push(`${path.replace(cwd + '/', '')} (${n})`)
97 }
98
99 if (home) await tryList(`${home}/${LIST_GLOBAL}`)
100 // Liste projet : dans le cwd, sinon dans un dossier parent (racine du projet).
101 for (const dir of parents(cwd)) {
102 if (await tryList(`${dir}/${LIST_PROJECT}`)) break
103 }
104
105 for (const sub of SUBDIRS) {
106 const dir = sub ? `${cwd}/${sub}` : cwd
107 if (sub && !(await $.fs.exists(dir).catch(() => false))) continue
108 await tryFile(`${dir}/wp-config.php`)
109 const entries = await $.fs.list(dir).catch(() => [])
110 for (const entry of entries) {
111 if (entry.kind === 'file' && /^\.env(\..+)?$/.test(entry.name) && entry.name !== '.env.example') {
112 await tryFile(`${dir}/${entry.name}`)
113 }
114 }
115 }
116 for (const f of SECRET_FILES) await tryFile(`${cwd}/${f}`)
117
118 await $.command.register({ name: 'secret-redact', description: 'État du masquage : valeurs connues, sources, total masqué.' })
119 if (known.size > 0 || known.keyCount > 0) {
120 $.ui.log(`secret-redact : ${known.size} valeur${known.size > 1 ? 's' : ''} et ${known.keyCount} clé${known.keyCount > 1 ? 's' : ''} apprises depuis ${sources.join(', ')}`)
121 status($)
122 }
123 return next(e)
124 })
125
126 on('command.run', { command: 'secret-redact' }, async () => ({
127 text:
128 `secret-redact\n` +
129 ` valeurs connues : ${known.size}\n` +
130 ` clés déclarées : ${known.keyCount}` +
131 (known.coveredKeys.length ? ` (+${known.coveredKeys.length} déjà couverte${known.coveredKeys.length > 1 ? 's' : ''} par défaut : ${known.coveredKeys.join(', ')})` : '') +
132 `\n` +
133 ` sources : ${sources.length ? sources.join(', ') : 'aucune'}\n` +
134 ` masquées : ${total} (session)\n` +
135 ` cwd : ${diagCwd}\n` +
136 ` listes :\n` +
137 diagLists.map((l) => ` ${l}`).join('\n') +
138 `\n (une liste modifiée est relue au prochain démarrage de Claude Code)`,
139 }))
140
141 // Couche 1a — Read : le contenu du fichier lu.
142 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
143 const got = await next(e)
144 if (got.deny !== undefined || got.isError === true) return got
145 const rec = got.result
146 if (rec.type !== 'text' || typeof rec.file?.content !== 'string') return got
147 const where = e.file_path.split('/').pop() ?? e.file_path
148
149 // Une liste de secrets ne se lit pas, même masquée ligne à ligne.
150 if (isListFile(e.file_path)) {
151 const lines = rec.file.content.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#')).length
152 known.learnList(rec.file.content)
153 $.ui.log(`secret-redact : ${where} retenu en entier (${lines} entrées)`)
154 return {
155 result: { ...rec, file: { ...rec.file, content: `${MASK('SECRET_LIST')} — ${lines} entrée${lines > 1 ? 's' : ''}, fichier réservé au mod secret-redact.` } },
156 context: [...(got.context ?? []), NOTE(`secret-redact : ${where} est une liste de secrets, son contenu t'est caché`)],
157 }
158 }
159
160 const r = redact(rec.file.content, known)
161 known.learnAll(r.found)
162 if (r.count === 0) return got
163 total += r.count
164 $.ui.log(summary(r, where))
165 status($)
166 return {
167 result: { ...rec, file: { ...rec.file, content: r.text } },
168 context: [...(got.context ?? []), NOTE(summary(r, where))],
169 }
170 }).catch(($, e) => ({ deny: FAILED(e.tool) }))
171
172 // Couche 1b — Bash : stdout et stderr.
173 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
174 // Une commande qui vise une liste de secrets ne s'exécute pas (cat, grep, sed, …).
175 if (LIST_RE.test(e.command)) {
176 return { deny: 'secret-redact : cette commande lit une liste de secrets réservée au mod. Son contenu ne t\'est pas accessible ; continue sans.' }
177 }
178 const got = await next(e)
179 if (got.deny !== undefined || got.isError === true) return got
180 const rec = got.result
181 // Commande passée en arrière-plan ou sortie persistée : rien à masquer ici.
182 if (typeof rec.stdout !== 'string' && typeof rec.stderr !== 'string') return got
183 const out = redact(typeof rec.stdout === 'string' ? rec.stdout : '', known)
184 const err = redact(typeof rec.stderr === 'string' ? rec.stderr : '', known)
185 known.learnAll([...out.found, ...err.found])
186 const count = out.count + err.count
187 if (count === 0) return got
188 total += count
189 const merged = { count, labels: [...new Set([...out.labels, ...err.labels])] }
190 const where = `la sortie de \`${e.command.slice(0, 40)}${e.command.length > 40 ? '…' : ''}\``
191 $.ui.log(summary(merged, where))
192 status($)
193 return {
194 result: { ...rec, stdout: out.text, stderr: err.text },
195 context: [...(got.context ?? []), NOTE(summary(merged, where))],
196 }
197 }).catch(($, e) => ({ deny: FAILED(e.tool) }))
198
199 // Couche 2 — toute ligne stockée dans la conversation hors prompt et réponse :
200 // résultats d'outils (Grep, Edit, MCP, …), sortie d'une commande `!` (qui ne
201 // passe pas par l'outil Bash), notices, pièces jointes du moteur, contexte des
202 // hooks de settings, livraisons entre sessions, résumés de compaction.
203 on('session.append', { door: ['tool-result', 'tool-message', 'command', 'notice', 'attachment', 'hook-context', 'delivery', 'compaction'] }, async ($, e, next) => {
204 let count = 0
205 const labels = new Set<string>()
206 const scrub = (text: string) => {
207 const r = redact(text, known)
208 known.learnAll(r.found)
209 if (r.count === 0) return null
210 count += r.count
211 r.labels.forEach((l) => labels.add(l))
212 return r.text
213 }
214 const scrubParts = (c: unknown): unknown => {
215 if (typeof c === 'string') return scrub(c) ?? c
216 if (!Array.isArray(c)) return c
217 let changed = false
218 const parts = c.map((p: unknown) => {
219 const part = p as { type?: string; text?: unknown }
220 if (part.type !== 'text' || typeof part.text !== 'string') return p
221 const t = scrub(part.text)
222 if (t === null) return p
223 changed = true
224 return { ...part, text: t }
225 })
226 return changed ? parts : c
227 }
228 const content = e.message.content.map((block) => {
229 if (block.type === 'text' && typeof block.text === 'string') {
230 const t = scrub(block.text)
231 return t === null ? block : { ...block, text: t }
232 }
233 if (block.type === 'tool_result') {
234 const c = scrubParts(block.content)
235 return c === block.content ? block : { ...block, content: c }
236 }
237 return block
238 })
239 if (count === 0) return next(e)
240 total += count
241 const where =
242 e.origin.kind === 'tool' ? `la sortie de ${e.origin.tool}` :
243 e.door === 'command' || e.message.name === 'local_command' ? "la sortie d'une commande !" :
244 `une ligne ${e.door}`
245 $.ui.log(summary({ count, labels: [...labels] }, where))
246 status($)
247 return next({ ...e, message: { ...e.message, content } })
248 })
249}
250hooks/redact.ts 344 lines1/**
2 * Moteur de masquage : pur, sans dépendance, testable.
3 *
4 * Deux mécanismes complémentaires :
5 * - des RÈGLES (regex) qui reconnaissent un secret à sa forme : clé nommée
6 * (DB_PASSWORD=…), format connu (AKIA…, ghp_…), bloc PEM, URL avec
7 * identifiants ;
8 * - des VALEURS CONNUES (`KnownSecrets`) : des chaînes littérales apprises
9 * (fichiers du projet, liste de l'utilisateur, matches précédents) masquées
10 * partout où elles apparaissent, quel que soit le contexte.
11 *
12 * Chaque masquage remplace la VALEUR par `[REDACTED:<label>]` en conservant
13 * le nom de la clé, pour que Claude sache qu'une valeur existe sans la lire.
14 */
15
16export type Found = { label: string; value: string }
17
18export type Redaction = {
19 /** Texte après masquage. */
20 text: string
21 /** Nombre de valeurs masquées. */
22 count: number
23 /** Labels distincts rencontrés (DB_PASSWORD, AWS_ACCESS_KEY, …). */
24 labels: string[]
25 /** Valeurs reconnues par les règles, à apprendre pour les masquer ailleurs. */
26 found: Found[]
27}
28
29type Rule = {
30 /** Nom de la règle, label par défaut. */
31 name: string
32 pattern: RegExp
33 /** Construit le remplacement ; `null` = ne pas masquer (placeholder). */
34 replace: (m: string, ...g: string[]) => string | null
35 /** Label rapporté ; par défaut `name`. */
36 label?: (m: string, ...g: string[]) => string
37 /** La valeur brute du secret dans le match ; par défaut le match entier. */
38 value?: (m: string, ...g: string[]) => string
39}
40
41export const MASK = (label: string) => `[REDACTED:${label}]`
42const MASK_RE = /\[REDACTED:[^\]]*\]/g
43
44/** Clés dont la valeur est un secret, quel que soit le format (env, ini, yaml, json, php). */
45const SECRET_KEY =
46 '(?:[A-Za-z0-9_]*_)?(?:PASSWORD|PASSWD|PASSPHRASE|PASS|SECRET|SECRET_KEY|TOKEN|API_?KEY|PRIVATE_KEY|ACCESS_KEY|ACCESS_KEY_ID|AUTH_KEY|NONCE_KEY|LOGGED_IN_KEY|APP_KEY|ENCRYPTION_KEY|SIGNING_KEY|AUTH_SALT|SALT|CLIENT_SECRET|DSN|CREDENTIALS?)(?:_[A-Za-z0-9_]*)?'
47
48/** Valeurs à ne pas masquer : vides, placeholders, variables, chemins. */
49const PLACEHOLDER =
50 /^(?:|\s*|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|<[^>]*>|x{3,}|\*{3,}|\.{3,}|changeme|change_me|your[-_ ]?[a-z_]*|put .*here|null|none|false|true|0|\/[^\s]*|\.{1,2}\/[^\s]*|~\/[^\s]*)$/i
51
52const unquote = (v: string) => v.trim().replace(/^(['"])(.*)\1$/, '$2')
53export const isPlaceholder = (v: string) => PLACEHOLDER.test(unquote(v))
54
55/**
56 * Suffixes qui font d'une clé « secrète » un paramètre et non un secret :
57 * TOKEN_TTL, API_KEY_HEADER, PASSWORD_RESET_URL, SECRET_KEY_LENGTH, SALT_ROUNDS…
58 * Appliqué aux règles par défaut seulement, jamais aux clés déclarées par
59 * l'utilisateur.
60 */
61const NON_SECRET_SUFFIX =
62 /_(?:URL|URI|PATH|FILE|DIR|HOST|PORT|TTL|EXPIRY|EXPIRES|EXPIRATION|LIFETIME|TIMEOUT|LENGTH|MIN|MAX|SIZE|ROUNDS|ENABLED|DISABLED|REQUIRED|TYPE|NAME|ID|ALGO|ALGORITHM|HEADER|PREFIX|SUFFIX|FORMAT|VERSION|MODE|METHOD|FIELD|COLUMN|LABEL|PLACEHOLDER)$/i
63export const isParameterKey = (key: string) => NON_SECRET_SUFFIX.test(key)
64
65/**
66 * Une valeur apprise n'est masquée PARTOUT que si elle ressemble à un
67 * secret : au moins 8 caractères, deux classes de caractères (ou 16+), et
68 * ni un hôte, ni une IP, ni une URL, ni un e-mail, ni un chemin. Sinon
69 * `localhost` ou `wordpress` disparaîtraient de toutes les sorties.
70 * Elle reste masquée à côté de son nom par les règles.
71 */
72export function looksSecret(value: string): boolean {
73 const v = unquote(value)
74 if (v.length < 8 || isPlaceholder(v) || v.includes('[REDACTED:')) return false
75 if (/^[\d.:]+$/.test(v)) return false // IP, port
76 if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d+)?$/i.test(v)) return false // nom d'hôte
77 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return false // URL (ses identifiants sont appris à part)
78 if (/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test(v)) return false // e-mail
79 if (/^[\/~.]/.test(v)) return false // chemin
80 const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(v)).length
81 return classes >= 2 || v.length >= 16
82}
83
84/** Règles « clé nommée » : construites pour une alternance de noms de clés donnée. */
85function namedRules(keys: string, builtin: boolean): Rule[] {
86 const skip = (key: string, value: string) => isPlaceholder(value) || (builtin && isParameterKey(key))
87 return [
88 // wp-config.php : define( 'DB_PASSWORD', 'xxx' ) et sels/clés d'authentification.
89 {
90 name: 'WP_DEFINE',
91 pattern: new RegExp(`(define\\s*\\(\\s*(['"])(${keys})\\2\\s*,\\s*)(['"])((?:\\\\.|(?!\\4).)*)\\4`, 'gi'),
92 replace: (_m, head, _q1, key, q2, value) => (skip(key, value) ? null : `${head}${q2}${MASK(key.toUpperCase())}${q2}`),
93 label: (_m, _h, _q, key) => key.toUpperCase(),
94 value: (_m, _h, _q, _k, _q2, value) => value,
95 },
96 // PHP : 'password' => 'xxx', "db_pass" => "xxx", $password = 'xxx'
97 {
98 name: 'PHP_ASSIGN',
99 pattern: new RegExp(`((?:['"](${keys})['"]\\s*=>|\\$(${keys})\\s*=)\\s*)(['"])((?:\\\\.|(?!\\4).)*)\\4`, 'gi'),
100 replace: (_m, head, k1, k2, q, value) => (skip(k1 ?? k2, value) ? null : `${head}${q}${MASK((k1 ?? k2).toUpperCase())}${q}`),
101 label: (_m, _h, k1, k2) => (k1 ?? k2).toUpperCase(),
102 value: (_m, _h, _k1, _k2, _q, value) => value,
103 },
104 // JSON / YAML à clé citée : "password": "xxx", 'api_key': 'xxx'
105 {
106 name: 'JSON_KEY',
107 pattern: new RegExp(`((['"])(${keys})\\2\\s*:\\s*)(['"])((?:\\\\.|(?!\\4).)*)\\4`, 'gi'),
108 replace: (_m, head, _q1, key, q2, value) => (skip(key, value) ? null : `${head}${q2}${MASK(key.toUpperCase())}${q2}`),
109 label: (_m, _h, _q, key) => key.toUpperCase(),
110 value: (_m, _h, _q, _k, _q2, value) => value,
111 },
112 // .env / ini / yaml / shell : DB_PASSWORD=xxx, api_key: xxx, export TOKEN="xxx"
113 {
114 name: 'ENV_ASSIGN',
115 // Tolère une balise ouvrante/fermante sur la ligne (sortie d'une commande `!` : <local-command-stdout>…</local-command-stdout>).
116 pattern: new RegExp(`(^[ \\t]*(?:<[a-z-]+>)?[ \\t]*(?:export[ \\t]+)?(${keys})[ \\t]*[=:][ \\t]*)(.+?)[ \\t]*(?:</[a-z-]+>)?$`, 'gim'),
117 replace: (_m, head, key, value) => {
118 if (skip(key, value)) return null
119 const q = /^['"]/.test(value) ? value[0] : ''
120 return `${head}${q}${MASK(key.toUpperCase())}${q}`
121 },
122 label: (_m, _h, key) => key.toUpperCase(),
123 value: (_m, _h, _k, value) => unquote(value),
124 },
125 ]
126}
127
128const RULES: Rule[] = [
129 // Bloc PEM (clé privée) : masqué en entier.
130 {
131 name: 'PRIVATE_KEY_PEM',
132 pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
133 replace: () => `REDACTED-PRIVATE-KEY-BY-SLOPSHOPPER`,
134 },
135
136 ...namedRules(SECRET_KEY, true),
137
138 // URL avec identifiants : mysql://user:REDACTED@host, https://user:PASS@host
139 {
140 name: 'URL_CREDENTIALS',
141 pattern: /([a-z][a-z0-9+.-]*:\/\/[^\s:/@"']+:)([^\s@/"']+)(@)/gi,
142 replace: (_m, head, _pass, at) => `${head}${MASK('URL_PASSWORD')}${at}`,
143 label: () => 'URL_PASSWORD',
144 value: (_m, _h, pass) => pass,
145 },
146
147 // En-têtes HTTP : Authorization: Bearer xxx / Basic xxx / X-Api-Key: xxx
148 {
149 name: 'AUTHORIZATION_HEADER',
150 pattern: /((?:authorization|x-api-key|x-auth-token)["']?\s*[:=]\s*["']?(?:(?:bearer|basic|token)\s+)?)([A-Za-z0-9._~+/=-]{8,})/gi,
151 replace: (_m, head, tok) => (isPlaceholder(tok) ? null : `${head}${MASK('AUTHORIZATION')}`),
152 label: () => 'AUTHORIZATION',
153 value: (_m, _h, tok) => tok,
154 },
155
156 // Formats de tokens connus, où qu'ils apparaissent (valeur = match entier).
157 { name: 'AWS_ACCESS_KEY', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: () => MASK('AWS_ACCESS_KEY') },
158 { name: 'GITHUB_TOKEN', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g, replace: () => MASK('GITHUB_TOKEN') },
159 { name: 'SLACK_TOKEN', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replace: () => MASK('SLACK_TOKEN') },
160 { name: 'STRIPE_KEY', pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g, replace: () => MASK('STRIPE_KEY') },
161 { name: 'GOOGLE_API_KEY', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: () => MASK('GOOGLE_API_KEY') },
162 { name: 'ANTHROPIC_KEY', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, replace: () => MASK('ANTHROPIC_KEY') },
163 { name: 'OPENAI_KEY', pattern: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/g, replace: () => MASK('OPENAI_KEY') },
164 { name: 'SENDGRID_KEY', pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, replace: () => MASK('SENDGRID_KEY') },
165 { name: 'JWT', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replace: () => MASK('JWT') },
166]
167
168/** Longueur minimale d'une valeur de liste : en dessous, trop de faux positifs. */
169export const MIN_VALUE_LENGTH = 6
170
171const BUILTIN_KEY = new RegExp(`^${SECRET_KEY}$`, 'i')
172
173/**
174 * Valeurs littérales à masquer partout. Apprises des fichiers du projet, de
175 * la liste de l'utilisateur et des matches des règles.
176 */
177export class KnownSecrets {
178 private readonly map = new Map<string, string>()
179 private alternation: RegExp | null = null
180 private readonly keys = new Set<string>()
181 private readonly covered = new Set<string>()
182 private keyRules: Rule[] | null = null
183
184 /** Nombre de valeurs connues. */
185 get size(): number {
186 return this.map.size
187 }
188
189 /** Nombre de noms de clés supplémentaires. */
190 get keyCount(): number {
191 return this.keys.size
192 }
193
194 /** Noms déclarés que les règles par défaut couvrent déjà (ignorés, signalés). */
195 get coveredKeys(): string[] {
196 return [...this.covered]
197 }
198
199 /**
200 * Déclare un nom de clé à masquer partout où il apparaît avec une valeur
201 * (sans connaître la valeur). Un nom déjà couvert par les règles par défaut
202 * n'est pas ajouté, pour ne pas le compter deux fois.
203 */
204 learnKey(name: string): boolean {
205 const k = name.trim().toUpperCase()
206 if (!/^[A-Z_][A-Z0-9_]*$/.test(k) || this.keys.has(k)) return false
207 if (BUILTIN_KEY.test(k) && !isParameterKey(k)) {
208 this.covered.add(k)
209 return false
210 }
211 this.keys.add(k)
212 this.keyRules = null
213 return true
214 }
215
216 /** Les règles « clé nommée » pour les noms déclarés ; vide si aucun. */
217 rules(): Rule[] {
218 if (this.keys.size === 0) return []
219 if (this.keyRules) return this.keyRules
220 this.keyRules = namedRules([...this.keys].join('|'), false)
221 return this.keyRules
222 }
223
224 /**
225 * Apprend une valeur, masquée ensuite partout où elle apparaît. Sans
226 * `force`, seule une valeur qui ressemble à un secret est retenue
227 * (`looksSecret`) ; `force` sert aux valeurs données explicitement dans
228 * une liste, retenues dès 6 caractères.
229 */
230 learn(value: string, label: string, force = false): boolean {
231 const v = unquote(value)
232 if (v.includes('[REDACTED:') || isPlaceholder(v)) return false
233 if (force ? v.length < MIN_VALUE_LENGTH : !looksSecret(v)) return false
234 if (this.map.has(v)) return false
235 this.map.set(v, label.toUpperCase())
236 this.alternation = null
237 return true
238 }
239
240 /** Apprend plusieurs valeurs d'un coup ; retourne le nombre ajouté. */
241 learnAll(found: Found[]): number {
242 let n = 0
243 for (const f of found) if (this.learn(f.value, f.label)) n++
244 return n
245 }
246
247 /**
248 * Lit une liste utilisateur, une entrée par ligne :
249 * `LABEL=valeur` valeur à masquer partout, sous ce label
250 * `NOM=` nom de clé à masquer partout où il porte une valeur
251 * `valeur` valeur à masquer partout, label LISTED
252 * Lignes vides et `#` ignorées. Retourne le nombre d'entrées ajoutées.
253 */
254 learnList(text: string): number {
255 let n = 0
256 for (const raw of text.split(/\r?\n/)) {
257 const line = raw.trim()
258 if (line === '' || line.startsWith('#')) continue
259 const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)
260 let ok: boolean
261 if (m && m[2]!.trim() === '') ok = this.learnKey(m[1]!)
262 else if (m) ok = this.learn(m[2]!, m[1]!, true)
263 else ok = this.learn(line, 'LISTED', true)
264 if (ok) n++
265 }
266 return n
267 }
268
269 private regex(): RegExp | null {
270 if (this.map.size === 0) return null
271 if (this.alternation) return this.alternation
272 // Les plus longues d'abord : une valeur contenue dans une autre ne doit pas gagner.
273 const values = [...this.map.keys()].sort((a, b) => b.length - a.length)
274 const escaped = values.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
275 this.alternation = new RegExp(escaped.join('|'), 'g')
276 return this.alternation
277 }
278
279 /** Masque toute occurrence littérale d'une valeur connue, hors marqueurs déjà posés. */
280 apply(text: string): { text: string; count: number; labels: string[] } {
281 const re = this.regex()
282 if (!re) return { text, count: 0, labels: [] }
283 let count = 0
284 const labels = new Set<string>()
285 // On ne touche pas l'intérieur des `[REDACTED:…]` : segmentation sur les marqueurs.
286 const parts = text.split(MASK_RE)
287 const masks = text.match(MASK_RE) ?? []
288 const out = parts.map((seg) =>
289 seg.replace(re, (v) => {
290 count++
291 const label = this.map.get(v) ?? 'KNOWN'
292 labels.add(label)
293 return MASK(label)
294 }),
295 )
296 let rebuilt = ''
297 for (let i = 0; i < out.length; i++) rebuilt += out[i] + (masks[i] ?? '')
298 return { text: rebuilt, count, labels: [...labels] }
299 }
300}
301
302/**
303 * Masque les secrets d'un texte : règles, puis valeurs connues. Idempotent :
304 * un texte déjà masqué ressort inchangé.
305 */
306export function redact(input: string, known?: KnownSecrets): Redaction {
307 let text = input
308 let count = 0
309 const labels = new Set<string>()
310 const found: Found[] = []
311 for (const rule of [...RULES, ...(known?.rules() ?? [])]) {
312 text = text.replace(rule.pattern, (m: string, ...rest: unknown[]) => {
313 // Déjà masqué (idempotence) : on ne touche pas.
314 if (m.includes('[REDACTED:')) return m
315 const groups = rest.slice(0, -2) as string[]
316 const out = rule.replace(m, ...groups)
317 if (out === null) return m
318 count++
319 const label = rule.label ? rule.label(m, ...groups) : rule.name
320 labels.add(label)
321 found.push({ label, value: rule.value ? rule.value(m, ...groups) : m })
322 return out
323 })
324 }
325 if (known) {
326 const k = known.apply(text)
327 text = k.text
328 count += k.count
329 k.labels.forEach((l) => labels.add(l))
330 }
331 return { text, count, labels: [...labels], found }
332}
333
334/** Extrait les secrets d'un fichier sans produire de texte masqué. */
335export function extract(input: string): Found[] {
336 return redact(input).found
337}
338
339/** Ligne de synthèse pour le transcript et le contexte modèle. */
340export function summary(r: Pick<Redaction, 'count' | 'labels'>, where: string): string {
341 const what = r.labels.slice(0, 6).join(', ') + (r.labels.length > 6 ? ', …' : '')
342 return `secret-redact : ${r.count} valeur${r.count > 1 ? 's' : ''} masquée${r.count > 1 ? 's' : ''} dans ${where} (${what})`
343}
344