edda_core/
secret_guard.rs1use regex::Regex;
18use serde::{Deserialize, Serialize};
19use std::sync::OnceLock;
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct SecretHit {
24 pub kind: &'static str,
25 pub start: usize,
26 pub end: usize,
27}
28
29struct Pattern {
30 kind: &'static str,
31 re: Regex,
32}
33
34fn patterns() -> &'static [Pattern] {
35 static SET: OnceLock<Vec<Pattern>> = OnceLock::new();
36 SET.get_or_init(|| {
37 vec![
38 Pattern {
40 kind: "private_key",
41 re: Regex::new(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----").unwrap(),
42 },
43 Pattern {
45 kind: "anthropic_api_key",
46 re: Regex::new(r"sk-ant-[A-Za-z0-9_\-]{20,}").unwrap(),
47 },
48 Pattern {
50 kind: "openai_api_key",
51 re: Regex::new(r"sk-[A-Za-z0-9]{20,}").unwrap(),
52 },
53 Pattern {
55 kind: "stripe_key",
56 re: Regex::new(r"sk_(?:live|test)_[A-Za-z0-9]{20,}").unwrap(),
57 },
58 Pattern {
60 kind: "github_token",
61 re: Regex::new(r"gh[pousr]_[A-Za-z0-9]{20,}").unwrap(),
62 },
63 Pattern {
64 kind: "github_pat",
65 re: Regex::new(r"github_pat_[A-Za-z0-9_]{20,}").unwrap(),
66 },
67 Pattern {
69 kind: "aws_access_key_id",
70 re: Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
71 },
72 Pattern {
74 kind: "bearer_token",
75 re: Regex::new(r"(?i)bearer\s+[A-Za-z0-9._~+/=\-]{20,}").unwrap(),
76 },
77 Pattern {
79 kind: "basic_auth_url",
80 re: Regex::new(r"(?i)(?:https?|ftp|ssh|redis|postgres|mysql|mongodb)://[A-Za-z0-9._%+-]+:[^\s@:/]+@").unwrap(),
81 },
82 ]
83 })
84}
85
86pub fn redact(text: &str) -> (String, Vec<SecretHit>) {
92 let mut hits: Vec<(usize, usize, &'static str)> = Vec::new();
93 for pattern in patterns() {
94 for m in pattern.re.find_iter(text) {
95 hits.push((m.start(), m.end(), pattern.kind));
96 }
97 }
98 if hits.is_empty() {
99 return (text.to_string(), Vec::new());
100 }
101
102 hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
104 let mut chosen: Vec<(usize, usize, &'static str)> = Vec::with_capacity(hits.len());
105 let mut cursor_end: usize = 0;
106 for h in &hits {
107 if h.0 >= cursor_end {
108 chosen.push(*h);
109 cursor_end = h.1;
110 }
111 }
112
113 let mut out = String::with_capacity(text.len());
114 let mut prev = 0;
115 let mut reported = Vec::with_capacity(chosen.len());
116 for (start, end, kind) in chosen {
117 out.push_str(&text[prev..start]);
118 out.push_str(&format!("[REDACTED:{kind}]"));
119 reported.push(SecretHit { kind, start, end });
120 prev = end;
121 }
122 out.push_str(&text[prev..]);
123 (out, reported)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn openai_key_redacted() {
132 let text = "auth: sk-abcdefghijklmnopqrstuvwxyz012345 do stuff";
133 let (out, hits) = redact(text);
134 assert!(out.contains("[REDACTED:openai_api_key]"), "openai key redacted; got: {out}");
135 assert_eq!(hits.len(), 1);
136 }
137
138 #[test]
139 fn anthropic_key_preferred_over_generic_sk() {
140 let text = "sk-ant-api03-abcdefghij0123456789012345 more";
141 let (out, hits) = redact(text);
142 assert!(out.contains("[REDACTED:anthropic_api_key]"));
143 assert_eq!(hits.len(), 1);
145 }
146
147 #[test]
148 fn private_key_block_redacted() {
149 let text = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\nafter";
150 let (out, _) = redact(text);
151 assert!(out.contains("[REDACTED:private_key]"));
152 assert!(out.starts_with("before\n"));
153 assert!(out.ends_with("\nafter"));
154 }
155
156 #[test]
157 fn bearer_token_redacted() {
158 let (out, hits) = redact("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc");
159 assert!(out.contains("[REDACTED:bearer_token]"), "got: {out}");
160 assert_eq!(hits[0].kind, "bearer_token");
161 }
162
163 #[test]
164 fn basic_auth_url_redacted() {
165 let (out, _) = redact("clone https://alice:s3cret@example.com/repo.git");
166 assert!(out.contains("[REDACTED:basic_auth_url]"));
167 assert!(out.contains("example.com/repo.git"));
168 }
169
170 #[test]
171 fn aws_access_key_redacted() {
172 let (out, hits) = redact("aws AKIAIOSFODNN7EXAMPLE etc");
173 assert!(out.contains("[REDACTED:aws_access_key_id]"));
174 assert_eq!(hits[0].kind, "aws_access_key_id");
175 }
176
177 #[test]
178 fn github_token_redacted() {
179 let (out, _) = redact("push ghp_abcdefghijklmnop0123456789 ok");
180 assert!(out.contains("[REDACTED:github_token]"));
181 }
182
183 #[test]
184 fn clean_text_unchanged_and_no_hits() {
185 let text = "this is a decision with no secrets in it at all.";
186 let (out, hits) = redact(text);
187 assert_eq!(out, text);
188 assert!(hits.is_empty());
189 }
190
191 #[test]
192 fn idempotent_second_pass_finds_nothing() {
193 let text = "sk-abcdefghijklmnopqrstuvwxyz012345 ghp_abcdefghijklmnop0123456789";
194 let (first, hits1) = redact(text);
195 assert!(!hits1.is_empty(), "first pass should hit");
196 let (second, hits2) = redact(&first);
197 assert_eq!(second, first, "second pass produces identical output");
198 assert!(hits2.is_empty(), "second pass has no hits (idempotent)");
199 }
200
201 #[test]
202 fn hot_path_under_ten_ms_for_realistic_length() {
203 let clean = "this is a normal decision reason about architecture choices ".repeat(60);
205 let start = std::time::Instant::now();
206 for _ in 0..1000 {
207 let _ = redact(&clean);
208 }
209 let elapsed = start.elapsed();
210 assert!(
211 elapsed.as_millis() < 500,
212 "1000 scans of ~4KB clean text should stay under 500ms; got {elapsed:?}"
213 );
214 }
215}