config-password-sanitisation

Swap the real secrets in a config file for stable placeholders so it can be pasted into a ticket, a chat, a mailing list or an LLM prompt — and swap them back afterwards, so the answer you get returned is a file you can actually deploy.

sanitise.sh --init                       # write ~/.config/sanitise/map, then edit it
sanitise.sh config.yaml                  # -> config-sanitised.yaml       (safe to hand over)
sanitise.sh -r config-sanitised.yaml     # -> config-sanitised-unsanitised.yaml

The point of stable placeholders is the round trip. Blanking secrets out with sed -i 's/password: .*/password: REDACTED/' is a one-way door: whoever helps you edits the redacted file, hands it back, and now you have to re-apply their changes to the real one by hand — a merge, done by eye, in the one file where a mistake locks you out of your own database. With a map, the same secret always becomes the same placeholder, so -r reconstructs the deployable file byte for byte.

The map file

The secrets live in the map, never in the script — which is why the script can be in this repository and the map cannot.

s3cr3t-db-pass!	password1
hunter2	password2
p@ss*w[ord?	password3

One replacement per line: the real secret, a single TAB, then its placeholder. A tab because it is the one character a password essentially never contains, unlike a space, =, :, # or a quote — no quoting rules to learn and no secret you cannot express. A line starting with # that contains no tab is a comment, so a secret may itself start with #. See sanitise.map.example.

--init writes a template with mode 600 and creates the directory if needed. The map is looked for at --map, else $SANITISE_MAP, else ${XDG_CONFIG_HOME:-~/.config}/sanitise/map; the current directory is deliberately not searched, so a map cannot be picked up by accident from a repository you happen to be standing in. If the map is readable by anyone but you, the tool says so on every run.

Substitution that survives its own output

Replacements are applied in one left-to-right pass, taking the leftmost match and, where several start at the same position, the longest of them. Text that has been replaced is never looked at again.

That is not the obvious implementation, and the obvious one is wrong in two ways that a config file will find:

Secrets are matched literally, so p@ss*w[ord? is a password and not a glob.

Three checks, because a silent failure here is a leaked credential

After sanitising, before you upload anything:

A per-secret count goes to stderr — password1: 3 — because the count you did not expect is how you find out that the map is stale, or that a secret is in the file twice as often as you thought.

$ sanitise.sh config.yaml
password1: 1
password2: 1
config-sanitised.yaml: 2 replacement(s)

Output files are never overwritten. If config-sanitised.yaml exists you get config-sanitised-2.yaml, then -3, and the file is created with noclobber rather than tested-then-written, so two runs at once cannot land on the same name. -o FILE overrides the name (--force to overwrite, -o - for stdout).

The reversed file is created mode 600 whatever your umask is: it holds the real secrets. The sanitised file is not, because handing it to someone is the entire point.

As a pre-commit hook

--check reports which secrets appear in the files you give it and exits 2 if any do, writing nothing:

$ sanitise.sh --check config.yaml
config.yaml:1,7: secret for placeholder password1
config.yaml:2: secret for placeholder password2

It prints the file, the lines and the placeholder — never the matched line. A secret scanner that echoes the secret into a terminal, a CI log or a hook's output has just leaked it somewhere new.

#!/bin/sh
# .git/hooks/pre-commit
files=$(git diff --cached --name-only --diff-filter=ACM)
[ -n "$files" ] && exec sanitise.sh --check $files

Tests

uv run tests/test_sanitise.py   # self-contained
pytest tests/                   # if pytest is already available

The tests drive the real CLI end-to-end in a temporary directory, including both overlap hazards, the glob-metacharacter secret, the file modes and every map-file error.

Limits

Contents

📁tests/
📄README.md6.5 KB
📄sanitise.map.example779 B
📄sanitise.sh12.9 KB