//! Redact secrets and partially mask remote server hostnames before log lines //! are stored or exported (PsyLab / Settings log export). const SENSITIVE_QUERY_KEYS: &[&str] = &[ "t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey", "access_token", "refresh_token", "auth", ]; const SENSITIVE_KV_KEYS: &[&str] = &[ "password", "passwd", "token", "secret", "api_key", "apikey", "access_token", "refresh_token", "authorization", "auth", ]; /// Sanitize one runtime log line for display and export. pub fn sanitize_log_line(line: &str) -> String { let mut out = redact_bearer_tokens(line); out = redact_sensitive_key_values(&out); out = redact_urls_in_text(&out); out } /// Never panic on the logging hot path — fall back to the raw line if needed. pub fn sanitize_log_line_infallible(line: &str) -> String { std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string()) } fn redact_bearer_tokens(line: &str) -> String { let marker = "Bearer "; let mut s = line.to_string(); let mut search_from = 0; while let Some(rel) = s[search_from..].find(marker) { let idx = search_from + rel; let start = idx + marker.len(); let end = s[start..] .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']') .map(|i| start + i) .unwrap_or(s.len()); if end > start { s.replace_range(start..end, "REDACTED"); } search_from = start + "REDACTED".len(); } s } fn redact_sensitive_key_values(line: &str) -> String { let mut out = line.to_string(); for key in SENSITIVE_KV_KEYS { for sep in [':', '='] { let needle = format!("{key}{sep}"); let lower = out.to_ascii_lowercase(); let mut search_from = 0; while let Some(rel) = lower[search_from..].find(&needle) { let idx = search_from + rel; let val_start = idx + needle.len(); let slice = &out[val_start..]; let trimmed = slice.trim_start(); let ws = slice.len().saturating_sub(trimmed.len()); let val_start = val_start + ws; let end = trimmed .find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')') .unwrap_or(trimmed.len()); if end > 0 { out.replace_range(val_start..val_start + end, "REDACTED"); } search_from = val_start + "REDACTED".len(); if search_from >= out.len() { break; } } } } out } fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool { if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' { return true; } if ch == ')' || ch == ']' || ch == ',' { if let Some(next) = s[byte_off..].chars().nth(1) { return next.is_whitespace() || next == '"' || next == '\''; } } false } fn redact_urls_in_text(line: &str) -> String { let mut out = String::with_capacity(line.len()); let mut cursor = 0; while cursor < line.len() { let slice = &line[cursor..]; let rel = match (slice.find("http://"), slice.find("https://")) { (Some(h), Some(s)) => Some(h.min(s)), (Some(h), None) => Some(h), (None, Some(s)) => Some(s), (None, None) => None, }; let Some(rel) = rel else { out.push_str(slice); break; }; out.push_str(&slice[..rel]); let url_start = cursor + rel; let url_slice = &line[url_start..]; let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 }; let mut url_end = scheme_len; for (off, ch) in url_slice[scheme_len..].char_indices() { let abs = scheme_len + off; if url_char_ends_url(ch, url_slice, abs) { break; } url_end = abs + ch.len_utf8(); } out.push_str(&redact_url(&line[url_start..url_start + url_end])); cursor = url_start + url_end; } out } fn redact_url(raw: &str) -> String { let (url, suffix) = split_trailing_punct(raw); let mut out = String::new(); let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0); out.push_str(&url[..scheme_end]); let mut rest = &url[scheme_end..]; if let Some(at) = rest.rfind('@') { // Drop userinfo entirely. out.push_str("***@"); rest = &rest[at + 1..]; } let (hostport, path) = split_host_path(rest); let (host, port) = split_host_port(&hostport); let masked_host = mask_hostname(&host); out.push_str(&masked_host); if let Some(p) = port { out.push(':'); out.push_str(&p); } if let Some((path_only, query)) = path.split_once('?') { out.push_str(path_only); out.push('?'); out.push_str(&redact_query_string(query)); } else { out.push_str(&path); } format!("{out}{suffix}") } fn split_trailing_punct(raw: &str) -> (&str, &str) { let mut end = raw.len(); while end > 0 { let ch = raw.as_bytes()[end - 1] as char; if ch == ')' || ch == ']' || ch == ',' { end -= 1; continue; } break; } (&raw[..end], &raw[end..]) } fn split_host_path(rest: &str) -> (String, String) { if rest.starts_with('[') { if let Some(end) = rest.find(']') { let hostport = &rest[..=end]; return (hostport.to_string(), rest[end + 1..].to_string()); } } if let Some(slash) = rest.find('/') { (rest[..slash].to_string(), rest[slash..].to_string()) } else { (rest.to_string(), String::new()) } } fn split_host_port(hostport: &str) -> (String, Option) { if hostport.starts_with('[') { if let Some(end) = hostport.find("]:") { return ( hostport[..=end].to_string(), Some(hostport[end + 2..].to_string()), ); } return (hostport.to_string(), None); } if let Some((h, p)) = hostport.rsplit_once(':') { if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') { return (h.to_string(), Some(p.to_string())); } } (hostport.to_string(), None) } fn redact_query_string(query: &str) -> String { query .split('&') .map(|pair| { let (k, _v) = pair.split_once('=').unwrap_or((pair, "")); if is_sensitive_query_key(k) { format!("{k}=REDACTED") } else { pair.to_string() } }) .collect::>() .join("&") } fn is_sensitive_query_key(key: &str) -> bool { let k = key.trim().to_ascii_lowercase(); SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k) } fn is_lan_ipv4(ip: &str) -> bool { let parts: Vec<&str> = ip.split('.').collect(); if parts.len() != 4 { return false; } let Ok(a) = parts[0].parse::() else { return false }; let Ok(b) = parts[1].parse::() else { return false }; a == 127 || a == 10 || (a == 172 && (16..=31).contains(&b)) || (a == 192 && b == 168) } fn is_lan_ipv6(host: &str) -> bool { let h = host.to_ascii_lowercase(); if h == "::1" { return true; } if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") { return true; } if h.starts_with("fc") || h.starts_with("fd") { return true; } if let Some(rest) = h.strip_prefix("::ffff:") { if rest.contains('.') { return is_lan_ipv4(rest); } if let Some((a, b)) = rest.split_once(':') { if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) { let ip = format!( "{}.{}.{}.{}", (v1 >> 8) & 0xff, v1 & 0xff, (v2 >> 8) & 0xff, v2 & 0xff ); return is_lan_ipv4(&ip); } } } false } fn is_lan_host(host: &str) -> bool { let stripped = host.trim().trim_matches(|c| c == '[' || c == ']'); let lower = stripped.to_ascii_lowercase(); if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") { return true; } if stripped.contains(':') { return is_lan_ipv6(stripped); } if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 { return is_lan_ipv4(stripped); } false } fn mask_label_prefix(label: &str) -> String { let mut chars = label.chars(); let c1 = chars.next(); let c2 = chars.next(); match (c1, c2) { (None, _) => "*".to_string(), (Some(a), None) => a.to_string(), (Some(a), Some(b)) => { let rest = label.chars().count().saturating_sub(2); let stars = rest.clamp(1, 4); format!("{a}{b}{}", "*".repeat(stars)) } } } fn mask_public_ipv4(ip: &str) -> String { let parts: Vec<&str> = ip.split('.').collect(); if parts.len() != 4 { return "***".to_string(); } format!("{}.*.*.{}", parts[0], parts[3]) } fn mask_hostname(host: &str) -> String { let stripped = host.trim().trim_matches(|c| c == '[' || c == ']'); if is_lan_host(stripped) { return host.to_string(); } if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 { return mask_public_ipv4(stripped); } if stripped.contains(':') { return "[ipv6-redacted]".to_string(); } let parts: Vec<&str> = stripped.split('.').collect(); if parts.is_empty() { return "***".to_string(); } let masked_first = mask_label_prefix(parts[0]); if parts.len() == 1 { masked_first } else { format!("{}.{}", masked_first, parts[1..].join(".")) } } #[cfg(test)] mod tests { use super::*; #[test] fn redacts_subsonic_wire_auth_params() { let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi"; let out = sanitize_log_line(line); assert!(out.contains("t=REDACTED")); assert!(out.contains("s=REDACTED")); assert!(out.contains("p=REDACTED")); assert!(!out.contains("abc")); } #[test] fn masks_remote_hostname_keeps_lan_ip() { let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping"); assert!(remote.contains("my****.example.com")); assert!(!remote.contains("my-server.example.com")); let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping"); assert!(lan.contains("192.168.1.42")); } #[test] fn redacts_bearer_and_password_kv() { let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit"; let out = sanitize_log_line(line); assert!(out.contains("Bearer REDACTED")); assert!(!out.contains("eyJhbGci")); assert!(out.contains("password=REDACTED")); assert!(!out.contains("sekrit")); } #[test] fn strips_url_userinfo() { let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping"; let out = sanitize_log_line(line); assert!(out.contains("***@10.0.0.5")); assert!(!out.contains("user:pass")); } #[test] fn stream_log_with_em_dash_does_not_panic() { let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")"; let out = sanitize_log_line(line); assert!(out.contains('—')); assert!(out.contains("RangedHttpSource")); } }