Skip to main content

laminar_connectors/storage/
masking.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    "account_key",
17    "private_key",
18    "sas_token",
19    "session_token",
20    "client_secret",
21    "service_account_key",
22];
23
24/// Utility for masking secret values in configuration maps.
25pub struct SecretMasker;
26
27impl SecretMasker {
28    /// Returns true if the key name suggests it holds a secret value.
29    ///
30    /// Matches case-insensitively against known secret patterns.
31    /// Deliberately does NOT match keys like `aws_access_key_id` (the ID
32    /// is not secret) or `aws_region`.
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// use laminar_connectors::storage::SecretMasker;
38    ///
39    /// assert!(SecretMasker::is_secret_key("aws_secret_access_key"));
40    /// assert!(SecretMasker::is_secret_key("password"));
41    /// assert!(!SecretMasker::is_secret_key("aws_region"));
42    /// assert!(!SecretMasker::is_secret_key("aws_access_key_id"));
43    /// ```
44    #[must_use]
45    pub fn is_secret_key(key: &str) -> bool {
46        let lower = key.to_lowercase();
47        SECRET_PATTERNS.iter().any(|p| lower.contains(p))
48    }
49
50    /// Returns a redacted copy of the map, replacing secret values with `"***"`.
51    #[must_use]
52    pub fn redact_map(map: &HashMap<String, String>) -> HashMap<String, String> {
53        map.iter()
54            .map(|(k, v)| {
55                if Self::is_secret_key(k) {
56                    (k.clone(), "***".to_string())
57                } else {
58                    (k.clone(), v.clone())
59                }
60            })
61            .collect()
62    }
63
64    /// Formats a map for display with secrets redacted and keys sorted.
65    #[must_use]
66    pub fn display_map(map: &HashMap<String, String>) -> String {
67        if map.is_empty() {
68            return String::new();
69        }
70
71        let mut pairs: Vec<_> = map.iter().collect();
72        pairs.sort_by_key(|(k, _)| k.as_str());
73        pairs
74            .iter()
75            .map(|(k, v)| {
76                if Self::is_secret_key(k) {
77                    format!("{k}=***")
78                } else {
79                    format!("{k}={v}")
80                }
81            })
82            .collect::<Vec<_>>()
83            .join(", ")
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    // ── is_secret_key tests ──
92
93    #[test]
94    fn test_secret_key_aws_secret() {
95        assert!(SecretMasker::is_secret_key("aws_secret_access_key"));
96    }
97
98    #[test]
99    fn test_secret_key_password() {
100        assert!(SecretMasker::is_secret_key("password"));
101    }
102
103    #[test]
104    fn test_secret_key_azure_account_key() {
105        assert!(SecretMasker::is_secret_key("azure_storage_account_key"));
106    }
107
108    #[test]
109    fn test_secret_key_sas_token() {
110        assert!(SecretMasker::is_secret_key("azure_storage_sas_token"));
111    }
112
113    #[test]
114    fn test_secret_key_session_token() {
115        assert!(SecretMasker::is_secret_key("aws_session_token"));
116    }
117
118    #[test]
119    fn test_secret_key_private_key() {
120        assert!(SecretMasker::is_secret_key("google_private_key"));
121    }
122
123    #[test]
124    fn test_secret_key_service_account_key() {
125        assert!(SecretMasker::is_secret_key("google_service_account_key"));
126    }
127
128    #[test]
129    fn test_secret_key_client_secret() {
130        assert!(SecretMasker::is_secret_key("azure_storage_client_secret"));
131    }
132
133    #[test]
134    fn test_secret_key_case_insensitive() {
135        assert!(SecretMasker::is_secret_key("AWS_SECRET_ACCESS_KEY"));
136        assert!(SecretMasker::is_secret_key("Password"));
137    }
138
139    #[test]
140    fn test_not_secret_region() {
141        assert!(!SecretMasker::is_secret_key("aws_region"));
142    }
143
144    #[test]
145    fn test_not_secret_access_key_id() {
146        assert!(!SecretMasker::is_secret_key("aws_access_key_id"));
147    }
148
149    #[test]
150    fn test_not_secret_table_path() {
151        assert!(!SecretMasker::is_secret_key("table.path"));
152    }
153
154    #[test]
155    fn test_not_secret_account_name() {
156        assert!(!SecretMasker::is_secret_key("azure_storage_account_name"));
157    }
158
159    #[test]
160    fn test_not_secret_endpoint() {
161        assert!(!SecretMasker::is_secret_key("aws_endpoint"));
162    }
163
164    #[test]
165    fn test_not_secret_service_account_path() {
166        // Path is not secret (it's just a filesystem path to the key file)
167        assert!(!SecretMasker::is_secret_key("google_service_account_path"));
168    }
169
170    // ── redact_map tests ──
171
172    #[test]
173    fn test_redact_map_replaces_secrets() {
174        let mut map = HashMap::new();
175        map.insert("aws_region".to_string(), "us-east-1".to_string());
176        map.insert(
177            "aws_secret_access_key".to_string(),
178            "REAL_SECRET".to_string(),
179        );
180        map.insert("aws_access_key_id".to_string(), "AKID123".to_string());
181
182        let redacted = SecretMasker::redact_map(&map);
183        assert_eq!(redacted["aws_region"], "us-east-1");
184        assert_eq!(redacted["aws_secret_access_key"], "***");
185        assert_eq!(redacted["aws_access_key_id"], "AKID123");
186    }
187
188    #[test]
189    fn test_redact_map_empty() {
190        let map = HashMap::new();
191        let redacted = SecretMasker::redact_map(&map);
192        assert!(redacted.is_empty());
193    }
194
195    // ── display_map tests ──
196
197    #[test]
198    fn test_display_map_sorted() {
199        let mut map = HashMap::new();
200        map.insert("z_key".to_string(), "z_val".to_string());
201        map.insert("a_key".to_string(), "a_val".to_string());
202
203        let display = SecretMasker::display_map(&map);
204        assert!(display.starts_with("a_key="));
205        assert!(display.contains("z_key="));
206    }
207
208    #[test]
209    fn test_display_map_redacts_secrets() {
210        let mut map = HashMap::new();
211        map.insert("aws_region".to_string(), "us-east-1".to_string());
212        map.insert(
213            "aws_secret_access_key".to_string(),
214            "TOP_SECRET".to_string(),
215        );
216
217        let display = SecretMasker::display_map(&map);
218        assert!(display.contains("aws_region=us-east-1"));
219        assert!(display.contains("aws_secret_access_key=***"));
220        assert!(!display.contains("TOP_SECRET"));
221    }
222
223    #[test]
224    fn test_display_map_empty() {
225        let map = HashMap::new();
226        let display = SecretMasker::display_map(&map);
227        assert!(display.is_empty());
228    }
229}