layover_core/help/
redact.rs1use std::borrow::Cow;
24
25pub const MAX_DETAIL: usize = 4_000;
31
32const MASK: &str = "[redacted]";
34
35#[must_use]
39pub fn secrets(text: &str) -> Cow<'_, str> {
40 let mut out = String::new();
41 let mut rest = text;
42 let mut changed = false;
43
44 while let Some((start, len)) = find_secret(rest) {
45 changed = true;
46 out.push_str(&rest[..start]);
47 out.push_str(MASK);
48 rest = &rest[start + len..];
49 }
50
51 if !changed {
52 return Cow::Borrowed(text);
53 }
54
55 out.push_str(rest);
56 Cow::Owned(out)
57}
58
59#[must_use]
61pub fn clamp(text: &str) -> Cow<'_, str> {
62 if text.len() <= MAX_DETAIL {
63 return Cow::Borrowed(text);
64 }
65
66 let mut end = MAX_DETAIL;
67 while end > 0 && !text.is_char_boundary(end) {
68 end -= 1;
69 }
70
71 Cow::Owned(format!("{}\n\n[truncated]", &text[..end]))
72}
73
74#[must_use]
77pub fn detail(text: &str) -> String {
78 clamp(&secrets(text)).into_owned()
79}
80
81fn find_secret(text: &str) -> Option<(usize, usize)> {
83 let bytes = text.as_bytes();
84
85 for (index, _) in text.char_indices() {
86 if index > 0 && is_token_byte(bytes[index - 1]) {
88 continue;
89 }
90
91 let run = token_run(text, index);
92 if run == 0 {
93 continue;
94 }
95
96 if let Some(found) = assignment(text, index) {
100 return Some(found);
101 }
102
103 if looks_like_secret(&text[index..index + run]) {
104 return Some((index, run));
105 }
106 }
107
108 None
109}
110
111fn name_run(text: &str, start: usize) -> usize {
115 text[start..]
116 .bytes()
117 .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_')
118 .count()
119}
120
121fn token_run(text: &str, start: usize) -> usize {
122 text[start..]
123 .bytes()
124 .take_while(|b| is_token_byte(*b))
125 .count()
126}
127
128fn is_token_byte(b: u8) -> bool {
129 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'+' | b'/' | b'=' | b'~')
130}
131
132fn looks_like_secret(token: &str) -> bool {
134 const PREFIXES: [&str; 10] = [
135 "sk-",
136 "pk-",
137 "ghp_",
138 "gho_",
139 "ghu_",
140 "ghs_",
141 "ghr_",
142 "github_pat_",
143 "xoxb-",
144 "xoxp-",
145 ];
146 if token.len() > 12 && PREFIXES.iter().any(|p| token.starts_with(p)) {
147 return true;
148 }
149
150 if token.starts_with("eyJ") && token.matches('.').count() == 2 {
152 return true;
153 }
154
155 token.len() >= 32 && !token.contains('/') && mixed_enough(token)
158}
159
160fn mixed_enough(token: &str) -> bool {
162 let digits = token.bytes().filter(u8::is_ascii_digit).count();
163 let upper = token.bytes().filter(u8::is_ascii_uppercase).count();
164 let lower = token.bytes().filter(u8::is_ascii_lowercase).count();
165
166 if digits >= 8 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
170 return true;
171 }
172
173 digits > 0 && upper > 0 && lower > 0
174}
175
176fn assignment(text: &str, start: usize) -> Option<(usize, usize)> {
179 let name_len = name_run(text, start);
180 if name_len == 0 || !names_a_secret(&text[start..start + name_len]) {
181 return None;
182 }
183
184 let bytes = text.as_bytes();
185 let mut at = start + name_len;
186
187 match bytes.get(at) {
188 Some(b'=' | b':') => at += 1,
189 _ => return None,
190 }
191
192 while bytes.get(at) == Some(&b' ') {
193 at += 1;
194 }
195
196 let len = token_run(text, at);
197 if len == 0 { None } else { Some((at, len)) }
198}
199
200fn names_a_secret(name: &str) -> bool {
202 const MARKERS: [&str; 11] = [
203 "TOKEN",
204 "SECRET",
205 "PASSWORD",
206 "PASSWD",
207 "APIKEY",
208 "API_KEY",
209 "_PAT",
210 "CREDENTIAL",
211 "PRIVATE_KEY",
212 "ACCESS_KEY",
213 "AUTHORIZATION",
214 ];
215
216 let upper = name.to_ascii_uppercase();
217 MARKERS.iter().any(|marker| upper.contains(marker))
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn ordinary_prose_is_left_alone_and_not_reallocated() {
226 let text = "Push to refs/heads/fix/1543477 returned 401. The token expires after 30 days.";
227 assert!(matches!(secrets(text), Cow::Borrowed(_)), "{text}");
228 }
229
230 #[test]
231 fn a_github_token_is_masked_but_the_sentence_survives() {
232 let clean = secrets("tried ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 and got 401");
233
234 assert!(!clean.contains("ghp_A1b2"), "{clean}");
235 assert!(
236 clean.contains("got 401"),
237 "the useful part must survive: {clean}"
238 );
239 }
240
241 #[test]
242 fn an_openai_style_key_is_masked() {
243 let clean = secrets("key sk-proj-abcdefghijklmnopqrstuvwxyz0123456789 rejected");
244 assert!(!clean.contains("sk-proj-abcdef"), "{clean}");
245 }
246
247 #[test]
248 fn a_jwt_is_masked() {
249 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk";
250 let text = format!("token {jwt} expired");
251 let clean = secrets(&text);
252
253 assert!(!clean.contains("eyJhbGciOi"), "{clean}");
254 assert!(clean.contains("expired"), "{clean}");
255 }
256
257 #[test]
258 fn the_variable_name_survives_because_it_is_the_useful_part() {
259 let clean = secrets("ADO_PAT=zq8Vv2Lm4Kp7Rt1Ns5Wx9Yb3Cd6Ef0Gh became invalid");
262
263 assert!(clean.starts_with("ADO_PAT="), "{clean}");
264 assert!(!clean.contains("zq8Vv2Lm"), "{clean}");
265 assert!(clean.contains("became invalid"), "{clean}");
266 }
267
268 #[test]
269 fn a_named_secret_is_masked_after_a_colon_too() {
270 let clean = secrets("AZURE_CLIENT_SECRET: Abcd1234Efgh5678Ijkl");
271 assert!(!clean.contains("Abcd1234"), "{clean}");
272 assert!(clean.starts_with("AZURE_CLIENT_SECRET:"), "{clean}");
273 }
274
275 #[test]
276 fn a_digest_is_masked_because_it_could_equally_be_a_key() {
277 let clean = secrets("at 5f2e9c4b8a1d3e7f0c6b2a9d8e4f1c3b5a7d9e0f");
278 assert!(clean.contains(MASK), "{clean}");
279 }
280
281 #[test]
282 fn paths_and_urls_are_not_mistaken_for_keys() {
283 for text in [
284 "could not read prompts/analyst.md",
285 "GET https://dev.azure.com/org/_apis/git/repositories returned 403",
286 "the workspace is at /home/ada/work/layover-project",
287 ] {
288 assert!(
289 matches!(secrets(text), Cow::Borrowed(_)),
290 "should not have matched: {text}"
291 );
292 }
293 }
294
295 #[test]
296 fn several_secrets_in_one_message_are_all_masked() {
297 let clean = secrets(
298 "tried ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 then sk-abcdefghijklmnopqrstuvwxyz012345",
299 );
300 assert_eq!(clean.matches(MASK).count(), 2, "{clean}");
301 }
302
303 #[test]
304 fn an_over_long_detail_is_cut_and_says_so() {
305 let long = "a".repeat(MAX_DETAIL + 500);
306 let capped = clamp(&long);
307
308 assert!(capped.len() < long.len());
309 assert!(
310 capped.ends_with("[truncated]"),
311 "a cut report has to admit it was cut"
312 );
313 }
314
315 #[test]
316 fn redaction_happens_before_the_cut() {
317 let secret = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
318 let text = format!("{}{secret}", "x".repeat(MAX_DETAIL - 10));
319
320 assert!(
321 !detail(&text).contains("ghp_A1b2"),
322 "the token survived being cut in half"
323 );
324 }
325}