Skip to main content

cuenv_events/
redaction.rs

1//! Global secret redaction for cuenv events.
2//!
3//! Provides a centralized registry for secrets that should be redacted from all output.
4//! Secrets are registered at runtime and automatically applied to event content
5//! before events reach renderers.
6
7use std::collections::HashSet;
8use std::sync::{LazyLock, RwLock};
9
10/// Minimum secret length to redact (shorter secrets may cause false positives)
11pub const MIN_SECRET_LENGTH: usize = 4;
12
13/// Placeholder for redacted secrets
14pub const REDACTED_PLACEHOLDER: &str = "*_*";
15
16/// Global registry of secrets to redact
17static SECRET_REGISTRY: LazyLock<RwLock<HashSet<String>>> =
18    LazyLock::new(|| RwLock::new(HashSet::new()));
19
20/// Register a secret value for redaction.
21///
22/// All future events will have this secret redacted from their content.
23/// Secrets shorter than `MIN_SECRET_LENGTH` are ignored (too many false positives).
24///
25/// # Example
26///
27/// ```rust
28/// use cuenv_events::redaction::register_secret;
29///
30/// // Register a secret for redaction
31/// register_secret("my-secret-token-12345");
32/// ```
33pub fn register_secret(secret: impl Into<String>) {
34    let secret = secret.into();
35    if secret.len() >= MIN_SECRET_LENGTH
36        && let Ok(mut registry) = SECRET_REGISTRY.write()
37    {
38        registry.insert(secret);
39    }
40}
41
42/// Register multiple secrets at once.
43///
44/// More efficient than calling `register_secret` multiple times.
45///
46/// # Example
47///
48/// ```rust
49/// use cuenv_events::redaction::register_secrets;
50///
51/// register_secrets(["secret1", "secret2", "secret3"]);
52/// ```
53pub fn register_secrets(secrets: impl IntoIterator<Item = impl Into<String>>) {
54    if let Ok(mut registry) = SECRET_REGISTRY.write() {
55        for secret in secrets {
56            let s = secret.into();
57            if s.len() >= MIN_SECRET_LENGTH {
58                registry.insert(s);
59            }
60        }
61    }
62}
63
64/// Redact all registered secrets from a string.
65///
66/// Returns the input with all registered secrets replaced with `*_*`.
67/// Uses greedy matching (longer secrets are replaced first) to handle
68/// overlapping secrets correctly.
69///
70/// # Example
71///
72/// ```rust
73/// use cuenv_events::redaction::{register_secret, redact};
74///
75/// register_secret("password123");
76/// let redacted = redact("The password is password123");
77/// assert!(redacted.contains("*_*"));
78/// ```
79#[must_use]
80pub fn redact(input: &str) -> String {
81    let secrets = match SECRET_REGISTRY.read() {
82        Ok(registry) => registry.clone(),
83        Err(_) => return input.to_string(),
84    };
85
86    if secrets.is_empty() {
87        return input.to_string();
88    }
89
90    // Sort by length descending for greedy matching (longer secrets first)
91    let mut sorted: Vec<_> = secrets.into_iter().collect();
92    sorted.sort_by_key(|s| std::cmp::Reverse(s.len()));
93
94    let mut result = input.to_string();
95    for secret in &sorted {
96        result = result.replace(secret, REDACTED_PLACEHOLDER);
97    }
98    result
99}
100
101/// Check if any secrets are registered.
102#[must_use]
103pub fn has_secrets() -> bool {
104    SECRET_REGISTRY
105        .read()
106        .map(|r| !r.is_empty())
107        .unwrap_or(false)
108}
109
110/// Get the number of registered secrets.
111#[must_use]
112pub fn secret_count() -> usize {
113    SECRET_REGISTRY.read().map(|r| r.len()).unwrap_or(0)
114}
115
116/// Clear all registered secrets.
117///
118/// This is primarily useful for testing to ensure test isolation.
119#[cfg(test)]
120pub fn clear_secrets() {
121    if let Ok(mut registry) = SECRET_REGISTRY.write() {
122        registry.clear();
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::sync::Mutex;
130
131    // Use a mutex to ensure tests don't interfere with each other
132    static TEST_LOCK: Mutex<()> = Mutex::new(());
133
134    fn with_clean_registry<F, R>(f: F) -> R
135    where
136        F: FnOnce() -> R,
137    {
138        let _guard = TEST_LOCK.lock().unwrap();
139        clear_secrets();
140        let result = f();
141        clear_secrets();
142        result
143    }
144
145    #[test]
146    fn test_simple_redaction() {
147        with_clean_registry(|| {
148            register_secret("secret123");
149            let result = redact("The password is secret123, don't share it");
150            assert_eq!(result, "The password is *_*, don't share it");
151        });
152    }
153
154    #[test]
155    fn test_multiple_secrets() {
156        with_clean_registry(|| {
157            register_secrets(["password123", "api_key_xyz"]);
158            let result = redact("password123 and api_key_xyz are both secrets");
159            assert_eq!(result, "*_* and *_* are both secrets");
160        });
161    }
162
163    #[test]
164    fn test_repeated_secret() {
165        with_clean_registry(|| {
166            register_secret("secret");
167            let result = redact("secret appears twice: secret");
168            assert_eq!(result, "*_* appears twice: *_*");
169        });
170    }
171
172    #[test]
173    fn test_short_secret_ignored() {
174        with_clean_registry(|| {
175            register_secret("ab"); // Too short (< 4 chars)
176            register_secret("abc"); // Too short
177            register_secret("abcd"); // Just right (= 4 chars)
178
179            assert_eq!(secret_count(), 1);
180
181            let result = redact("ab abc abcd");
182            assert_eq!(result, "ab abc *_*");
183        });
184    }
185
186    #[test]
187    fn test_empty_input() {
188        with_clean_registry(|| {
189            register_secret("secret");
190            let result = redact("");
191            assert_eq!(result, "");
192        });
193    }
194
195    #[test]
196    fn test_no_secrets_registered() {
197        with_clean_registry(|| {
198            assert!(!has_secrets());
199            let result = redact("nothing to redact here");
200            assert_eq!(result, "nothing to redact here");
201        });
202    }
203
204    #[test]
205    fn test_greedy_matching() {
206        with_clean_registry(|| {
207            // Longer secret should be matched first
208            register_secrets(["pass", "password"]);
209            let result = redact("the password is set");
210            // Should redact "password" not just "pass"
211            assert_eq!(result, "the *_* is set");
212        });
213    }
214
215    #[test]
216    fn test_secret_at_boundaries() {
217        with_clean_registry(|| {
218            register_secret("secret");
219
220            // Secret at start
221            let result = redact("secret is here");
222            assert_eq!(result, "*_* is here");
223
224            // Secret at end
225            let result = redact("here is secret");
226            assert_eq!(result, "here is *_*");
227        });
228    }
229
230    #[test]
231    fn test_special_characters() {
232        with_clean_registry(|| {
233            register_secret("pass$word!@#");
234            let result = redact("the pass$word!@# is special");
235            assert_eq!(result, "the *_* is special");
236        });
237    }
238
239    #[test]
240    fn test_multiline_content() {
241        with_clean_registry(|| {
242            register_secret("secretkey");
243            let input = "line1\nsecretkey\nline3";
244            let result = redact(input);
245            assert_eq!(result, "line1\n*_*\nline3");
246        });
247    }
248
249    #[test]
250    fn test_has_secrets() {
251        with_clean_registry(|| {
252            assert!(!has_secrets());
253            register_secret("test_secret");
254            assert!(has_secrets());
255        });
256    }
257
258    #[test]
259    fn test_secret_count() {
260        with_clean_registry(|| {
261            assert_eq!(secret_count(), 0);
262            register_secret("secret1");
263            assert_eq!(secret_count(), 1);
264            register_secret("secret2");
265            assert_eq!(secret_count(), 2);
266            // Duplicate should not increase count
267            register_secret("secret1");
268            assert_eq!(secret_count(), 2);
269        });
270    }
271}