#![forbid(unsafe_code)]
#![deny(missing_docs)]
use regex::Regex;
use rlg::log::Log;
use serde_json::Value;
use std::sync::LazyLock;
pub const DEFAULT_MARKER: &str = "[REDACTED]";
pub const CREDIT_CARD: &str = r"\b(?:\d[ -]?){12,18}\d\b";
pub const JWT: &str =
r"\beyJ[A-Za-z0-9_=-]+\.[A-Za-z0-9._=-]+\.[A-Za-z0-9._=-]+\b";
pub const BEARER_TOKEN: &str = r"(?i)Bearer\s+[A-Za-z0-9._~+/-]+=*";
pub const EMAIL: &str =
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b";
pub const IPV4: &str = r"\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b";
pub const AWS_ACCESS_KEY: &str =
r"\b(?:AKIA|ASIA|AGPA|ANPA|ANVA|AROA|AIPA)[A-Z0-9]{16}\b";
const DEFAULT_SOURCES: [&str; 6] =
[CREDIT_CARD, JWT, BEARER_TOKEN, EMAIL, IPV4, AWS_ACCESS_KEY];
static DEFAULT_COMBINED: LazyLock<Regex> = LazyLock::new(|| {
build_combined(&DEFAULT_SOURCES)
.expect("built-in patterns must compile as an alternation")
});
fn build_combined<S: AsRef<str>>(
sources: &[S],
) -> Result<Regex, regex::Error> {
debug_assert!(!sources.is_empty(), "must not build from empty set");
let alternation = sources
.iter()
.map(|p| format!("(?:{})", p.as_ref()))
.collect::<Vec<_>>()
.join("|");
Regex::new(&alternation)
}
#[derive(Debug, Clone)]
pub struct Redactor {
sources: Vec<String>,
combined: Option<Regex>,
marker: String,
}
impl Default for Redactor {
fn default() -> Self {
Self::empty()
}
}
impl Redactor {
#[must_use]
pub fn empty() -> Self {
Self {
sources: Vec::new(),
combined: None,
marker: DEFAULT_MARKER.to_string(),
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self {
sources: DEFAULT_SOURCES
.iter()
.map(|s| (*s).to_string())
.collect(),
combined: Some(DEFAULT_COMBINED.clone()),
marker: DEFAULT_MARKER.to_string(),
}
}
pub fn with_pattern(
mut self,
pattern: &str,
) -> Result<Self, regex::Error> {
let _ = Regex::new(pattern)?;
self.sources.push(pattern.to_string());
self.combined = Some(build_combined(&self.sources)?);
Ok(self)
}
#[must_use]
pub fn marker(mut self, marker: impl Into<String>) -> Self {
self.marker = marker.into();
self
}
#[must_use]
pub fn scrub(&self, mut log: Log) -> Log {
log.description = self.apply(&log.description);
for value in log.attributes.values_mut() {
*value =
self.scrub_value(std::mem::replace(value, Value::Null));
}
log
}
#[must_use]
pub fn apply(&self, input: &str) -> String {
match &self.combined {
None => input.to_string(),
Some(re) => {
re.replace_all(input, self.marker.as_str()).into_owned()
}
}
}
fn scrub_value(&self, v: Value) -> Value {
match v {
Value::String(s) => Value::String(self.apply(&s)),
Value::Array(items) => Value::Array(
items
.into_iter()
.map(|i| self.scrub_value(i))
.collect(),
),
Value::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, self.scrub_value(v)))
.collect(),
),
other => other,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.sources.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rlg::log_level::LogLevel;
#[test]
fn empty_redactor_is_a_no_op() {
let r = Redactor::empty();
let log = Log::info("4111-1111-1111-1111");
let out = r.scrub(log.clone());
assert_eq!(out.description, log.description);
}
#[test]
fn default_marker_is_redacted() {
assert_eq!(DEFAULT_MARKER, "[REDACTED]");
let r = Redactor::with_defaults();
let out = r.apply("email me at user@example.com");
assert!(out.contains("[REDACTED]"));
assert!(!out.contains("user@example.com"));
}
#[test]
fn custom_marker_replaces_default() {
let r = Redactor::with_defaults().marker("***");
let out = r.apply("ip 192.168.0.1 down");
assert!(out.contains("***"));
assert!(!out.contains("192.168.0.1"));
}
#[test]
fn credit_card_pattern_matches_visa() {
let r = Redactor::empty().with_pattern(CREDIT_CARD).unwrap();
assert!(!r.apply("4111-1111-1111-1111").contains("4111"));
assert!(!r.apply("4111 1111 1111 1111").contains("4111"));
assert!(!r.apply("4111111111111111").contains("4111"));
}
#[test]
fn jwt_pattern_matches() {
let r = Redactor::empty().with_pattern(JWT).unwrap();
let token =
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcdEFGHijk";
let out = r.apply(&format!("auth={token} ok"));
assert!(!out.contains("eyJ"));
assert!(out.contains("[REDACTED]"));
}
#[test]
fn bearer_token_pattern_matches() {
let r = Redactor::empty().with_pattern(BEARER_TOKEN).unwrap();
let out = r.apply("Authorization: Bearer abc123XYZ.foo");
assert!(out.contains("[REDACTED]"));
assert!(!out.contains("abc123XYZ"));
}
#[test]
fn email_pattern_matches() {
let r = Redactor::empty().with_pattern(EMAIL).unwrap();
let out = r.apply("sent to alice+test@example.co.uk today");
assert!(out.contains("[REDACTED]"));
assert!(!out.contains("alice"));
}
#[test]
fn ipv4_pattern_matches() {
let r = Redactor::empty().with_pattern(IPV4).unwrap();
let out = r.apply("client 10.0.1.42 disconnected");
assert!(out.contains("[REDACTED]"));
assert!(!out.contains("10.0.1.42"));
}
#[test]
fn aws_key_pattern_matches() {
let r = Redactor::empty().with_pattern(AWS_ACCESS_KEY).unwrap();
let out = r.apply("AKIAIOSFODNN7EXAMPLE leaked");
assert!(out.contains("[REDACTED]"));
}
#[test]
fn scrub_walks_string_attributes() {
let r = Redactor::with_defaults();
let log = Log::build(LogLevel::INFO, "user@host.com signed in")
.with("email", "other@host.com")
.with("session_id_num", 42_u64);
let out = r.scrub(log);
assert!(!out.description.contains("user@host.com"));
assert_eq!(
out.attributes.get("session_id_num"),
Some(&serde_json::json!(42_u64))
);
let email = out.attributes.get("email").unwrap();
assert!(email.as_str().unwrap().contains("[REDACTED]"));
}
#[test]
fn scrub_recurses_into_nested_json() {
let r = Redactor::with_defaults();
let log = Log::info("x").with(
"payload",
serde_json::json!({
"user": { "email": "x@y.com" },
"ips": ["10.0.0.1", "192.168.0.1"]
}),
);
let out = r.scrub(log);
let payload = out.attributes.get("payload").unwrap();
let serialised = payload.to_string();
assert!(!serialised.contains("x@y.com"));
assert!(!serialised.contains("10.0.0.1"));
}
#[test]
fn with_pattern_rejects_invalid_regex() {
let r = Redactor::empty().with_pattern("[unclosed");
assert!(r.is_err());
}
#[test]
fn len_reflects_patterns_loaded() {
let r = Redactor::with_defaults();
assert_eq!(r.len(), 6);
assert!(!r.is_empty());
assert!(Redactor::empty().is_empty());
}
#[test]
fn fusion_scans_all_pattern_kinds_in_one_pass() {
let r = Redactor::with_defaults();
let out = r.apply(
"cc 4111-1111-1111-1111, jwt \
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcd, \
Bearer xyz.abc, alice@example.com, 10.0.0.1, \
AKIAIOSFODNN7EXAMPLE",
);
for needle in [
"4111",
"eyJhbGciOiJIUzI1NiJ9",
"xyz.abc",
"alice@example.com",
"10.0.0.1",
"AKIAIOSFODNN7EXAMPLE",
] {
assert!(
!out.contains(needle),
"fusion missed {needle:?} in output {out:?}"
);
}
assert!(out.matches("[REDACTED]").count() >= 6);
}
#[test]
fn fusion_prefers_leftmost_match_across_pattern_kinds() {
let r = Redactor::empty()
.with_pattern(CREDIT_CARD)
.unwrap()
.with_pattern(IPV4)
.unwrap();
let out = r.apply("card 4111-1111-1111-1111 from 10.0.0.1");
assert!(!out.contains("4111"));
assert!(!out.contains("10.0.0.1"));
assert_eq!(out.matches("[REDACTED]").count(), 2);
}
#[test]
fn fusion_compiles_alternation_from_chained_with_pattern() {
let r = Redactor::empty()
.with_pattern(r"AAA-\d+")
.unwrap()
.with_pattern(r"BBB-\d+")
.unwrap()
.with_pattern(r"CCC-\d+")
.unwrap();
assert_eq!(r.len(), 3);
let out = r.apply("AAA-1 BBB-2 CCC-3 DDD-4");
assert!(out.contains("[REDACTED] [REDACTED] [REDACTED] DDD-4"));
}
}