SLOPSHOPPER

secret-scrubber

Redacts API keys, tokens, passwords, .env values and private keys from tool output, subagent output and @-mentioned files before Claude reads them, and counts…

newguardtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-scrubber
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-scrubber │ ⏺ Read(src/auth.ts) │ secret-scrubber: redacted 2 from Bash │ ⎿ Read 6 lines │ output (env-value) │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-scrubber: 🔒 2 secrets redacted
README

guardrail-mods

Four Claude Code mods that hold Claude to your project's rules while it works. Each mod is its own plugin under mods/, written in TypeScript, and the repository is a plugin marketplace named guardrail-mods.

ModWhat it doesEvent it hooks
wcag-enforcerAfter a Write, Edit or shell command changes a .tsx, .jsx, .html, .vue or .css file, runs automated WCAG checks (eslint-plugin-jsx-a11y, axe-core, and a WCAG 2.2 AA text contrast check) and hands Claude each failure with file, line and ruletool.call (after Write, Edit and Bash run), classic.Stop
design-guardrailBefore a Write or Edit to a UI file, blocks hardcoded hex/rgb/hsl colors and px spacing and font values, naming the design token to use; refuses shell commands that write UI files and checks what other commands changedtool.call (before Write and Edit, around Bash)
qa-gateWhen Claude tries to finish a turn after changing code (by any tool, the shell included), runs the lint, type check, spell check and test scripts in package.json and keeps Claude working until they passtool.call, classic.Stop
secret-scrubberRedacts API keys, tokens, passwords, .env values and private keys from tool output, subagent output and @-mentioned files before Claude reads them, with a running count in the status linesession.append (tool results), prompt.attachment, prompt.mention, tool.call

Built and type-checked against the declarations of Claude Code 2.1.296, using only events and APIs that build declares. Mods need Claude Code 2.1.287 or later; qa-gate's package manager and scope pickers need 2.1.271 or later.

What wcag-enforcer is, and isn't. It runs automated WCAG checks on the code Claude writes. That is not a WCAG compliance audit: static checks like these catch only part of WCAG (often put at around a third of issues), and nothing here tests keyboard operation, focus order, screen reader output or how content reads in context. Keep testing those by hand and with assistive technology.

Install

Mods are code that runs with your permissions. Read them first: claude plugin validate mods/<mod> lists every event a mod hooks and every call it makes.

In a Claude Code session in your terminal, add the marketplace once and install the mods you want:

/plugin marketplace add WasteOfADrumBum/one-small-mods-pack
/plugin install wcag-enforcer@guardrail-mods
/plugin install design-guardrail@guardrail-mods
/plugin install qa-gate@guardrail-mods
/plugin install secret-scrubber@guardrail-mods

Or install one mod in a single line, answering y to add the marketplace and then picking a scope:

/plugin install secret-scrubber --marketplace WasteOfADrumBum/one-small-mods-pack

From your shell instead: claude plugin marketplace add WasteOfADrumBum/one-small-mods-pack, then claude plugin install <mod>@guardrail-mods, then /reload-plugins in any open session.

To try a mod from a clone without installing it, start Claude Code with claude --plugin-dir ./mods/<mod> (repeat the flag for several).

What each mod needs in your project

ModNeeds
wcag-enforcerFor components: ESLint with eslint-plugin-jsx-a11y (or eslint-plugin-vuejs-accessibility for .vue) turned on in your ESLint config. For HTML: npm i -D axe-core jsdom. The contrast check needs nothing. A check whose tool is missing is skipped, and the status line keeps saying so (♿ eslint not running) for as long as it is, so it never reads as a pass.
design-guardrailA tokens file, design-tokens.json by default. Without one it stands aside, and the status line says so (🎨 design-guardrail idle: no tokens file at design-tokens.json).
qa-gateA package.json with any of the scripts it looks for. The status line names the checks that have no script (✅ qa-gate: 2/2 passed · no spellcheck, test script).
secret-scrubberNothing.

Configure

