Skip to main content

laminar_connectors/storage/masking/
mod.rs

1//! Secret masking for safe logging of connector configuration.
2//!
3//! [`SecretMasker`] identifies keys that hold secret values (passwords,
4//! access keys, tokens) and replaces their values with `"***"` in
5//! `Display`/`Debug` output.
6#![allow(clippy::disallowed_types)] // cold path: storage configuration
7
8use std::collections::HashMap;
9
10/// Substring patterns that indicate a key holds a secret value.
11///
12/// Matched case-insensitively against the full key name.
13const SECRET_PATTERNS: &[&str] = &[
14    "secret",
15    "password",
16    "access_key",
17    "account_key",
18    "private_key",
19    "token",
20    "credential",
21    "service_account",
22    "client_id",
23    "tenant_id",
24    "account_name",
25    "profile",
26    "role_arn",
27    "authorization",
28    "kms_key",
29];
30
31/// Utility for masking secret values in configuration maps.
32pub struct SecretMasker;
33
34impl SecretMasker {
35    /// Returns true if the key name suggests it holds a secret value.
36    ///
37    /// Matches case-insensitively against known secret patterns.
38    /// Credential identifiers are redacted as well as secret material so
39    /// logs cannot be used to inventory cloud identities.
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// use laminar_connectors::storage::SecretMasker;
45    ///
46    /// assert!(SecretMasker::is_secret_key("aws_secret_access_key"));
47    /// assert!(SecretMasker::is_secret_key("password"));
48    /// assert!(!SecretMasker::is_secret_key("aws_region"));
49    /// assert!(SecretMasker::is_secret_key("aws_access_key_id"));
50    /// ```
51    #[must_use]
52    pub fn is_secret_key(key: &str) -> bool {
53        let lower = key.to_lowercase();
54        SECRET_PATTERNS.iter().any(|p| lower.contains(p))
55    }
56
57    /// Returns a redacted copy of the map, replacing secret values with `"***"`.
58    #[must_use]
59    pub fn redact_map(map: &HashMap<String, String>) -> HashMap<String, String> {
60        map.iter()
61            .map(|(k, v)| (k.clone(), Self::redact_value(k, v)))
62            .collect()
63    }
64
65    /// Formats a map for display with secrets redacted and keys sorted.
66    #[must_use]
67    pub fn display_map(map: &HashMap<String, String>) -> String {
68        if map.is_empty() {
69            return String::new();
70        }
71
72        let mut pairs: Vec<_> = map.iter().collect();
73        pairs.sort_by_key(|(k, _)| k.as_str());
74        pairs
75            .iter()
76            .map(|(k, v)| format!("{k}={}", Self::redact_value(k, v)))
77            .collect::<Vec<_>>()
78            .join(", ")
79    }
80
81    fn redact_value(key: &str, value: &str) -> String {
82        if Self::is_secret_key(key) {
83            return "***".into();
84        }
85        let normalized = key.to_ascii_lowercase();
86        if normalized.contains("endpoint") || normalized.ends_with("base_url") {
87            return endpoint_description(value);
88        }
89        if value.contains("://") {
90            if let Ok(parsed) = url::Url::parse(value) {
91                if !parsed.username().is_empty()
92                    || parsed.password().is_some()
93                    || parsed.query().is_some()
94                    || parsed.fragment().is_some()
95                {
96                    return "<redacted-url>".into();
97                }
98            } else if value.contains('?') || value.contains('@') || value.contains('#') {
99                return "<redacted-url>".into();
100            }
101        }
102        value.to_string()
103    }
104}
105
106fn endpoint_description(value: &str) -> String {
107    let Ok(parsed) = url::Url::parse(value) else {
108        return "<configured-endpoint>".into();
109    };
110    match parsed.scheme() {
111        "http" => "<custom-http-endpoint>".into(),
112        "https" => "<custom-https-endpoint>".into(),
113        _ => "<configured-endpoint>".into(),
114    }
115}
116
117#[cfg(test)]
118mod tests;