tokenfold_core/transforms/
redaction.rs1use regex::Regex;
16use std::sync::OnceLock;
17
18pub struct RedactionOutcome {
20 pub bytes: Vec<u8>,
22 pub redacted_count: usize,
24}
25
26fn jwt_pattern() -> &'static Regex {
27 static RE: OnceLock<Regex> = OnceLock::new();
28 RE.get_or_init(|| {
29 Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
30 .expect("jwt_pattern regex is a fixed valid literal")
31 })
32}
33
34fn api_key_pattern() -> &'static Regex {
35 static RE: OnceLock<Regex> = OnceLock::new();
36 RE.get_or_init(|| {
37 Regex::new(r"sk-[A-Za-z0-9]{20,}").expect("api_key_pattern regex is a fixed valid literal")
38 })
39}
40
41fn aws_key_pattern() -> &'static Regex {
42 static RE: OnceLock<Regex> = OnceLock::new();
43 RE.get_or_init(|| {
44 Regex::new(r"AKIA[0-9A-Z]{16}").expect("aws_key_pattern regex is a fixed valid literal")
45 })
46}
47
48fn basic_auth_pattern() -> &'static Regex {
49 static RE: OnceLock<Regex> = OnceLock::new();
50 RE.get_or_init(|| {
51 Regex::new(r"(https?://)[^:/\s@]+:[^@/\s]+@")
52 .expect("basic_auth_pattern regex is a fixed valid literal")
53 })
54}
55
56fn bearer_pattern() -> &'static Regex {
57 static RE: OnceLock<Regex> = OnceLock::new();
58 RE.get_or_init(|| {
59 Regex::new(r"(?i)Bearer\s+[A-Za-z0-9\-._~+/]+=*")
60 .expect("bearer_pattern regex is a fixed valid literal")
61 })
62}
63
64pub fn contains_secret(input: &[u8]) -> bool {
68 let text = String::from_utf8_lossy(input);
69 jwt_pattern().is_match(&text)
70 || api_key_pattern().is_match(&text)
71 || aws_key_pattern().is_match(&text)
72 || basic_auth_pattern().is_match(&text)
73 || bearer_pattern().is_match(&text)
74}
75
76pub fn redact(input: &[u8]) -> RedactionOutcome {
84 let mut text = String::from_utf8_lossy(input).into_owned();
85 let mut redacted_count = 0usize;
86
87 let re = jwt_pattern();
88 redacted_count += re.find_iter(&text).count();
89 text = re.replace_all(&text, "[REDACTED:jwt]").into_owned();
90
91 let re = api_key_pattern();
92 redacted_count += re.find_iter(&text).count();
93 text = re.replace_all(&text, "[REDACTED:api_key]").into_owned();
94
95 let re = aws_key_pattern();
96 redacted_count += re.find_iter(&text).count();
97 text = re.replace_all(&text, "[REDACTED:aws_key]").into_owned();
98
99 let re = basic_auth_pattern();
100 redacted_count += re.find_iter(&text).count();
101 text = re
107 .replace_all(&text, "${1}[REDACTED-BASIC-AUTH]@")
108 .into_owned();
109
110 let re = bearer_pattern();
111 redacted_count += re.find_iter(&text).count();
112 text = re
113 .replace_all(&text, "Bearer [REDACTED:bearer_token]")
114 .into_owned();
115
116 RedactionOutcome {
117 bytes: text.into_bytes(),
118 redacted_count,
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
127 !needle.is_empty()
128 && haystack.len() >= needle.len()
129 && haystack.windows(needle.len()).any(|w| w == needle)
130 }
131
132 #[test]
133 fn jwt_token_is_redacted() {
134 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQ";
135 let input = format!("Authorization header carried: {jwt} in the clear");
136 let outcome = redact(input.as_bytes());
137 let output = String::from_utf8(outcome.bytes).unwrap();
138
139 assert!(!output.contains(jwt));
140 assert!(output.contains("[REDACTED:jwt]"));
141 assert!(outcome.redacted_count > 0);
142 }
143
144 #[test]
145 fn openai_api_key_is_redacted() {
146 let key = "sk-ABCDEFGHIJ1234567890abcdefgh";
147 let input = format!("OPENAI_API_KEY={key}");
148 let outcome = redact(input.as_bytes());
149 let output = String::from_utf8(outcome.bytes).unwrap();
150
151 assert!(!output.contains(key));
152 assert!(output.contains("[REDACTED:api_key]"));
153 assert!(outcome.redacted_count > 0);
154 }
155
156 #[test]
157 fn aws_access_key_is_redacted() {
158 let key = "AKIAIOSFODNN7EXAMPLE";
161 let input = format!("AWS_ACCESS_KEY_ID={key}");
162 let outcome = redact(input.as_bytes());
163 let output = String::from_utf8(outcome.bytes).unwrap();
164
165 assert!(!output.contains(key));
166 assert!(output.contains("[REDACTED:aws_key]"));
167 assert!(outcome.redacted_count > 0);
168 }
169
170 #[test]
171 fn basic_auth_in_url_is_redacted() {
172 let input = "see https://alice:hunter2@example.com/path for details";
173 let outcome = redact(input.as_bytes());
174 let output = String::from_utf8(outcome.bytes).unwrap();
175
176 assert!(!output.contains("alice:hunter2"));
177 assert!(output.contains("example.com/path"));
178 assert!(output.contains("[REDACTED-BASIC-AUTH]"));
179 assert!(outcome.redacted_count > 0);
180 }
181
182 #[test]
183 fn redacting_already_redacted_basic_auth_output_finds_no_further_matches() {
184 let input = "see https://alice:hunter2@example.com/path for details";
188 let once = redact(input.as_bytes());
189 let twice = redact(&once.bytes);
190 assert_eq!(twice.redacted_count, 0);
191 assert_eq!(once.bytes, twice.bytes);
192 }
193
194 #[test]
195 fn bearer_token_is_redacted() {
196 let input = "Authorization: Bearer abcDEF123.token-value";
197 let outcome = redact(input.as_bytes());
198 let output = String::from_utf8(outcome.bytes).unwrap();
199
200 assert!(!output.contains("abcDEF123.token-value"));
201 assert!(output.contains("Bearer [REDACTED:bearer_token]"));
202 assert!(outcome.redacted_count > 0);
203 }
204
205 #[test]
212 fn redos_canary_completes_within_time_budget() {
213 let long_run_of_a = "a".repeat(40_000);
214 let start = std::time::Instant::now();
215 let outcome = redact(long_run_of_a.as_bytes());
216 assert!(start.elapsed() < std::time::Duration::from_secs(2));
217 assert_eq!(outcome.redacted_count, 0);
218
219 let nested_quantifier_bait = "a".repeat(20_000) + "!";
220 let start = std::time::Instant::now();
221 let outcome = redact(nested_quantifier_bait.as_bytes());
222 assert!(start.elapsed() < std::time::Duration::from_secs(2));
223 assert_eq!(outcome.redacted_count, 0);
224 }
225
226 #[test]
227 fn contains_secret_matches_the_same_inputs_redact_would_change() {
228 assert!(contains_secret(b"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"));
229 assert!(contains_secret(
230 b"Authorization: Bearer abcDEF123.token-value"
231 ));
232 assert!(!contains_secret(
233 b"just a normal log line, nothing sensitive"
234 ));
235 }
236
237 #[test]
238 fn text_with_no_secrets_is_returned_unchanged() {
239 let input = b"just a normal log line with nothing sensitive in it at all.";
240 let outcome = redact(input);
241
242 assert_eq!(outcome.bytes, input.to_vec());
243 assert_eq!(outcome.redacted_count, 0);
244 }
245
246 #[test]
247 fn redacted_output_never_contains_literal_secret_bytes() {
248 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQ";
249 let api_key = "sk-ABCDEFGHIJ1234567890abcdefgh";
250 let input = format!("jwt={jwt} key={api_key}");
251 let outcome = redact(input.as_bytes());
252
253 assert!(!contains_subslice(&outcome.bytes, jwt.as_bytes()));
254 assert!(!contains_subslice(&outcome.bytes, api_key.as_bytes()));
255 }
256}