Every mod has two layers of settings, and the second wins:

  1. Plugin options, set in /config or when you install, stored under pluginConfigs in your settings: enabled (on/off), configFile (the path of layer 2), and each mod's own path options (tokensFile, packageJson, patternsFile, ...).
  2. A project config file, .claude/guardrail-mods/<mod>.json by default, checked in with the project so the whole team gets the same rules. It takes every option plus the lists. Each mod's config.example.json shows every key.
// .claude/guardrail-mods/design-guardrail.json
{ "tokensFile": "src/styles/tokens.css", "ignore": ["**/*.stories.tsx"] }
ModOptions (/config)Extra keys in the project file
wcag-enforcerenabled, configFile, extensions, eslintCommand, blockStopUntilFixedeslint, eslintRulePrefixes, axe, nodeCommand, contrast, watchBash, timeoutMs
design-guardrailenabled, configFile, tokensFile, extensions, ignore, allowallowSvgColors, blockBashWrites, watchBash, maxReported
qa-gateenabled, configFile, packageJson, packageManager, maxAttempts, scope, timeoutSecondschecks, scripts, codeExtensions, testArgs, watchBash
secret-scrubberenabled, configFile, patternsFile, showToastdisabledRules, patterns

Demos

wcag-enforcer

Claude writes Card.tsx with and note.css with .note { color: #aaaaaa; background: #ffffff; }. Each tool result comes back with a note Claude reads before its next step:

wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in src/Card.tsx. Fix every one before moving on:
- src/Card.tsx:3:5 [jsx-a11y/alt-text] img elements must have an alt prop, either with meaningful text, or an empty string for decorative images.

wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in note.css. Fix every one before moving on:
- note.css:1 [wcag/contrast-minimum] text #aaaaaa on #ffffff has contrast 2.32:1; WCAG 2.2 AA (1.4.3) needs 4.5:1

The status line shows ♿ 2 WCAG failures in 2 files until they're fixed. If Claude tries to finish with failures still open, the mod sends it back with the list (twice at most, then it lets the turn end with a toast). In a test run, Claude changed the color to #767676 (4.54:1) on its own.

Files changed through the shell are checked too: the ones a command names as written (redirects, tee, sed -i, cp/mv targets, curl -o) and, in a git repository, any UI file whose change time moved while the command ran, so a code generator's output is covered. Set watchBash to false to skip that.

HTML files go through axe-core in jsdom (mods/wcag-enforcer/scripts/axe-check.mjs), reported as [axe/image-alt], [axe/html-has-lang] and so on, with line numbers. jsdom does no layout, so axe's own contrast rule is off and the mod's contrast check covers it: literal color pairs in CSS rules, style="" attributes and JSX style objects, with var(--x) resolved when the file defines --x, and 3:1 for large text.

design-guardrail

With this design-tokens.json:

{ "color": { "brand": { "$value": "#3366ff" } }, "space": { "3": "12px" } }

Claude tries to write .card { color: #3366ff; padding: 13px; }. The Write is refused, and Claude reads:

design-guardrail: this Write to src/card.css hardcodes 2 values that should be design tokens (from design-tokens.json):
- line 1: `#3366ff` → use `color.brand` (`var(--color-brand)`).
- line 1: padding `13px` is not a token value → use the nearest token, `space.3` (`var(--space-3)`) = 12px.
Replace each with the token named and retry. A value that truly must stay can be kept with a `guardrail-ignore` comment on its line.

It retries with .card { color: var(--color-brand); padding: var(--space-3); }. The tokens file can be JSON (nested, W3C $value or Style Dictionary value leaves) or a CSS, SCSS or Less file of variables. It catches hex, rgb(), hsl(), px in spacing and font properties, React style numbers (marginTop: 12) and Tailwind arbitrary values (p-[10px], text-[#f00]). An Edit is judged only on the values it adds, so legacy values already in a file don't block unrelated edits.

What it leaves alone, each covered by a test: borders and other properties outside spacing and type (border: 1px solid ..., box-shadow, width: 24px), 0 and 0px, media query breakpoints (@media (min-width: 768px)), SVG attributes (width="24", stroke-width, and paint colors like fill="#111827" unless allowSvgColors is false), var() references and their fallbacks, token definitions themselves, and any line marked guardrail-ignore. Add values to allow (such as 1px) to let them through everywhere.

The shell can't sidestep it. A command that writes a UI file directly (echo ... > a.css, tee, sed -i, perl -pi, curl -o) is refused with a note to use Write or Edit. Any other command that changes a UI file (a generator script, say) runs, and in a git repository the values it added since the last commit come back to Claude as a note to fix with Edit. blockBashWrites and watchBash turn those off.

qa-gate

Claude edits src/a.ts and tries to finish. The gate runs npm run --silent lint, typecheck, spellcheck and test (whichever exist; npm's placeholder test is skipped) with CI=true, and when one fails Claude reads:

qa-gate: 1 check failed after your code changes. Fix the errors below, then finish:

### typecheck (`npm run --silent typecheck` exited 2)

src/a.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.

The status line tracks it: 🧪 qa-gate: running typecheck…, then ❌ qa-gate: typecheck failed or ✅ qa-gate: 3/3 passed. A turn that changed no code is never held, and code changed through the shell counts (what a command names as written, and in a git repository what changed while it ran). After maxAttempts holds in a row (3 by default) it lets the turn end with a toast. pnpm, yarn and bun are picked from the lockfile.

For bigger repositories:

  • timeoutSeconds (300 by default) stops a script that runs too long and tells Claude it timed out.
  • scope: "changed" passes only the files Claude changed to the lint and spell check scripts (npm run --silent lint -- src/a.ts). Tests get them too once you set testArgs to your runner's flag, such as ["--findRelatedTests"] for Jest; otherwise the full suite runs. The type check always runs on the whole project, since it can't be judged file by file.

secret-scrubber

Claude reads .env.local (with Read, cat, or an @.env.local mention in your prompt), then runs a script that prints a token. What Claude reads is:

PORT=[REDACTED:env-value]
DATABASE_URL=[REDACTED:env-value]
Using token [REDACTED:github-token] for api.github.com

The status line keeps a running count (🔒 3 secrets redacted) and a toast says what was caught. It covers private key blocks, JWTs, GitHub, Google (API keys, OAuth tokens, client secrets), AWS (key IDs and secret keys), Vercel, Todoist, Slack, Stripe, Anthropic and OpenAI keys, Bearer tokens, credentials in URLs, every value of a .env file Claude reads, and generic password=, api_key:, "secret": "..." pairs whose value looks like a secret (code like token = getToken() is left alone). Add your own with patterns.

Where it scrubs: every tool result before it's stored (in the main conversation and in every subagent's, so a subagent never reads the secret and its report back to Claude is scrubbed too), and every attachment as a request carries it, which covers files you @-mention, files changed outside Claude that it's shown, and settings hook output. Your terminal still shows the real output; only what Claude reads is scrubbed. If the scan itself fails, the output is withheld from Claude rather than passed through.

How they fail

  • design-guardrail fails open: if the guard itself errors, the edit goes ahead.
  • secret-scrubber fails closed: if scrubbing errors, Claude gets a placeholder instead of the output.
  • wcag-enforcer and qa-gate never hold a turn forever (two and maxAttempts holds in a row).

Develop

npm run types       # copy your Claude Code build's type declarations into types/ (see below)
npm run typecheck   # tsc against them
npm run validate    # claude plugin validate on the marketplace and each mod
npm test            # claude plugin test in each mod

The type declarations (claude-code.d.ts) are Anthropic's and are not redistributed here (Claude Code's license reserves all rights), so types/ is git-ignored. Claude Code writes them for your build beside a mod it loads from a folder it watches: start claude --plugin-dir mods/qa-gate once, then run npm run types, which copies them from mods/*/.claude-plugin/types/ into types/claude-code.d.ts. You can also pass a path: node scripts/link-types.mjs /path/to/claude-code.d.ts.

Each mod's tests live in mods/<mod>/tests/ and use claude-code/testing: they raise the events the mod hooks and stub every file, process and display call beneath it, covering pass and fail cases. claude plugin validate and claude plugin test need no types.

License

MIT

Source 3 files
hooks/register.ts 161 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { asBool, asList, asString, parseConfigText, pick } from './config'
4import type { RawConfig } from './config'
5import { BUILTIN_RULES, scrub, userRules } from './patterns'
6import type { SecretRule } from './patterns'
7
8type Block = { type: string; [field: string]: unknown }
9
10type Settings = {
11  enabled: boolean
12  showToast: boolean
13  rules: SecretRule[]
14}
15
16// A .env file, but not the checked-in examples beside it.
17const ENV_FILE = /(^|[\\/])\.env(\.(?!example\b|sample\b|template\b|dist\b)[\w-]+)?$/
18const ENV_IN_COMMAND = /(^|[\s'"=\\/])\.env(\.(?!example\b|sample\b|template\b|dist\b)[\w-]+)?(?=$|[\s'";|&)])/
19
20/** Reads a JSON config file of the project; a missing or broken one is empty. */
21async function readConfigFile($: EngineInterface, path: string): Promise<RawConfig> {
22  if (path.trim() === '' || !(await $.fs.exists(path))) return {}
23  const parsed = parseConfigText(path, await $.fs.read(path))
24  if (typeof parsed === 'string') {
25    $.ui.log(parsed)
26    return {}
27  }
28  return parsed
29}
30
31async function load($: EngineInterface, options: PluginOptions): Promise<Settings> {
32  const configFile = asString(options.configFile) ?? '.claude/guardrail-mods/secret-scrubber.json'
33  const file = await readConfigFile($, configFile)
34  const log = (message: string) => $.ui.log(message)
35
36  const disabled = new Set(pick(file, options, 'disabledRules', [] as string[], asList))
37  const rules = BUILTIN_RULES.filter(rule => !disabled.has(rule.name))
38  rules.push(...userRules(file.patterns, log))
39
40  const patternsFile = pick(file, options, 'patternsFile', '', asString)
41  if (patternsFile !== '') {
42    const extra = await readConfigFile($, patternsFile)
43    rules.push(...userRules(extra.patterns, log))
44  }
45
46  return {
47    enabled: pick(file, options, 'enabled', true, asBool),
48    showToast: pick(file, options, 'showToast', true, asBool),
49    rules,
50  }
51}
52
53/** Scrubs a tool_result block's text, whether its content is a string or blocks. */
54function scrubBlock(block: Block, rules: readonly SecretRule[], isEnv: boolean) {
55  let count = 0
56  const kinds: string[] = []
57  const take = (text: string) => {
58    const done = scrub(text, rules, isEnv)
59    count += done.count
60    kinds.push(...done.kinds)
61    return done.text
62  }
63
64  if (block.type === 'text' && typeof block.text === 'string') {
65    return { block: { ...block, text: take(block.text) }, count, kinds }
66  }
67  if (block.type !== 'tool_result') return { block, count, kinds }
68
69  const { content } = block
70  if (typeof content === 'string') return { block: { ...block, content: take(content) }, count, kinds }
71  if (Array.isArray(content)) {
72    const next = content.map((inner: unknown) => {
73      const item = inner as Block
74      return item.type === 'text' && typeof item.text === 'string' ? { ...item, text: take(item.text) } : item
75    })
76    return { block: { ...block, content: next }, count, kinds }
77  }
78  return { block, count, kinds }
79}
80
81// Secrets redacted this session, shown in the status line.
82let total = 0
83
84function announce($: EngineInterface, settings: Settings, count: number, kinds: Iterable<string>, source: string) {
85  total += count
86  $.ui.status(`🔒 ${total} secret${total === 1 ? '' : 's'} redacted`)
87  if (settings.showToast) $.ui.toast(`secret-scrubber: redacted ${count} from ${source} (${[...kinds].join(', ')})`)
88}
89
90export const register: Register = (on, options) => {
91  // Tool calls whose output is the text of a .env file, by tool_use_id
92  const envCalls = new Set<string>()
93
94  on('tool.call', ($, e, next) => {
95    const isEnv =
96      (e.tool === 'Read' && ENV_FILE.test(e.file_path)) ||
97      (e.tool === 'Bash' && ENV_IN_COMMAND.test(e.command))
98    if (isEnv) envCalls.add(e.tool_use_id)
99    return next(e)
100  })
101
102  on('session.append', { door: 'tool-result' }, async ($, e, next) => {
103    const settings = await load($, options)
104    if (!settings.enabled) return next(e)
105
106    let count = 0
107    const kinds = new Set<string>()
108    const content = e.message.content.map(raw => {
109      const block = raw as Block
110      const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : ''
111      const done = scrubBlock(block, settings.rules, envCalls.has(id))
112      envCalls.delete(id)
113      count += done.count
114      for (const kind of done.kinds) kinds.add(kind)
115      return done.block
116    })
117    if (count === 0) return next(e)
118
119    const tool = e.origin.kind === 'tool' ? e.origin.tool : 'tool'
120    announce($, settings, count, kinds, `${tool} output${e.agentId === undefined ? '' : ' in a subagent'}`)
121    return next({ ...e, message: { ...e.message, content } })
122  }).catch(($, e, next) => {
123    // Fail closed: if the scan itself failed, Claude reads no output at all.
124    if (next.called) return next(e)
125    const content = e.message.content.map(raw => {
126      const block = raw as Block
127      return block.type === 'tool_result'
128        ? { ...block, content: '[secret-scrubber could not scan this output, so it was withheld]' }
129        : block
130    })
131    return next({ ...e, message: { ...e.message, content } })
132  })
133
134  // Files the person @-mentions reach Claude as attachments, not tool
135  // results, and are rendered again for each request: scrub them there.
136  const mentionedEnv = new Set<string>()
137  const counted = new Set<string>()
138
139  on('prompt.mention', ($, e, next) => {
140    if (ENV_FILE.test(e.path)) mentionedEnv.add(e.path)
141    return next(e)
142  })
143
144  on('prompt.attachment', async ($, e, next) => {
145    const settings = await load($, options)
146    if (!settings.enabled) return next(e)
147    const isEnv = [...mentionedEnv].some(path => e.text.includes(path))
148    const done = scrub(e.text, settings.rules, isEnv)
149    if (done.count === 0) return next(e)
150    // Count each attachment once, however many requests carry it.
151    if (!counted.has(e.text)) {
152      counted.add(e.text)
153      announce($, settings, done.count, done.kinds, e.type === 'file' ? 'a mentioned file' : `the ${e.type} attachment`)
154    }
155    return next({ ...e, text: done.text })
156  }).catch(($, e, next) =>
157    // Fail closed, as for tool output.
158    next.called ? next(e) : next({ ...e, text: '[secret-scrubber could not scan this attachment, so it was withheld]' }),
159  )
160}
161
hooks/config.ts 53 lines
1// Shared by every guardrail mod (each plugin carries its own copy, since a
2// plugin may only import its own files). Settings are layered:
3//   1. the mod's built-in defaults,
4//   2. the plugin's userConfig options (/config, or pluginConfigs in settings),
5//   3. the project's JSON config file, when it exists.
6// Reading the file needs `$`, which never crosses an import, so each
7// register.ts reads it and hands the text to `parseConfigText`.
8import type { PluginOptions } from 'claude-code'
9
10export type RawConfig = Record<string, unknown>
11
12const isRecord = (value: unknown): value is RawConfig =>
13  typeof value === 'object' && value !== null && !Array.isArray(value)
14
15/** Parses a config file's text: an object, or an error message to log. */
16export function parseConfigText(path: string, text: string): RawConfig | string {
17  try {
18    const parsed: unknown = JSON.parse(text)
19    return isRecord(parsed) ? parsed : `${path} is not a JSON object; using the defaults`
20  } catch (error) {
21    return `could not parse ${path}: ${error instanceof Error ? error.message : String(error)}`
22  }
23}
24
25/** The first defined value among the file's, the option's and the default. */
26export function pick<T>(
27  file: RawConfig,
28  options: PluginOptions,
29  key: string,
30  fallback: T,
31  read: (value: unknown) => T | undefined,
32): T {
33  return read(file[key]) ?? read(options[key]) ?? fallback
34}
35
36export const asBool = (value: unknown): boolean | undefined =>
37  typeof value === 'boolean' ? value : value === 'true' ? true : value === 'false' ? false : undefined
38
39export const asString = (value: unknown): string | undefined =>
40  typeof value === 'string' ? value : undefined
41
42export const asNumber = (value: unknown): number | undefined =>
43  typeof value === 'number' && Number.isFinite(value) ? value : undefined
44
45/** A list from a JSON array, a `multiple` option, or a comma-separated string. */
46export const asList = (value: unknown): string[] | undefined => {
47  if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string')
48  if (typeof value === 'string' && value.trim() !== '') {
49    return value.split(',').map(item => item.trim()).filter(item => item !== '')
50  }
51  return undefined
52}
53
hooks/patterns.ts 157 lines
1// What counts as a secret, and how a text is scrubbed of them. Pure
2// functions, so the tests exercise them without the engine.
3
4export type SecretRule = {
5  /** Shown in the placeholder: `[REDACTED:<name>]`. */
6  name: string
7  pattern: RegExp
8  /**
9   * The capture group holding the secret itself, when the match also holds a
10   * label to keep (`password=` in `password=hunter2`); 0 (the default) is the
11   * whole match.
12   */
13  group?: number
14  /** Keeps a candidate value that is not a secret after all (code, a variable reference). */
15  accept?: (value: string) => boolean
16}
17
18// A generic key=value hit is a secret only when the value looks like one: a
19// literal with a digit or a long opaque run, never code (`getToken()`,
20// `process.env.TOKEN`, `${token}`) or a type (`token: string`).
21const looksLikeSecretValue = (value: string): boolean => {
22  if (/[()${}<>]/.test(value)) return false
23  if (/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+!?$/.test(value)) return false
24  if (/^(?:true|false|null|undefined|none|string|number|boolean|required|optional|changeme|redacted|xxx+|\*+)$/i.test(value)) return false
25  return /\d/.test(value) || value.length >= 20
26}
27
28// Names that mark the value beside them as a secret in key=value, key: value
29// and "key": "value" forms, including .env lines and JSON.
30const SECRET_NAME =
31  String.raw`[A-Za-z0-9_.-]*(?:secret|passw(?:or)?d|pwd|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?key|credential|session[_-]?key|signing[_-]?key)[A-Za-z0-9_.-]*`
32
33export const BUILTIN_RULES: readonly SecretRule[] = [
34  {
35    name: 'private-key',
36    pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g,
37  },
38  { name: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
39  { name: 'github-token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/g },
40  { name: 'google-api-key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
41  { name: 'google-oauth-token', pattern: /\bya29\.[0-9A-Za-z_-]{20,}/g },
42  { name: 'google-client-secret', pattern: /\bGOCSPX-[0-9A-Za-z_-]{20,}/g },
43  { name: 'aws-access-key-id', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AROA|ANPA|ANVA|AIPA)[0-9A-Z]{16}\b/g },
44  {
45    name: 'aws-secret-access-key',
46    pattern: /(aws_?secret_?(?:access_?)?key["']?\s*[:=]\s*["']?)([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/gi,
47    group: 2,
48  },
49  // Vercel tokens carry no fixed prefix in older formats, so they are caught
50  // by their label (VERCEL_TOKEN=..., "vercelToken": "...") as well as the
51  // newer vc*_ prefixes.
52  { name: 'vercel-token', pattern: /\bvc[a-z]_[A-Za-z0-9]{24,}\b/g },
53  {
54    name: 'vercel-token',
55    pattern: /(vercel[_-]?(?:api[_-]?)?(?:token|key)["']?\s*[:=]\s*["']?)([A-Za-z0-9_-]{20,})/gi,
56    group: 2,
57  },
58  // Todoist API tokens are 40 hex characters with no prefix: caught by label.
59  {
60    name: 'todoist-token',
61    pattern: /(todoist[_-]?(?:api[_-]?)?(?:token|key)["']?\s*[:=]\s*["']?)([0-9a-f]{40})\b/gi,
62    group: 2,
63  },
64  { name: 'slack-token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
65  { name: 'stripe-key', pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
66  { name: 'anthropic-key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
67  { name: 'openai-key', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
68  { name: 'bearer-token', pattern: /(\bBearer\s+)([A-Za-z0-9._~+/-]{20,}=*)/g, group: 2 },
69  { name: 'url-credentials', pattern: /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@/]{3,})(?=@)/gi, group: 2 },
70  {
71    name: 'generic-secret',
72    pattern: new RegExp(
73      String.raw`(["']?${SECRET_NAME}["']?\s*[:=]\s*)(?:"([^"\s]{6,})"|'([^'\s]{6,})'|([^\s"',;}{)\]]{6,}))`,
74      'gi',
75    ),
76    group: -1,
77    accept: looksLikeSecretValue,
78  },
79]
80
81// A line of a .env file: every value is treated as a secret, whatever its name.
82const ENV_LINE = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*=\s*)(.+?)\s*$/gm
83
84const placeholder = (name: string) => `[REDACTED:${name}]`
85
86const isPlaceholder = (text: string) => /^\[REDACTED:[a-z0-9-]+\]$/.test(text)
87
88export type ScrubResult = { text: string; count: number; kinds: string[] }
89
90/** Replaces every secret the rules find in `text`; `isEnvFile` also blanks each KEY=value line's value. */
91export function scrub(text: string, rules: readonly SecretRule[], isEnvFile = false): ScrubResult {
92  let count = 0
93  const kinds = new Set<string>()
94  let out = text
95
96  if (isEnvFile) {
97    out = out.replace(ENV_LINE, (whole, head: string, value: string) => {
98      if (value.trim() === '' || value.trim().startsWith('#') || isPlaceholder(value)) return whole
99      count += 1
100      kinds.add('env-value')
101      return head + placeholder('env-value')
102    })
103  }
104
105  for (const rule of rules) {
106    const pattern = new RegExp(rule.pattern.source, rule.pattern.flags.includes('g') ? rule.pattern.flags : rule.pattern.flags + 'g')
107    out = out.replace(pattern, (...args: unknown[]) => {
108      const match = args[0] as string
109      const groups = args.slice(1, -2) as (string | undefined)[]
110      if (match.includes('[REDACTED:')) return match
111      if (rule.accept !== undefined) {
112        const candidate = rule.group === -1 ? groups.slice(1, 4).find(g => g !== undefined) : rule.group ? groups[rule.group - 1] : match
113        if (candidate !== undefined && !rule.accept(candidate)) return match
114      }
115      count += 1
116      kinds.add(rule.name)
117      if (rule.group === undefined || rule.group === 0) return placeholder(rule.name)
118      if (rule.group === -1) {
119        // generic-secret: keep the label and any quotes, replace the value
120        const [label = '', doubleQuoted, singleQuoted] = groups
121        if (doubleQuoted !== undefined) return `${label}"${placeholder(rule.name)}"`
122        if (singleQuoted !== undefined) return `${label}'${placeholder(rule.name)}'`
123        return label + placeholder(rule.name)
124      }
125      const secret = groups[rule.group - 1] ?? ''
126      const label = groups.slice(0, rule.group - 1).join('')
127      return match.startsWith(label) && secret !== '' ? label + placeholder(rule.name) : placeholder(rule.name)
128    })
129  }
130
131  return { text: out, count, kinds: [...kinds] }
132}
133
134/** Builds rules from a user's `{ name, pattern, flags?, group? }` entries, skipping bad ones. */
135export function userRules(entries: unknown, onError: (message: string) => void): SecretRule[] {
136  if (!Array.isArray(entries)) return []
137  const rules: SecretRule[] = []
138  for (const entry of entries) {
139    if (typeof entry !== 'object' || entry === null) continue
140    const { name, pattern, flags, group } = entry as Record<string, unknown>
141    if (typeof name !== 'string' || typeof pattern !== 'string') {
142      onError('a custom pattern needs a string "name" and "pattern"')
143      continue
144    }
145    try {
146      rules.push({
147        name: name.toLowerCase().replace(/[^a-z0-9-]+/g, '-'),
148        pattern: new RegExp(pattern, typeof flags === 'string' ? flags : 'g'),
149        group: typeof group === 'number' ? group : undefined,
150      })
151    } catch (error) {
152      onError(`custom pattern "${name}" is not a valid regular expression: ${String(error)}`)
153    }
154  }
155  return rules
156}
157