apollo-redaction 0.2.0

Redaction utility for sensitive data in error messages, debug logs, etc.
Documentation
//! Redaction utility for sensitive data in error messages, debug logs, etc.
//!
//! # Usage
//! Wrap values in [`Redacted`] to prevent them from being printed directly.
//!
//! ```rust
//! use apollo_redaction::Redacted;
//! use apollo_redaction::strategy::SimpleRedactor;
//!
//! let secret = Redacted::<_, SimpleRedactor>::new("hunter2");
//! assert_eq!(secret.to_string(), "[REDACTED]");
//! assert_eq!(secret.unredact().to_string(), "hunter2");
//! ```
//!
//! ## Deserialization
//!
//! Often, you won't create [`Redacted`] instances manually. Instead, you might deserialize them
//! from a configuration structure:
//!
//! ```rust
//! use apollo_redaction::Redacted;
//!
//! #[derive(Debug, serde::Deserialize)]
//! struct RedisConfig {
//!     username: Redacted<String>,
//!     password: Redacted<String>,
//! }
//!
//! let redis_config: RedisConfig = serde_json::from_str(r#"
//!     {
//!         "username": "admin",
//!         "password": "topsecret"
//!     }
//! "#).unwrap();
//!
//! // If you did eg. `dbg!(redis_config)`...
//! assert_eq!(
//!     format!("{redis_config:?}"),
//!     r#"RedisConfig { username: [REDACTED], password: [REDACTED] }"#,
//! );
//! ```
//!
//! ## Serialization
//!
//! Values wrapped in a [`Redacted`] cannot be serialized with [serde]. This is to prevent
//! accidentally serializing either the redacted or the unredacted form when you don't expect it.
//! You can use the `#[serde(serialize_with)]` attribute to serialize a [`Redacted`] field.
//!
//! apollo-redaction comes with serialization helpers:
//! - [`ser::unredacted`] - serializes the unredacted representation
//! - [`ser::display_redacted`] - serializes the redacted [`Display`] representation
//!
//! # Strategies
//!
//! A strategy must be provided as a type parameter. For example:
//!
//! ```rust
//! use apollo_redaction::Redacted;
//! use apollo_redaction::strategy::UrlHostRedactor;
//! use apollo_redaction::strategy::EmailRedactor;
//!
//! // when deserializing a field...
//! struct TelemetryConfig {
//!     url: Redacted<String, UrlHostRedactor>,
//! }
//! // when constructing a value manually...
//! let email = Redacted::<_, EmailRedactor>::new("myemail@address.com");
//! ```
//!
//! | Strategy | Sample input | Sample output |
//! |----------|--------------|---------------|
//! | [`SimpleRedactor`] | any | `[REDACTED]` |
//! | [`FullRedactor`] | `secret` | `******` |
//! | [`PasswordRedactor`] | any | `********` |
//! | [`IpRedactor`] | `192.168.1.100` | `192.168.*.*` |
//! | [`UrlHostRedactor`] | `https://username:passwd@mytelemetryhost.com/v1/traces` | `****/v1/traces` |
//! | [`UrlPasswordRedactor`] | `https://alice:s3cr3t@proxy.example.com/` | `https://alice:***@proxy.example.com/` |
//! | [`EmailRedactor`] | `janedoe@gmail.com` | `j***e@gmail.com` |
//! | [`First4Redactor`] | `sk_live_abc123` | `sk_l****` |
//! | [`Last4Redactor`] | `4242424242424242` | `************4242` |
//! | [`CardRedactor`] | `4242-4242-4242-4242` | `****-****-****-4242` |
//! | [`FixedRedactor<N>`] | `password123` (N=4) | `pass*******` |
//!
//! [`IpRedactor`]: crate::strategy::IpRedactor
//! [`UrlHostRedactor`]: crate::strategy::UrlHostRedactor
//! [`UrlPasswordRedactor`]: crate::strategy::UrlPasswordRedactor
//! [`EmailRedactor`]: crate::strategy::EmailRedactor
//! [`FullRedactor`]: crate::strategy::FullRedactor
//! [`PasswordRedactor`]: crate::strategy::PasswordRedactor
//! [`First4Redactor`]: crate::strategy::First4Redactor
//! [`Last4Redactor`]: crate::strategy::Last4Redactor
//! [`CardRedactor`]: crate::strategy::CardRedactor
//! [`FixedRedactor<N>`]: crate::strategy::FixedRedactor
//!
//! # Feature flags
//! - `schemars` enables a transparent [`schemars::JsonSchema`] implementation for `Redacted<T>` when `T`
//!   implements `schemars::JsonSchema`.

use crate::strategy::SimpleRedactor;
use std::fmt;
use std::fmt::Debug;
use std::fmt::Display;
use std::str::FromStr;

pub mod ser;
pub mod strategy;

/// A redaction strategy tells apollo-redaction how to redact values for `Display` and `Debug`.
///
/// The trait is generic over the value being redacted. If you know your redaction strategy is just
/// going to be used for a single type, you can write:
///
/// ```rust,ignore
/// use url::Url;
/// use apollo_redaction::RedactionStrategy;
///
/// #[derive(Default, Clone)]
/// struct UrlRedactor;
/// impl RedactionStrategy<Url> for UrlRedactor {
///     // ...
/// }
/// ```
///
/// ## Bounds and deserialization
/// It's strongly recommended that all your redaction strategies derive [`Default`] and [`Clone`].
/// Without [`Default`], a redacted value cannot be deserialized with [serde], making it
/// significantly less useful.
pub trait RedactionStrategy<T> {
    /// How to redact the value for the [`Debug`] (`{:?}`) implementation. By default, this
    /// delegates to [`RedactionStrategy::display`].
    fn debug(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.display(value, f)
    }

