hen 0.20.2

Run protocol-aware API request collections from the command line or through MCP.
Documentation
use std::collections::HashMap;

use serde_json::Value;

const REDACTED_PLACEHOLDER: &str = "[redacted]";
const DEFAULT_SENSITIVE_HEADER_NAMES: &[&str] = &[
    "authorization",
    "proxy-authorization",
    "cookie",
    "set-cookie",
    "api-key",
    "apikey",
    "x-api-key",
    "x-auth-token",
    "x-access-token",
];

#[derive(Debug, Clone, Default)]
pub struct OutputRedactor {
    sensitive_values: Vec<String>,
    sensitive_header_names: Vec<String>,
}

impl OutputRedactor {
    pub fn new(values: &[String]) -> Self {
        Self::with_header_names(values, &[])
    }

    pub fn with_header_names(values: &[String], header_names: &[String]) -> Self {
        let mut sensitive_values = values
            .iter()
            .filter(|value| !value.is_empty())
            .cloned()
            .collect::<Vec<_>>();

        let mut sensitive_header_names = header_names
            .iter()
            .map(|name| name.trim().to_ascii_lowercase())
            .filter(|name| !name.is_empty())
            .collect::<Vec<_>>();

        sensitive_values.sort_by(|left, right| {
            right
                .len()
                .cmp(&left.len())
                .then_with(|| left.cmp(right))
        });
        sensitive_values.dedup();

        sensitive_header_names.sort();
        sensitive_header_names.dedup();

        Self {
            sensitive_values,
            sensitive_header_names,
        }
    }

    pub fn redact_text(&self, value: &str) -> String {
        let mut redacted = value.to_string();

        for sensitive in &self.sensitive_values {
            if !redacted.contains(sensitive) {
                continue;
            }

            redacted = redacted.replace(sensitive, REDACTED_PLACEHOLDER);
        }

        self.redact_header_lines(&redacted)
    }

    pub fn redact_optional_text(&self, value: Option<&str>) -> Option<String> {
        value.map(|value| self.redact_text(value))
    }

    pub fn redact_string_map(
        &self,
        values: &HashMap<String, String>,
    ) -> HashMap<String, String> {
        values
            .iter()
            .map(|(key, value)| {
                let redacted = if self.is_sensitive_header_name(key) {
                    REDACTED_PLACEHOLDER.to_string()
                } else {
                    self.redact_text(value)
                };

                (key.clone(), redacted)
            })
            .collect()
    }

    pub fn redact_json_value(&self, value: &Value) -> Value {
        match value {
            Value::String(text) => Value::String(self.redact_text(text)),
            Value::Array(values) => Value::Array(
                values
                    .iter()
                    .map(|value| self.redact_json_value(value))
                    .collect(),
            ),
            Value::Object(values) => Value::Object(
                values
                    .iter()
                    .map(|(key, value)| {
                        let redacted = if self.is_sensitive_header_name(key) {
                            Value::String(REDACTED_PLACEHOLDER.to_string())
                        } else {
                            self.redact_json_value(value)
                        };

                        (key.clone(), redacted)
                    })
                    .collect(),
            ),
            _ => value.clone(),
        }
    }

    fn is_sensitive_header_name(&self, name: &str) -> bool {
        let normalized = name.trim().to_ascii_lowercase();

        DEFAULT_SENSITIVE_HEADER_NAMES
            .iter()
            .any(|candidate| *candidate == normalized)
            || self
                .sensitive_header_names
                .iter()
                .any(|candidate| candidate == &normalized)
            || normalized.ends_with("-api-key")
    }

    fn redact_header_lines(&self, value: &str) -> String {
        if !value.contains(':') {
            return value.to_string();
        }

        value
            .split_inclusive('\n')
            .map(|segment| {
                let (line, newline) = match segment.strip_suffix('\n') {
                    Some(line) => (line, "\n"),
                    None => (segment, ""),
                };

                self.redact_header_line(line)
                    .map(|line| format!("{line}{newline}"))
                    .unwrap_or_else(|| segment.to_string())
            })
            .collect()
    }

    fn redact_header_line(&self, line: &str) -> Option<String> {
        let separator = line.find(':')?;
        let header_name = line[..separator].trim();
        if !self.is_sensitive_header_name(header_name) {
            return None;
        }

        let remainder = &line[separator + 1..];
        let leading_whitespace_end = remainder
            .char_indices()
            .find(|(_, ch)| !ch.is_whitespace())
            .map(|(index, _)| index)
            .unwrap_or(remainder.len());

        Some(format!(
            "{}:{}{}",
            &line[..separator],
            &remainder[..leading_whitespace_end],
            REDACTED_PLACEHOLDER
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::OutputRedactor;
    use serde_json::json;
    use std::collections::HashMap;

    #[test]
    fn redacts_text_and_json_values() {
        let redactor = OutputRedactor::new(&[
            "super-secret-token".to_string(),
            "abc123".to_string(),
        ]);

        assert_eq!(
            redactor.redact_text("Bearer super-secret-token"),
            "Bearer [redacted]"
        );
        assert_eq!(
            redactor.redact_json_value(&json!({"token":"abc123"})),
            json!({"token":"[redacted]"})
        );
    }

    #[test]
    fn redacts_map_values() {
        let redactor = OutputRedactor::new(&["secret".to_string()]);
        let values = HashMap::from([("token".to_string(), "secret".to_string())]);

        assert_eq!(
            redactor.redact_string_map(&values).get("token"),
            Some(&"[redacted]".to_string())
        );
    }

    #[test]
    fn redacts_default_sensitive_headers_in_text_maps_and_json_values() {
        let redactor = OutputRedactor::new(&[]);

        assert_eq!(
            redactor.redact_text(
                "Authorization: Bearer dynamic-token\nCookie: session=abc\nX-Trace: keep"
            ),
            "Authorization: [redacted]\nCookie: [redacted]\nX-Trace: keep"
        );

        let values = HashMap::from([
            ("Set-Cookie".to_string(), "session=abc; Path=/".to_string()),
            ("X-Trace".to_string(), "keep".to_string()),
        ]);

        assert_eq!(
            redactor.redact_string_map(&values).get("Set-Cookie"),
            Some(&"[redacted]".to_string())
        );
        assert_eq!(
            redactor.redact_string_map(&values).get("X-Trace"),
            Some(&"keep".to_string())
        );

        assert_eq!(
            redactor.redact_json_value(&json!({
                "Authorization": "Bearer dynamic-token",
                "nested": {
                    "X-Api-Key": "dynamic-key",
                    "status": "ok"
                }
            })),
            json!({
                "Authorization": "[redacted]",
                "nested": {
                    "X-Api-Key": "[redacted]",
                    "status": "ok"
                }
            })
        );
    }

    #[test]
    fn redacts_configured_sensitive_headers() {
        let redactor = OutputRedactor::with_header_names(
            &[],
            &["x-custom-secret".to_string()],
        );

        assert_eq!(
            redactor.redact_text("X-Custom-Secret: token\nX-Trace: keep"),
            "X-Custom-Secret: [redacted]\nX-Trace: keep"
        );

        let values = HashMap::from([
            ("X-Custom-Secret".to_string(), "token".to_string()),
            ("X-Trace".to_string(), "keep".to_string()),
        ]);

        assert_eq!(
            redactor.redact_string_map(&values).get("X-Custom-Secret"),
            Some(&"[redacted]".to_string())
        );
    }
}