SLOPSHOPPER

secret-shield

Masks API keys, tokens, passwords, private keys, and database URL passwords in tool output, prompts, and attached files before Claude reads them. /shield lists…

newpanespinnerguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-shield
│ ┃ secret-shield ✕ › fix the failing auth test and add an audit log call │ ┃ 這個 session 遮蔽了 2 處,1 │ ┃ 處要注意(不顯示值) ● secret-shield: 已遮蔽 1 個 stripe-key、1 個 url-password(Bash cat .env) │ ┃ ⚠ = ● secret-shield: ⚠ 已遮蔽 1 個 stripe-key、1 個 url-password(對話紀錄 tool-resu │ ┃ 密鑰出現在不該有的地方:原始碼、log、網頁、M ⏺ Read(src/auth.ts) │ ┃ CP 回應、CLAUDE.md ⎿ Read 6 lines │ ┃ ⚠ 對話紀錄 tool-result ⏺ Update(src/auth.ts) │ ┃ 第 2 行 STRIPE_SECRET_KEY stripe-key ⎿ Added 2 lines, removed 1 line │ ┃ 第 1 行 postgres://app url-password ⏺ Bash(bun test) │ ┃ Bash cat .env ⎿ 3 pass, 1 fail │ ┃ 第 2 行 STRIPE_SECRET_KEY stripe-key │ ┃ 第 1 行 postgres://app url-password ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Esc 關閉 · /shield 再打開 │ ✻ Worked for 42s · done 4:20 PM │ │ › /shield │ ⎿ secret-shield: 這個 session 遮蔽了 2 處,1 處要注意(⚠) │ │ ✻ Thinking 🛡… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · secret-shield
這個 session 遮蔽了 2 處,1 處要注意(不顯示值) ⚠ = 密鑰出現在不該有的地方:原始碼、log、網頁、MCP 回應、CLAUDE.md ⚠ 對話紀錄 tool-result 第 2 行 STRIPE_SECRET_KEY stripe-key 第 1 行 postgres://app url-password Bash cat .env 第 2 行 STRIPE_SECRET_KEY stripe-key 第 1 行 postgres://app url-password Esc 關閉 · /shield 再打開
README

claude-secret-shield

Claude Code mod:在 Claude 讀到之前,遮蔽 API key、token、密碼、私鑰和資料庫 URL 裡的密碼。在 Claude Code 2.1.288 上測過。

涵蓋的地方:

  • 工具輸出(Bash、Read、MCP 等),包括錯誤訊息
  • 你送出的訊息
  • 附加內容(@ 檔案、CLAUDE.md 等開場內容)
  • 每一筆存進對話紀錄的內容(最後一道檢查)

不連網、不讀寫檔案、不跑程式、不讀環境變數。只呼叫 $.ui.log、$.ui.toast、$.ui.open、$.command.register 和 $.state,顯示的是種類、變數名稱和行號,從不顯示值。

安裝

claude plugin marketplace add justinhsu1477/claude-secret-shield
claude plugin install secret-shield@claude-secret-shield

這個 repo 是 private,git clone 用的 GitHub 帳號要有讀取權限。

確認有在運作

Claude 工作時,spinner 後面會出現 🛡。沒看到 🛡 就代表 mod 沒載入,沒有任何遮蔽。可以跑 /plugin 看 mods active 那一行。

在哪裡遮蔽了什麼:/shield

  • 每次遮蔽,對話裡會印一行,例如 已遮蔽 1 個 env-secret(Read /app/.env)。
  • /shield 開清單,列出這個 session 每個遮蔽過的地方:來源(檔案路徑、指令、MCP 工具)、行號、變數名稱、種類,不顯示值。清單只存在這個 session,關掉就沒了。
  • ⚠ 要注意:密鑰出現在不該有的地方時,那行前面加 ⚠,而且同一個地方第一次出現時清單會自己打開。
  • 不該有的地方:原始碼、log、網頁、MCP 回應、CLAUDE.md、.env.example 這類會被 commit 的範本、settings hook 或其他 plugin 加進來的內容
  • 本來就放密鑰、不算 ⚠:.env、*.pem、*.key、credentials、.npmrc、.netrc、id_rsa、printenv / env 的輸出、Neon get_connection_string 這類本來就給密鑰的 MCP 工具、你自己 @ 的檔案
  • grep -n / rg 的輸出看每一行開頭的檔名判斷
  • 終端機太窄時:清單是 Claude 的動作觸發的,不是你開的,終端機不到 144 格寬時 Claude Code 會先不畫,改跳一個 toast 叫你輸入 /shield。
  • 顯示失敗不影響遮蔽:遮蔽照常完成,只是少了提示。

行為

  • 遮蔽後的值會換成遮罩標籤,例如 [[redacted:env-secret]],種類名稱保留,值不保留。
  • 有工具呼叫想把遮罩寫回檔案時會被擋下,以免蓋掉真正的密鑰。要改含密鑰的那幾行,請自己動手。
  • 檢查失敗時會擋下(fail-closed):該筆內容不給 Claude 看,不會未經檢查就放行。

認得的格式

  • 有固定前綴的 token:Anthropic、OpenAI、Stripe、GitHub、GitLab、Slack(含 webhook)、AWS access key id、Google API key、npm、Neon、Notion、ClickUp、JWT、Bearer / Basic
  • PEM 私鑰(含 OpenSSH)
  • URL 裡的密碼:postgres://user:密碼@host
  • 名稱像密鑰的值:.env 的 API_KEY=...、export DB_PASSWORD="...",JSON / PHP / JS 的 "HashKey": "...",YAML / INI 的 password: ...

限制

靠規則比對,不是萬能:

  • 編碼過的密鑰(base64、hex、URL encode、拆成幾段字串相加)認不出來
  • 命令列參數(mysql -p...、--password=、curl -u user:pass、-H "X-API-Key: ...")、.netrc、docker auth、kubeconfig client-key-data、Azure 連線字串、Cookie 還沒涵蓋
  • Hugging Face、SendGrid、Twilio、Telegram、Discord webhook、Linear 的 token 還沒涵蓋
  • 沒有明顯格式、也不在 PASSWORD= 這類名稱後面的密鑰認不出來
  • 只管 Claude「看到」的內容,不管指令「做」了什麼:curl -d @.env 這種不需要看到值的外傳擋不住,要靠 Claude Code 的 sandbox 和 permissions.deny
  • 你在 prompt 輸入的原文,Claude Code 會先寫進本機 transcript 的 queue-operation 紀錄(~/.claude/projects/),mod 攔不到;送給模型的內容已經遮蔽
  • 安裝前的對話紀錄、~/.claude/file-history 裡的檔案快照不會被回頭遮蔽

開發

# 直接載入目錄,存檔即熱重載
claude --plugin-dir plugins/secret-shield

# 測試與檢查
cd plugins/secret-shield && claude plugin test && claude plugin validate --strict .

測試裡的假密鑰都是分段拼出來的,所以這個 repo 本身不會被當成含有密鑰。完整寫出的 HashKey / HashIV 是綠界官方文件公開的測試商店金鑰(MerchantID 2000132)。

改完要讓已安裝的版本生效:先把 plugins/secret-shield/.claude-plugin/plugin.json 的 version 加一,再跑 claude plugin marketplace update claude-secret-shield 和 claude plugin update secret-shield@claude-secret-shield。

關閉

  • 在 /plugin 停用,或 claude plugin disable secret-shield@claude-secret-shield
  • 只關這次 session:claude --safe-mode
Source 3 files
hooks/register.js 224 lines
1// secret-shield: masks secrets before Claude reads them, in tool results, in the
2// prompts you send, in what Claude Code attaches for Claude, such as an
3// @-mentioned file or CLAUDE.md, and in every row the conversation stores. It
4// sends nothing and keeps nothing past the session. It shows where it masked
5// what (kinds, names, lines, never values) in a transcript line and in /shield,
6// and opens /shield by itself the first time a place holds a secret it
7// shouldn't (⚠): source code, a log, a web page, CLAUDE.md.
8//
9// When a check fails, the content is withheld rather than passed on unchecked.
10// Showing where something was masked never decides that: its failure is ignored.
11
12import { atom, read, update } from 'claude-code'
13import { MARKER, maskText, maskValue, total, describe } from './redact.js'
14
15const WITHHELD = 'secret-shield could not check this output for secrets, so it was withheld. Tell the user; do not retry the same call.'
16const PANE = 'shield'
17const OPEN = { id: PANE, title: 'secret-shield', focus: true, closeOnEscape: true, rows: 14 }
18
19// What was masked this session, newest last: { where, warn, spots }
20const masked = atom({ plugin: 'secret-shield', key: 'masked' }, [])
21
22// Files whose job is to hold secrets, so one found there is expected. An example
23// or template copy is meant to be committed, so one found there gets ⚠.
24const SECRET_FILE = /(?:^|[/\\])(?:\.env(?!\.(?:example|sample|template|dist)$)(?:\.[\w-]+)?|\.dev\.vars|\.npmrc|\.pypirc|\.netrc|\.pgpass|\.git-credentials|credentials(?:\.[\w-]+)?|secrets?(?:\.[\w-]+)?|id_(?:rsa|dsa|ecdsa|ed25519)|[\w.-]+\.(?:pem|key|p12|pfx|jks|keystore))$/i
25// MCP tools that hand out a secret by design, such as Neon's get_connection_string
26const SECRET_TOOL = /credential|connection_string|secret|token|password|api_?key/i
27
28// Whether a secret is expected where a tool call found it
29function expected(e, spot) {
30  if (spot.file) return SECRET_FILE.test(spot.file)
31  const path = e.file_path ?? e.notebook_path ?? e.path
32  if (path) return SECRET_FILE.test(path)
33  if (e.tool === 'Bash') return /^\s*(?:env|printenv)\b/.test(e.command) || e.command.split(/[\s;|&<>'"=]+/).some((word) => SECRET_FILE.test(word))
34  return e.tool.startsWith('mcp__') && SECRET_TOOL.test(e.tool)
35}
36
37const oneLine = (text) => {
38  const line = String(text).replace(/\s+/g, ' ').trim()
39  return line.length > 70 ? line.slice(0, 69) + '…' : line
40}
41
42// Where a tool's output came from, in one line; a long path keeps its end, the
43// file's name, and a command is masked as well
44function where(e) {
45  const path = e.file_path ?? e.notebook_path
46  if (path) return e.tool + ' ' + (path.length > 60 ? '…' + path.slice(-59) : path)
47  const detail = e.url ?? (typeof e.command === 'string' ? maskText(e.command, {}) : e.pattern !== undefined ? e.pattern + (e.path ? ' ' + e.path : '') : '')
48  return oneLine(e.tool + ' ' + detail)
49}
50
51// One hit as a line: 第 3 行  ECPAY_HASH_KEY  env-secret
52const spotText = (spot) => [spot.file ? spot.file + ':' + spot.fileLine : '第 ' + spot.line + ' 行', spot.name, spot.kind].filter(Boolean).join('  ')
53
54// Put a place last in the session's list, once; true when it needs a look and hadn't yet
55async function remember($, place, spots, warn) {
56  const isNew = warn && !(await read($, masked)).some((one) => one.warn && one.where === place)
57  await update($, masked, (list) => [...list.filter((one) => one.where !== place), { where: place, warn, spots: spots.slice(0, 20) }].slice(-100))
58  return isNew
59}
60
61// A transcript line, the session's list, and the list opened the first time a place needs a look
62async function report($, hits, place, spots, warn) {
63  $.ui.log((warn ? '⚠ ' : '') + '已遮蔽 ' + describe(hits) + '(' + place + ')' + (warn ? ',/shield 看位置' : ''))
64  if (!(await remember($, place, spots, warn))) return
65  // Opened unasked, a narrow terminal waits to draw it, so say where to look. A
66  // toast is a small box under the mod's name: keep it short, the place is in the line above
67  const opened = await $.ui.open(OPEN)
68  if (!opened.isPlaced) $.ui.toast('⚠ 密鑰出現在不該有的地方,輸入 /shield 查看')
69}
70
71const quietly = (work) => work.catch(() => undefined)
72
73async function shieldToolCall($, e, next) {
74  // A mask written back into a file would replace the real secret
75  if (JSON.stringify(e).includes(MARKER)) {
76    return { deny: 'This call contains a secret-shield mask (' + MARKER + '...]]). Running it would overwrite the real secret with the mask. Leave the lines that hold secrets unchanged, or ask the user to edit them.' }
77  }
78  const out = await next(e)
79  const hits = {}
80  const spots = []
81
82  // A refusal or an error reaches Claude as text, so mask that text
83  if (out.deny !== undefined || out.isError) {
84    const text = maskText(out.deny ?? out.text ?? String(out.result ?? ''), hits, spots)
85    if (total(hits) === 0) return out
86    await quietly(report($, hits, where(e) + ' 的錯誤訊息', spots, spots.some((spot) => !expected(e, spot))))
87    return { deny: text }
88  }
89
90  const result = maskValue(out.result, hits, spots)
91  const context = out.context?.map((c) => maskText(c, hits, spots))
92  if (total(hits) === 0) return out
93  // A read from line 40 counts its lines from there
94  const startLine = e.tool === 'Read' ? out.result?.file?.startLine ?? 1 : 1
95  for (const spot of spots) spot.line += startLine - 1
96  await quietly(report($, hits, where(e), spots, spots.some((spot) => !expected(e, spot))))
97  // Without core's ref, Claude Code maps the masked record for Claude afresh
98  return context ? { result, context } : { result }
99}
100
101async function shieldPrompt($, e, next) {
102  const hits = {}
103  const spots = []
104  const text = maskText(e.text, hits, spots)
105  const context = e.context?.map((c) => maskText(c, hits, spots))
106  if (total(hits) === 0) return next(e)
107  $.ui.toast('secret-shield:你的訊息裡有 ' + describe(hits) + ',已遮蔽後才送出')
108  await quietly(remember($, '你的訊息', spots, false))
109  return next(context ? { ...e, text, context } : { ...e, text })
110}
111
112async function shieldAttachment($, e, next) {
113  const hits = {}
114  const spots = []
115  const text = maskText(e.text, hits, spots)
116  if (total(hits) === 0) return next(e)
117  // A file you @-mentioned is the engine's; a settings hook's or plugin's text has no business holding one
118  await quietly(report($, hits, '附加內容 ' + e.type, spots, e.origin?.kind !== 'engine'))
119  return next({ ...e, text })
120}
121
122async function shieldContext($, e, next) {
123  const hits = {}
124  const spots = []
125  const blocks = e.blocks.map((b) => ({ ...b, text: maskText(b.text, hits, spots) }))
126  if (total(hits) === 0) return next(e)
127  await quietly(report($, hits, '開場內容,例如 CLAUDE.md', spots, true))
128  // The rewritten CLAUDE.md text no longer matches the file list, so leave the list out
129  const { instructionFiles, ...rest } = e
130  return next({ ...rest, blocks })
131}
132
133// The last check: every row the conversation keeps, before it's stored and sent.
134// It catches what the hooks above don't see, such as a skill's text or a hook's
135// added context. Claude's own replies are left out, since Claude can't repeat a
136// secret it never received.
137const ROW_DOORS = ['prompt', 'command', 'tool-result', 'tool-message', 'delivery', 'attachment', 'hook-context', 'note', 'compaction', 'notice']
138
139async function shieldRow($, e, next) {
140  const hits = {}
141  const spots = []
142  const content = maskValue(e.message.content, hits, spots)
143  if (total(hits) === 0) return next(e)
144  await quietly(report($, hits, '對話紀錄 ' + e.door, spots, true))
145  return next({ ...e, message: { ...e.message, content } })
146}
147
148// A row's text replaced by a notice, keeping each tool result's block so the
149// conversation still pairs every tool call with a result
150const WITHHELD_ROW = '[secret-shield withheld this content because it could not be checked for secrets]'
151function withhold(content) {
152  return content.map((block) => {
153    if (block.type === 'text') return { ...block, text: WITHHELD_ROW }
154    if (block.type === 'tool_result') return { ...block, content: [{ type: 'text', text: WITHHELD_ROW }] }
155    return block
156  })
157}
158
159// The session's list, newest first, ⚠ places in amber; values are never in it
160async function drawList($, e) {
161  const { Box, Text } = $.ui.resolve(e)
162  const list = await read($, masked)
163  const lines = []
164  for (const one of [...list].reverse()) {
165    lines.push(h(Text, one.warn ? { color: '#fbbf24', bold: true } : {}, (one.warn ? '⚠ ' : '  ') + one.where))
166    for (const spot of one.spots.slice(0, 5)) lines.push(h(Text, one.warn ? {} : { dimColor: true }, '    ' + spotText(spot)))
167    if (one.spots.length > 5) lines.push(h(Text, { dimColor: true }, '    …還有 ' + (one.spots.length - 5) + ' 個'))
168  }
169  const warns = list.filter((one) => one.warn).length
170  return h(
171    Box,
172    { flexDirection: 'column' },
173    h(Text, { bold: true }, list.length ? '這個 session 遮蔽了 ' + list.length + ' 處' + (warns ? ',' + warns + ' 處要注意' : '') + '(不顯示值)' : '這個 session 還沒有遮蔽任何東西'),
174    h(Text, { dimColor: true }, '⚠ = 密鑰出現在不該有的地方:原始碼、log、網頁、MCP 回應、CLAUDE.md'),
175    ...lines.slice(0, 80),
176    h(Text, { dimColor: true }, 'Esc 關閉 · /shield 再打開'),
177  )
178}
179
180export function register(on) {
181  on('tool.call', shieldToolCall).catch(async ($, e, next) => {
182    return { deny: WITHHELD + ' (' + next.error.kind + ')' }
183  })
184
185  on('prompt.submit', shieldPrompt).catch(async ($, e, next) => {
186    // Once the prompt was passed on, it went in masked; otherwise keep it from going in
187    return next.called ? next(e) : { drop: 'secret-shield 無法檢查這則訊息,所以沒有送出。' }
188  })
189
190  on('prompt.attachment', shieldAttachment).catch(async () => {
191    return { text: null }
192  })
193
194  on('prompt.context', shieldContext).catch(async ($, e, next) => {
195    // Send the conversation without the blocks that couldn't be checked
196    return { blocks: e.blocks.filter((b) => b.name === 'currentDate') }
197  })
198
199  on('session.append', { door: ROW_DOORS }, shieldRow).catch(async ($, e, next) => {
200    // Once the row was passed on, it went in masked; otherwise store it withheld
201    return next.called ? next(e) : next({ ...e, message: { ...e.message, content: withhold(e.message.content) } })
202  })
203
204  on('session.start', async ($, e, next) => {
205    await $.command.register({ name: 'shield', description: '這個 session 遮蔽了什麼、在哪裡(不顯示值)' })
206    return next(e)
207  })
208
209  on('command.run', { command: 'shield' }, async ($) => {
210    const list = await read($, masked)
211    await $.ui.open(OPEN)
212    const warns = list.filter((one) => one.warn).length
213    return { text: list.length ? '這個 session 遮蔽了 ' + list.length + ' 處' + (warns ? ',' + warns + ' 處要注意(⚠)' : '') : '這個 session 還沒有遮蔽任何東西' }
214  })
215
216  on('ui.render', { component: 'Pane', requestId: PANE }, drawList)
217
218  // A 🛡 after the spinner's word while Claude works shows the shield is running.
219  // If it's missing, this mod didn't load and nothing is being masked.
220  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
221    return next({ ...e, props: { ...e.props, suffix: (e.props.suffix ?? '') + ' 🛡' } })
222  })
223}
224
hooks/redact.js 141 lines
1// Finds secrets in text and replaces each with a mask such as [[redacted:github-token]].
2// Pure functions with no mods API calls, so tests can run them directly.
3
4// Every mask starts with this, so a call that would write a mask back into a file
5// can be recognized. Built from two parts so this file never contains it whole,
6// which would make edits to this file look like a mask being written back.
7export const MARKER = '[[' + 'redacted:'
8const mask = (kind) => MARKER + kind + ']]'
9
10// Name parts that mark a value as a secret, such as API_KEY, hashKey, or clientSecret.
11// The lookahead stops at the end of the word, so "tokenizer" doesn't count.
12const NAME = '(?:secret[_-]?key|secret|token|passw(?:or)?d|pwd|api[_-]?key|apikey|hash[_-]?key|hash[_-]?iv|private[_-]?key|access[_-]?key|auth[_-]?key|credentials?)s?(?![a-z])'
13const ENV_NAME = '(?:SECRET_?KEY|SECRET|TOKEN|PASSW(?:OR)?D|PWD|API_?KEY|APIKEY|HASH_?KEY|HASH_?IV|PRIVATE_?KEY|ACCESS_?KEY|AUTH_?KEY|CREDENTIALS?)S?(?![A-Za-z])'
14
15// A value that says where a secret lives, rather than being the secret
16const REFERENCE = /^\$|\$\{|process\.env|os\.environ|getenv|\[\[redacted:/
17// A value that is clearly a placeholder, a type, or a setting rather than a secret
18const NOT_SECRET = /^(?:x+|\*+|\.+|<[^>]*>|your[-_ ].*|changeme|change[-_]me|example.*|dummy.*|placeholder.*|null|none|nil|undefined|true|false|string|number|required|optional|\d+|https?:\/\/.*)$/i
19
20const isSecret = (v) => !REFERENCE.test(v) && !NOT_SECRET.test(v)
21// Code such as settings.API_KEY or getKey() on the right of an assignment
22const isCode = (v) => /^[A-Za-z_]\w*(?:\.\w+)+$/.test(v) || /[()[\]{}]/.test(v)
23
24// Each rule's regex names the secret part (?<secret>); the text around it is kept.
25// Quantifiers on names are bounded so long runs of letters can't make a rule slow.
26const RULES = [
27  // A PEM private key, through its END line, or to the end of text that was cut off
28  { kind: 'private-key', re: /(?<secret>-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----|[\s\S]*$))/g },
29
30  // Tokens with a recognizable prefix
31  { kind: 'anthropic-key', re: /(?<secret>\bsk-ant-[A-Za-z0-9_-]{20,})/g },
32  { kind: 'openai-key', re: /(?<secret>\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,})/g },
33  { kind: 'stripe-key', re: /(?<secret>\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,})/g },
34  { kind: 'stripe-webhook-secret', re: /(?<secret>\bwhsec_[A-Za-z0-9+/=]{20,})/g },
35  { kind: 'github-token', re: /(?<secret>\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}))/g },
36  { kind: 'gitlab-token', re: /(?<secret>\bglpat-[A-Za-z0-9_-]{20,})/g },
37  { kind: 'slack-token', re: /(?<secret>\bxox[abposr]-[A-Za-z0-9-]{10,})/g },
38  { kind: 'slack-webhook', re: /(?<before>https:\/\/hooks\.slack\.com\/services\/)(?<secret>[A-Za-z0-9_/-]{20,})/g },
39  { kind: 'aws-access-key-id', re: /(?<secret>\b(?:AKIA|ASIA)[0-9A-Z]{16}\b)/g },
40  { kind: 'google-api-key', re: /(?<secret>\bAIza[0-9A-Za-z_-]{35})/g },
41  { kind: 'npm-token', re: /(?<secret>\bnpm_[A-Za-z0-9]{36})/g },
42  { kind: 'neon-api-key', re: /(?<secret>\bnapi_[A-Za-z0-9]{30,})/g },
43  { kind: 'notion-token', re: /(?<secret>\b(?:secret_|ntn_)[A-Za-z0-9]{40,})/g },
44  { kind: 'clickup-token', re: /(?<secret>\bpk_\d{3,}_[A-Z0-9]{20,})/g },
45  { kind: 'jwt', re: /(?<secret>\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g },
46  { kind: 'bearer-token', re: /(?<before>\b(?:Bearer|Basic)[ \t]+)(?<secret>[A-Za-z0-9._~+/=-]{16,})/g },
47
48  // The password in a URL such as postgres://user:REDACTED@host
49  { kind: 'url-password', re: /(?<before>\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:/?#@]{1,100}:)(?<secret>[^\s@/]{3,200})(?=@)/gi, check: isSecret },
50
51  // An environment-style line: API_KEY=..., export DB_PASSWORD="...", - POSTGRES_PASSWORD: ...
52  { kind: 'env-secret', re: new RegExp('(?<before>^[ \\t]*(?:export[ \\t]+|-[ \\t]*)?[A-Z0-9_]{0,40}' + ENV_NAME + '[A-Z0-9_]{0,40}[ \\t]*[=:][ \\t]*)(?<q>["\']?)(?<secret>[^\\s"\'#]{4,})\\k<q>', 'gm'), check: (v) => isSecret(v) && !isCode(v) },
53
54  // A quoted value given to a secret-looking name: "HashKey": "...", $apiKey = '...', token: `...`
55  { kind: 'secret-value', re: new RegExp('(?<before>[\\w$.-]{0,40}' + NAME + '[\\w.-]{0,40}["\'`]?[ \\t]*(?::=|=>|:|=)[ \\t]*)(?<q>["\'`])(?<secret>[^"\'`\\s]{6,})\\k<q>', 'gi'), check: isSecret },
56
57  // An unquoted YAML or INI value with letters and digits: password: hunter2abc
58  { kind: 'secret-value', re: new RegExp('(?<before>^[ \\t]*-?[ \\t]*[\\w.-]{0,40}' + NAME + '[\\w.-]{0,40}[ \\t]*[:=][ \\t]*)(?<secret>[^\\s"\'#,;{}()[\\]]{8,})(?=[ \\t]*$)', 'gim'), check: (v) => isSecret(v) && /\d/.test(v) && /[A-Za-z]/.test(v) },
59]
60
61// Mask every secret in a string, adding one to hits[kind] for each. Given an
62// array `found`, also add where each one was (see locate), never its value.
63export function maskText(text, hits, found) {
64  let out = text
65  // Masks hold no line breaks, so only a private key block, masked first, takes
66  // lines away: each fold says after which line and how many, so later hits
67  // still get their line in the text as given
68  const folds = []
69  for (const rule of RULES) {
70    let from = 0
71    let line = 1
72    const pass = []
73    out = out.replace(rule.re, (...args) => {
74      const groups = args[args.length - 1]
75      if (rule.check && !rule.check(groups.secret)) return args[0]
76      hits[rule.kind] = (hits[rule.kind] ?? 0) + 1
77      if (found) {
78        const offset = args[args.length - 3]
79        const src = args[args.length - 2]
80        line += breaks(src, from, offset)
81        from = offset
82        const shift = folds.reduce((n, fold) => n + (fold.line < line ? fold.lost : 0), 0)
83        found.push(locate(rule.kind, groups.before, src, offset, line + shift))
84        const lost = breaks(args[0], 0, args[0].length)
85        if (lost) pass.push({ line: line - pass.reduce((n, fold) => n + fold.lost, 0), lost })
86      }
87      const q = groups.q ?? ''
88      return (groups.before ?? '') + q + mask(rule.kind) + q
89    })
90    folds.push(...pass)
91  }
92  return out
93}
94
95function breaks(text, from, to) {
96  let n = 0
97  for (let i = text.indexOf('\n', from); i !== -1 && i < to; i = text.indexOf('\n', i + 1)) n++
98  return n
99}
100
101// Where a hit was: its kind and line, the name it was given on its line
102// (API_KEY=, "HashKey": , postgres://app:), and for grep-style output
103// (src/pay.ts:12:...) the file and line
104function locate(kind, before, text, offset, line) {
105  const spot = { kind, line }
106  const start = text.lastIndexOf('\n', offset - 1) + 1
107  const lead = text.slice(Math.max(start, offset - 300), offset) + (before ?? '')
108  const name = /([\w$.-]+(?::\/\/[\w.-]+)?)["'`]?\s*(?::=|=>|[:=])\s*["'`]?$/.exec(lead)?.[1]
109  if (name) spot.name = name.slice(-60)
110  const grep = /^([^\s:]*[./][^\s:]*):(\d+)[:-]/.exec(text.slice(start, start + 300))
111  if (grep) {
112    spot.file = grep[1]
113    spot.fileLine = Number(grep[2])
114  }
115  return spot
116}
117
118// Binary content, such as an image's bytes, sits in these fields and is left alone
119function isBinaryField(obj, key) {
120  return key === 'base64' || (key === 'data' && ('media_type' in obj || 'mimeType' in obj || obj.type === 'base64'))
121}
122
123// Mask every string inside a value, keeping its shape so the tool's schema still fits
124export function maskValue(value, hits, found) {
125  if (typeof value === 'string') return maskText(value, hits, found)
126  if (Array.isArray(value)) return value.map((item) => maskValue(item, hits, found))
127  if (value && typeof value === 'object') {
128    const out = {}
129    for (const [key, item] of Object.entries(value)) {
130      out[key] = typeof item === 'string' && isBinaryField(value, key) ? item : maskValue(item, hits, found)
131    }
132    return out
133  }
134  return value
135}
136
137export const total = (hits) => Object.values(hits).reduce((sum, n) => sum + n, 0)
138
139// A summary that names the kinds and counts, never the values
140export const describe = (hits) => Object.entries(hits).map(([kind, n]) => n + ' 個 ' + kind).join('、')
141
types/index.d.ts 14 lines
1// What /shield lists, held in $.state for the session: where, never the value
2
3// One hit: its kind and line, the name it was given, and for grep-style output the file and line it names
4export type Spot = { kind: string; line: number; name?: string; file?: string; fileLine?: number }
5
6// One place something was masked; warn when a secret has no business being there (⚠)
7export type Masked = { where: string; warn: boolean; spots: Spot[] }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'secret-shield': { masked: Masked[] }
12  }
13}
14