Repository navigation
fix(redaction): stop reading shell expansions as secret assignments - #13
Merged
Merged
Conversation
`${PWD:t}` matched the bare-assignment pattern as `PWD: t}` (PWD is a
password key), and redacting the "value" ate the closing brace. The
published dotfile no longer parsed, and every other machine received it as
its working copy while the author's machine kept the original.
- A key read through `${…}` or `$KEY:` is never an assignment. The one
part of an expansion that can hold a literal secret, the word of a
default, assign, or alternate, is redacted inside the braces instead.
- Values that only refer to something else (`$(gh auth token)`, `$GH_TOKEN`,
`${GH_TOKEN}`) are no longer replaced.
- PWD stays a password key outside expansions (ODBC `PWD=`).
- Filter revision 3, so every checkout reprocesses tracked files and the
authoring machine republishes what the old filter mangled.
- Review packets list every added line carrying a placeholder and tell the
reviewer that a false positive replaces the text on every other machine.
Co-Authored-By: Claude <noreply@anthropic.com>
- `${#NAME:-word}` defaults are redacted: the read exemption and the default
rule share one grammar.
- Double-quoted strings skip their own expansions, so a quote inside
`$(…)` no longer ends the word early and leaves a secret behind.
- Only an unquoted command with nothing inside to redact is left alone;
quoted commands and `$((…))` arithmetic are redacted as before.
- Nesting is bounded and reference words are scanned once, so hostile input
stays linear in the clean filter.
- The review packet decodes git-quoted and tab-terminated paths.
Co-Authored-By: Claude <noreply@anthropic.com>
A command substitution was left alone when redacting its inside changed nothing, but `$(cat<<<hunter2)` has no whitespace, so the old pattern redacted all of it. Recursing into the command was also combinatorial. Now only the span the old pattern would have replaced is exempt, and only when it is `$(` and a plain command name. Co-Authored-By: Claude <noreply@anthropic.com>
`PASSWORD=$(sk-…)` passed the command-name exemption, then the token patterns replaced the name, and the next pass no longer exempted `$(<API_KEY>)`. A placeholder now passes as a command name. The docs no longer claim a command's arguments go unchecked. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happened
A dotfiles sidecar (
redaction = "secrets",review = "required") published this zsh line fromhome/.zshrc_mods:as:
The other machines received the redacted blob as their working copy, so
zsh -nfailed there and every interactive shell broke. The authoring machine keeps its original, so the author never saw it. The review gate approved the change: the reviewer saw only placeholders, and the instructions called them "not leaks".Root cause
PWDis a password key (pwdinCOMPACT_SENSITIVE_KEYS,PWDin the last-part list).BARE_ASSIGNMENT_SECRET_REGEXread zsh's${PWD:t}as the assignmentPWD: t}and replaced the "value", including the closing brace. The same shape broke${PASSWORD:0:3},${GITHUB_TOKEN:?msg},$TOKEN:foo, and, on the value side,export GITHUB_TOKEN=$(gh auth token)(which became<TOKEN> auth token)).Changes
${…(including zsh(flags)and the#!^=~prefixes), or through$KEYbefore a:, is skipped by the assignment patterns.$password = '…'(PHP/PowerShell) is still redacted.:-,:=,:+(and the forms without:) is replaced up to its matching brace. The brace match handles escapes, quotes, backticks, and nested${}/$(). So${DB_PASSWORD:-hunter2}→${DB_PASSWORD:-<SECRET>}, where main produced a broken${DB_PASSWORD:<SECRET>. A word that is only a reference passes through. Nesting is bounded, so hostile input stays linear.$UPPER_NAME, a${NAME}, or a command name ($(gh …, exactly the span the old pattern would have replaced) is left alone. A quoted"$(…)",$((…)),$(cat<<<x), and anything less plain are redacted as before.PWDstays a password key outside expansions (ODBCUID=sa;PWD=…;). Inside an expansion,PWD/OLDPWDare the shell's working directory.REDACTION_FILTER_REVISION2 → 3, so every checkout renormalizes on its next snapshot. The authoring machine then republishes files the old filter mangled.docs/redaction.mdgains "Shell expansions" and "False positives break other machines, not yours" (with this incident).docs/review.mddescribes the inventory.Considered and not done
zsh -n/bash -n) before and after redaction. True positives break shell syntax too (export GITHUB_TOKEN=<TOKEN>parses as a redirection), so a check can't tell false positives from real ones. It would also spawn interpreters inside the clean filter on everygit add.sidecar status. Deferred: status does no redaction work today, the snapshot message andsidecar redactionsalready exist, and the gated path now gets the inventory.URL_CREDENTIALS_REGEXstill rewrites${a}//b:c@and${v//a:b@/x}. Tightening it would drop schemeless detection; these are rare.Rollout notes
Every machine needs the new version: an old filter still applies the old patterns when it re-commits a file. After upgrade, the authoring machine's next snapshot republishes the original (through review on gated peers), and clean checkouts elsewhere fast-forward to it.
Tests
bun run checkpasses.bun run test: 285 passed, 23 skipped (integration tests not run). New cases:redactTextandredactBuffer(the clean filter's path), published unchanged in both modesDesigned and reviewed with Codex (gpt-6.1-sol) over four rounds.
🤖 Generated with Claude Code