Redaction
redaction helps you keep sensitive values (tokens, secrets, PII) out of places they don't belong by:
- Deriving
Sensitiveon your types with#[derive(Sensitive)] - Marking sensitive fields with
#[sensitive(Classification)] - Calling
.redact()to produce a copy that is safe to log or serialize - Generating
Debugoutput that prints"[REDACTED]"for sensitive fields (independent of policies)
Design (DDD / Clean Architecture friendly)
- Classifications are domain concepts: marker types like
Secret,Token, or your ownInternalIdrepresent what kind of data a field contains. - Policies belong in the application layer: policies are attached to classification types (
impl RedactionPolicy for MyClassification) in the layer where you define “what is safe to expose”, typically close to logging/serialization boundaries. - Sinks are optional adapters: integrations like
sloglive behind feature flags; your domain types don’t depend on a logging framework. - Layering is optional: you can put classifications, policies, and redaction calls in a single crate if you prefer. The library supports both “clean architecture” layering and simple, pragmatic project layouts.
The Problem
Sensitive data ends up places it shouldn't:
- Logging: Structured logs capture request/response bodies containing passwords, tokens, PII
- Serialization: API responses, database exports, and message queues include fields that should be hidden
- Error reporting: Stack traces and error contexts expose sensitive state
- Debug output:
#[derive(Debug)]prints everything, including secrets
Once sensitive data reaches these systems, it is often:
- Stored long-term (retention policies, backups)
- Indexed and searchable
- Replicated across environments
- Visible to anyone with access to logs/telemetry
let request = LoginRequest ;
// Debug output exposes the password
println!;
// → LoginRequest { username: "alice", password: "hunter2" }
// Serialization also exposes the password
let json = to_string.unwrap;
// → {"username":"alice","password":"hunter2"}
//
// (This example uses `serde_json` to make the risk concrete. The same problem
// exists with any serializer that includes `password`.)
The Solution
Mark sensitive fields with a classification. This crate provides:
- Safe Debug: sensitive fields print as
[REDACTED] - Explicit redaction: call
.redact()to get a copy safe for serialization and logging - Policy control: choose how each classification is redacted (full, keep, mask)
use ;
let request = LoginRequest ;
// Get a redacted copy for serialization, APIs, and logging
let safe = request.redact;
assert_eq!; // Secret: fully redacted
assert_eq!; // Token: only last 4 visible
assert_eq!; // Not sensitive: unchanged
// Debug output is also safe, but it does NOT apply policies:
// it always prints `"[REDACTED]"` for `#[sensitive(...)]` fields.
println!;
// → LoginRequest { username: "alice", password: "[REDACTED]", api_key: "[REDACTED]" }
Installation
[]
= "0.1"
Basic Usage
- Add
#[derive(Clone, Sensitive)]to your type - Mark sensitive fields with
#[sensitive(Classification)] - Call
.redact()before you log, serialize, return, or persist the value
use ;
let creds = ApiCredentials ;
let redacted = creds.redact;
assert_eq!; // Secret → fully redacted
assert_eq!; // Token → only last 4 visible
assert_eq!; // unchanged
Built-in Classifications
Each classification has a default redaction policy. Use the one that matches your data:
| Classification | Use for | Example output |
|---|---|---|
Secret |
Passwords, private keys | [REDACTED] |
Token |
API keys, bearer tokens | …abcd (last 4) |
Email |
Email addresses | jo… (first 2) |
CreditCard |
Card numbers (PANs) | …1234 (last 4) |
Pii |
Generic PII | …_doe (last 4) |
PhoneNumber |
Phone numbers | …12 (last 2) |
NationalId |
SSN, passport numbers | …6789 (last 4) |
AccountId |
Account identifiers | …abcd (last 4) |
SessionId |
Session tokens | …wxyz (last 4) |
IpAddress |
IP addresses | …1.1 (last 4 chars) |
DateOfBirth |
Birth dates | [REDACTED] |
BlockchainAddress |
Wallet addresses | …abc123 (last 6) |
Custom Classifications
When built-in classifications don't fit, create your own:
use ;
;
Clean architecture note:
- Put the classification type (
InternalId) in your domain crate/module. - Put the policy implementation (
impl RedactionPolicy for InternalId) in your application or infrastructure layer (where you define what is safe to expose and where logging/serialization happens).
Then use it like any built-in:
Policies
Three policy types control how values are transformed:
- Full: replace the entire value with a placeholder
default_full // → "[REDACTED]"
full_with // → "<hidden>"
- Keep: keep specified characters visible, mask everything else
keep_first // "secret123" → "secr*****"
keep_last // "secret123" → "*****t123"
keep_with // "secret" → "se**et"
- Mask: mask specified characters, keep the rest visible
mask_first // "secret123" → "****et123"
mask_last // "secret123" → "secre****"
Logging with slog
With the slog feature, Sensitive types automatically redact when logged:
[]
= { = "0.1", = ["slog"] }
// Redacts automatically (no explicit .redact() needed)
info!;
Requirements:
- Type must implement
Clone - Type must implement
serde::Serialize(for the redacted output)
Reference
Supported Field Types
String-like: Use #[sensitive(Classification)]:
StringCow<'_, str>(redaction returns an owned value)
Scalars: Use bare #[sensitive] (no classification):
- Integers:
i8-i128,u8-u128,isize,usize - Floats:
f32,f64 bool→ redacts tofalsechar→ redacts to'X'
Containers: Traversed automatically:
Option<T>: redacts inner value if presentVec<T>: redacts all elementsBox<T>: redacts inner valueHashMap<K, V>,BTreeMap<K, V>: redacts values only (keys unchanged)HashSet<T>,BTreeSet<T>: redacts elementsResult<T, E>: redacts bothOkandErrsides
Policy Behavior
- Empty string (
""):- Keep/Mask: returns
"" - Full: returns the placeholder (default:
"[REDACTED]")
- Keep/Mask: returns
- Keep policies (
keep_first,keep_last,KeepConfig::both) operate on Unicode scalar values:- If
visible_prefix + visible_suffix >= length, the value is returned unchanged
- If
- Mask policies (
mask_first,mask_last,MaskConfig::both) operate on Unicode scalar values:- If
mask_prefix + mask_suffix >= length, the entire value is masked
- If
- Length: keep/mask policies preserve the input length (full does not)
Edge Cases
Scalar type aliases: Only bare primitive names (i32, bool) are recognized as scalars. Type aliases like type MyInt = i32 or qualified paths like std::primitive::i32 are treated as string-like and require a classification.
Foreign string types: For string-like types from other crates, wrap in a newtype:
use SensitiveValue;
;
Map keys: Never redacted. Move sensitive data into values.
Debug vs redact(): The derived Debug formats the type normally, but replaces the values of #[sensitive(...)] fields with the string \"[REDACTED]\". It does not apply the field's policy. Use .redact() when you need policy-based output.
Testing: Enable the testing feature to get unredacted Debug output in tests:
[]
= { = "0.1", = ["testing"] }
Security Considerations
See SECURITY.md for:
- Information leakage (length preservation, timing)
- Memory safety considerations
- Compliance notes (GDPR, HIPAA, PCI DSS)
Documentation
License
Licensed under the MIT license (LICENSE.md or opensource.org/licenses/MIT).