Oscura i segreti (token, chiavi, password) nei risultati degli strumenti prima che Claude li legga

Oscura token, chiavi e password nei risultati degli strumenti prima che Claude li legga, così un segreto stampato da un comando non finisce nella conversazione.
Non serve fare nulla: lavora da sola. Il comando /segreti dice quanti segreti ha oscurato nella sessione, per tipo:
secret-guard ha oscurato in questa sessione:
- token Google ya29.: 2
- stringa casuale: 1
Al posto del valore Claude legge, tra parentesi quadre, «segreto oscurato:» seguito dal tipo, ad esempio «chiave AWS AKIA». Ogni volta compare un avviso (toast), con un lucchetto davanti: «Oscurato 1 segreto (token Google ya29.) nel risultato di Bash».
Cosa riconosce, in quest'ordine:
| Tipo | Come lo riconosce |
|---|---|
| Forme note | chiave privata PEM, JWT, token Google (1//, ya29., AIza), ntok_/nins_/ngat_, Anthropic sk-ant-, OpenAI sk-, GitHub (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_), GitLab glpat-, Slack xox…-, AWS AKIA, Stripe sk_live_, rk_test_…, npm npm_ |
Righe in stile .env | NOME=valore dove il nome contiene PASSWORD, PASSWD, SECRET, TOKEN, API_KEY, APIKEY, PRIVATE_KEY o CREDENTIAL(S); oscura solo il valore |
| Stringhe casuali | 32 caratteri o più, con almeno tre classi di caratteri e un'entropia di almeno 4,3 bit per carattere |
counts), mai un valore. Per le righe .env il tipo contiene il nome della variabile (ad esempio «valore di DB_PASSWORD»).Nessuno strumento esterno.
PASSWORD=…) passa..env non viene oscurata se il valore ha meno di 8 caratteri, se comincia con $ o se è un segnaposto (changeme, xxx, <…>, your_…, example…, todo, none, true…).sha…- dei lockfile, i percorsi e gli identificatori lunghi in camelCase o snake_case.base64, type, filePath, rawOutputPath, backgroundTaskId, mimeType e media_type dei risultati (immagini, percorsi, identificativi).claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install secret-guard@enrik-mods
Oppure, dal prompt di Claude Code: /plugin install secret-guard --marketplace Wowbagger1994/claude-mods.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
Oggi i test sono 32.
hooks/register.tsx 84 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Finding } from './detect'
5import { describe, hasMask, maskDeep, maskText } from './detect'
6
7const PLUGIN = 'secret-guard'
8/** Tools that write text into files: a blotted-out secret must never be written back. */
9const WRITING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
10/** The fields of those tools that carry the text written; `old_string` only names what is replaced. */
11const WRITTEN_FIELDS = ['new_string', 'content', 'new_source', 'edits'] as const
12
13const counts = atom({ plugin: 'secret-guard', key: 'counts' } as const, {})
14
15export const WRITE_DENIED =
16 'Il testo contiene un segreto oscurato da secret-guard («[segreto oscurato: …]»): non si può scrivere nel file. ' +
17 'Riscrivi la modifica senza toccare la parte con il segreto, ad esempio con un Edit più piccolo che la lasci com’è, ' +
18 'oppure chiedi all’utente di inserire lui il valore.'
19
20/** The text a writing tool would put in a file, all its fields together. */
21const writtenText = (e: Record<string, unknown>): string =>
22 WRITTEN_FIELDS.map(field => e[field])
23 .filter(v => v !== undefined)
24 .map(v => (typeof v === 'string' ? v : JSON.stringify(v)))
25 .join('\n')
26
27const plural = (n: number) => (n === 1 ? '1 segreto' : `${n} segreti`)
28
29/** Counts what was blotted out, by label, and tells the user without ever showing a value. */
30const report = async ($: EngineInterface, found: readonly Finding[], where: string) => {
31 await update($, counts, c =>
32 found.reduce<Record<string, number>>((acc, f) => ({ ...acc, [f.label]: (acc[f.label] ?? 0) + 1 }), c),
33 )
34 $.ui.toast(`🔒 Oscurato ${plural(found.length)} (${describe(found)}) ${where}`)
35}
36
37export const register: Register = on => {
38 on('session.start', async ($, e, next) => {
39 await $.command.register({ name: 'segreti', description: 'Quanti segreti secret-guard ha oscurato in questa sessione, per tipo' })
40 return next(e)
41 })
42
43 on('command.run', { command: 'segreti' }, async $ => {
44 const c = await read($, counts)
45 const lines = Object.entries(c).map(([label, n]) => `- ${label}: ${n}`)
46 return {
47 text:
48 lines.length === 0
49 ? 'secret-guard: nessun segreto oscurato in questa sessione.'
50 : `secret-guard ha oscurato in questa sessione:\n${lines.join('\n')}`,
51 }
52 })
53
54 // Every tool: what a tool hands back is masked before Claude reads it.
55 on('tool.call', async ($, e, next) => {
56 const input = e as unknown as Record<string, unknown>
57 if (WRITING_TOOLS.has(e.tool) && hasMask(writtenText(input))) return { deny: WRITE_DENIED }
58
59 const ran = await next(e)
60 if (ran.deny !== undefined) return ran
61 if (ran.isError === true) {
62 const masked = maskText(ran.text ?? '')
63 if (masked.found.length === 0) return ran
64 await report($, masked.found, `nell’errore di ${e.tool}`)
65 return { deny: masked.text }
66 }
67 const masked = maskDeep(ran.result)
68 if (masked.found.length === 0) return ran
69 await report($, masked.found, `nel risultato di ${e.tool}`)
70 // A result of our own (no `ref`): core maps it for Claude and records it in place of the real one.
71 return ran.context === undefined ? { result: masked.value } : { result: masked.value, context: ran.context }
72 }).catch(($, e, next) => next(e))
73
74 // The user's own words are theirs: only a warning, never a change.
75 on('prompt.submit', async ($, e, next) => {
76 const { found } = maskText(e.text)
77 if (found.length > 0) $.ui.toast(`⚠ Il messaggio contiene ${plural(found.length)} (${describe(found)}): Claude lo leggerà.`)
78 return next(e)
79 }).catch(($, e, next) => {
80 $.ui.log(`${PLUGIN}: controllo del messaggio non riuscito`, { to: 'debug' })
81 return next(e)
82 })
83}
84hooks/detect.ts 197 lines1/**
2 * Finds secrets by their value, wherever they appear, and blots them out:
3 * known token shapes first, then .env-style assignments, then long random
4 * strings. Pure: nothing here touches the session.
5 */
6
7/** One secret found: its kind and the label shown in its place (a public prefix at most). */
8export type Finding = { kind: string; label: string }
9export type Masked = { text: string; found: Finding[] }
10
11export const MASK_OPEN = '[segreto oscurato: '
12const MASK_CLOSE = ']'
13
14type Rule = { kind: string; pattern: RegExp; label: (match: string) => string }
15
16const NOT_AFTER_WORD = '(?<![A-Za-z0-9])'
17
18/** Token shapes with a known prefix, most specific first. */
19const RULES: readonly Rule[] = [
20 {
21 kind: 'private-key',
22 pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
23 label: () => 'chiave privata',
24 },
25 {
26 kind: 'jwt',
27 pattern: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
28 label: () => 'token JWT',
29 },
30 { kind: 'google-refresh', pattern: new RegExp(`${NOT_AFTER_WORD}1//[A-Za-z0-9_-]{20,}`, 'g'), label: () => 'token di aggiornamento Google 1//' },
31 { kind: 'google-access', pattern: /ya29\.[A-Za-z0-9_-]{20,}/g, label: () => 'token Google ya29.' },
32 { kind: 'google-api', pattern: /AIza[0-9A-Za-z_-]{35}/g, label: () => 'chiave API Google AIza' },
33 {
34 kind: 'user-token',
35 pattern: new RegExp(`${NOT_AFTER_WORD}(?:ntok|nins|ngat)_[A-Za-z0-9_-]{8,}`, 'g'),
36 label: m => `token ${m.slice(0, 5)}`,
37 },
38 { kind: 'anthropic', pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, label: () => 'chiave Anthropic sk-ant-' },
39 { kind: 'openai', pattern: new RegExp(`${NOT_AFTER_WORD}sk-(?:proj-)?[A-Za-z0-9_-]{20,}`, 'g'), label: () => 'chiave OpenAI sk-' },
40 { kind: 'github', pattern: new RegExp(`${NOT_AFTER_WORD}gh[pousr]_[A-Za-z0-9]{30,}`, 'g'), label: m => `token GitHub ${m.slice(0, 4)}` },
41 { kind: 'github', pattern: /github_pat_[A-Za-z0-9_]{20,}/g, label: () => 'token GitHub github_pat_' },
42 { kind: 'gitlab', pattern: /glpat-[A-Za-z0-9_-]{20,}/g, label: () => 'token GitLab glpat-' },
43 { kind: 'slack', pattern: /xox[abposr]-[A-Za-z0-9-]{10,}/g, label: m => `token Slack ${m.slice(0, 5)}` },
44 { kind: 'aws', pattern: /(?<![A-Z0-9])AKIA[0-9A-Z]{16}(?![A-Z0-9])/g, label: () => 'chiave AWS AKIA' },
45 { kind: 'stripe', pattern: new RegExp(`${NOT_AFTER_WORD}[sr]k_(?:live|test)_[A-Za-z0-9]{10,}`, 'g'), label: m => `chiave Stripe ${m.slice(0, 8)}` },
46 { kind: 'npm', pattern: new RegExp(`${NOT_AFTER_WORD}npm_[A-Za-z0-9]{30,}`, 'g'), label: () => 'token npm npm_' },
47]
48
49const maskOf = (label: string) => `${MASK_OPEN}${label}${MASK_CLOSE}`
50
51/** True when the text holds a blotted-out secret: what a write must not put back in a file. */
52export const hasMask = (text: string): boolean => text.includes(MASK_OPEN)
53
54const applyRule = (text: string, rule: Rule): Masked => {
55 const found: Finding[] = []
56 const out = text.replace(rule.pattern, match => {
57 const label = rule.label(match)
58 found.push({ kind: rule.kind, label })
59 return maskOf(label)
60 })
61 return { text: out, found }
62}
63
64// .env-style: NAME=value where NAME says it is a secret; only the value is blotted out.
65const ENV_LINE =
66 /^(\s*(?:export\s+)?[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|APIKEY|PRIVATE_KEY|CREDENTIALS?)[A-Z0-9_]*\s*=\s*)(.+)$/gm
67const MIN_ENV_VALUE = 8
68const PLACEHOLDER = /^(?:changeme|change-me|xxx+|\*{3,}|<.*>|your[-_].*|example.*|placeholder.*|todo|none|null|undefined|true|false)$/i
69
70const isRealValue = (raw: string): boolean => {
71 const value = raw.trim().replace(/^['"]|['"]$/g, '')
72 if (value.length < MIN_ENV_VALUE || hasMask(value)) return false
73 if (value.startsWith('$') || PLACEHOLDER.test(value)) return false
74 return true
75}
76
77const maskEnv = (text: string): Masked => {
78 const found: Finding[] = []
79 const out = text.replace(ENV_LINE, (line, head: string, value: string) => {
80 if (!isRealValue(value)) return line
81 const name = head.replace(/^\s*(?:export\s+)?/, '').replace(/\s*=\s*$/, '')
82 const label = `valore di ${name}`
83 found.push({ kind: 'env', label })
84 return `${head}${maskOf(label)}`
85 })
86 return { text: out, found }
87}
88
89/** A candidate: base64-ish characters, '=' only as trailing padding, so `NAME=value` splits at the sign. */
90const RANDOM_TOKEN = /[A-Za-z0-9_\-+/.]{32,}={0,2}/g
91const MIN_ENTROPY = 4.3
92const MIN_CLASSES = 3
93// A lockfile's integrity hash: the prefix may sit just before the token or, since '-' is a token character, open it.
94const INTEGRITY_PREFIX = /sha(?:1|256|384|512)-$/
95const INTEGRITY_TOKEN = /^sha(?:1|256|384|512)-/
96const HEX = /^[0-9a-fA-F]+$/
97const UUID_LIKE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
98const WORDY_SEGMENT = /^[A-Za-z][a-z0-9]*(?:[._-][A-Za-z0-9]+)*$/
99/** A file name's extension: base64 and random keys have no dots, a path's last segment often does. */
100const FILE_EXTENSION = /\.[a-z][a-z0-9]{0,4}$/
101
102/** Bits per character of the string's own symbol frequencies. */
103export const entropy = (s: string): number => {
104 const counts = new Map<string, number>()
105 for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1)
106 return [...counts.values()].reduce((sum, n) => {
107 const p = n / s.length
108 return sum - p * Math.log2(p)
109 }, 0)
110}
111
112const classesOf = (s: string): number =>
113 [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(re => re.test(s)).length
114
115/** A path: rooted, or slash-separated with mostly word-like segments. */
116const isPath = (s: string): boolean => {
117 if (/^(?:\/|\.\.?\/|~\/)/.test(s)) return true
118 if (!s.includes('/')) return false
119 const segments = s.split('/').filter(Boolean)
120 if (segments.some(seg => FILE_EXTENSION.test(seg))) return true
121 return segments.filter(seg => WORDY_SEGMENT.test(seg)).length * 2 >= segments.length
122}
123
124/** A long identifier: camelCase or snake words, mostly of 3 letters or more. */
125const isIdentifier = (s: string): boolean => {
126 if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(s)) return false
127 const words = s.split(/_|(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z0-9])/).filter(Boolean)
128 return words.filter(w => w.length >= 3).length >= words.length * 0.7
129}
130
131/** A long string that looks random enough to be a credential. */
132export const isRandomSecret = (token: string, before: string): boolean =>
133 !HEX.test(token.replace(/-/g, '')) &&
134 !INTEGRITY_PREFIX.test(before) &&
135 !INTEGRITY_TOKEN.test(token) &&
136 !UUID_LIKE.test(token) &&
137 !isPath(token) &&
138 !isIdentifier(token) &&
139 classesOf(token) >= MIN_CLASSES &&
140 entropy(token) >= MIN_ENTROPY
141
142const maskRandom = (text: string): Masked => {
143 const found: Finding[] = []
144 const out = text.replace(RANDOM_TOKEN, (token: string, at: number) => {
145 if (!isRandomSecret(token, text.slice(Math.max(0, at - 8), at))) return token
146 found.push({ kind: 'random', label: 'stringa casuale' })
147 return maskOf('stringa casuale')
148 })
149 return { text: out, found }
150}
151
152/** The text with every secret blotted out, and what was found, in order of the passes. */
153export const maskText = (text: string): Masked => {
154 const steps = [...RULES.map(rule => (t: string) => applyRule(t, rule)), maskEnv, maskRandom]
155 return steps.reduce<Masked>(
156 (acc, step) => {
157 const next = step(acc.text)
158 return { text: next.text, found: [...acc.found, ...next.found] }
159 },
160 { text, found: [] },
161 )
162}
163
164/** Keys whose strings are data, not text a secret hides in: images, paths, ids, types. */
165const SKIPPED_KEYS = new Set(['base64', 'type', 'filePath', 'rawOutputPath', 'backgroundTaskId', 'mimeType', 'media_type'])
166
167export type MaskedValue = { value: unknown; found: Finding[] }
168
169/** A copy of any JSON-like value with every string masked; the value itself is never changed. */
170export const maskDeep = (value: unknown): MaskedValue => {
171 if (typeof value === 'string') {
172 const m = maskText(value)
173 return { value: m.text, found: m.found }
174 }
175 if (Array.isArray(value)) {
176 const parts = value.map(maskDeep)
177 return { value: parts.map(p => p.value), found: parts.flatMap(p => p.found) }
178 }
179 if (typeof value === 'object' && value !== null) {
180 const entries = Object.entries(value).map(([key, v]): [string, MaskedValue] =>
181 SKIPPED_KEYS.has(key) ? [key, { value: v, found: [] }] : [key, maskDeep(v)],
182 )
183 return {
184 value: Object.fromEntries(entries.map(([k, m]) => [k, m.value])),
185 found: entries.flatMap(([, m]) => m.found),
186 }
187 }
188 return { value, found: [] }
189}
190
191/** «token Google ya29., stringa casuale ×2»: what was found, each label once with its count. */
192export const describe = (found: readonly Finding[]): string => {
193 const counts = new Map<string, number>()
194 for (const f of found) counts.set(f.label, (counts.get(f.label) ?? 0) + 1)
195 return [...counts.entries()].map(([label, n]) => (n > 1 ? `${label} ×${n}` : label)).join(', ')
196}
197types/index.d.ts 12 lines1declare module 'claude-code' {
2 interface PluginState {
3 'secret-guard': {
4 /** Secrets blotted out this session, by label (`token Google ya29.`): never a value. */
5 counts: Record<string, number>
6 }
7 }
8}
9
10/** A label of a kind of secret, as shown in place of its value. */
11export type SecretLabel = string
12