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!(
135 out.contains("[REDACTED:openai_api_key]"),
136 "openai key redacted; got: {out}"
137 );
138 assert_eq!(hits.len(), 1);
139 }
140
141 #[test]
142 fn anthropic_key_preferred_over_generic_sk() {
143 let text = "sk-ant-api03-abcdefghij0123456789012345 more";
144 let (out, hits) = redact(text);
145 assert!(out.contains("[REDACTED:anthropic_api_key]"));
146 assert_eq!(hits.len(), 1);
148 }
149
150 #[test]
151 fn private_key_block_redacted() {
152 let text = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\nafter";
153 let (out, _) = redact(text);
154 assert!(out.contains("[REDACTED:private_key]"));
155 assert!(out.starts_with("before\n"));
156 assert!(out.ends_with("\nafter"));
157 }
158
159 #[test]
160 fn bearer_token_redacted() {
161 let (out, hits) = redact("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc");
162 assert!(out.contains("[REDACTED:bearer_token]"), "got: {out}");
163 assert_eq!(hits[0].kind, "bearer_token");
164 }
165
166 #[test]
167 fn basic_auth_url_redacted() {
168 let (out, _) = redact("clone https://alice:s3cret@example.com/repo.git");
169 assert!(out.contains("[REDACTED:basic_auth_url]"));
170 assert!(out.contains("example.com/repo.git"));
171 }
172
173 #[test]
174 fn aws_access_key_redacted() {
175 let (out, hits) = redact("aws AKIAIOSFODNN7EXAMPLE etc");
176 assert!(out.contains("[REDACTED:aws_access_key_id]"));
177 assert_eq!(hits[0].kind, "aws_access_key_id");
178 }
179
180 #[test]
181 fn github_token_redacted() {
182 let (out, _) = redact("push ghp_abcdefghijklmnop0123456789 ok");
183 assert!(out.contains("[REDACTED:github_token]"));
184 }
185
186 #[test]
187 fn clean_text_unchanged_and_no_hits() {
188 let text = "this is a decision with no secrets in it at all.";
189 let (out, hits) = redact(text);
190 assert_eq!(out, text);
191 assert!(hits.is_empty());
192 }
193
194 #[test]
195 fn idempotent_second_pass_finds_nothing() {
196 let text = "sk-abcdefghijklmnopqrstuvwxyz012345 ghp_abcdefghijklmnop0123456789";
197 let (first, hits1) = redact(text);
198 assert!(!hits1.is_empty(), "first pass should hit");
199 let (second, hits2) = redact(&first);
200 assert_eq!(second, first, "second pass produces identical output");
201 assert!(hits2.is_empty(), "second pass has no hits (idempotent)");
202 }
203
204 #[test]
205 fn hot_path_under_ten_ms_for_realistic_length() {
206 let clean = "this is a normal decision reason about architecture choices ".repeat(60);
208 let start = std::time::Instant::now();
209 for _ in 0..1000 {
210 let _ = redact(&clean);
211 }
212 let elapsed = start.elapsed();
213 assert!(
214 elapsed.as_millis() < 500,
215 "1000 scans of ~4KB clean text should stay under 500ms; got {elapsed:?}"
216 );
217 }
218}