244 lines
7.0 KiB
Rust
244 lines
7.0 KiB
Rust
use std::fmt;
|
|
|
|
/// A wrapper type that redacts sensitive values when displayed or logged.
|
|
/// Used for private keys, auth tokens, and session secrets.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Redacted(String);
|
|
|
|
impl Redacted {
|
|
/// Create a new Redacted value.
|
|
pub fn new(value: impl Into<String>) -> Self {
|
|
Self(value.into())
|
|
}
|
|
|
|
/// Get the inner value. Use only when the value must be used programmatically
|
|
/// (e.g., written to a file). Never pass this directly to logging.
|
|
#[allow(dead_code)]
|
|
pub fn into_inner(self) -> String {
|
|
self.0
|
|
}
|
|
|
|
/// Get a reference to the inner value. Use with caution.
|
|
#[allow(dead_code)]
|
|
pub fn inner(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Redacted {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let len = self.0.len();
|
|
if len == 0 {
|
|
write!(f, "[REDACTED]")
|
|
} else {
|
|
write!(f, "[REDACTED(len={})]", len)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&str> for Redacted {
|
|
fn from(s: &str) -> Self {
|
|
Self(s.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<String> for Redacted {
|
|
fn from(s: String) -> Self {
|
|
Self(s)
|
|
}
|
|
}
|
|
|
|
impl PartialEq for Redacted {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.0 == other.0
|
|
}
|
|
}
|
|
|
|
/// Redact a PEM certificate or key block for safe logging.
|
|
/// Returns a string showing only the block type and line count.
|
|
#[allow(dead_code)]
|
|
pub fn redact_pem(pem: &str) -> String {
|
|
let lines = pem.lines().count();
|
|
// Extract block type
|
|
let block_type = pem
|
|
.lines()
|
|
.find(|l| l.starts_with("-----BEGIN "))
|
|
.map(|l| {
|
|
l.strip_prefix("-----BEGIN ")
|
|
.and_then(|s| s.strip_suffix(" -----"))
|
|
.unwrap_or("UNKNOWN")
|
|
})
|
|
.unwrap_or("UNKNOWN");
|
|
format!("[REDACTED-PEM type={} lines={}]", block_type, lines)
|
|
}
|
|
|
|
/// Redact a JSON configuration containing sensitive fields.
|
|
/// Returns a display-safe representation.
|
|
#[allow(dead_code)]
|
|
pub fn redact_config_json(json: &str) -> String {
|
|
let sensitive_keys = ["auth_token", "token", "private_key", "secret"];
|
|
let mut val = json.to_string();
|
|
for key in &sensitive_keys {
|
|
let pat = format!("\"{}\":\"", key);
|
|
while let Some(start) = val.find(&pat) {
|
|
let after = &val[start + pat.len()..];
|
|
// Skip if already redacted
|
|
if after.starts_with("[REDACTED]\"") {
|
|
break;
|
|
}
|
|
let end = after
|
|
.find('"')
|
|
.map(|p| start + pat.len() + p + 1)
|
|
.unwrap_or(val.len());
|
|
let replacement = format!("\"{}\":\"[REDACTED]\"", key);
|
|
val.replace_range(start..end, &replacement);
|
|
}
|
|
}
|
|
val
|
|
}
|
|
|
|
/// Check if a string looks like a private key and should be redacted.
|
|
#[allow(dead_code)]
|
|
pub fn is_sensitive(content: &str) -> bool {
|
|
content.contains("-----BEGIN")
|
|
&& (content.contains("PRIVATE-KEY")
|
|
|| content.contains("PRIVATE KEY")
|
|
|| content.contains("EC PRIVATE-KEY")
|
|
|| content.contains("EC PRIVATE KEY")
|
|
|| content.contains("RSA PRIVATE-KEY")
|
|
|| content.contains("RSA PRIVATE KEY"))
|
|
}
|
|
|
|
/// Check if a string looks like an auth token and should be redacted.
|
|
#[allow(dead_code)]
|
|
pub fn is_auth_token(content: &str) -> bool {
|
|
content.len() > 16
|
|
&& content
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn is_connection_key(content: &str) -> bool {
|
|
content.trim_start().starts_with("rtun2.")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn redacted_display_hides_value() {
|
|
let secret = Redacted::new("my-secret-token-12345");
|
|
let display = format!("{}", secret);
|
|
assert!(display.contains("REDACTED"));
|
|
assert!(!display.contains("my-secret"));
|
|
assert!(!display.contains("token"));
|
|
// Should show length hints
|
|
assert!(display.contains("len="));
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_empty_shows_marker() {
|
|
let empty = Redacted::new("");
|
|
assert_eq!(format!("{}", empty), "[REDACTED]");
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_short_shows_length() {
|
|
let short = Redacted::new("abc");
|
|
let display = format!("{}", short);
|
|
assert!(display.contains("REDACTED"));
|
|
assert!(!display.contains("abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_preserves_value_for_comparison() {
|
|
let a = Redacted::new("same-value");
|
|
let b = Redacted::new("same-value");
|
|
assert_eq!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_inner_returns_value() {
|
|
let secret = Redacted::new("real-value");
|
|
assert_eq!(secret.inner(), "real-value");
|
|
}
|
|
|
|
#[test]
|
|
fn redact_pem_hides_content() {
|
|
let pem = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBg...\n-----END PRIVATE KEY-----";
|
|
let redacted = redact_pem(pem);
|
|
assert!(redacted.contains("REDACTED"));
|
|
assert!(!redacted.contains("MIIEvg"));
|
|
}
|
|
|
|
#[test]
|
|
fn redact_config_json_hides_tokens() {
|
|
let json = r#"{"auth_token":"secret123","listen":"127.0.0.1:4180"}"#;
|
|
let redacted = redact_config_json(json);
|
|
assert!(redacted.contains("REDACTED"));
|
|
assert!(!redacted.contains("secret123"));
|
|
// Non-sensitive fields should remain
|
|
assert!(redacted.contains("127.0.0.1:4180"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_sensitive_detects_private_key() {
|
|
let pem = "-----BEGIN PRIVATE KEY-----\ndata";
|
|
assert!(is_sensitive(pem));
|
|
}
|
|
|
|
#[test]
|
|
fn is_sensitive_detects_ec_private_key() {
|
|
let pem = "-----BEGIN EC PRIVATE KEY-----\ndata";
|
|
assert!(is_sensitive(pem));
|
|
}
|
|
|
|
#[test]
|
|
fn is_sensitive_false_for_certificate() {
|
|
let pem = "-----BEGIN CERTIFICATE-----\ndata";
|
|
assert!(!is_sensitive(pem));
|
|
}
|
|
|
|
#[test]
|
|
fn is_auth_token_long_alphanumeric() {
|
|
assert!(is_auth_token("abcdef1234567890ab"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_auth_token_short_is_false() {
|
|
assert!(!is_auth_token("short"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_connection_key_detects_rtun_prefix() {
|
|
assert!(is_connection_key("rtun2.abc"));
|
|
assert!(!is_connection_key("abc"));
|
|
assert!(!is_connection_key("rtun1.abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn tracing_logs_redact_secrets() {
|
|
// Verify that tracing with a Redacted value doesn't leak secrets
|
|
let secret = Redacted::new("sensitive-data");
|
|
// The display impl ensures the formatted output is redacted
|
|
let display = format!("{}", secret);
|
|
assert!(!display.contains("sensitive-data"));
|
|
}
|
|
|
|
#[test]
|
|
fn from_str_creates_redacted() {
|
|
let r: Redacted = "test-value".into();
|
|
assert_eq!(r.inner(), "test-value");
|
|
assert!(!format!("{}", r).contains("test-value"));
|
|
}
|
|
|
|
#[test]
|
|
fn from_string_creates_redacted() {
|
|
let s = String::from("another-value");
|
|
let r: Redacted = s.into();
|
|
assert!(!format!("{}", r).contains("another-value"));
|
|
}
|
|
}
|