    /// How to redact the value for the [`Display`] (`{}`) implementation.
    fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result;
}

/// A wrapper type for redacting values, so the actual values are not accidentally printed out.
///
/// A redacted value comes with a redaction strategy. The strategy provides the [`Display`] and
/// [`Debug`] implementations for the inner type.
///
/// To access the redacted value proper, you must use the [`Redacted::unredact`] method.
#[derive(Default, Clone)]
pub struct Redacted<T, R = SimpleRedactor> {
    value: T,
    strategy: R,
}

impl<T, R: Default> Redacted<T, R> {
    /// Create a new redacted value with a [`Default`] redaction strategy.
    ///
    /// ```rust
    /// use apollo_redaction::Redacted;
    /// use apollo_redaction::strategy::SimpleRedactor;
    ///
    /// let redacted = Redacted::<_, SimpleRedactor>::new("password for all the company's secrets");
    /// ```
    pub fn new(value: T) -> Self {
        Self::from(value)
    }
}

impl<T, R> Redacted<T, R> {
    /// Create a new redacted value with a redaction strategy.
    pub fn with_strategy(value: T, strategy: R) -> Self {
        Self { value, strategy }
    }
}

impl<T, R: Default> From<T> for Redacted<T, R> {
    /// Wrap a value in [`Redacted`] with a [`Default`] redaction strategy.
    ///
    /// ```rust
    /// use apollo_redaction::Redacted;
    /// use apollo_redaction::strategy::SimpleRedactor;
    ///
    /// let redacted = Redacted::<_, SimpleRedactor>::from("password for all the company's secrets");
    /// ```
    fn from(value: T) -> Self {
        Self::with_strategy(value, Default::default())
    }
}

impl<T: FromStr, R: Default> FromStr for Redacted<T, R> {
    type Err = T::Err;

    /// Parse a value from a string with a [`Default`] redaction strategy.
    ///
    /// ```rust
    /// use apollo_redaction::Redacted;
    /// use apollo_redaction::strategy::SimpleRedactor;
    /// use url::Url;
    ///
    /// let redacted: Redacted<Url, SimpleRedactor> = "https://apollographql.com".parse().expect("it's valid");
    /// ```
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        T::from_str(s).map(Self::from)
    }
}

impl<'de, T: serde::Deserialize<'de>, R: Default> serde::Deserialize<'de> for Redacted<T, R> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        T::deserialize(deserializer)
            .map(Self::new)
            // We don't want to leak the actual value in the error message, so we return a generic error.
            .map_err(|_| {
                let type_name = std::any::type_name::<T>();
                let short_name = type_name.rsplit("::").next().unwrap_or(type_name);
                serde::de::Error::custom(format!("failed to parse '{short_name}'"))
            })
    }
}

impl<T, R> Redacted<T, R> {
    /// Access the underlying value.
    pub fn unredact(&self) -> &T {
        &self.value
    }

    /// Access the underlying value.
    pub fn unredact_mut(&mut self) -> &mut T {
        &mut self.value
    }
}

impl<T, R: RedactionStrategy<T>> Display for Redacted<T, R> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.strategy.display(&self.value, f)
    }
}

impl<T, R: RedactionStrategy<T>> Debug for Redacted<T, R> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.strategy.debug(&self.value, f)
    }
}

#[cfg(feature = "schemars")]
impl<T, R> schemars::JsonSchema for Redacted<T, R>
where
    T: schemars::JsonSchema,
{
    fn schema_id() -> std::borrow::Cow<'static, str> {
        T::schema_id()
    }

    fn schema_name() -> std::borrow::Cow<'static, str> {
        T::schema_name()
    }

    fn inline_schema() -> bool {
        T::inline_schema()
    }

    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
        T::json_schema(generator)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unredact() {
        let redacted = Redacted::<_, SimpleRedactor>::new(1234);
        assert_eq!(*redacted.unredact(), 1234);
        let mut redacted = redacted;
        assert_eq!(*redacted.unredact_mut(), 1234);

        *redacted.unredact_mut() = 1235;
        assert_eq!(*redacted.unredact(), 1235);
    }

    #[test]
    fn deserialize_error_does_not_leak_value() {
        // URLs often contain credentials (usernames/passwords). When deserializing fails,
        // the url crate's error message would normally include the malformed URL, potentially
        // leaking these credentials. Redacted<Url> prevents this.

        // Without redaction this would leak an error "relative URL without a base: \"not-a-url-with-secret-api-key-12345\" at line 1 column 37"
        let redacted_err =
            serde_json::from_str::<Redacted<url::Url>>(r#""not-a-url-with-secret-api-key-12345""#)
                .unwrap_err()
                .to_string();
        assert_eq!(redacted_err, "failed to parse 'Url'");
        assert!(
            !redacted_err.contains("secret-api-key-12345"),
            "Redacted<Url> error must not contain the secret"
        );
    }
}