Pipe in a stack trace or log spew and launder hands back the same text with identifying paths, usernames, secrets, and contacts swapped for readable placeholders — while keeping everything a reader needs to actually help you: relative paths, file extensions, line numbers, system paths, and diagnostic IDs.
$ myapp 2>&1 | launder | pbcopy # clean trace on the clipboard$ launder crash.log
/Users/dpep/code/proj/src/db.rs:42 → ~/code/proj/src/db.rs:42
Authorization: Bearer eyJhbGciOi... → Authorization: Bearer <JWT_1>
ghp_AbCdEf0123... → <TOKEN_1>
alerts@dpep.io → <EMAIL_1>
203.0.113.7 → <IP_1>Positionals are files; with none, launder reads stdin. It's streaming and line-oriented, so it works on tail -f. The default action is "read a stream, print it laundered" — every other behavior is a flag.
The bias is asymmetric, on purpose:
- Paths and IPs lean toward under-scrubbing. System paths (
/usr,/etc, …), file extensions, line numbers, private/loopback IPs, and diagnostic IDs (UUIDs, git SHAs) survive untouched — over-scrubbing destroys the trace's usefulness. A home path collapses to~but its relative tail lives on:/Users/dpep/code/proj/src/db.rs:42→~/code/proj/src/db.rs:42. - Secrets lean toward over-detection. A missed credential is the worst possible outcome of pasting a log, so known-prefix tokens (GitHub, AWS, Stripe, Slack, …), JWTs, private-key blocks,
Authorization:headers, and URL passwords are always redacted — plus any high-entropy value sitting under a suspicious key (password=,api_key=).
Replacements are readable, numbered placeholders — <EMAIL_1>, <TOKEN_2>, <USER_1>, … — counted per type in first-seen order, so a repeated value reuses its placeholder and "user X did A then B" stays linkable. Everything else — system paths, extensions, line numbers, UUIDs, git SHAs, request IDs, private IPs — passes through untouched.
launder reads your $USER, $HOME, and hostname (offline) and uses them as a watchlist — extra known-identifying tokens to scrub on sight, so your username gets caught even where no home path reveals it, and your machine name gets redacted. It's pure signal: it never pins your identity or imposes ordering. So a trace generated on your laptop and one you pulled off a remote box both launder cleanly — on the remote log the subject is whoever appears (they become ~), and your local names simply stay inert because they don't show up.
brew install dpep/tools/launder # builds from source; no runtime depsOr build it yourself — launder needs Rust only at build time:
cargo install --path . # or: make installlaunder [FILE...] # files, or stdin if none
launder -o/--output FILE # write laundered text to FILE (default: stdout)
launder --no-keep-system # scrub OS paths too (kept by default)
launder --all-ips # scrub private/loopback IPs too (kept by default)
launder --only TYPES # comma list: path,secret,email,ip,host,mac,user
launder --except TYPES # run everything except these types
launder -r/--report # summary of what changed (counts by type) → stderr
launder -n/--dry-run # detect + report, pass input through UNCHANGED
launder -j/--json # buffered: one object {laundered, findings, summary}
launder -J/--ndjson # streaming: one JSON finding per line
launder --with-originals # include original values in JSON (never for secrets)
launder -h/--help · -V/--version-j and -J replace the default text output with structured output; --report adds a stderr summary without touching stdout. One rule overrides everything: a raw secret never appears in any output mode — --with-originals is honored for paths, emails, and the like, but never for a secret.
A short streaming pipeline, one line in flight:
read line → detect spans → resolve overlaps → assign placeholders → emit.
- Detect runs structural matchers only — known prefixes, structural validation (IP octet ranges, JWT segment shape, UUID/SHA recognition), entropy under a key, and a local-identity signal ($USER / $HOME / hostname, used as a watchlist). No ML, no NER, no network.
- Resolve reduces overlapping matches to a non-overlapping set by precedence (a credentialed URL is caught as one credential, not split into host + path).
- Assign maps each distinct value to a stable placeholder for the life of the run.
- Emit substitutes spans, honoring the secret rule.
See CLAUDE.md for development conventions.