Scrub your first log line¶
By the end of this you'll have a small program that takes a realistic error
string with a database password and an API key in it, runs it through
rtb-redact, and prints both versions side by side. You'll also see two lines
the crate leaves alone, which is the part worth remembering.
Allow about fifteen minutes. Nothing here touches a real credential or a real service — every value is made up and stays on your machine.
Before you start¶
You need Rust 1.82 or newer, which is the crate's minimum. Check with:
If that's older, install a current toolchain with rustup first.
Create the project¶
cargo add writes the dependency into Cargo.toml. It'll pick up 0.6 or newer;
the crate pulls in regex and phf, so the first build takes a minute or so.
Leak a password on purpose¶
Open src/main.rs and replace it with a line of the kind a connection failure
actually produces:
fn main() {
let line = "connect failed: postgres://svc:hunter2@db.internal/orders";
println!("raw: {line}");
}
Run it:
That's the whole problem in one line. Nobody wrote println!("{password}") —
the password came along inside a connection string that a library formatted into
an error. If that line goes to a log aggregator, the password goes with it.
Redact it¶
Add the import and a second println!:
use rtb_redact::string;
fn main() {
let line = "connect failed: postgres://svc:hunter2@db.internal/orders";
println!("raw: {line}");
println!("redacted: {}", string(line));
}
raw: connect failed: postgres://svc:hunter2@db.internal/orders
redacted: connect failed: postgres://[redacted]@db.internal/orders
The userinfo is gone and everything you need to debug with — the scheme, the host, the database name — is still there. That's the shape of every rule in the crate: mask the credential, keep the context.
string returns a Cow<str>. When nothing matches it hands back a borrow of
your input and allocates nothing, so calling it on every line is cheap.
Redact an API key in a URL¶
Swap the line for one with a query parameter and run it again:
raw: GET /v1/orders?api_key=sk-ant-api03-abcdefghijklmnop failed: 401 Unauthorized
redacted: GET /v1/orders?api_key=[redacted] failed: 401 Unauthorized
Two separate rules would each have caught that one: the parameter name api_key
is on the recognised list, and the value's sk-ant- prefix is a known
credential shape. The parameter name survives, because knowing which key was
rejected is usually the point of the log line.
See what gets through¶
This is the step people skip, and it's the one that decides whether you can rely on the crate. Put three more lines through it:
use rtb_redact::string;
fn main() {
for line in [
r#"login failed: {"user":"alice","password":"hunter2"}"#,
"session sess_9f2a expired",
"built from commit 0f3a5b6c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a",
] {
println!("{line}\n -> {}\n", string(line));
}
}
login failed: {"user":"alice","password":"hunter2"}
-> login failed: {"user":"alice","password":"hunter2"}
session sess_9f2a expired
-> session sess_9f2a expired
built from commit 0f3a5b6c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a
-> built from commit [redacted]
Three lessons, in order:
- JSON is not parsed. The rules match
password=value, not"password": "value". A secret inside a serialised payload survives unless the value itself looks like a known credential. Redact structured fields before you serialise them. - Short secrets survive.
sess_9f2ais a credential in some services and nothing at all in others; it's too short and too ordinary to match anything, and no rule could catch it without wrecking normal text. - A git commit hash gets redacted. It's a 40-character run bounded by spaces, which is exactly what the catch-all rule looks for. Over-redaction is the deliberate choice here: a lost hash costs you a re-read, a leaked token costs you a rotation.
Redaction rules lists all seven rules and what each one misses.
Put the call where it belongs¶
Redacting in main was for the demo. In real code the call goes at the boundary
— the last point before a string leaves your process:
use rtb_redact::string;
fn ship(line: &str) {
sink.send(&string(line)); // one call; every path into `line` is covered
}
One call at the edge covers every code path that feeds it, including code you didn't write — which is the code that leaked the password in the first step. Leave your local debug logs alone: those never leave the machine, and you'll want the raw string when you're the one debugging.
Where to go next¶
- Redact before an external surface — where to put the call in a real service, and how to reuse a buffer.
- Redact HTTP headers — headers need a
separate call;
stringdoesn't know header names. - Redaction rules — the full catalogue with thresholds.
- What rtb-redact does not do — the limits, stated as limits.