use std::sync::OnceLock;
use regex::Regex;
pub const REDACT_HOST: &str = "<hostname>";
pub const REDACT_IPV4: &str = "<ipv4>";
pub const REDACT_IPV6: &str = "<ipv6>";
pub const REDACT_MAC: &str = "<mac>";
pub const REDACT_USER: &str = "<user>";
pub const REDACT_KPTR: &str = "<kernel-ptr>";
fn ipv4_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b",
)
.expect("ipv4 regex must compile")
})
}
fn ipv6_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b|\b(?:[0-9A-Fa-f]{1,4}:){1,7}:(?:[0-9A-Fa-f]{1,4})?\b|\b::(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4}\b|\bfe80::[0-9A-Fa-f:]+\b",
)
.expect("ipv6 regex must compile")
})
}
fn mac_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b").expect("mac regex must compile")
})
}
fn kptr_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\bf{4,}[0-9a-f]{8,12}\b|\b0x[0-9a-fA-F]{8,16}\b")
.expect("kptr regex must compile")
})
}
fn username_token(name: &str) -> Regex {
Regex::new(&format!(r"(?i)\b{}\b", regex::escape(name))).expect("username regex must compile")
}
fn hostname_token(name: &str) -> Regex {
Regex::new(&format!(r"(?i)\b{}\b", regex::escape(name))).expect("hostname regex must compile")
}
#[derive(Clone, Debug)]
pub struct RedactOptions {
pub hostname: Option<String>,
pub username: Option<String>,
pub scrub_kernel_pointers: bool,
pub enabled: bool,
}
impl Default for RedactOptions {
fn default() -> Self {
Self {
hostname: std::env::var("HOSTNAME")
.ok()
.or_else(hostname_from_libc)
.filter(|h| !h.is_empty()),
username: std::env::var("USER")
.ok()
.or_else(|| std::env::var("LOGNAME").ok())
.filter(|u| !u.is_empty()),
scrub_kernel_pointers: true,
enabled: true,
}
}
}
impl RedactOptions {
pub fn passthrough() -> Self {
Self {
hostname: None,
username: None,
scrub_kernel_pointers: false,
enabled: false,
}
}
}
fn hostname_from_libc() -> Option<String> {
whoami::hostname().ok().filter(|h| !h.is_empty())
}
pub fn scrub(input: &str, opts: &RedactOptions) -> String {
if !opts.enabled {
return input.to_string();
}
let mut out = input.to_string();
if let Some(ref host) = opts.hostname
&& !host.is_empty()
{
out = hostname_token(host)
.replace_all(&out, REDACT_HOST)
.into_owned();
}
if let Some(ref user) = opts.username
&& !user.is_empty()
{
out = username_token(user)
.replace_all(&out, REDACT_USER)
.into_owned();
}
out = ipv6_re().replace_all(&out, REDACT_IPV6).into_owned();
out = ipv4_re().replace_all(&out, REDACT_IPV4).into_owned();
out = mac_re().replace_all(&out, REDACT_MAC).into_owned();
if opts.scrub_kernel_pointers {
out = kptr_re().replace_all(&out, REDACT_KPTR).into_owned();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scrub_ipv4_plain() {
let opts = RedactOptions {
hostname: None,
username: None,
..RedactOptions::default()
};
let got = scrub("link 192.168.1.42 is up", &opts);
assert!(got.contains(REDACT_IPV4));
assert!(!got.contains("192.168.1.42"));
}
#[test]
fn scrub_mac() {
let opts = RedactOptions {
hostname: None,
username: None,
..RedactOptions::default()
};
let got = scrub("ether 02:42:ac:11:00:02 brd", &opts);
assert!(got.contains(REDACT_MAC));
}
#[test]
fn scrub_username() {
let opts = RedactOptions {
hostname: None,
username: Some("alice".to_string()),
..RedactOptions::default()
};
let got = scrub("/home/alice/logs", &opts);
assert!(got.contains(REDACT_USER));
assert!(!got.contains("/alice/"));
}
#[test]
fn scrub_kernel_pointer() {
let opts = RedactOptions {
hostname: None,
username: None,
..RedactOptions::default()
};
let got = scrub("fault at ffff8abc12345678 (oops)", &opts);
assert!(got.contains(REDACT_KPTR));
}
#[test]
fn passthrough_skips_work() {
let opts = RedactOptions::passthrough();
let input = "host alice 10.0.0.1";
assert_eq!(scrub(input, &opts), input);
}
#[test]
fn scrub_hostname_is_case_insensitive() {
let opts = RedactOptions {
hostname: Some("myserver".to_string()),
username: None,
..RedactOptions::default()
};
let got = scrub("visit MYSERVER or MyServer now", &opts);
assert!(got.contains(REDACT_HOST));
assert!(!got.contains("MYSERVER"));
assert!(!got.contains("MyServer"));
}
#[test]
fn scrub_username_is_case_insensitive() {
let opts = RedactOptions {
hostname: None,
username: Some("alice".to_string()),
..RedactOptions::default()
};
let got = scrub("ALICE logged in", &opts);
assert!(got.contains(REDACT_USER));
assert!(!got.contains("ALICE"));
}
#[test]
fn scrub_kernel_pointer_canonical() {
let opts = RedactOptions {
hostname: None,
username: None,
..RedactOptions::default()
};
let got = scrub("fault at ffff8abc12345678 (oops)", &opts);
assert!(got.contains(REDACT_KPTR));
}
#[test]
fn scrub_kernel_pointer_0x_prefixed() {
let opts = RedactOptions {
hostname: None,
username: None,
..RedactOptions::default()
};
let got = scrub("module loaded at 0xffffffffc08a0000 (end)", &opts);
assert!(got.contains(REDACT_KPTR));
}
#[test]
fn scrub_kernel_pointer_leaves_version_strings_alone() {
let opts = RedactOptions {
hostname: None,
username: None,
..RedactOptions::default()
};
let got = scrub("kernel 5.15.0-89-generic", &opts);
assert!(!got.contains(REDACT_KPTR));
}
}