1use std::sync::OnceLock;
38
39use regex::Regex;
40use serde::Serialize;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct RedactedMatch {
46 pub kind: &'static str,
47 pub start: usize,
50 pub len: usize,
52}
53
54#[derive(Debug, Clone, Default, Serialize)]
57pub struct RedactionResult {
58 pub text: String,
59 pub matches: Vec<RedactedMatch>,
60}
61
62impl RedactionResult {
63 pub fn was_redacted(&self) -> bool {
64 !self.matches.is_empty()
65 }
66 pub fn summary(&self) -> String {
69 if self.matches.is_empty() {
70 return String::new();
71 }
72 let mut kinds: Vec<&'static str> = self.matches.iter().map(|m| m.kind).collect();
73 kinds.sort_unstable();
74 kinds.dedup();
75 format!(
76 "redacted {} secret{}: {}",
77 self.matches.len(),
78 if self.matches.len() == 1 { "" } else { "s" },
79 kinds.join(", ")
80 )
81 }
82}
83
84pub fn redact_secrets(text: &str) -> RedactionResult {
89 let patterns = patterns();
90 let mut spans: Vec<(usize, usize, &'static str)> = Vec::new();
94 for pat in patterns {
95 for m in pat.regex.find_iter(text) {
96 spans.push((m.start(), m.end(), pat.kind));
97 }
98 }
99 if spans.is_empty() {
100 return RedactionResult {
101 text: text.to_string(),
102 matches: Vec::new(),
103 };
104 }
105 spans.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
106 let mut accepted: Vec<(usize, usize, &'static str)> = Vec::new();
109 let mut cursor = 0usize;
110 for (start, end, kind) in spans {
111 if start < cursor {
112 continue;
113 }
114 accepted.push((start, end, kind));
115 cursor = end;
116 }
117
118 let mut redacted = String::with_capacity(text.len());
120 let mut matches: Vec<RedactedMatch> = Vec::with_capacity(accepted.len());
121 let mut last = 0usize;
122 for (start, end, kind) in accepted {
123 redacted.push_str(&text[last..start]);
124 redacted.push_str(&format!("[REDACTED:{kind}]"));
125 matches.push(RedactedMatch {
126 kind,
127 start,
128 len: end - start,
129 });
130 last = end;
131 }
132 redacted.push_str(&text[last..]);
133 RedactionResult {
134 text: redacted,
135 matches,
136 }
137}
138
139struct SecretPattern {
140 kind: &'static str,
141 regex: Regex,
142}
143
144fn patterns() -> &'static [SecretPattern] {
145 static CELL: OnceLock<Vec<SecretPattern>> = OnceLock::new();
146 CELL.get_or_init(|| {
147 let mut out = Vec::new();
152 out.push(SecretPattern {
153 kind: "anthropic_oauth",
154 regex: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),
157 });
158 out.push(SecretPattern {
159 kind: "openai_api_key",
160 regex: Regex::new(r"sk-[A-Za-z0-9_-]{32,}").unwrap(),
165 });
166 out.push(SecretPattern {
167 kind: "github_pat",
168 regex: Regex::new(
172 r"(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}",
173 )
174 .unwrap(),
175 });
176 out.push(SecretPattern {
177 kind: "slack_token",
178 regex: Regex::new(r"xox[bopasr]-[A-Za-z0-9-]{10,}").unwrap(),
179 });
180 out.push(SecretPattern {
181 kind: "aws_access_key",
182 regex: Regex::new(r"(?:AKIA|ASIA)[A-Z0-9]{16}").unwrap(),
185 });
186 out.push(SecretPattern {
187 kind: "jwt",
188 regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
192 });
193 out.push(SecretPattern {
194 kind: "private_key_pem",
195 regex: Regex::new(
198 r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
199 )
200 .unwrap(),
201 });
202 out.push(SecretPattern {
203 kind: "google_api_key",
204 regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
206 });
207 out.push(SecretPattern {
212 kind: "generic_bearer",
213 regex: Regex::new(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{12,}").unwrap(),
215 });
216 out.push(SecretPattern {
217 kind: "generic_api_key",
218 regex: Regex::new(
222 r#"(?i)api[_\-]?key\s*[:=]\s*"?[A-Za-z0-9_\-]{12,}"?"#,
223 )
224 .unwrap(),
225 });
226 out.push(SecretPattern {
227 kind: "generic_token",
228 regex: Regex::new(r#"(?i)\btoken\s*[:=]\s*"?[A-Za-z0-9_\-\.]{20,}"?"#).unwrap(),
229 });
230 out.push(SecretPattern {
231 kind: "generic_password",
232 regex: Regex::new(r#"(?i)\bpassword\s*[:=]\s*"?[^\s"]{8,}"?"#).unwrap(),
233 });
234 out
235 })
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn clean_text_round_trips_untouched() {
244 let raw = "use ripgrep before broad file reads, prefer thiserror for errors";
245 let r = redact_secrets(raw);
246 assert!(!r.was_redacted());
247 assert_eq!(r.text, raw);
248 assert!(r.summary().is_empty());
249 }
250
251 #[test]
252 fn anthropic_oauth_token_is_redacted() {
253 let raw = "export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf";
254 let r = redact_secrets(raw);
255 assert!(r.was_redacted(), "{:?}", r);
256 assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
257 assert!(!r.text.contains("sk-ant-api03"));
258 assert_eq!(r.matches.len(), 1);
259 assert_eq!(r.matches[0].kind, "anthropic_oauth");
260 }
261
262 #[test]
263 fn openai_key_is_redacted_without_shadowing_anthropic_prefix() {
264 let raw =
266 "two: sk-1234567890abcdef1234567890abcdef AND sk-ant-1234567890abcdef1234567890abcdef";
267 let r = redact_secrets(raw);
268 let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
269 assert!(kinds.contains(&"openai_api_key"));
270 assert!(kinds.contains(&"anthropic_oauth"));
271 assert!(r.text.contains("[REDACTED:openai_api_key]"));
272 assert!(r.text.contains("[REDACTED:anthropic_oauth]"));
273 }
274
275 #[test]
276 fn github_pat_classic_and_fine_grained_redacted() {
277 let raw = concat!(
280 "classic: ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ ",
281 "fine: github_pat_11AAA_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJabcdef",
282 );
283 let r = redact_secrets(raw);
284 let kinds: Vec<_> = r.matches.iter().map(|m| m.kind).collect();
285 assert_eq!(
286 kinds.iter().filter(|k| **k == "github_pat").count(),
287 2,
288 "two github_pat matches expected; got matches: {:?}",
289 r.matches
290 );
291 assert!(!r.text.contains("ghp_abcdef"));
292 assert!(!r.text.contains("github_pat_11AAA"));
293 }
294
295 #[test]
296 fn slack_aws_jwt_pem_google_all_redact() {
297 let raw = concat!(
298 "slack=xoxb-12345678-abcdefghijklmnop ",
299 "aws=AKIAIOSFODNN7EXAMPLE ",
300 "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abcdef ",
301 "google=AIzaSyDx0o-1234567890abcdefghijklmnopqrs ",
302 "pem=-----BEGIN RSA PRIVATE KEY-----\nABCDEFG\n-----END RSA PRIVATE KEY-----"
303 );
304 let r = redact_secrets(raw);
305 let kinds: Vec<&'static str> = {
306 let mut k = r.matches.iter().map(|m| m.kind).collect::<Vec<_>>();
307 k.sort_unstable();
308 k.dedup();
309 k
310 };
311 for expected in [
312 "aws_access_key",
313 "google_api_key",
314 "jwt",
315 "private_key_pem",
316 "slack_token",
317 ] {
318 assert!(kinds.contains(&expected), "missing kind {expected}: {kinds:?}");
319 }
320 }
321
322 #[test]
323 fn generic_assignments_match_only_with_secret_looking_value() {
324 let bad = "config: api_key = \"abcdef1234567890\" \n token : 0123456789abcdefghij1234567890\n password = hunter2hunter2";
326 let r_bad = redact_secrets(bad);
327 let kinds: Vec<_> = r_bad.matches.iter().map(|m| m.kind).collect();
328 assert!(kinds.contains(&"generic_api_key"));
329 assert!(kinds.contains(&"generic_token"));
330 assert!(kinds.contains(&"generic_password"));
331
332 let safe = "api_key = short token: 12345 password = a";
334 let r_safe = redact_secrets(safe);
335 assert!(
336 r_safe.matches.is_empty(),
337 "short values should not trip generic patterns: {r_safe:?}"
338 );
339 }
340
341 #[test]
342 fn bearer_token_in_curl_log_is_redacted() {
343 let raw = "curl -H 'Authorization: Bearer abc123def456ghi789' https://api.example.com";
344 let r = redact_secrets(raw);
345 assert!(r.was_redacted());
346 assert_eq!(r.matches[0].kind, "generic_bearer");
347 assert!(r.text.contains("[REDACTED:generic_bearer]"));
348 assert!(!r.text.contains("abc123def456ghi789"));
349 }
350
351 #[test]
352 fn overlapping_matches_keep_first_only() {
353 let raw = "Authorization: Bearer sk-1234567890abcdef1234567890abcdef1234";
357 let r = redact_secrets(raw);
358 assert_eq!(
359 r.matches.len(),
360 1,
361 "non-overlapping rule should pick one: {r:?}"
362 );
363 }
364
365 #[test]
366 fn summary_lists_unique_kinds() {
367 let raw = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ and ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
368 let r = redact_secrets(raw);
369 let summary = r.summary();
370 assert!(summary.contains("github_pat"));
371 assert!(summary.starts_with("redacted 2 secrets: github_pat"));
373 }
374
375 #[test]
376 fn match_offsets_point_into_original_text() {
377 let raw = "prefix sk-ant-api03-1234567890abcdef1234567890abcdef suffix";
378 let r = redact_secrets(raw);
379 assert_eq!(r.matches.len(), 1);
380 let m = &r.matches[0];
381 let original_match = &raw[m.start..m.start + m.len];
383 assert!(original_match.starts_with("sk-ant-api03"));
384 }
385
386 #[test]
387 fn redaction_preserves_non_secret_surroundings() {
388 let raw =
389 "# Save to .env\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf\n# Use it";
390 let r = redact_secrets(raw);
391 assert!(r.text.starts_with("# Save to .env"));
392 assert!(r.text.ends_with("# Use it"));
393 assert!(r.text.contains("CLAUDE_CODE_OAUTH_TOKEN=[REDACTED:anthropic_oauth]"));
394 }
395}