From 2c99b49e29a8527f82f3854df4e96eeda4a02868 Mon Sep 17 00:00:00 2001 From: Karan Bansal Date: Sat, 3 Oct 2026 19:21:56 +0530 Subject: [PATCH] Add the redact mod: secrets and PII redaction at prompt.submit and session.append, restore in edits --- .claude-plugin/marketplace.json | 18 + .github/workflows/test.yml | 17 + .gitignore | 3 + LICENSE | 21 + plugins/redact/.claude-plugin/plugin.json | 31 + plugins/redact/.gitignore | 2 + plugins/redact/DECISIONS.md | 20 + plugins/redact/README.md | 70 +++ plugins/redact/RULES.md | 52 ++ plugins/redact/hooks/hooks.json | 4 + plugins/redact/hooks/register.ts | 127 +++++ plugins/redact/hooks/rules.ts | 526 +++++++++++++++++ plugins/redact/hooks/scrub.ts | 98 ++++ plugins/redact/tests/register.test.ts | 129 +++++ plugins/redact/tests/rules.test.ts | 653 ++++++++++++++++++++++ plugins/redact/tests/scrub.test.ts | 116 ++++ plugins/redact/tsconfig.json | 3 + plugins/redact/types/index.d.ts | 10 + 18 files changed, 1900 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 plugins/redact/.claude-plugin/plugin.json create mode 100644 plugins/redact/.gitignore create mode 100644 plugins/redact/DECISIONS.md create mode 100644 plugins/redact/README.md create mode 100644 plugins/redact/RULES.md create mode 100644 plugins/redact/hooks/hooks.json create mode 100644 plugins/redact/hooks/register.ts create mode 100644 plugins/redact/hooks/rules.ts create mode 100644 plugins/redact/hooks/scrub.ts create mode 100644 plugins/redact/tests/register.test.ts create mode 100644 plugins/redact/tests/rules.test.ts create mode 100644 plugins/redact/tests/scrub.test.ts create mode 100644 plugins/redact/tsconfig.json create mode 100644 plugins/redact/types/index.d.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..993e0e0 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "claude-code-redact", + "description": "A Claude Code mod that redacts secrets and PII before Claude reads them and before the transcript stores them.", + "owner": { + "name": "Karan Bansal", + "url": "https://github.com/karanb192" + }, + "plugins": [ + { + "name": "redact", + "source": "./plugins/redact", + "description": "Redacts secrets and PII from every row Claude Code stores: prompts, file reads, shell output, MCP results, attachments. Placeholders are restored only inside Edit and Write arguments, so file edits still work. Reach L0: no network, no processes, no files.", + "category": "security", + "tags": ["mods", "security", "secrets", "pii", "redaction", "dlp"] + } + ] +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d5b4948 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: test +on: + push: + branches: [main] + pull_request: +jobs: + plugin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install -g @anthropic-ai/claude-code + - run: claude --version + - run: claude plugin validate plugins/redact --strict + - run: claude plugin test plugins/redact diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3290c53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.DS_Store +plugins/*/.claude-plugin/types/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..56b1020 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Karan Bansal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/redact/.claude-plugin/plugin.json b/plugins/redact/.claude-plugin/plugin.json new file mode 100644 index 0000000..f2d54c7 --- /dev/null +++ b/plugins/redact/.claude-plugin/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "redact", + "version": "0.1.0", + "description": "Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it. Reach L0: no network, no processes, no files.", + "author": { "name": "Karan Bansal", "url": "https://github.com/karanb192" }, + "homepage": "https://redact.aidojo.si", + "repository": "https://github.com/karanb192/claude-code-redact", + "license": "MIT", + "keywords": ["mods", "security", "secrets", "pii", "redaction", "dlp"], + "types": "./types/index.d.ts", + "userConfig": { + "pii": { + "type": "boolean", + "title": "Redact PII", + "description": "Also hide email addresses, phone numbers, payment cards, SSNs, Aadhaar, PAN and IBAN. Secrets are always hidden.", + "default": false + }, + "quiet": { + "type": "boolean", + "title": "Quiet", + "description": "Hide the transcript line that reports what was redacted. /redact still shows the session total.", + "default": false + }, + "off": { + "type": "string", + "title": "Rules off", + "description": "Comma-separated rule ids to skip, for example generic-assignment,email. Ids are listed in RULES.md.", + "default": "" + } + } +} diff --git a/plugins/redact/.gitignore b/plugins/redact/.gitignore new file mode 100644 index 0000000..79bca9d --- /dev/null +++ b/plugins/redact/.gitignore @@ -0,0 +1,2 @@ +.claude-plugin/types/ +node_modules/ diff --git a/plugins/redact/DECISIONS.md b/plugins/redact/DECISIONS.md new file mode 100644 index 0000000..70303bc --- /dev/null +++ b/plugins/redact/DECISIONS.md @@ -0,0 +1,20 @@ +# Decisions: redact + +## Decisions +- Redact at `session.append`, the one event that sees every row before it is stored and sent (prompts, tool results, Claude's own blocks, attachments, notices). Rejected: rewriting results in `tool.call` after `next(e)`, because it misses prompts, attachments and plugin notes, and it leaves the transcript file raw. +- Also redact at `prompt.submit`. Rejected: relying on `session.append` alone, because the engine writes a `queue-operation` record and a command's `args` stamp from the typed text, and those are stored as made. `prompt.submit` cleans the text the screen echoes and the command args; the headless `-p` path still writes the queue record first (see Assumptions). +- Restore real values only inside `Edit`, `Write` and `NotebookEdit` arguments. Rejected: restoring in `Bash`, because the model could echo a placeholder into a network command and the real value would leave the machine. +- Placeholders are `[REDACTED:LABEL#hash]` with an FNV-1a hash of a per-session salt plus the value. Rejected: HMAC with `node:crypto`, because the validator admits no bare import other than `claude-code`. Rejected: a counter, because the same value must map to the same placeholder across rows so edits and compaction summaries line up. +- Fail closed. A row the scanner throws on is stored as `[redact withheld this content ...]` with its `tool_use_id` kept. Rejected: fail open, because a redactor that stores raw on error is a redactor that stops working silently. +- PII rules are opt-in (`pii: true`). Rejected: on by default, because email addresses and phone numbers in code and docs are common and mostly harmless, and a redactor that hides a package author's email on every read gets switched off. +- One standing note in the `env_info_simple` section tells Claude the placeholders are opaque. Rejected: no note, because Claude otherwise asks the person for the hidden value or tries to "fix" the placeholder. Rejected: `prompt.compose`, because adding a section is more surface than appending to one that is always rendered. +- The vault lives in `$.state`, not `$.store`. Rejected: `$.store`, because real values would persist across sessions on disk. +- No `turn.complete` line and no `$.ui.notice`. Rejected: a per-turn summary, because the per-row `$.ui.log` line already says what was hidden and where; `$.ui.notice` is removed when the tool call resolves, which is before the result row is appended. +- The scrub logic is a pure module (`hooks/scrub.ts`) tested directly. Rejected: testing it through `session.append` in the kit, because on Claude Code 2.1.288 a test stub for `session.append` cannot answer (every answer must follow a `next`, and nothing is beneath the stub). + +## Assumptions not verified +- In an interactive session, `prompt.submit` runs before the `queue-operation` record is written, so that record holds the placeholder. In the headless `-p` path it is written first and holds the typed text. If wrong: the typed prompt stays raw in that one bookkeeping record (never sent to the model). How to check: type a prompt with a fake key in an interactive session and grep the session file for `queue-operation`. +- Subagent rows and subagent `Edit` calls pass through the same hooks with `agentId` set, as the types say. If wrong: a subagent could read a raw secret. How to check: run a subagent that reads a file with a fake key and grep its rows in the session file. +- The restore path works against a real file edit by the model. The kit proves the argument rewrite; a live run with a logged-in session has not been done. How to check: ask Claude to move a key from `.env` to another file and confirm the new file holds the real value. +- `$.state` survives `/reload-plugins`, so placeholders made before a reload still restore. If wrong: an edit using an older placeholder fails and Claude re-reads the file. How to check: hide a value, run `/reload-plugins`, ask for an edit of that line. +- The `$.ui.log` line draws in the terminal transcript. The `-p` path receives it as `ui_log`. If wrong: the person loses the per-row notice; `/redact` still reports totals. How to check: an interactive session with a fake key in the prompt. diff --git a/plugins/redact/README.md b/plugins/redact/README.md new file mode 100644 index 0000000..ef2e3b7 --- /dev/null +++ b/plugins/redact/README.md @@ -0,0 +1,70 @@ +# redact + +Redacts secrets and PII from every row Claude Code stores, before the model reads it and before the transcript keeps it, and puts the real value back only inside `Edit`, `Write` and `NotebookEdit` arguments so file edits still work. + +Built on Claude Code 2.1.288. Proven on 2.1.288 (stages: validate, load, typecheck, test, command, isolation). Requires Claude Code 2.1.287 or later. + +## What it can reach + + ❯ ./register.ts hooks: session.start, prompt.submit, session.append, tool.call{tool=Edit|Write|NotebookEdit}, prompt.section{name=env_info_simple}, command.run{command=redact} + ❯ ./register.ts calls: $.command.register, $.state.get, $.state.set, $.ui.log + ❯ ./register.ts state writes: redact.counts, redact.restored, redact.salt, redact.vault + ❯ ./register.ts state reads: redact.counts, redact.restored, redact.salt, redact.vault + +Reach L0, draws and remembers. + +``` +Threat model for redact (reach L0, draws and remembers) +1. Reads: the text of every prompt and every stored row (prompts, tool results, Claude's blocks, attachments, notices) and the arguments of Edit, Write and NotebookEdit calls, in memory; state keys redact.vault, redact.salt, redact.counts, redact.restored; no files, no env vars, no settings +2. Runs: nothing +3. Sends: nothing leaves the machine; no network call, no model call +4. Persists: placeholder-to-value pairs, the salt and counters in $.state for this session only (reset by /clear, /resume, /branch; gone at exit); nothing in $.store, no files +5. Hostile input: a crafted row can only change what gets redacted; the rule set is fixed in source and nothing reaches a process, a file, the network or the model. A crafted placeholder in a tool argument restores only a value this session hid itself; an unknown one passes through unchanged. A row that makes the scanner throw is withheld from the model, never stored raw +``` + +## How it works + +1. `prompt.submit`: the typed prompt is scanned and each hit becomes `[REDACTED:LABEL#hash]` before the engine queues it. +2. `session.append`: every row the conversation stores is scanned the same way: tool results (file reads, shell output, MCP results), Claude's own blocks, attachments such as nested memory, notices. The rewritten row is what the model reads and what the session file keeps. +3. `tool.call` on `Edit`, `Write` and `NotebookEdit`: placeholders in the arguments are swapped back for the real value, so an edit to a line that held a key still matches the file. `Bash` never gets the real value. +4. `prompt.section`: one paragraph is appended to the environment section telling Claude the placeholders are opaque. +5. `/redact`: the session total by label and how many placeholders were restored. + +The hash is FNV-1a over a per-session salt plus the value, so the same secret gets the same placeholder across rows, edits and compaction summaries, and a placeholder from another session means nothing here. + +Detection is deterministic: 23 secret rules and 7 PII rules, regex plus checksums (Luhn, Verhoeff, IBAN mod-97, JWT header decode), no model call. The list with sources is in `RULES.md`. + +## Install + +One session: `claude --plugin-dir ./plugins/redact`. To keep it: + +``` +claude plugin marketplace add karanb192/claude-code-redact +claude plugin install redact@claude-code-redact +``` + +then `/reload-plugins` in an open session. Installed copies are cached by version: bump `version` before reinstalling. + +## Options + +Stored under `pluginConfigs` in settings; a change reloads the mod. + +- `pii` (boolean, default `false`): also hide email, phone, payment cards, US SSN, Aadhaar, PAN and IBAN. Secrets are always hidden. +- `quiet` (boolean, default `false`): no `redact: hid ...` line in the transcript. `/redact` still reports totals. +- `off` (string, default empty): comma-separated rule ids to skip, for example `generic-secret,email`. Ids are in `RULES.md`. + +## Limitations + +- Two engine bookkeeping records in the session file are stored as made and can keep the typed prompt: the `queue-operation` record written at enqueue, and a command's `args` stamp. Neither is sent to the model. In the headless `-p` path the queue record is written before `prompt.submit` runs. +- A secret the rules do not know stays visible. Generic `key=value` detection needs the value to look like a secret (length and entropy); `password=hunter2` is caught, `token=abc` is not. +- A secret split across two rows, or shown partially (the last four characters), is not caught. +- Images are not scanned. A screenshot of a key goes through. +- Claude cannot search for a secret by value: `Grep` for a placeholder finds nothing. Search by the line around it. +- Plugins loaded ahead of this one see the row before it is rewritten. +- The screen may show a row just before its rewrite; the model and the session file never see that form. +- Nothing is drawn in `claude -p`, the SDK, the VS Code panel or cloud sessions; the `redact: hid ...` line arrives as `ui_log` there, and `/redact` works everywhere. +- A pattern guard is not a permission rule. A determined prompt can describe a secret in words the rules do not match. + +## Uninstall + +Disable it in `/plugin`. It leaves nothing: no store keys, no files. diff --git a/plugins/redact/RULES.md b/plugins/redact/RULES.md new file mode 100644 index 0000000..abe1c53 --- /dev/null +++ b/plugins/redact/RULES.md @@ -0,0 +1,52 @@ +# Detection rules + +Every rule lives in `hooks/rules.ts`. Secret rules always run. PII rules run only when PII redaction is on. Any rule can be switched off by its id. + +How a scan settles conflicts: + +- A hit covers only the value (the capture group), not the key name or quotes around it. +- When two hits overlap, the leftmost wins, then the longest, then the rule listed first below. +- Text inside an existing `[REDACTED:LABEL#hex]` placeholder is never matched again. +- Every quantifier is bounded, so a 2 MB blob of random base64 scans in well under a second. + +All examples below are fake. They have the right shape and nothing else. + +## Secret rules + +| id | label | kind | what it catches | example that matches | source | +| --- | --- | --- | --- | --- | --- | +| `aws-access-key` | `AWS_KEY` | secret | AWS access key id: `AKIA`, `ASIA`, `ABIA` or `ACCA` plus 16 base32 characters; ids ending in `EXAMPLE` are skipped | `AKIAFAKE2345TEST6767` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `aws-secret-key` | `AWS_SECRET` | secret | 40-character AWS secret access key assigned to an `aws_secret_access_key` style name | `aws_secret_access_key = FAKEfake0123456789abcdefghijklmnopqrstuv` | [AWS docs](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html) | +| `github-token` | `GITHUB_TOKEN` | secret | GitHub tokens: `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` and fine-grained `github_pat_` | `ghp_0123456789abcdefghijklmnopqrstuvwxyz` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `gitlab-pat` | `GITLAB_TOKEN` | secret | GitLab personal access token `glpat-`, including the newer dotted form | `glpat-FAKE0123456789abcdef` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `slack-token` | `SLACK_TOKEN` | secret | Slack bot, user, app, refresh and session tokens (`xoxa-`, `xoxb-`, `xoxp-`, `xoxr-`, `xoxs-`) | `xoxb-1234567890-1234567890123-FAKEfakeFAKEfake01234567` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `slack-webhook` | `SLACK_WEBHOOK` | secret | Slack incoming webhook, workflow and trigger URLs | `https://hooks.slack.com/services/TFAKE0001/BFAKE0002/FAKEfake0123456789abcdef` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `stripe-live-key` | `STRIPE_KEY` | secret | Stripe live secret and restricted keys (`sk_live_`, `rk_live_`); test keys are left alone | `sk_live_FAKE0123456789abcdefghij` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `sendgrid-key` | `SENDGRID_KEY` | secret | SendGrid API key: `SG.` plus a 22 and a 43 character segment | `SG.FAKE0123456789abcdefgh.FAKE0123456789abcdefghijklmnopqrstuvwxyzABC` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `twilio-key` | `TWILIO_KEY` | secret | Twilio API key SID: `SK` plus 32 hex characters | `SK0123456789abcdef0123456789abcdef` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `google-api-key` | `GOOGLE_API_KEY` | secret | Google API key: `AIza` plus 35 characters | `AIzaFAKE0123456789abcdefghijklmnopqrstu` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `google-oauth-secret` | `GOOGLE_OAUTH_SECRET` | secret | Google OAuth client secret: `GOCSPX-` plus 28 characters | `GOCSPX-FAKE0123456789abcdefghijklmn` | [GitHub secret scanning patterns](https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns) | +| `anthropic-key` | `ANTHROPIC_KEY` | secret | Anthropic API, admin and OAuth keys (`sk-ant-api03-`, `sk-ant-admin01-`, `sk-ant-oat01-`) | `sk-ant-api03-FAKE0123456789abcdefghijklmnopqrstuvwxyz-FAKE` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `openai-key` | `OPENAI_KEY` | secret | OpenAI keys: `sk-proj-`, `sk-svcacct-`, `sk-admin-`, the `T3BlbkFJ` legacy form and 48-character `sk-` keys | `sk-proj-FAKE0123456789abcdefghijklmnopqrstuvwxyzABCD` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `huggingface-token` | `HF_TOKEN` | secret | Hugging Face access token: `hf_` plus 34 letters | `hf_FAKEfakeABCDEFGHIJKLMNOPQRSTUVWXYZ` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `npm-token` | `NPM_TOKEN` | secret | npm access token: `npm_` plus 36 characters | `npm_FAKE0123456789abcdefghijklmnopqrstuv` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `pypi-token` | `PYPI_TOKEN` | secret | PyPI upload token: `pypi-AgEIcHlwaS5vcmc` plus at least 50 characters | `pypi-AgEIcHlwaS5vcmcFAKE0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `azure-storage-key` | `AZURE_STORAGE_KEY` | secret | Azure storage account key (86 base64 characters plus `==`) after `AccountKey=` or a storage key name | `AccountKey=FAKEfake0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/FAKEfake012345==` | [Microsoft Learn](https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string) | +| `jwt` | `JWT` | secret | JSON Web Token: three base64url segments whose header decodes to JSON with an `alg` field | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c` | [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519) | +| `private-key` | `PRIVATE_KEY` | secret | Whole PEM private key block (RSA, EC, DSA, OPENSSH, ENCRYPTED, PGP), across lines | `-----BEGIN RSA PRIVATE KEY-----MIIEFAKEFAKEFAKE0123-----END RSA PRIVATE KEY-----` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `db-connection-url` | `DB_URL` | secret | postgres, postgresql, mysql, mariadb, mongodb, mongodb+srv, redis, rediss, amqp and amqps URLs that carry `user:password@`; placeholder passwords are skipped | `postgres://admin:FakePassw0rd1@db.example.test:5432/app` | [GitHub secret scanning patterns](https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns) | +| `auth-header` | `AUTH_HEADER` | secret | Value of an `Authorization: Bearer`, `Basic` or `Token` header | `Authorization: Bearer FAKE0123456789abcdef` | [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-authorization) | +| `generic-secret-quoted` | `SECRET` | secret | Quoted value of 8+ characters assigned to a name containing api key, secret, token, password, passwd, pwd, auth or credential | `password: "Fake-Passw0rd!"` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | +| `generic-secret` | `SECRET` | secret | Same names with a bare value; skips booleans, placeholders, env references, paths, code identifiers and values under 3.0 bits per character of entropy | `API_KEY=fake0123456789abcdef` | [gitleaks](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) | + +## PII rules (opt-in) + +| id | label | kind | what it catches | example that matches | source | +| --- | --- | --- | --- | --- | --- | +| `email` | `EMAIL` | pii | Email address; skips URL userinfo (`user:token@host`) and asset names like `icon@2x.png` | `jane.doe@example.com` | [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322#section-3.4.1) | +| `payment-card` | `CARD` | pii | 13 to 19 digit card number starting 2 to 6, plain or with one kind of separator, checked with Luhn | `4111 1111 1111 1111` | [Luhn algorithm](https://en.wikipedia.org/wiki/Luhn_algorithm) | +| `aadhaar` | `AADHAAR` | pii | India Aadhaar: 12 digits starting 2 to 9, optional 4-4-4 grouping, checked with Verhoeff | `2345 6789 0124` | [UIDAI](https://uidai.gov.in/en/my-aadhaar/about-your-aadhaar.html) | +| `us-ssn` | `SSN` | pii | US SSN as `xxx-xx-xxxx`; skips area 000, 666 and 900 to 999, group 00, serial 0000 and known published numbers | `234-56-7890` | [SSA](https://www.ssa.gov/employer/randomization.html) | +| `iban` | `IBAN` | pii | IBAN, compact or in groups of four, checked with mod-97 | `GB82WEST12345698765432` | [IBAN](https://en.wikipedia.org/wiki/International_Bank_Account_Number) | +| `india-pan` | `PAN` | pii | India PAN: 5 letters, 4 digits, 1 letter, with a valid holder type (A, B, C, F, G, H, J, L, P, T) as the 4th letter | `ABCPE1234F` | [PAN](https://en.wikipedia.org/wiki/Permanent_account_number) | +| `phone` | `PHONE` | pii | E.164 numbers and common formatted forms with 10 to 15 digits; skips timestamps, dates, IP addresses and digits inside longer runs | `+919876543210` | [ITU E.164](https://www.itu.int/rec/T-REC-E.164) | diff --git a/plugins/redact/hooks/hooks.json b/plugins/redact/hooks/hooks.json new file mode 100644 index 0000000..e4a1f37 --- /dev/null +++ b/plugins/redact/hooks/hooks.json @@ -0,0 +1,4 @@ +{ + "description": "redact: prompt.submit, session.append, tool.call{tool=Edit|Write|NotebookEdit}, prompt.section{name=env_info_simple}, session.start, command.run{command=redact}", + "modules": ["./register.ts"] +} diff --git a/plugins/redact/hooks/register.ts b/plugins/redact/hooks/register.ts new file mode 100644 index 0000000..0efcfc7 --- /dev/null +++ b/plugins/redact/hooks/register.ts @@ -0,0 +1,127 @@ +import type { EngineInterface, PluginOptions, Register, SessionAppendInput } from 'claude-code' +import { atom, read, update } from 'claude-code' +import { describe, fnv1a, redactBlocks, redactText, restore, tally, withhold, type Settings } from './scrub' + +const vault = atom({ plugin: 'redact', key: 'vault' } as const, {} as Record) +const salt = atom({ plugin: 'redact', key: 'salt' } as const, '') +const counts = atom({ plugin: 'redact', key: 'counts' } as const, {} as Record) +const restored = atom({ plugin: 'redact', key: 'restored' } as const, 0) + +const NOTE = [ + 'Redaction notice: strings shaped like [REDACTED:LABEL#hex] are placeholders a local redactor inserted', + 'in place of secrets and personal data before you saw them. The real value is restored only when a', + 'placeholder is passed verbatim in Edit, Write or NotebookEdit arguments. Never ask the person for the', + 'hidden value, never try to reconstruct it, and never rely on a placeholder in a shell command.', +].join(' ') + +const DOORS: Record = { + 'prompt': 'your prompt', + 'command': 'a command result', + 'response': "Claude's reply", + 'tool-result': 'a tool result', + 'tool-message': 'a tool message', + 'delivery': 'a delivered message', + 'attachment': 'an attachment', + 'hook-context': 'hook context', + 'note': 'a plugin note', + 'compaction': 'the compaction summary', + 'notice': 'a notice', +} + +function settings(options: PluginOptions): Settings { + const off = typeof options.off === 'string' ? options.off.split(',').map(s => s.trim()).filter(Boolean) : [] + return { pii: options.pii === true, quiet: options.quiet === true, off: new Set(off) } +} + +async function ensureSalt($: EngineInterface): Promise { + const have = await read($, salt) + if (have) return have + const fresh = fnv1a(String(Math.random()) + String(Date.now())) + fnv1a(String(Math.random()) + String(performance.now())) + await update($, salt, () => fresh) + return fresh +} + +async function remember($: EngineInterface, pairs: ReadonlyArray, found: Record, cfg: Settings, place: string): Promise { + await update($, vault, v => ({ ...v, ...Object.fromEntries(pairs) })) + await update($, counts, c => { + const total: Record = { ...c } + for (const [label, n] of Object.entries(found)) total[label] = (total[label] ?? 0) + n + return total + }) + if (!cfg.quiet) $.ui.log(`redact: hid ${describe(found)} in ${place}`) +} + +function where(e: SessionAppendInput): string { + const agent = e.agentId ? ' (subagent)' : '' + return (DOORS[e.door] ?? e.door) + agent +} + +export const register: Register = (on, options) => { + const cfg = settings(options) + + on('session.start', async ($, e, next) => { + const started = await next(e) + await $.command.register({ name: 'redact', description: 'Show what redact hid in this session' }) + return started + }) + + // The typed prompt is scrubbed here, before the engine queues it, so the + // queue record, the on-screen echo and the command args never hold the value. + on('prompt.submit', async ($, e, next) => { + const s = await ensureSalt($) + const r = redactText(e.text, s, cfg) + if (r.hits.length === 0) return next(e) + await remember($, r.pairs, tally(r.hits), cfg, 'your prompt') + return next({ ...e, text: r.text }) + }) + + on('session.append', async ($, e, next) => { + const s = await ensureSalt($) + const r = redactBlocks(e.message.content, s, cfg) + if (r.hits.length === 0) return next(e) + await remember($, r.pairs, tally(r.hits), cfg, where(e)) + return next({ ...e, message: { ...e.message, content: r.blocks } }) + }).catch(($, e, next) => { + if (next.called) return next(e) + $.ui.log(`redact: scanning failed, so this row was withheld from the model (${where(e)})`) + return next({ ...e, message: { ...e.message, content: withhold(e.message.content) } }) + }) + + on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => { + const v = await read($, vault) + if (e.tool === 'Edit') { + const o = restore(e.old_string, v) + const n = restore(e.new_string, v) + if (o.n + n.n === 0) return next(e) + await update($, restored, k => k + o.n + n.n) + return next({ ...e, old_string: o.text, new_string: n.text }) + } + if (e.tool === 'Write') { + const c = restore(e.content, v) + if (c.n === 0) return next(e) + await update($, restored, k => k + c.n) + return next({ ...e, content: c.text }) + } + if (e.tool === 'NotebookEdit') { + const c = restore(e.new_source, v) + if (c.n === 0) return next(e) + await update($, restored, k => k + c.n) + return next({ ...e, new_source: c.text }) + } + return next(e) + }) + + on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => { + const r = await next(e) + return { text: r.text ? `${r.text}\n\n${NOTE}` : NOTE } + }) + + on('command.run', { command: 'redact' }, async $ => { + const c = await read($, counts) + const k = await read($, restored) + const total = Object.values(c).reduce((a, b) => a + b, 0) + const rules = cfg.pii ? 'secrets and PII' : 'secrets only (set pii to true for email, phone, cards, national ids)' + if (total === 0) return { text: `redact: nothing hidden yet this session. Scanning ${rules}.` } + return { text: `redact: hid ${total} values this session (${describe(c)}); restored ${k} placeholders inside Edit, Write and NotebookEdit arguments. Scanning ${rules}.` } + }) +} diff --git a/plugins/redact/hooks/rules.ts b/plugins/redact/hooks/rules.ts new file mode 100644 index 0000000..48ea2ae --- /dev/null +++ b/plugins/redact/hooks/rules.ts @@ -0,0 +1,526 @@ +export type RuleKind = 'secret' | 'pii' + +export type Rule = { + id: string + label: string + kind: RuleKind + pattern: RegExp + group?: number + verify?: (value: string) => boolean + source: string +} + +export type Hit = { id: string; label: string; kind: RuleKind; start: number; end: number; value: string } + +const GITLEAKS = 'https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml' +const GITHUB_PATTERNS = 'https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns' + +function entropy(s: string): number { + if (s.length === 0) return 0 + const counts = new Map() + for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1) + let bits = 0 + for (const n of counts.values()) { + const p = n / s.length + bits -= p * Math.log2(p) + } + return bits +} + +function luhn(digits: string): boolean { + let sum = 0 + let double = false + for (let i = digits.length - 1; i >= 0; i--) { + let d = digits.charCodeAt(i) - 48 + if (double) { + d *= 2 + if (d > 9) d -= 9 + } + sum += d + double = !double + } + return digits.length > 0 && sum % 10 === 0 +} + +const VERHOEFF_D = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], + [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], + [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], + [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], + [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], + [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], + [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], + [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], + [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], +] +const VERHOEFF_P = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], + [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], + [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], + [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], + [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], + [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], + [7, 0, 4, 6, 9, 1, 3, 2, 5, 8], +] + +function verhoeff(digits: string): boolean { + let c = 0 + for (let i = 0; i < digits.length; i++) { + const d = digits.charCodeAt(digits.length - 1 - i) - 48 + c = VERHOEFF_D[c]![VERHOEFF_P[i % 8]![d]!]! + } + return digits.length > 0 && c === 0 +} + +function ibanValid(raw: string): boolean { + const iban = raw.replace(/ /g, '') + if (iban.length < 15 || iban.length > 34) return false + const moved = iban.slice(4) + iban.slice(0, 4) + let rem = 0 + for (const ch of moved) { + const code = ch.charCodeAt(0) + const n = code >= 65 ? code - 55 : code - 48 + rem = n > 9 ? (rem * 100 + n) % 97 : (rem * 10 + n) % 97 + } + return rem === 1 +} + +const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + +function base64urlDecode(s: string): string | null { + let acc = 0 + let bits = 0 + let out = '' + for (const ch of s.replace(/=+$/, '')) { + let v = B64URL.indexOf(ch) + if (ch === '+') v = 62 + else if (ch === '/') v = 63 + if (v < 0) return null + acc = ((acc << 6) | v) & 0xffffff + bits += 6 + if (bits >= 8) { + bits -= 8 + out += String.fromCharCode((acc >> bits) & 0xff) + } + } + return out +} + +function jwtHeaderHasAlg(token: string): boolean { + const decoded = base64urlDecode(token.slice(0, token.indexOf('.'))) + if (decoded === null) return false + try { + const header = JSON.parse(decoded) + return header !== null && typeof header === 'object' && typeof header.alg === 'string' + } catch { + return false + } +} + +const NOT_SECRETS = new Set([ + 'true', 'false', 'null', 'none', 'nil', 'undefined', 'changeme', 'password', 'passw0rd', + 'secret', 'token', 'placeholder', 'redacted', 'example', 'default', 'required', 'optional', +]) + +function plausibleSecret(v: string): boolean { + const lower = v.toLowerCase() + if (NOT_SECRETS.has(lower)) return false + if (/^\$\{[^}]*\}$|^\$\(?[A-Za-z_]\w*\)?$|^%[A-Za-z_]\w*%$|^\{\{.*\}\}$|^<.*>$/.test(v)) return false + if (v.includes('${') || v.includes('{{')) return false + if (/^(?:\/|\.{1,2}\/|~\/|[A-Za-z]:\\|\.\w)/.test(v)) return false + if (/^(?:https?|file):\/\//i.test(lower)) return false + if (/^(?:process\.env|os\.environ|env\.|import\.meta\.env)/.test(v)) return false + if (/^(.)\1*$/.test(v)) return false + if (/x{4,}|\*{3,}|\.{3}|your[_-]?|example|placeholder|changeme|redacted|dummy|sample|insert[_-]?/i.test(v)) return false + return true +} + +function notFiller(v: string): boolean { + return entropy(v) >= 2.5 && !/x{6,}|X{6,}|0{8,}|EXAMPLE/.test(v) +} + +function quotedSecret(v: string): boolean { + // lowercase words joined by - _ . with no digit read as enum values ('same-origin', 'github_webhook') + return plausibleSecret(v) && entropy(v) >= 2.0 && !/^[a-z]+(?:[-_.][a-z]*)+$/.test(v) +} + +function bareSecret(v: string): boolean { + if (!plausibleSecret(v) || entropy(v) < 3.0) return false + // a code identifier or member chain with no digit (getToken, process.env.KEY, my-secret-name) is not a value + if (!/\d/.test(v) && /^[A-Za-z_$][\w$]*(?:[.\-][A-Za-z_$][\w$]*)*$/.test(v)) return false + return true +} + +function dbUrlHasRealPassword(url: string): boolean { + const password = /^[^:]+:\/\/[^:@\/]*:([^@\/]*)@/.exec(url)?.[1] ?? '' + return password.length > 0 && plausibleSecret(password) && !/^(?:pass|pwd|secret)$/i.test(password) +} + +const FILE_TLDS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'pdf', 'js', 'mjs', 'ts', 'tsx', 'jsx', 'css', 'json', + 'html', 'md', 'txt', 'yaml', 'yml', 'xml', 'zip', 'gz', 'py', 'rb', 'go', 'rs', 'java', 'lock', 'map', +]) + +function emailPlausible(v: string): boolean { + const at = v.lastIndexOf('@') + const local = v.slice(0, at) + const tld = v.slice(v.lastIndexOf('.') + 1).toLowerCase() + return !local.endsWith('.') && !local.includes('..') && !FILE_TLDS.has(tld) +} + +function digitsOf(v: string): string { + return v.replace(/\D/g, '') +} + +function phonePlausible(v: string): boolean { + const d = digitsOf(v) + if (d.length < 10 || d.length > 15) return false + if (/^(\d)\1+$/.test(d)) return false + if (/^\d+$/.test(v) && v.startsWith('1') && (v.length === 10 || v.length === 13)) return false + if (/^\d{4}[-.\/]\d{2}[-.\/]\d{2}/.test(v)) return false + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(v)) return false + return true +} + +function cardPlausible(v: string): boolean { + const d = digitsOf(v) + if (d.length < 13 || d.length > 19) return false + if (/ /.test(v) && /-/.test(v)) return false + if (/^(\d)\1+$/.test(d)) return false + return luhn(d) +} + +function ssnPlausible(v: string): boolean { + return !['078-05-1120', '219-09-9999', '123-45-6789'].includes(v) +} + +export const RULES: readonly Rule[] = [ + { + id: 'aws-access-key', + label: 'AWS_KEY', + kind: 'secret', + pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'aws-secret-key', + label: 'AWS_SECRET', + kind: 'secret', + pattern: /(?:aws[_.-]?secret[_.-]?(?:access[_.-]?)?key|secret[_.-]?access[_.-]?key)["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]?([A-Za-z0-9\/+]{40})(?![A-Za-z0-9\/+=])/gi, + group: 1, + verify: (v) => entropy(v) >= 3.5 && !/EXAMPLE/.test(v), + source: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html', + }, + { + id: 'github-token', + label: 'GITHUB_TOKEN', + kind: 'secret', + pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{82,255})\b/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'gitlab-pat', + label: 'GITLAB_TOKEN', + kind: 'secret', + pattern: /\bglpat-[A-Za-z0-9_-]{20,128}(?:\.[A-Za-z0-9_-]{2,128}){0,3}(?![\w-])/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'slack-token', + label: 'SLACK_TOKEN', + kind: 'secret', + pattern: /\bxox[abprs]-[0-9]{8,14}-[A-Za-z0-9-]{10,250}(?![\w-])/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'slack-webhook', + label: 'SLACK_WEBHOOK', + kind: 'secret', + pattern: /(?:https?:\/\/)?hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9+\/]{43,56}(?![A-Za-z0-9+\/])/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'stripe-live-key', + label: 'STRIPE_KEY', + kind: 'secret', + pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,247}\b/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'sendgrid-key', + label: 'SENDGRID_KEY', + kind: 'secret', + pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![\w-])/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'twilio-key', + label: 'TWILIO_KEY', + kind: 'secret', + pattern: /\bSK[0-9a-fA-F]{32}\b/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'google-api-key', + label: 'GOOGLE_API_KEY', + kind: 'secret', + pattern: /\bAIza[0-9A-Za-z_-]{35}(?![\w-])/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'google-oauth-secret', + label: 'GOOGLE_OAUTH_SECRET', + kind: 'secret', + pattern: /\bGOCSPX-[A-Za-z0-9_-]{28}(?![\w-])/g, + verify: notFiller, + source: GITHUB_PATTERNS, + }, + { + id: 'anthropic-key', + label: 'ANTHROPIC_KEY', + kind: 'secret', + pattern: /\bsk-ant-[a-z]{3,8}\d{2}-[A-Za-z0-9_-]{32,200}(?![\w-])/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'openai-key', + label: 'OPENAI_KEY', + kind: 'secret', + pattern: /\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,250}|[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}|[A-Za-z0-9]{48})(?![\w-])/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'huggingface-token', + label: 'HF_TOKEN', + kind: 'secret', + pattern: /\bhf_[A-Za-z]{34}\b/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'npm-token', + label: 'NPM_TOKEN', + kind: 'secret', + pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, + verify: notFiller, + source: GITLEAKS, + }, + { + id: 'pypi-token', + label: 'PYPI_TOKEN', + kind: 'secret', + pattern: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,1000}(?![\w-])/g, + source: GITLEAKS, + }, + { + id: 'azure-storage-key', + label: 'AZURE_STORAGE_KEY', + kind: 'secret', + pattern: /(?:AccountKey|azure[_.-]?storage[_.-]?(?:account[_.-]?)?key|storage[_.-]?account[_.-]?key)["'`]?[ \t]{0,5}[=:][ \t]{0,5}["'`]?([A-Za-z0-9+\/]{86}==)/gi, + group: 1, + verify: notFiller, + source: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string', + }, + { + id: 'jwt', + label: 'JWT', + kind: 'secret', + pattern: /\beyJ[A-Za-z0-9_-]{10,4096}\.ey[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{0,4096}(?![\w-])/g, + verify: jwtHeaderHasAlg, + source: 'https://datatracker.ietf.org/doc/html/rfc7519', + }, + { + id: 'private-key', + label: 'PRIVATE_KEY', + kind: 'secret', + // the body never holds five dashes, so a BEGIN without an END stops at the next marker instead of scanning 16 KB + pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----(?:[^-]|-{1,4}(?!-)){16,16384}?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----/g, + source: GITLEAKS, + }, + { + id: 'db-connection-url', + label: 'DB_URL', + kind: 'secret', + pattern: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:@\/'"`<>]{0,128}:[^\s@\/'"`<>]{1,256}@[^\s\/'"`<>?#@(){}\[\],;]{1,256}(?:[\/?#][^\s'"`<>(){}\[\],;]{0,1024})?/gi, + verify: dbUrlHasRealPassword, + source: GITHUB_PATTERNS, + }, + { + id: 'auth-header', + label: 'AUTH_HEADER', + kind: 'secret', + pattern: /\bAuthorization["']?[ \t]{0,5}[:=][ \t]{0,5}["']?(?:Bearer|Basic|Token)[ \t]{1,5}([A-Za-z0-9._~+\/=-]{8,4096})/gi, + group: 1, + verify: plausibleSecret, + source: 'https://www.rfc-editor.org/rfc/rfc9110#name-authorization', + }, + { + id: 'generic-secret-quoted', + label: 'SECRET', + kind: 'secret', + pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}["'`]([^\s"'`]{8,256})["'`]/gi, + group: 1, + verify: quotedSecret, + source: GITLEAKS, + }, + { + id: 'generic-secret', + label: 'SECRET', + kind: 'secret', + pattern: /(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth|credential)s?["'`]?[ \t]{0,5}(?::=|=>|[=:])[ \t]{0,5}([^\s"'`;,(){}<>\[\]]{8,256})(?![^\s"'`;,(){}<>\[\]])/gi, + group: 1, + verify: bareSecret, + source: GITLEAKS, + }, + { + id: 'email', + label: 'EMAIL', + kind: 'pii', + // a colon or slash before the local part means URL userinfo (user:token@host), not an address + pattern: /(?:^|mailto:|[^\w.%+\/:-])([A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.){1,8}[A-Za-z]{2,24})\b/gm, + group: 1, + verify: emailPlausible, + source: 'https://www.rfc-editor.org/rfc/rfc5322#section-3.4.1', + }, + { + id: 'payment-card', + label: 'CARD', + kind: 'pii', + pattern: /(?:^|[^\w.+\/-])([2-6](?:[ -]?\d){12,18})(?![\w-]|[ -]\d)/gm, + group: 1, + verify: cardPlausible, + source: 'https://en.wikipedia.org/wiki/Luhn_algorithm', + }, + { + id: 'aadhaar', + label: 'AADHAAR', + kind: 'pii', + pattern: /(?:^|[^\w.+\/-])([2-9]\d{3}([ -]?)\d{4}\2\d{4})(?![\w-]|[ -]\d)/gm, + group: 1, + verify: (v) => verhoeff(digitsOf(v)), + source: 'https://uidai.gov.in/en/my-aadhaar/about-your-aadhaar.html', + }, + { + id: 'us-ssn', + label: 'SSN', + kind: 'pii', + pattern: /(?:^|[^\w.\/-])((?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4})(?![\w-]|\.\d)/gm, + group: 1, + verify: ssnPlausible, + source: 'https://www.ssa.gov/employer/randomization.html', + }, + { + id: 'iban', + label: 'IBAN', + kind: 'pii', + pattern: /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?\b/g, + verify: ibanValid, + source: 'https://en.wikipedia.org/wiki/International_Bank_Account_Number', + }, + { + id: 'india-pan', + label: 'PAN', + kind: 'pii', + pattern: /\b[A-Z]{3}[ABCFGHLJPT][A-Z]\d{4}[A-Z]\b/g, + source: 'https://en.wikipedia.org/wiki/Permanent_account_number', + }, + { + id: 'phone', + label: 'PHONE', + kind: 'pii', + pattern: /(?:^|[^\w+.\/@#-])(\+[1-9]\d{9,14}|\+[1-9]\d{0,2}[ .-]?\(?\d{1,4}\)?(?:[ .-]\d{2,5}){1,4}|\(\d{3}\)[ .-]?\d{3}[ .-]\d{4}|\d{3}([.-])\d{3}\2\d{4}|[6-9]\d{4}[ -]\d{5}|0\d{2,4}[ -]\d{3,4}[ -]\d{3,4})(?![\w-]|[ .)]\d)/gm, + group: 1, + verify: phonePlausible, + source: 'https://www.itu.int/rec/T-REC-E.164', + }, +] + +const PLACEHOLDER = /\[REDACTED:[A-Z0-9_]{1,64}#[0-9a-fA-F]{1,64}\]/g + +type Compiled = { re: RegExp; indexed: boolean } +const compiled = new Map() + +function compile(pattern: RegExp): Compiled { + let c = compiled.get(pattern) + if (c) return c + const flags = pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g' + try { + c = { re: new RegExp(pattern.source, flags.includes('d') ? flags : flags + 'd'), indexed: true } + } catch { + c = { re: new RegExp(pattern.source, flags), indexed: false } + } + compiled.set(pattern, c) + return c +} + +type Ranked = Hit & { order: number } + +export function scan(text: string, opts: { pii?: boolean; off?: ReadonlySet } = {}): Hit[] { + const spans: [number, number][] = [] + PLACEHOLDER.lastIndex = 0 + let masked = text + for (let m = PLACEHOLDER.exec(text); m !== null; m = PLACEHOLDER.exec(text)) { + spans.push([m.index, m.index + m[0].length]) + } + if (spans.length > 0) { + // spaces keep every offset stable while no rule can match through a placeholder + let out = '' + let at = 0 + for (const [s, e] of spans) { + out += text.slice(at, s) + ' '.repeat(e - s) + at = e + } + masked = out + text.slice(at) + } + + const found: Ranked[] = [] + RULES.forEach((rule, order) => { + if (rule.kind === 'pii' && !opts.pii) return + if (opts.off?.has(rule.id)) return + const { re, indexed } = compile(rule.pattern) + const g = rule.group ?? 0 + re.lastIndex = 0 + for (let m = re.exec(masked); m !== null; m = re.exec(masked)) { + if (m[0] === '') { + re.lastIndex++ + continue + } + const raw = m[g] + if (raw === undefined || raw === '') continue + const start = indexed ? (m.indices?.[g]?.[0] ?? -1) : m.index + m[0].lastIndexOf(raw) + if (start < 0) continue + const end = start + raw.length + const value = text.slice(start, end) + const inPlaceholder = spans.some(([s, e]) => start < e && end > s) + if (inPlaceholder || (rule.verify && !rule.verify(value))) { + re.lastIndex = m.index + 1 + continue + } + found.push({ id: rule.id, label: rule.label, kind: rule.kind, start, end, value, order }) + } + re.lastIndex = 0 + }) + + found.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.order - b.order) + const hits: Hit[] = [] + let lastEnd = -1 + for (const h of found) { + if (h.start < lastEnd) continue + hits.push({ id: h.id, label: h.label, kind: h.kind, start: h.start, end: h.end, value: h.value }) + lastEnd = h.end + } + return hits +} diff --git a/plugins/redact/hooks/scrub.ts b/plugins/redact/hooks/scrub.ts new file mode 100644 index 0000000..0c4fea6 --- /dev/null +++ b/plugins/redact/hooks/scrub.ts @@ -0,0 +1,98 @@ +import type { ApiContentBlock } from 'claude-code' +import { scan, type Hit } from './rules' + +export type Settings = { pii: boolean; quiet: boolean; off: ReadonlySet } + +export const PLACEHOLDER = /\[REDACTED:[A-Z0-9_]+#[0-9a-f]{8}\]/g + +export function fnv1a(s: string): string { + let h = 0x811c9dc5 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 0x01000193) >>> 0 + } + return h.toString(16).padStart(8, '0') +} + +export function placeholder(hit: Hit, salt: string): string { + return `[REDACTED:${hit.label}#${fnv1a(salt + hit.value)}]` +} + +export type Redacted = { text: string; hits: Hit[]; pairs: Array<[string, string]> } + +export function redactText(text: string, salt: string, cfg: Settings): Redacted { + const hits = scan(text, { pii: cfg.pii, off: cfg.off }) + if (hits.length === 0) return { text, hits, pairs: [] } + const pairs: Array<[string, string]> = [] + let out = '' + let last = 0 + for (const h of hits) { + const p = placeholder(h, salt) + out += text.slice(last, h.start) + p + last = h.end + pairs.push([p, h.value]) + } + out += text.slice(last) + return { text: out, hits, pairs } +} + +export type RedactedBlocks = { blocks: ApiContentBlock[]; hits: Hit[]; pairs: Array<[string, string]> } + +export function redactBlocks(blocks: readonly ApiContentBlock[], salt: string, cfg: Settings): RedactedBlocks { + const out: ApiContentBlock[] = [] + const hits: Hit[] = [] + const pairs: Array<[string, string]> = [] + for (const block of blocks) { + if (block.type === 'text' && typeof block.text === 'string') { + const r = redactText(block.text, salt, cfg) + hits.push(...r.hits) + pairs.push(...r.pairs) + out.push(r.hits.length ? { ...block, text: r.text } : block) + } else if (block.type === 'tool_result' && typeof block.content === 'string') { + const r = redactText(block.content, salt, cfg) + hits.push(...r.hits) + pairs.push(...r.pairs) + out.push(r.hits.length ? { ...block, content: r.text } : block) + } else if (block.type === 'tool_result' && Array.isArray(block.content)) { + const r = redactBlocks(block.content as ApiContentBlock[], salt, cfg) + hits.push(...r.hits) + pairs.push(...r.pairs) + out.push(r.hits.length ? { ...block, content: r.blocks } : block) + } else { + out.push(block) + } + } + return { blocks: out, hits, pairs } +} + +export const WITHHELD = '[redact withheld this content: the redactor failed while scanning it]' + +// Fail closed: a row the redactor could not scan is replaced, never stored as it came. +export function withhold(blocks: readonly ApiContentBlock[]): ApiContentBlock[] { + return blocks.map(block => { + if (block.type === 'text') return { ...block, text: WITHHELD } + if (block.type === 'tool_result') return { ...block, content: WITHHELD } + return block + }) +} + +export function tally(hits: readonly Hit[]): Record { + const t: Record = {} + for (const h of hits) t[h.label] = (t[h.label] ?? 0) + 1 + return t +} + +export function describe(t: Record): string { + return Object.entries(t).sort((a, b) => b[1] - a[1]).map(([label, n]) => `${n} ${label}`).join(', ') +} + +export function restore(text: string, vault: Record): { text: string; n: number } { + let n = 0 + const out = text.replace(PLACEHOLDER, p => { + const real = vault[p] + if (real === undefined) return p + n += 1 + return real + }) + return { text: out, n } +} diff --git a/plugins/redact/tests/register.test.ts b/plugins/redact/tests/register.test.ts new file mode 100644 index 0000000..d29c4c6 --- /dev/null +++ b/plugins/redact/tests/register.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'claude-code/testing' +import type { CommandRunInput, On, SessionStartInput } from 'claude-code' + +// The session.append path is not in this file: on Claude Code 2.1.288 the kit +// cannot answer session.append beneath the plugins (every answer must follow a +// next, and a test stub has nothing beneath it). scrub.test.ts covers the +// rewrite itself; the headless probe in the README covers the hook end to end. + +const start: SessionStartInput = { surface: 'terminal', isInteractive: true, cwd: '/work' } +const run: CommandRunInput = { + command: 'redact', args: '', origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 80 }, +} +const UNKNOWN = '[REDACTED:AWS_KEY#00000000]' + +describe('register', () => { + test('registers /redact on session.start', async ($, on) => { + const names: string[] = [] + on('command.register', ($, e) => { names.push(e.name); return { value: { command: e.name } } }) + on('session.start', ($, e) => ({ cwd: e.cwd })) + await $.session.start(start) + expect(names).toEqual(['redact']) + }) + + test('/redact says nothing was hidden in a fresh session, secrets only', async ($, on) => { + on('ui.log', () => ({ value: undefined })) + expect((await $.command.run(run)).text).toBe('redact: nothing hidden yet this session. Scanning secrets only (set pii to true for email, phone, cards, national ids).') + }) + + test('/redact names PII scanning when the option is on', { options: { pii: true } }, async ($, on) => { + on('ui.log', () => ({ value: undefined })) + expect((await $.command.run(run)).text).toBe('redact: nothing hidden yet this session. Scanning secrets and PII.') + }) + + test('Edit, Write and NotebookEdit pass through unchanged when no placeholder is known', async ($, on) => { + const seen: string[] = [] + on('tool.call', { tool: 'Edit' }, ($, e) => { seen.push(e.old_string, e.new_string); return { result: 'ok' } }) + on('tool.call', { tool: 'Write' }, ($, e) => { seen.push(e.content); return { result: 'ok' } }) + on('tool.call', { tool: 'NotebookEdit' }, ($, e) => { seen.push(e.new_source); return { result: 'ok' } }) + await $.tool.call({ tool: 'Edit', file_path: '/work/.env', old_string: `A=${UNKNOWN}`, new_string: `B=${UNKNOWN}` }) + await $.tool.call({ tool: 'Write', file_path: '/work/x', content: `C=${UNKNOWN}` }) + await $.tool.call({ tool: 'NotebookEdit', notebook_path: '/work/n.ipynb', new_source: `D=${UNKNOWN}` }) + expect(seen).toEqual([`A=${UNKNOWN}`, `B=${UNKNOWN}`, `C=${UNKNOWN}`, `D=${UNKNOWN}`]) + expect((await $.command.run(run)).text).toMatch(/^redact: nothing hidden yet/) + }) + + test('Bash is never touched', async ($, on) => { + const seen: string[] = [] + on('tool.call', { tool: 'Bash' }, ($, e) => { seen.push(e.command); return { result: 'ok' } }) + await $.tool.call({ tool: 'Bash', command: `echo ${UNKNOWN}` }) + expect(seen).toEqual([`echo ${UNKNOWN}`]) + }) + + test('the standing note is appended to the env section and stands alone when core omits it', async ($, on) => { + on('prompt.section', ($, e) => ({ text: e.text })) + const r = await $.prompt.section({ name: 'env_info_simple', text: 'Working directory: /work' }) + expect(r.text).toMatch(/^Working directory: \/work\n\nRedaction notice: /) + const empty = await $.prompt.section({ name: 'env_info_simple', text: null }) + expect(empty.text).toMatch(/^Redaction notice: /) + const other = await $.prompt.section({ name: 'memory', text: 'untouched' }) + expect(other.text).toBe('untouched') + }) + + test('gate: no model call and no tool approval while the mod runs', async ($, on) => { + let model = 0 + on('model.complete', () => { model += 1; return { deny: 'gate' } }) + on('model.fork', () => { model += 1; return { deny: 'gate' } }) + on('tool.call', { tool: 'Write' }, () => ({ result: 'ok' })) + on('ui.log', () => ({ value: undefined })) + await $.tool.call({ tool: 'Write', file_path: '/work/x', content: UNKNOWN }) + await $.command.run(run) + expect(model).toBe(0) + }) +}) + +describe('prompt.submit', () => { + const KEY = 'AKIAQ4ZKX7TR2MDPLW5B' + const ONE = /\[REDACTED:AWS_KEY#[0-9a-f]{8}\]/ + function submitStub(on: On) { + const sent: string[] = [] + const lines: string[] = [] + on('prompt.submit', ($, e) => { sent.push(e.text); return { text: e.text } }) + on('ui.log', ($, e) => { lines.push(e.text); return { value: undefined } }) + return { sent, lines } + } + + test('a key typed in the prompt is replaced before the engine queues it, and the line says so', async ($, on) => { + const { sent, lines } = submitStub(on) + await $.prompt.submit({ text: `my key is ${KEY} keep it`, origin: { kind: 'composer' }, wait: false }) + expect(sent).toEqual([expect.stringMatching(/^my key is \[REDACTED:AWS_KEY#[0-9a-f]{8}\] keep it$/)]) + expect(lines).toEqual(['redact: hid 1 AWS_KEY in your prompt']) + }) + + test('a clean prompt passes through as the same text with no line', async ($, on) => { + const { sent, lines } = submitStub(on) + await $.prompt.submit({ text: 'rename the function', origin: { kind: 'composer' }, wait: false }) + expect(sent).toEqual(['rename the function']) + expect(lines).toEqual([]) + }) + + test('Edit gets the real value back for a placeholder the prompt created; Bash does not', async ($, on) => { + const { sent } = submitStub(on) + const edits: string[] = [] + const shells: string[] = [] + on('tool.call', { tool: 'Edit' }, ($, e) => { edits.push(e.old_string, e.new_string); return { result: 'ok' } }) + on('tool.call', { tool: 'Bash' }, ($, e) => { shells.push(e.command); return { result: 'ok' } }) + await $.prompt.submit({ text: `SECRET=${KEY}`, origin: { kind: 'composer' }, wait: false }) + const p = sent[0]?.match(ONE)?.[0] + if (!p) throw new Error('no placeholder in the submitted prompt') + await $.tool.call({ tool: 'Edit', file_path: '/work/.env', old_string: `SECRET=${p}`, new_string: `# moved\nSECRET=${p}` }) + await $.tool.call({ tool: 'Bash', command: `echo ${p}` }) + expect(edits).toEqual([`SECRET=${KEY}`, `# moved\nSECRET=${KEY}`]) + expect(shells).toEqual([`echo ${p}`]) + expect((await $.command.run({ command: 'redact', args: '', origin: { kind: 'composer' }, presentation: { isFullscreen: false, columns: 80 } })).text) + .toBe('redact: hid 1 values this session (1 AWS_KEY); restored 2 placeholders inside Edit, Write and NotebookEdit arguments. Scanning secrets only (set pii to true for email, phone, cards, national ids).') + }) + + test('with pii on, an email in the prompt is hidden; off by default', { options: { pii: true } }, async ($, on) => { + const { sent } = submitStub(on) + await $.prompt.submit({ text: 'mail jane.doe@example.com now', origin: { kind: 'composer' }, wait: false }) + expect(sent).toEqual([expect.stringMatching(/^mail \[REDACTED:EMAIL#[0-9a-f]{8}\] now$/)]) + }) + + test('quiet keeps the line off', { options: { quiet: true } }, async ($, on) => { + const { sent, lines } = submitStub(on) + await $.prompt.submit({ text: KEY, origin: { kind: 'composer' }, wait: false }) + expect(sent).toEqual([expect.stringMatching(ONE)]) + expect(lines).toEqual([]) + }) +}) diff --git a/plugins/redact/tests/rules.test.ts b/plugins/redact/tests/rules.test.ts new file mode 100644 index 0000000..11efc1f --- /dev/null +++ b/plugins/redact/tests/rules.test.ts @@ -0,0 +1,653 @@ +import { describe, expect, test } from 'claude-code/testing' +import { scan, RULES } from '../hooks/rules' + +type Positive = { text: string; value: string } +type Case = { id: string; label: string; positives: Positive[]; negatives: string[] } + +// Fixtures are joined from parts so secret scanners, push protection and this mod itself +// never see a whole token in the source file. +const cat = (...parts: string[]): string => parts.join('') + +const AKIA = cat('AK', 'IAZ7Q2M4X6V3B5N7K2') +const ASIA = cat('AS', 'IAJ4T6Y2R5W3E7U2I4') +const ABIA = cat('AB', 'IAP3L5K7J2H4G6F3D5') +const AWS_SECRET = [ + cat('ulZfUvdsU7QV2eE8oDK9', '+LSGd1T8YaIeFAENK1Kk'), + cat('bTYgFFTGDVgehX3E5P9w', '4T/LiR0g1c+xMMe6MnDl'), + cat('dpeBYmPVRcKtWy0Ywn/8', 'RuVymwkIKHxGh92sKxb8'), +] as const +const GHP = cat('gh', 'p_', 'FsypJPdBdA4WCgo30YdzsLt4tEtEyxDDBuNQ') +const GHO = cat('gh', 'o_', 'DYQzQpf9hvbioqAEDLkEy2qLW2CwWiwlcmAR') +const GHU = cat('gh', 'u_', 'alg4prs802M7MaFdL1sPtAAQDLCTVfV4AX85') +const GH_PAT = cat('github', '_pat_', 'khqE2t30Uw8cDpslAsiqglOrmLxao2BFyLbFqWkin1NsCZVUfnj3W0528xzFSIfQVMmOB28JyF1zu1NS1o') +const GLPAT = [ + cat('gl', 'pat-', '6nHSchOA-GK-qzzztWjM'), + cat('gl', 'pat-', 'dWY6vWcmIi4bgFMft1yj'), + cat('gl', 'pat-', 'ZKlaXq8ChoiAgAqquwBPHG57ogd', '.01.', 'EXTlUFWc1'), +] as const +const SLACK = [ + cat('xo', 'xb-', '1234567890-1234567890123-', '4z8NsCjKayFmY7irzjkZ8wru'), + cat('xo', 'xp-', '1234567890-1234567890-1234567890123-', 'cf9cb79a4f7480a8ebc3dab64044e06a'), + cat('xo', 'xr-', '987654321098-', 'xgIxaRnUZmGr5RXP8JGakrmZGBLwzn'), +] as const +const HOOKS = [ + cat('https://hooks.', 'slack.com/services/', 'TFS9XEYDG/BK2FJMM70/', 'Sjo3rut1uVG9iWFRCetbd3ht'), + cat('hooks.', 'slack.com/services/', 'T0K2FJMM7/B0FS9XEYD/', 'WKRNN3bxPj1VBXyp7R99teFV'), + cat('https://hooks.', 'slack.com/workflows/', 'TFS9XEYDG/', 'd1UjPxcFWJ5aCO78qbq8R4AR3wD5DOJziGnGvfqW'), +] as const +const STRIPE = [ + cat('sk', '_live_', 'nwOFXOaYo6ChDQHBEY6SFY3e'), + cat('rk', '_live_', 'YjLIkYyAEOeLrysbOF3Ay0Wt'), + cat('sk', '_live_', '4Q7x2uFWZifv0fWOnNcg6JQTthGw24WHRFVmq5onoM9PI16esCWkQFcgAStqYQY2lkC25wSviAgFucip1OCtZwuVJMUZuqzzwrb'), +] as const +const SENDGRID = [ + cat('SG', '.e5knhnKB9adyvmiKcDbZgn', '.hJOPjamz5lwV3RCK_3OwkezFdfLzGClpVXhWzJxQwdw'), + cat('SG', '.CB4ZnHeqiS4d77OqSXxFb5', '.putHI1V6l2H98CL3fHToINFwWo14oCAegYjusd2ufQg'), + cat('SG', '.NYKceDvndzdOdZ2B9EV7P0', '.Nmz7J3OBtdp4bD565eY9yT10w56p4sy8-jAwrwzTOCK'), +] as const +const TWILIO = [ + cat('S', 'K4954f4c36546dc3ec8575ed4b156e487'), + cat('S', 'K13ad849d37b9de5b487e24ef062d6d68'), + cat('S', 'K62E6FA69548E8D63893E7D6659D17091'), +] as const +const GOOGLE_KEY = [ + cat('AI', 'za', 'Qdj6oeIuNOmvjq-eGPDkvLOdDu8cykSiT0X'), + cat('AI', 'za', '7KUlwvsRdFgi4y4hX1miFAQUQlMkxoYa2aR'), + cat('AI', 'za', 'pSw6lmCzVDjSa9SHtlxlJuLOBpLF4qQUrzo'), +] as const +const GOCSPX = [ + cat('GOC', 'SPX-', 'ULkh0aC1B-YsPCWOZCH4zuWWM9uA'), + cat('GOC', 'SPX-', 'ooAMR1MiUvrh0m740qFiis59QGpJ'), + cat('GOC', 'SPX-', 'VWNlnManj_ry-RhfKK7vQN0HdWLs'), +] as const +const ANTHROPIC = [ + cat('sk-', 'ant-api03-', 'FXuLycHaLR6x3bUKoVVNH56jYNyAAjdz_OFd6Wrjz1W83-bJ9QU1ny02_Cmft7BjM4J-CDW-AJiCHghFApFyS0ZFP0_ij', 'AA'), + cat('sk-', 'ant-admin01-', '3rsiyv7L4tKWue4H-0NEeeNzzN34YDcGIwCQbSi49Bjq1ybIBvga5CDsT_j8ap62tQ6HWL5mykk_PXh6'), + cat('sk-', 'ant-oat01-', 'qRP2wqK4JvUk1En9xsZoTO4IYR00OR8vzzpkPrphY1_2XaiifmajVI1GwAoZ9aoI1EnhUfVj2s2VDv-JBtv301vCVJ'), +] as const +const OPENAI = [ + cat('sk-', 'proj-', 'qYTyU3jMdkaT1mWtlZkPFlhdScFxMH2mn7PeZorOMnASxxbXgQS1SmD4rcL0SOdxXAaG-Rkl5u_QOfZyg8xg6Yl4dVv53LPVDIq0PjSLmp66PrH0VTsH2WRnej7c_ElpKmxlD5eb0wK5UGOEwxlAa0cLOKml'), + cat('sk-', 'ToH0G9IQFqbqBDAHeD9T', 'T3Blbk', 'FJ', 'JNUcf8slJRxueFtuDHTN'), + cat('sk-', 'Qni1h1fInfgw9mq2U4e2751hCrFlC7A00CbGgDVNaww5Wyhf'), +] as const +const HF = [ + cat('hf', '_', 'GdbEOAIQPbzbevwwdmEHYwhBJnolpVCLij'), + cat('hf', '_', 'XgMPAtwFURotFKFjQazMjzyhjerhcpaFOz'), + cat('hf', '_', 'sziHiOzgKxaJnCCSjqGefcIZTIiPHFrast'), +] as const +const NPM = [ + cat('npm', '_', '6M7LcSkGFbK7oqDlcLInEl1oiIOGGACjGcC4'), + cat('npm', '_', 'JUG9uAbzLM58dvlzppWxJW85RKTjrXp3l2pd'), + cat('npm', '_', 'I4YTHRu3kowQ2hZAPfWq6SBYARDJtJJlKm84'), +] as const +const PYPI_PREFIX = cat('pypi-', 'AgEIcHlwaS5vcmc') +const PYPI = [ + cat(PYPI_PREFIX, 'uXnsqsuBDBYQ8S65XqMU6ujnhYSW-CawB7uuZEncIfJQ17TKOEtlrVjCejWAuZJYfUKVXu4qcT7HKwjrAOqTxnzPgV6zr7XnbDKCavqT7yzQYmoAwaRjTICU'), + cat(PYPI_PREFIX, 'bYKGfIUZYasaPu9xNiZ2ozCMEGdoyyYgfnDMDJwezrhPDwRWNeYqXaBAdMihZtoZ0gJlPk'), + cat(PYPI_PREFIX, '4hWnlZflI0C11Ys4dXAEazn355k7M6Hyyj0tNXzoY6nIKpcLxSGxrGUS3PSFGJptXl1exJDDUC54MPVx3nFZvhMN6y104JRtWBzwlfvrzrPELs4XH3eLlfqRabLAwrESAWJCwN6KtWeyjTWw3F7G7krtcmzMmLsk'), +] as const +const AZURE = [ + cat('tZQDbrYHyvi/61fdOgWwjO6hhbdu/SyPpt2caNe9OQ1uKScn/', 'E9cZaTS3bkCfx4scIdx4GpIZV4dmvKGi9SSVt=='), + cat('orcIIKKXkEAEJ4M3AWCIOu/kJqhx/Vet2zJpwzxxZ7po7i2Gb', 'Fi9t4rThVyklqQ0YqdpjD/Q/dD6OCa+ZzgNN3=='), + cat('OIAv7gEehNejIFq68FWAe9jUDa+75BnhMWKrCikaxvY7vhagM', '/Q4GPcEhwSDMcnTbP2bNAxUfS0/ZWrzdIOhmw=='), +] as const +const JWTS = [ + cat('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', '.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImlhdCI6MTUxNjIzOTAyMn0', '.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'), + cat('eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEifQ', '.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLnRlc3QiLCJhdWQiOiJkZW1vIn0', '.dGVzdC1zaWduYXR1cmUtbm90LXJlYWwtMDEyMzQ1Njc4OQ'), + cat('eyJhbGciOiJub25lIn0', '.eyJ1c2VyIjoiZmFrZSIsInJvbGUiOiJ0ZXN0ZXIifQ', '.'), +] as const +const pem = (kind: string, body: string): string => + cat('-----BEGIN ', kind, 'PRIVATE KEY-----\n', body, '\n-----END ', kind, 'PRIVATE KEY-----') +const PEMS = [ + pem('RSA ', 'NzUDrjtWMlTaVU8fegcu4NGEGhSJVAKyvROGigfbStjS1QatbvuAu2TY7H8wwL/e\njNv2GzzFUCju0Nebd+Agw+PwbvYyCrEvpO4+/0zKPwueRumokDsoOAv0biO77Q3n'), + pem('OPENSSH ', 'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nnVTT+6/YRAc6ag9pSZKthcOLxBxCYRgp8xYIN4yj'), + pem('EC ', 'MHcCAQEEIFakeFakeFakeKeyMaterialForTestsOnly0123456789oAoGCCqGSM49'), + cat('-----BEGIN PGP ', 'PRIVATE KEY BLOCK-----\n\nlQOYBGFakeTestKeyBlockNotRealAtAll0123456789abcdef\n=AbCd\n-----END PGP ', 'PRIVATE KEY BLOCK-----'), +] as const +const DB_URLS = [ + cat('postgres', '://admin:S3cr3tPa55w0rd', '@db.internal.test:5432/app'), + cat('mongodb+srv', '://svc_user:Xk9mP2vL7qR4', '@cluster0.abcde.mongodb.net/prod?retryWrites=true'), + cat('redis', '://:r3d1sP4ss!', '@cache.test:6379/0'), + cat('amqp', '://guest:Gu3stPw77', '@mq.test:5672/vhost'), +] as const +const BEARER = cat('B7U3ts9mwuLTxQYen8gvFVIks', 'TqnsCWhAV3vAUlr') +const BASIC = cat('dXNlcjpwYXNz', 'd29yZDEyMw==') + +const SECRET_CASES: Case[] = [ + { + id: 'aws-access-key', + label: 'AWS_KEY', + positives: [ + { text: `aws_access_key_id = ${AKIA}`, value: AKIA }, + { text: `{"AccessKeyId": "${ASIA}", "Expiration": "2026-10-03"}`, value: ASIA }, + { text: `export AWS_ACCESS_KEY_ID=${ABIA}\n`, value: ABIA }, + ], + negatives: [ + cat('aws_access_key_id = AKIA', 'IOSFODNN7EXAMPLE'), + cat('key: AKIA', 'XXXXXXXXXXXXXXXX'), + cat('id AKIA', '1234567890123456 is not base32'), + `commit 3f786850e387550fdab836ed7e6dc881de23001b`, + `${AKIA}Q is one character too long`, + ], + }, + { + id: 'aws-secret-key', + label: 'AWS_SECRET', + positives: [ + { text: `aws_secret_access_key = ${AWS_SECRET[0]}`, value: AWS_SECRET[0] }, + { text: `{"SecretAccessKey": "${AWS_SECRET[1]}"}`, value: AWS_SECRET[1] }, + { text: `AWS_SECRET_ACCESS_KEY: ${AWS_SECRET[2]}\n`, value: AWS_SECRET[2] }, + ], + negatives: [ + cat('aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/', 'bPxRfiCYEXAMPLEKEY'), + 'aws_secret_access_key = ${AWS_SECRET_ACCESS_KEY}', + 'secret_access_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'sha1 3f786850e387550fdab836ed7e6dc881de23001b', + ], + }, + { + id: 'github-token', + label: 'GITHUB_TOKEN', + positives: [ + { text: `GITHUB_TOKEN=${GHP}`, value: GHP }, + { text: `Authorization: token ${GHO}`, value: GHO }, + { text: `{"access_token": "${GHU}"}`, value: GHU }, + { text: `fine-grained ${GH_PAT} expires soon`, value: GH_PAT }, + ], + negatives: [ + cat('gh', 'p_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), + cat('gh', 'p_short123 in the docs'), + cat('gh', 'x_FsypJPdBdA4WCgo30YdzsLt4tEtEyxDDBuNQ'), + cat('github', '_pat_tooShort'), + 'merge 9fceb02d0ae598e95dc970b74767f19372d61af8', + ], + }, + { + id: 'gitlab-pat', + label: 'GITLAB_TOKEN', + positives: [ + { text: `PRIVATE-TOKEN: ${GLPAT[0]}`, value: GLPAT[0] }, + { text: `git remote add origin https://oauth2:${GLPAT[1]}@gitlab.test/g/p.git`, value: GLPAT[1] }, + { text: `new format ${GLPAT[2]}.`, value: GLPAT[2] }, + ], + negatives: [ + cat('gl', 'pat-short'), + cat('gl', 'pat-xxxxxxxxxxxxxxxxxxxxxxxx'), + cat('gl', 'pat_6nHSchOA-GK-qzzztWjM'), + 'see glpat- prefixed tokens in the GitLab docs', + ], + }, + { + id: 'slack-token', + label: 'SLACK_TOKEN', + positives: [ + { text: `SLACK_BOT_TOKEN=${SLACK[0]}`, value: SLACK[0] }, + { text: `token: "${SLACK[1]}"`, value: SLACK[1] }, + { text: `refresh with ${SLACK[2]} then retry`, value: SLACK[2] }, + ], + negatives: [ + cat('xo', 'xb-your-bot-token'), + cat('xo', 'xb-0000000000-xxxxxxxxxxxx'), + cat('xo', 'xz-1234567890-abcdefghijk'), + cat('xo', 'x-1234567890-abcdefghijk'), + ], + }, + { + id: 'slack-webhook', + label: 'SLACK_WEBHOOK', + positives: [ + { text: `SLACK_WEBHOOK_URL=${HOOKS[0]}`, value: HOOKS[0] }, + { text: `curl -X POST ${HOOKS[1]} -d '{}'`, value: HOOKS[1] }, + { text: `"url": "${HOOKS[2]}"`, value: HOOKS[2] }, + ], + negatives: [ + cat('https://hooks.', 'slack.com/services/T000/B000/XXXX'), + cat('https://hooks.', 'slack.com/'), + 'https://api.slack.com/apps/A0123456789/incoming-webhooks', + ], + }, + { + id: 'stripe-live-key', + label: 'STRIPE_KEY', + positives: [ + { text: `STRIPE_SECRET_KEY=${STRIPE[0]}`, value: STRIPE[0] }, + { text: `stripe.api_key = "${STRIPE[1]}"`, value: STRIPE[1] }, + { text: `Stripe(${STRIPE[2]})`, value: STRIPE[2] }, + ], + negatives: [ + cat('sk', '_test_nwOFXOaYo6ChDQHBEY6SFY3e'), + cat('sk', '_live_xxxxxxxxxxxxxxxxxxxxxxxx'), + cat('sk', '_live_short'), + cat('pk', '_live_nwOFXOaYo6ChDQHBEY6SFY3e'), + ], + }, + { + id: 'sendgrid-key', + label: 'SENDGRID_KEY', + positives: [ + { text: `SENDGRID_API_KEY=${SENDGRID[0]}`, value: SENDGRID[0] }, + { text: `sg = SendGridAPIClient("${SENDGRID[1]}")`, value: SENDGRID[1] }, + { text: `key ${SENDGRID[2]}\n`, value: SENDGRID[2] }, + ], + negatives: [ + cat('S', 'G.short.value'), + cat('S', 'G.xxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), + 'SGX.e5knhnKB9adyvmiKcDbZgn', + 'the SG. prefix marks SendGrid keys', + ], + }, + { + id: 'twilio-key', + label: 'TWILIO_KEY', + positives: [ + { text: `TWILIO_API_KEY=${TWILIO[0]}`, value: TWILIO[0] }, + { text: `client = Client("${TWILIO[1]}", secret)`, value: TWILIO[1] }, + { text: `sid ${TWILIO[2]}`, value: TWILIO[2] }, + ], + negatives: [ + 'md5 d41d8cd98f00b204e9800998ecf8427e', + cat('S', 'K4954f4c36546dc3ec8575ed4b156e48'), + cat('TAS', 'K4954f4c36546dc3ec8575ed4b156e487'), + 'SKU12345 in stock', + ], + }, + { + id: 'google-api-key', + label: 'GOOGLE_API_KEY', + positives: [ + { text: `GOOGLE_API_KEY=${GOOGLE_KEY[0]}`, value: GOOGLE_KEY[0] }, + { text: `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_KEY[1]}&callback=init`, value: GOOGLE_KEY[1] }, + { text: `"apiKey": "${GOOGLE_KEY[2]}",`, value: GOOGLE_KEY[2] }, + ], + negatives: [ + cat('AI', 'zaQdj6oeIuNOmvjq-eGPDkvLOdDu8'), + cat('AI', 'zaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'), + cat('AI', 'ZAQdj6oeIuNOmvjq-eGPDkvLOdDu8cykSiT0X'), + ], + }, + { + id: 'google-oauth-secret', + label: 'GOOGLE_OAUTH_SECRET', + positives: [ + { text: `GOOGLE_CLIENT_SECRET=${GOCSPX[0]}`, value: GOCSPX[0] }, + { text: `"client_secret":"${GOCSPX[1]}"`, value: GOCSPX[1] }, + { text: `secret ${GOCSPX[2]} rotated`, value: GOCSPX[2] }, + ], + negatives: [ + cat('GOC', 'SPX-short'), + cat('GOC', 'SPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxx'), + cat('GOC', 'SPY-ULkh0aC1B-YsPCWOZCH4zuWWM9uA'), + ], + }, + { + id: 'anthropic-key', + label: 'ANTHROPIC_KEY', + positives: [ + { text: `ANTHROPIC_API_KEY=${ANTHROPIC[0]}`, value: ANTHROPIC[0] }, + { text: `x-api-key: ${ANTHROPIC[1]}`, value: ANTHROPIC[1] }, + { text: `oauth ${ANTHROPIC[2]}\n`, value: ANTHROPIC[2] }, + ], + negatives: [ + cat('sk-', 'ant-api03-short'), + cat('sk-', 'ant-xxx'), + cat('sk-', 'ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), + ], + }, + { + id: 'openai-key', + label: 'OPENAI_KEY', + positives: [ + { text: `OPENAI_API_KEY=${OPENAI[0]}`, value: OPENAI[0] }, + { text: `openai.api_key = '${OPENAI[1]}'`, value: OPENAI[1] }, + { text: `old key ${OPENAI[2]} revoked`, value: OPENAI[2] }, + ], + negatives: [ + cat('sk-', 'proj-short'), + cat('sk-', '1234'), + cat('sk-', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), + cat('task-', 'Qni1h1fInfgw9mq2U4e2751hCrFlC7A00CbGgDVNaww5Wyhf'), + ], + }, + { + id: 'huggingface-token', + label: 'HF_TOKEN', + positives: [ + { text: `HF_TOKEN=${HF[0]}`, value: HF[0] }, + { text: `login(token="${HF[1]}")`, value: HF[1] }, + { text: `huggingface-cli login --token ${HF[2]}`, value: HF[2] }, + ], + negatives: [ + 'from huggingface_hub import hf_hub_download', + cat('hf', '_GdbEOAIQPbzbevwwdmEHYwhBJnolpVCLi'), + cat('hf', '_GdbEOAIQPbzbevwwdmEHYwhBJnolpVCL1j'), + cat('hf', '_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), + ], + }, + { + id: 'npm-token', + label: 'NPM_TOKEN', + positives: [ + { text: `//registry.npmjs.org/:_authToken=${NPM[0]}`, value: NPM[0] }, + { text: `NPM_TOKEN: ${NPM[1]}`, value: NPM[1] }, + { text: `publish with ${NPM[2]} today`, value: NPM[2] }, + ], + negatives: [ + 'echo $npm_config_registry', + cat('npm', '_6M7LcSkGFbK7oqDlcLInEl1oiIOGGACjGcC'), + 'process.env.npm_package_version', + ], + }, + { + id: 'pypi-token', + label: 'PYPI_TOKEN', + positives: [ + { text: `password = ${PYPI[0]}`, value: PYPI[0] }, + { text: `TWINE_PASSWORD="${PYPI[1]}"`, value: PYPI[1] }, + { text: `uv publish --token ${PYPI[2]}`, value: PYPI[2] }, + ], + negatives: [ + cat(PYPI_PREFIX, 'tooShort1234'), + 'username = __token__ and a pypi-token goes in password', + cat('pypi-', 'AgEIcHlwaS5vcmd', 'uXnsqsuBDBYQ8S65XqMU6ujnhYSW-CawB7uuZEncIfJQ17TKOEt'), + ], + }, + { + id: 'azure-storage-key', + label: 'AZURE_STORAGE_KEY', + positives: [ + { + text: `DefaultEndpointsProtocol=https;AccountName=fakeacct;AccountKey=${AZURE[0]};EndpointSuffix=core.windows.net`, + value: AZURE[0], + }, + { text: `AZURE_STORAGE_KEY="${AZURE[1]}"`, value: AZURE[1] }, + { text: `storage_account_key: ${AZURE[2]}`, value: AZURE[2] }, + ], + negatives: [ + 'AccountName=fakeacct;AccountKey=${AZURE_KEY};EndpointSuffix=core.windows.net', + 'AccountKey=tZQDbrYHyvi/61fdOgWwjO6hhbdu/SyPpt2caNe9', + `blob ${AZURE[0]}`, + ], + }, + { + id: 'jwt', + label: 'JWT', + positives: [ + { text: `Authorization: Bearer ${JWTS[0]}`, value: JWTS[0] }, + { text: `{"id_token":"${JWTS[1]}"}`, value: JWTS[1] }, + { text: `unsigned ${JWTS[2]}`, value: JWTS[2] }, + ], + negatives: [ + 'eyJ0eXAiOiJKV1QiLCJraWQiOiJhYmMifQ.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghijklmnop', + 'eyJhbGciIGJyb2tlbiBqc29u.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghijklmnop', + 'version 1.2.3 and 10.0.19045', + 'eyJ.short.x', + ], + }, + { + id: 'private-key', + label: 'PRIVATE_KEY', + positives: [ + { text: `key.pem:\n${PEMS[0]}\n`, value: PEMS[0] }, + { text: `"${PEMS[1]}"`, value: PEMS[1] }, + { text: `${PEMS[2]}`, value: PEMS[2] }, + { text: `gpg export\n${PEMS[3]}\ndone`, value: PEMS[3] }, + ], + negatives: [ + '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFakePublicKeyMaterial\n-----END PUBLIC KEY-----', + '-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUFakeCertificateBody0123\n-----END CERTIFICATE-----', + cat('-----BEGIN RSA ', 'PRIVATE KEY----- truncated, no end marker'), + ], + }, + { + id: 'db-connection-url', + label: 'DB_URL', + positives: [ + { text: `DATABASE_URL=${DB_URLS[0]}`, value: DB_URLS[0] }, + { text: `MONGO_URI="${DB_URLS[1]}"`, value: DB_URLS[1] }, + { text: `cache: ${DB_URLS[2]}`, value: DB_URLS[2] }, + { text: `broker_url = '${DB_URLS[3]}'`, value: DB_URLS[3] }, + ], + negatives: [ + 'DATABASE_URL=postgres://user:password@localhost/db', + 'postgres://localhost:5432/app', + 'mongodb://${DB_USER}:${DB_PASS}@mongo.test/db', + 'mysql://root:@127.0.0.1:3306/app', + ], + }, + { + id: 'auth-header', + label: 'AUTH_HEADER', + positives: [ + { text: `Authorization: Bearer ${BEARER}`, value: BEARER }, + { text: `curl -H "Authorization: Basic ${BASIC}" https://api.test`, value: BASIC }, + { text: `{"Authorization": "Token ${TWILIO[0].slice(2)}"}`, value: TWILIO[0].slice(2) }, + ], + negatives: [ + 'Authorization: Bearer $TOKEN', + 'Authorization: Bearer ', + 'Authorization: Bearer YOUR_ACCESS_TOKEN', + 'Authorization: Basic', + ], + }, + { + id: 'generic-secret-quoted', + label: 'SECRET', + positives: [ + { text: `password: "hunter2-Correct!"`, value: 'hunter2-Correct!' }, + { text: `const apiKey = '${cat('OWzE65iZIr', 'tvsKC8rnto')}'`, value: cat('OWzE65iZIr', 'tvsKC8rnto') }, + { text: `{"client_secret": "Zx8#kL2!pQ9@vR4$"}`, value: 'Zx8#kL2!pQ9@vR4$' }, + { text: `DB_PASSWORD="s0me-Pa55word"`, value: 's0me-Pa55word' }, + ], + negatives: [ + 'password: "password"', + 'api_key: ""', + 'token: "${GITHUB_TOKEN}"', + 'secret = "changeme"', + '"token": "xxxxxxxxxxxxxxxx"', + 'password: "%DB_PASSWORD%"', + ], + }, + { + id: 'generic-secret', + label: 'SECRET', + positives: [ + { text: `API_KEY=${cat('46j0025ou', 'VvU0lcS')}`, value: cat('46j0025ou', 'VvU0lcS') }, + { text: `db_password: Tr0ub4dor&3xyz\n`, value: 'Tr0ub4dor&3xyz' }, + { text: `run --token=${cat('1e64c201a6ccba54', '93ddc9c2cb8a61df')} --verbose`, value: cat('1e64c201a6ccba54', '93ddc9c2cb8a61df') }, + { text: `export SECRET_KEY=k3J9mQ2xV7pL4nR8`, value: 'k3J9mQ2xV7pL4nR8' }, + ], + negatives: [ + 'const token = getToken()', + 'password: ${DB_PASSWORD}', + 'apiKey: process.env.API_KEY', + 'auth=true', + 'secret: my-secret-name', + 'pwd = /usr/local/bin', + 'password=aaaaaaaaaaaa', + 'token: 1727950000', + ], + }, +] + +const PII_CASES: Case[] = [ + { + id: 'email', + label: 'EMAIL', + positives: [ + { text: 'mail jane.doe@example.com today', value: 'jane.doe@example.com' }, + { text: 'contact: test+tag@sub.example.org', value: 'test+tag@sub.example.org' }, + { text: 'From: ', value: 'a_b-c@mail.example.co.in' }, + ], + negatives: ['icon@2x.png', 'ssh user@localhost', 'npm i @scope/pkg@1.2.3', '@Component decorator'], + }, + { + id: 'payment-card', + label: 'CARD', + positives: [ + { text: 'card 4111 1111 1111 1111 exp 12/30', value: '4111 1111 1111 1111' }, + { text: 'mc: 5555-5555-5555-4444', value: '5555-5555-5555-4444' }, + { text: 'amex=378282246310005', value: '378282246310005' }, + { text: '"pan": "6011111111111117"', value: '6011111111111117' }, + ], + negatives: [ + 'card 4111 1111 1111 1112', + 'charge 4242424242424241', + 'ts 1727950000123', + 'order 1234-5678-9012-3456', + 'mixed 4111 1111-1111 1111', + 'id 123e4567-e89b-12d3-a456-426614174000', + ], + }, + { + id: 'aadhaar', + label: 'AADHAAR', + positives: [ + { text: 'aadhaar 2345 6789 0124', value: '2345 6789 0124' }, + { text: 'uid=987654321012', value: '987654321012' }, + { text: 'UID: 5555-1234-5673.', value: '5555-1234-5673' }, + ], + negatives: ['aadhaar 2345 6789 0125', 'id 1234 5678 9012', 'ref 2345 6789 01245', 'mixed 2345-6789 0124'], + }, + { + id: 'us-ssn', + label: 'SSN', + positives: [ + { text: 'ssn 234-56-7890', value: '234-56-7890' }, + { text: 'SSN: 345-67-8901', value: '345-67-8901' }, + { text: 'ssn=456-78-9012;', value: '456-78-9012' }, + ], + negatives: ['000-12-3456', '666-12-3456', '912-34-5678', '123-00-4567', '078-05-1120', 'date 2024-10-03'], + }, + { + id: 'iban', + label: 'IBAN', + positives: [ + { text: 'IBAN GB82WEST12345698765432', value: 'GB82WEST12345698765432' }, + { text: 'iban: DE89 3704 0044 0532 0130 00', value: 'DE89 3704 0044 0532 0130 00' }, + { text: '"FR1420041010050500013M02606"', value: 'FR1420041010050500013M02606' }, + ], + negatives: ['IBAN GB82WEST12345698765433', 'DE88 3704 0044 0532 0130 00', 'GB82 only', 'ID AB12CDEFGHIJKLMN'], + }, + { + id: 'india-pan', + label: 'PAN', + positives: [ + { text: 'PAN ABCPE1234F', value: 'ABCPE1234F' }, + { text: 'pan: AAAPZ1234C', value: 'AAAPZ1234C' }, + { text: '"pan":"BNZCA5678K"', value: 'BNZCA5678K' }, + ], + negatives: ['PAN ABCDE1234F', 'abcpe1234f', 'ABCPE12345', 'ABCPE1234'], + }, + { + id: 'phone', + label: 'PHONE', + positives: [ + { text: 'call +919876543210 now', value: '+919876543210' }, + { text: 'tel: +1 (415) 555-0132', value: '+1 (415) 555-0132' }, + { text: 'office +44 20 7946 0958', value: '+44 20 7946 0958' }, + { text: '(415) 555-0199 ext 2', value: '(415) 555-0199' }, + { text: 'mobile 98765 43210', value: '98765 43210' }, + ], + negatives: [ + 'ts 1727950000', + 'at 2024-10-03 12:34:56', + 'host 192.168.100.200', + 'v1.2.3', + 'id 123e4567-e89b-12d3-a456-426614174000', + 'dial +1 415', + ], + }, +] + +const CASES = [...SECRET_CASES, ...PII_CASES] + +function checkCase(c: Case): void { + for (const p of c.positives) { + const hits = scan(p.text, { pii: true }) + const hit = hits.find((h) => h.id === c.id) + expect(hit, `${c.id} should match: ${p.text}`).toBeDefined() + expect(hit?.value).toBe(p.value) + expect(hit?.label).toBe(c.label) + expect(p.text.slice(hit?.start ?? 0, hit?.end ?? 0)).toBe(p.value) + } + for (const n of c.negatives) { + expect(scan(n, { pii: true }), `${c.id} should not match: ${n}`).toEqual([]) + } +} + +describe('rules', () => { + test('every rule has a case, a global pattern and a source URL', ($, on) => { + const ids = CASES.map((c) => c.id) + for (const rule of RULES) { + expect(ids).toContain(rule.id) + expect(rule.pattern.flags).toMatch(/g/) + expect(rule.source).toMatch(/^https:\/\//) + expect(rule.label).toMatch(/^[A-Z][A-Z0-9_]*$/) + } + expect(new Set(RULES.map((r) => r.id)).size).toBe(RULES.length) + }) + + for (const c of CASES) { + test(`${c.id}: ${c.positives.length} positives, ${c.negatives.length} negatives`, ($, on) => { + expect(c.positives.length).toBeGreaterThanOrEqual(3) + expect(c.negatives.length).toBeGreaterThanOrEqual(3) + checkCase(c) + }) + } +}) + +describe('scan', () => { + test('pii rules stay silent unless pii is on', ($, on) => { + const text = 'jane.doe@example.com, 4111 1111 1111 1111, 234-56-7890, ABCPE1234F, +919876543210, GB82WEST12345698765432' + expect(scan(text)).toEqual([]) + expect(scan(text, { pii: false })).toEqual([]) + const ids = scan(text, { pii: true }).map((h) => h.id) + expect(ids).toEqual(['email', 'payment-card', 'us-ssn', 'india-pan', 'phone', 'iban']) + }) + + test('off skips the named rules only', ($, on) => { + const text = `token ${GHP} and key ${AKIA}` + expect(scan(text).map((h) => h.id)).toEqual(['github-token', 'aws-access-key']) + expect(scan(text, { off: new Set(['github-token']) }).map((h) => h.id)).toEqual(['aws-access-key']) + expect(scan('mail jane.doe@example.com', { pii: true, off: new Set(['email']) })).toEqual([]) + }) + + test('an existing placeholder is never matched again', ($, on) => { + expect(scan('aws_access_key_id = [REDACTED:AWS_KEY#a1b2c3]', { pii: true })).toEqual([]) + expect(scan('password=[REDACTED:SECRET#00ff] token: "[REDACTED:SECRET#abc123]"', { pii: true })).toEqual([]) + expect(scan('[REDACTED:AWS_KEY#a1b2c3][REDACTED:EMAIL#ffff]', { pii: true })).toEqual([]) + const text = `old [REDACTED:AWS_KEY#a1b2c3] new ${AKIA}` + const hits = scan(text) + expect(hits.length).toBe(1) + expect(hits[0]?.value).toBe(AKIA) + expect(hits[0]?.start).toBe(text.indexOf(AKIA)) + }) + + test('overlapping rules give one hit, leftmost then longest', ($, on) => { + const url = DB_URLS[0] + const hits = scan(`DB_PASSWORD=${url}`) + expect(hits).toEqual([{ id: 'db-connection-url', label: 'DB_URL', kind: 'secret', start: 12, end: 12 + url.length, value: url }]) + const nested = scan(`token: postgres://token:${cat('S3cr3tPa55', 'w0rd')}@db.test/app`) + expect(nested.map((h) => h.id)).toEqual(['db-connection-url']) + const sorted = scan(`${GHP} then ${AKIA} then ${BEARER}`) + expect(sorted.map((h) => h.id)).toEqual(['github-token', 'aws-access-key']) + }) + + test('2 MB of random base64 scans in under 1000 ms', ($, on) => { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + let seed = 0x2f6b1d3a + const next = (): number => { + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + const chunks: string[] = [] + for (let i = 0; i < 2 * 1024 * 1024; i++) chunks.push(alphabet.charAt(Math.floor(next() * 64))) + const blob = chunks.join('') + const t0 = performance.now() + scan(blob, { pii: true }) + const ms = performance.now() - t0 + expect(ms).toBeLessThan(1000) + }) +}) diff --git a/plugins/redact/tests/scrub.test.ts b/plugins/redact/tests/scrub.test.ts new file mode 100644 index 0000000..faca95d --- /dev/null +++ b/plugins/redact/tests/scrub.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'claude-code/testing' +import type { ApiContentBlock } from 'claude-code' +import { PLACEHOLDER, WITHHELD, fnv1a, redactBlocks, redactText, restore, tally, describe as describeCounts, withhold, type Settings } from '../hooks/scrub' + +const KEY = 'AKIAQ4ZKX7TR2MDPLW5B' +const EMAIL = 'jane.doe@example.com' +const SALT = 'test-salt' +const cfg = (over: Partial = {}): Settings => ({ pii: false, quiet: false, off: new Set(), ...over }) +const one = /^\[REDACTED:[A-Z0-9_]+#[0-9a-f]{8}\]$/ + +function at(rows: readonly T[], i = 0): T { + const row = rows[i] + if (row === undefined) throw new Error(`no row ${i}`) + return row +} + +describe('redactText', () => { + test('replaces a key with a placeholder and keeps the text around it', async () => { + const r = redactText(`here is my key ${KEY} please use it`, SALT, cfg()) + expect(r.text).toMatch(/^here is my key \[REDACTED:[A-Z0-9_]+#[0-9a-f]{8}\] please use it$/) + expect(r.hits.length).toBe(1) + expect(r.pairs).toEqual([[expect.stringMatching(one), KEY]]) + }) + + test('the same value and salt give the same placeholder, a different salt a different one', async () => { + const a = redactText(KEY, SALT, cfg()).text + const b = redactText(`x ${KEY} y`, SALT, cfg()).text + const c = redactText(KEY, 'other', cfg()).text + expect(a).toMatch(one) + expect(b).toBe(`x ${a} y`) + expect(c).not.toBe(a) + }) + + test('clean text comes back as the same string with no hits', async () => { + const text = 'rename the function and add a test' + const r = redactText(text, SALT, cfg()) + expect(r).toEqual({ text, hits: [], pairs: [] }) + }) + + test('PII is left alone until pii is on', async () => { + expect(redactText(`mail ${EMAIL}`, SALT, cfg()).text).toContain(EMAIL) + const on = redactText(`mail ${EMAIL}`, SALT, cfg({ pii: true })) + expect(on.text).not.toContain(EMAIL) + expect(on.text).toMatch(/^mail \[REDACTED:EMAIL#[0-9a-f]{8}\]$/) + }) + + test('a rule in off is skipped', async () => { + expect(redactText(`key ${KEY}`, SALT, cfg({ off: new Set(['aws-access-key']) })).text).toContain(KEY) + }) + + test('a placeholder already in the text is not matched again', async () => { + const once = redactText(KEY, SALT, cfg()).text + const twice = redactText(once, SALT, cfg()) + expect(twice.hits).toEqual([]) + expect(twice.text).toBe(once) + }) +}) + +describe('redactBlocks', () => { + test('rewrites text blocks, string tool results and nested tool result blocks; keeps the rest', async () => { + const image: ApiContentBlock = { type: 'image', source: { data: 'abc' } } + const blocks: ApiContentBlock[] = [ + { type: 'text', text: `a ${KEY}` }, + { type: 'tool_result', tool_use_id: 't1', content: `b ${KEY}` }, + { type: 'tool_result', tool_use_id: 't2', content: [{ type: 'text', text: `c ${KEY}` }, image] }, + image, + { type: 'thinking', thinking: KEY }, + ] + const r = redactBlocks(blocks, SALT, cfg()) + expect(r.hits.length).toBe(3) + expect(JSON.stringify(r.blocks.slice(0, 3))).not.toContain(KEY) + expect(at(r.blocks, 1)).toMatchObject({ type: 'tool_result', tool_use_id: 't1' }) + const nested = at(r.blocks, 2).content as ApiContentBlock[] + expect(at(nested, 1)).toBe(image) + expect(at(r.blocks, 3)).toBe(image) + expect(at(r.blocks, 4)).toBe(at(blocks, 4)) + }) + + test('a clean block is the same object, not a copy', async () => { + const block: ApiContentBlock = { type: 'text', text: 'nothing here' } + expect(at(redactBlocks([block], SALT, cfg()).blocks)).toBe(block) + }) +}) + +describe('restore', () => { + test('puts the real value back for a known placeholder and leaves an unknown one', async () => { + const r = redactText(`SECRET=${KEY}`, SALT, cfg()) + const vault = Object.fromEntries(r.pairs) + const p = at(r.pairs)[0] + expect(restore(`SECRET=${p}\n# ${p}`, vault)).toEqual({ text: `SECRET=${KEY}\n# ${KEY}`, n: 2 }) + expect(restore('v=[REDACTED:AWS_KEY#00000000]', vault)).toEqual({ text: 'v=[REDACTED:AWS_KEY#00000000]', n: 0 }) + }) +}) + +describe('the rest', () => { + test('withhold replaces text and tool results, keeps ids and media', async () => { + const image: ApiContentBlock = { type: 'image', source: {} } + const out = withhold([{ type: 'text', text: KEY }, { type: 'tool_result', tool_use_id: 't1', content: KEY, is_error: true }, image]) + expect(out).toEqual([{ type: 'text', text: WITHHELD }, { type: 'tool_result', tool_use_id: 't1', content: WITHHELD, is_error: true }, image]) + expect(JSON.stringify(out)).not.toContain(KEY) + }) + + test('tally and describe count by label, most frequent first', async () => { + const r = redactText(`${KEY} ${EMAIL} ${EMAIL}`, SALT, cfg({ pii: true })) + const t = tally(r.hits) + expect(t).toEqual({ AWS_KEY: 1, EMAIL: 2 }) + expect(describeCounts(t)).toBe('2 EMAIL, 1 AWS_KEY') + }) + + test('fnv1a is stable and eight hex chars', async () => { + expect(fnv1a('')).toBe('811c9dc5') + expect(fnv1a('a')).toBe('e40c292c') + expect(fnv1a(KEY)).toMatch(/^[0-9a-f]{8}$/) + expect(PLACEHOLDER.source).toContain('REDACTED') + }) +}) diff --git a/plugins/redact/tsconfig.json b/plugins/redact/tsconfig.json new file mode 100644 index 0000000..8681ec0 --- /dev/null +++ b/plugins/redact/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.claude-plugin/types/tsconfig.json" +} diff --git a/plugins/redact/types/index.d.ts b/plugins/redact/types/index.d.ts new file mode 100644 index 0000000..85fefaa --- /dev/null +++ b/plugins/redact/types/index.d.ts @@ -0,0 +1,10 @@ +declare module 'claude-code' { + interface PluginState { + redact: { + vault: Record + salt: string + counts: Record + restored: number + } + } +}