Skip to main content

oxicode_ai/utils/
secret_obfuscator.rs

1//! Secret obfuscator — ported from omp
2//! `packages/coding-agent/src/secrets/obfuscator.ts`.
3//!
4//! MIT — attribution: adapted from
5//! [omp](https://github.com/can1357/oh-my-pi) (Can Berk Güder, earendil-works).
6//!
7//! ## Purpose
8//!
9//! When building LLM context (system prompts, tool results, session
10//! logs), API keys and other secrets may leak into the text the model
11//! sees. This module provides [`SecretObfuscator`] — a bidirectional
12//! text scrubber that replaces known secret strings with stable
13//! `#XXXX#` placeholders before the text reaches the model, then
14//! restores the originals on the way back.
15//!
16//! ## Modes
17//!
18//! - **Obfuscate** (default): replaces the secret with a `#XXXX#`
19//!   placeholder. The original is recoverable via
20//!   [`deobfuscate`](SecretObfuscator::deobfuscate). Use this for
21//!   API keys that appear in tool output.
22//! - **Replace**: replaces the secret with a deterministic same-length
23//!   random-looking string. The original is NOT recoverable. Use this
24//!   for secrets that must never be stored (even as a placeholder
25//!   mapping).
26//!
27//! ## Plain mode only (MVP)
28//!
29//! This implementation handles **plain string** secrets only — exact
30//! matches. Regex-based pattern detection (for credit cards, SSNs, etc.)
31//! is a future extension; omp's `compileSecretRegex` path is documented
32//! in the TODO.
33
34use sha2::{Digest, Sha256};
35
36/// A single secret entry to register with the obfuscator.
37#[derive(Debug, Clone)]
38pub struct SecretEntry {
39    /// The secret string to detect and replace.
40    pub content: String,
41    /// `"obfuscate"` (default, reversible) or `"replace"` (one-way).
42    pub mode: SecretMode,
43    /// For `"replace"` mode: the replacement text. If `None`, a
44    /// deterministic same-length replacement is generated.
45    pub replacement: Option<String>,
46}
47
48/// How a secret is handled.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum SecretMode {
51    /// Replace with a `#XXXX#` placeholder. Reversible via
52    /// [`SecretObfuscator::deobfuscate`].
53    #[default]
54    Obfuscate,
55    /// Replace with a deterministic string. NOT reversible.
56    Replace,
57}
58
59/// Placeholder format: `#` + 4 uppercase-hex chars + `#`.
60const PLACEHOLDER_LEN: usize = 6; // #XXXX#
61const HASH_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
62
63/// Minimum secret length for obfuscation. Secrets shorter than this are
64/// skipped to avoid false matches on common short words (e.g. "esp").
65const MIN_SECRET_LEN: usize = 8;
66
67/// Characters used for deterministic replacements.
68const REPLACEMENT_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
69
70/// Build a deterministic `#XXXX#` placeholder for index `n`.
71fn build_placeholder(n: usize) -> String {
72    let hash = Sha256::digest(format!("oxicode-secret-placeholder-{n}").as_bytes());
73    let mut tag = String::with_capacity(PLACEHOLDER_LEN);
74    tag.push('#');
75    for i in 0..4 {
76        let byte = hash[i] as usize;
77        tag.push(HASH_CHARS[byte % HASH_CHARS.len()] as char);
78    }
79    tag.push('#');
80    tag
81}
82
83/// Generate a deterministic, same-length replacement string from a
84/// secret value. The output looks random but is reproducible — the same
85/// secret always maps to the same replacement.
86fn deterministic_replacement(secret: &str) -> String {
87    let hash = Sha256::digest(secret.as_bytes());
88    let chars: Vec<char> = secret.chars().collect();
89    let mut out = String::with_capacity(secret.len());
90    for (i, _) in chars.iter().enumerate() {
91        // Mix the hash with the position to produce per-character
92        // variation.
93        let h = hash[i % hash.len()]
94            .wrapping_mul((i as u8).wrapping_add(1))
95            .wrapping_add(0x9e);
96        out.push(REPLACEMENT_CHARS[h as usize % REPLACEMENT_CHARS.len()] as char);
97    }
98    out
99}
100
101/// Bidirectional secret obfuscator.
102///
103/// Construct with a list of [`SecretEntry`] values, then call
104/// [`obfuscate`](Self::obfuscate) on text before it reaches the LLM and
105/// [`deobfuscate`](Self::deobfuscate) on text coming back.
106pub struct SecretObfuscator {
107    /// Obfuscate-mode: secret → index.
108    plain_to_index: std::collections::HashMap<String, usize>,
109    /// Obfuscate-mode: index → (secret, placeholder).
110    obfuscate_mappings: std::collections::HashMap<usize, (String, String)>,
111    /// Reverse lookup: placeholder → secret.
112    deobfuscate_map: std::collections::HashMap<String, String>,
113    /// Replace-mode: secret → replacement.
114    replace_mappings: std::collections::HashMap<String, String>,
115    /// Whether any real secrets were configured.
116    has_any: bool,
117}
118
119impl std::fmt::Debug for SecretObfuscator {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.debug_struct("SecretObfuscator")
122            .field("secret_count", &self.obfuscate_mappings.len())
123            .field("replace_count", &self.replace_mappings.len())
124            .field("has_any", &self.has_any)
125            .finish_non_exhaustive()
126    }
127}
128
129impl Default for SecretObfuscator {
130    fn default() -> Self {
131        Self::new(&[])
132    }
133}
134
135impl SecretObfuscator {
136    /// Construct from a list of secret entries.
137    ///
138    /// Secrets shorter than 8 chars in obfuscate mode are silently
139    /// skipped (false-positive avoidance). Invalid regex entries (future
140    /// extension) are also skipped.
141    pub fn new(entries: &[SecretEntry]) -> Self {
142        let mut ob = Self {
143            plain_to_index: std::collections::HashMap::new(),
144            obfuscate_mappings: std::collections::HashMap::new(),
145            deobfuscate_map: std::collections::HashMap::new(),
146            replace_mappings: std::collections::HashMap::new(),
147            has_any: false,
148        };
149
150        for (index, entry) in entries.iter().enumerate() {
151            match entry.mode {
152                SecretMode::Obfuscate => {
153                    if entry.content.len() < MIN_SECRET_LEN {
154                        continue;
155                    }
156                    let placeholder = build_placeholder(index);
157                    ob.plain_to_index.insert(entry.content.clone(), index);
158                    ob.obfuscate_mappings
159                        .insert(index, (entry.content.clone(), placeholder.clone()));
160                    ob.deobfuscate_map
161                        .insert(placeholder, entry.content.clone());
162                    ob.has_any = true;
163                }
164                SecretMode::Replace => {
165                    let replacement = entry
166                        .replacement
167                        .clone()
168                        .unwrap_or_else(|| deterministic_replacement(&entry.content));
169                    ob.replace_mappings
170                        .insert(entry.content.clone(), replacement);
171                    ob.has_any = true;
172                }
173            }
174        }
175
176        ob
177    }
178
179    /// Returns `true` if any secrets were configured.
180    pub fn has_secrets(&self) -> bool {
181        self.has_any
182    }
183
184    /// Obfuscate all known secrets in `text`.
185    ///
186    /// Obfuscate-mode secrets are replaced with `#XXXX#` placeholders
187    /// (reversible via [`deobfuscate`](Self::deobfuscate)). Replace-mode
188    /// secrets are replaced with deterministic strings (NOT reversible).
189    ///
190    /// Processing order: replace-mode first (longest first to handle
191    /// prefix overlaps), then obfuscate-mode (longest first).
192    pub fn obfuscate(&self, text: &str) -> String {
193        if !self.has_any {
194            return text.to_string();
195        }
196        let mut result = text.to_string();
197
198        // Replace-mode: sort by secret length descending so longer
199        // secrets are replaced first (prevents partial matches).
200        let mut replace_sorted: Vec<(&String, &String)> = self.replace_mappings.iter().collect();
201        replace_sorted.sort_by_key(|(s, _)| std::cmp::Reverse(s.len()));
202        for (secret, replacement) in replace_sorted {
203            if !result.contains(secret.as_str()) {
204                continue;
205            }
206            result = result.replace(secret.as_str(), replacement.as_str());
207        }
208
209        // Obfuscate-mode: same longest-first ordering.
210        let mut obfuscate_sorted: Vec<(&String, &usize)> = self.plain_to_index.iter().collect();
211        obfuscate_sorted.sort_by_key(|(s, _)| std::cmp::Reverse(s.len()));
212        for (secret, index) in obfuscate_sorted {
213            if !result.contains(secret.as_str()) {
214                continue;
215            }
216            if let Some((_, placeholder)) = self.obfuscate_mappings.get(index) {
217                result = result.replace(secret.as_str(), placeholder.as_str());
218            }
219        }
220
221        result
222    }
223
224    /// Deobfuscate `#XXXX#` placeholders back to their original secrets.
225    ///
226    /// Only obfuscate-mode placeholders are reversed. Replace-mode
227    /// replacements are permanent (by design — the original is never
228    /// recoverable).
229    pub fn deobfuscate(&self, text: &str) -> String {
230        if !self.has_any || !text.contains('#') {
231            return text.to_string();
232        }
233        let mut result = text.to_string();
234        for (placeholder, secret) in &self.deobfuscate_map {
235            if result.contains(placeholder.as_str()) {
236                result = result.replace(placeholder.as_str(), secret.as_str());
237            }
238        }
239        result
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn entry(content: &str, mode: SecretMode) -> SecretEntry {
248        SecretEntry {
249            content: content.to_string(),
250            mode,
251            replacement: None,
252        }
253    }
254
255    #[test]
256    fn empty_entries_passthrough() {
257        let ob = SecretObfuscator::new(&[]);
258        assert!(!ob.has_secrets());
259        assert_eq!(ob.obfuscate("hello world"), "hello world");
260        assert_eq!(ob.deobfuscate("hello world"), "hello world");
261    }
262
263    #[test]
264    fn obfuscate_replaces_long_secret() {
265        let ob =
266            SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Obfuscate)]);
267        let text = "Bearer sk-ant-api03-abcdef123456";
268        let obfuscated = ob.obfuscate(text);
269        assert!(obfuscated.contains("Bearer"));
270        assert!(!obfuscated.contains("sk-ant-api03"));
271        assert!(obfuscated.contains("#"));
272    }
273
274    #[test]
275    fn deobfuscate_restores_original() {
276        let ob =
277            SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Obfuscate)]);
278        let original = "Bearer sk-ant-api03-abcdef123456";
279        let obfuscated = ob.obfuscate(original);
280        let restored = ob.deobfuscate(&obfuscated);
281        assert_eq!(restored, original);
282    }
283
284    #[test]
285    fn short_secret_skipped() {
286        // Secrets < 8 chars are silently skipped.
287        let ob = SecretObfuscator::new(&[entry("short", SecretMode::Obfuscate)]);
288        assert!(!ob.has_secrets());
289        assert_eq!(ob.obfuscate("has short word"), "has short word");
290    }
291
292    #[test]
293    fn replace_mode_not_reversible() {
294        let ob = SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Replace)]);
295        let original = "Bearer sk-ant-api03-abcdef123456";
296        let obfuscated = ob.obfuscate(original);
297        assert!(!obfuscated.contains("sk-ant-api03"));
298        // Deobfuscate is a no-op for replace-mode.
299        let restored = ob.deobfuscate(&obfuscated);
300        assert_eq!(restored, obfuscated);
301    }
302
303    #[test]
304    fn replace_mode_with_custom_replacement() {
305        let ob = SecretObfuscator::new(&[SecretEntry {
306            content: "sk-ant-api03-abcdef123456".into(),
307            mode: SecretMode::Replace,
308            replacement: Some("[REDACTED]".into()),
309        }]);
310        let obfuscated = ob.obfuscate("key: sk-ant-api03-abcdef123456");
311        assert_eq!(obfuscated, "key: [REDACTED]");
312    }
313
314    #[test]
315    fn multiple_secrets_all_replaced() {
316        let ob = SecretObfuscator::new(&[
317            entry("sk-ant-api03-key1abcdef", SecretMode::Obfuscate),
318            entry("sk-openai-key2ghijkl", SecretMode::Obfuscate),
319        ]);
320        let text = "keys: sk-ant-api03-key1abcdef and sk-openai-key2ghijkl";
321        let obfuscated = ob.obfuscate(text);
322        assert!(!obfuscated.contains("sk-ant-api03"));
323        assert!(!obfuscated.contains("sk-openai"));
324        let restored = ob.deobfuscate(&obfuscated);
325        assert_eq!(restored, text);
326    }
327
328    #[test]
329    fn longest_first_ordering() {
330        // If one secret is a prefix of another, the longer one should be
331        // replaced first so the shorter one doesn't partially match.
332        let ob = SecretObfuscator::new(&[
333            entry("sk-ant-api03-key123456789", SecretMode::Obfuscate),
334            entry("sk-ant-api03-key123456789-extra", SecretMode::Obfuscate),
335        ]);
336        let text = "found sk-ant-api03-key123456789-extra here";
337        let obfuscated = ob.obfuscate(text);
338        // Both replaced; no partial leak.
339        assert!(!obfuscated.contains("sk-ant-api03"));
340    }
341
342    #[test]
343    fn deterministic_replacement_is_stable() {
344        let r1 = deterministic_replacement("sk-ant-api03-key");
345        let r2 = deterministic_replacement("sk-ant-api03-key");
346        assert_eq!(r1, r2);
347        assert_eq!(r1.len(), "sk-ant-api03-key".len());
348    }
349
350    #[test]
351    fn build_placeholder_format() {
352        let p0 = build_placeholder(0);
353        let p1 = build_placeholder(1);
354        assert!(p0.starts_with('#'));
355        assert!(p0.ends_with('#'));
356        assert_eq!(p0.len(), PLACEHOLDER_LEN);
357        assert_ne!(p0, p1, "different indices produce different placeholders");
358    }
359
360    #[test]
361    fn deobfuscate_without_placeholders_is_passthrough() {
362        let ob =
363            SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Obfuscate)]);
364        // Text with no placeholders and no '#' chars.
365        assert_eq!(ob.deobfuscate("plain text"), "plain text");
366    }
367
368    #[test]
369    fn debug_does_not_leak_secrets() {
370        let ob = SecretObfuscator::new(&[entry(
371            "sk-super-secret-key1234567890",
372            SecretMode::Obfuscate,
373        )]);
374        let dbg = format!("{ob:?}");
375        assert!(!dbg.contains("sk-super-secret"));
376    }
377}