Files
rustunnel/src/redact.rs
T

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"));
}
}