ricecoder_providers/
redaction.rs

1//! Key redaction and safety utilities
2//!
3//! This module provides utilities for redacting sensitive information (API keys, credentials)
4//! from logs, error messages, and debug output to prevent accidental credential leakage.
5
6use regex::Regex;
7use std::sync::OnceLock;
8
9/// Redaction filter for removing sensitive information from strings
10pub struct RedactionFilter {
11    /// Patterns to redact (regex patterns for API keys, tokens, etc.)
12    patterns: Vec<RedactionPattern>,
13}
14
15/// A pattern to redact with its replacement
16struct RedactionPattern {
17    /// Regex pattern to match
18    regex: Regex,
19    /// Replacement string (e.g., "[REDACTED]")
20    replacement: String,
21}
22
23impl RedactionFilter {
24    /// Create a new redaction filter with default patterns
25    pub fn new() -> Self {
26        Self {
27            patterns: vec![
28                // OpenAI API keys (sk-*)
29                RedactionPattern {
30                    regex: Regex::new(r"sk-[A-Za-z0-9]{20,}").unwrap(),
31                    replacement: "[REDACTED_OPENAI_KEY]".to_string(),
32                },
33                // Anthropic API keys (sk-ant-*)
34                RedactionPattern {
35                    regex: Regex::new(r"sk-ant-[A-Za-z0-9]{20,}").unwrap(),
36                    replacement: "[REDACTED_ANTHROPIC_KEY]".to_string(),
37                },
38                // Generic API keys (api_key=*, apiKey=*, api-key=*)
39                RedactionPattern {
40                    regex: Regex::new(r"(?i)(api[_-]?key|token|secret|password)\s*=\s*[^\s,;]+")
41                        .unwrap(),
42                    replacement: "$1=[REDACTED]".to_string(),
43                },
44                // Bearer tokens
45                RedactionPattern {
46                    regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9._\-/+=]+").unwrap(),
47                    replacement: "Bearer [REDACTED]".to_string(),
48                },
49                // Authorization headers
50                RedactionPattern {
51                    regex: Regex::new(r"(?i)authorization:\s*[^\s,;]+").unwrap(),
52                    replacement: "Authorization: [REDACTED]".to_string(),
53                },
54                // Environment variable patterns
55                RedactionPattern {
56                    regex: Regex::new(
57                        r"(?i)(OPENAI|ANTHROPIC|GOOGLE|GROQ|MISTRAL)_API_KEY\s*=\s*[^\s,;]+",
58                    )
59                    .unwrap(),
60                    replacement: "$1_API_KEY=[REDACTED]".to_string(),
61                },
62            ],
63        }
64    }
65
66    /// Add a custom redaction pattern
67    pub fn add_pattern(&mut self, pattern: &str, replacement: &str) -> Result<(), String> {
68        let regex = Regex::new(pattern).map_err(|e| e.to_string())?;
69        self.patterns.push(RedactionPattern {
70            regex,
71            replacement: replacement.to_string(),
72        });
73        Ok(())
74    }
75
76    /// Redact sensitive information from a string
77    pub fn redact(&self, input: &str) -> String {
78        let mut result = input.to_string();
79        for pattern in &self.patterns {
80            result = pattern
81                .regex
82                .replace_all(&result, &pattern.replacement)
83                .to_string();
84        }
85        result
86    }
87
88    /// Check if a string contains any sensitive information
89    pub fn contains_sensitive_info(&self, input: &str) -> bool {
90        self.patterns.iter().any(|p| p.regex.is_match(input))
91    }
92}
93
94impl Default for RedactionFilter {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100/// Get the global redaction filter (singleton)
101pub fn get_redaction_filter() -> &'static RedactionFilter {
102    static FILTER: OnceLock<RedactionFilter> = OnceLock::new();
103    FILTER.get_or_init(RedactionFilter::new)
104}
105
106/// Redact sensitive information from a string using the global filter
107pub fn redact(input: &str) -> String {
108    get_redaction_filter().redact(input)
109}
110
111/// Check if a string contains sensitive information
112pub fn contains_sensitive_info(input: &str) -> bool {
113    get_redaction_filter().contains_sensitive_info(input)
114}
115
116/// A wrapper type that automatically redacts its Debug output
117pub struct Redacted<T: AsRef<str>>(pub T);
118
119impl<T: AsRef<str>> std::fmt::Debug for Redacted<T> {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        write!(f, "{}", redact(self.0.as_ref()))
122    }
123}
124
125impl<T: AsRef<str>> std::fmt::Display for Redacted<T> {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(f, "{}", redact(self.0.as_ref()))
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_redact_openai_key() {
137        let filter = RedactionFilter::new();
138        let input = "My API key is sk-1234567890abcdefghij";
139        let redacted = filter.redact(input);
140        assert!(!redacted.contains("sk-1234567890abcdefghij"));
141        assert!(redacted.contains("[REDACTED_OPENAI_KEY]"));
142    }
143
144    #[test]
145    fn test_redact_anthropic_key() {
146        let filter = RedactionFilter::new();
147        let input = "My API key is sk-ant-1234567890abcdefghij";
148        let redacted = filter.redact(input);
149        assert!(!redacted.contains("sk-ant-1234567890abcdefghij"));
150        assert!(redacted.contains("[REDACTED_ANTHROPIC_KEY]"));
151    }
152
153    #[test]
154    fn test_redact_api_key_equals() {
155        let filter = RedactionFilter::new();
156        let input = "api_key=secret123456789";
157        let redacted = filter.redact(input);
158        assert!(!redacted.contains("secret123456789"));
159        assert!(redacted.contains("[REDACTED]"));
160    }
161
162    #[test]
163    fn test_redact_bearer_token() {
164        let filter = RedactionFilter::new();
165        let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
166        let redacted = filter.redact(input);
167        assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"));
168        // Bearer token should be redacted (either as part of Authorization header or Bearer pattern)
169        assert!(redacted.contains("[REDACTED]"));
170    }
171
172    #[test]
173    fn test_redact_env_var() {
174        let filter = RedactionFilter::new();
175        let input = "OPENAI_API_KEY=sk-1234567890abcdefghij";
176        let redacted = filter.redact(input);
177        assert!(!redacted.contains("sk-1234567890abcdefghij"));
178        assert!(redacted.contains("[REDACTED]"));
179    }
180
181    #[test]
182    fn test_contains_sensitive_info_true() {
183        let filter = RedactionFilter::new();
184        assert!(filter.contains_sensitive_info("My key is sk-1234567890abcdefghij"));
185        assert!(filter.contains_sensitive_info("api_key=secret123"));
186        assert!(filter.contains_sensitive_info("Bearer token123"));
187    }
188
189    #[test]
190    fn test_contains_sensitive_info_false() {
191        let filter = RedactionFilter::new();
192        assert!(!filter.contains_sensitive_info("This is a normal message"));
193        assert!(!filter.contains_sensitive_info("No secrets here"));
194    }
195
196    #[test]
197    fn test_add_custom_pattern() {
198        let mut filter = RedactionFilter::new();
199        filter
200            .add_pattern(r"custom_secret_\d+", "[CUSTOM_REDACTED]")
201            .unwrap();
202
203        let input = "Found custom_secret_12345";
204        let redacted = filter.redact(input);
205        assert!(redacted.contains("[CUSTOM_REDACTED]"));
206    }
207
208    #[test]
209    fn test_redacted_debug() {
210        let secret = "sk-1234567890abcdefghij";
211        let redacted = Redacted(secret);
212        let debug_str = format!("{:?}", redacted);
213        assert!(!debug_str.contains("sk-1234567890abcdefghij"));
214        assert!(debug_str.contains("[REDACTED_OPENAI_KEY]"));
215    }
216
217    #[test]
218    fn test_redacted_display() {
219        let secret = "api_key=secret123";
220        let redacted = Redacted(secret);
221        let display_str = format!("{}", redacted);
222        assert!(!display_str.contains("secret123"));
223        assert!(display_str.contains("[REDACTED]"));
224    }
225
226    #[test]
227    fn test_global_redaction_filter() {
228        let filter = get_redaction_filter();
229        let input = "My key is sk-1234567890abcdefghij";
230        let redacted = filter.redact(input);
231        assert!(redacted.contains("[REDACTED_OPENAI_KEY]"));
232    }
233
234    #[test]
235    fn test_global_redact_function() {
236        let input = "My key is sk-1234567890abcdefghij";
237        let redacted = redact(input);
238        assert!(redacted.contains("[REDACTED_OPENAI_KEY]"));
239    }
240
241    #[test]
242    fn test_multiple_keys_in_string() {
243        let filter = RedactionFilter::new();
244        let input = "openai: sk-1234567890abcdefghij, anthropic: sk-ant-1234567890abcdefghij";
245        let redacted = filter.redact(input);
246        assert!(!redacted.contains("sk-1234567890abcdefghij"));
247        assert!(!redacted.contains("sk-ant-1234567890abcdefghij"));
248        assert!(redacted.contains("[REDACTED_OPENAI_KEY]"));
249        assert!(redacted.contains("[REDACTED_ANTHROPIC_KEY]"));
250    }
251
252    #[test]
253    fn test_case_insensitive_redaction() {
254        let filter = RedactionFilter::new();
255        let input = "API_KEY=secret123 and ApiKey=secret456";
256        let redacted = filter.redact(input);
257        assert!(!redacted.contains("secret123"));
258        assert!(!redacted.contains("secret456"));
259    }
260}