1#![forbid(unsafe_code)]
16
17pub const ADVICE: &str = "keep the secret in a keyring (OS keychain, pass, systemd-credentials) and store a reference in memory instead; memory privacy flags are not encryption";
19
20#[must_use]
23pub fn credential_shaped_content(content: &str) -> Vec<&'static str> {
24 let mut kinds: Vec<&'static str> = Vec::new();
25 let push = |k: &'static str, kinds: &mut Vec<&'static str>| {
26 if !kinds.contains(&k) {
27 kinds.push(k);
28 }
29 };
30
31 if content.contains("-----BEGIN") && content.contains("PRIVATE KEY") {
33 push("private_key_pem", &mut kinds);
34 }
35
36 if token_after(content, "AKIA", 16, |c| {
38 c.is_ascii_uppercase() || c.is_ascii_digit()
39 }) {
40 push("aws_access_key_id", &mut kinds);
41 }
42
43 let alnum = |c: char| c.is_ascii_alphanumeric() || c == '_';
45 if token_after(content, "ghp_", 30, alnum)
46 || token_after(content, "gho_", 30, alnum)
47 || token_after(content, "github_pat_", 20, alnum)
48 {
49 push("github_token", &mut kinds);
50 }
51
52 if token_after(content, "sk-", 20, |c| {
54 c.is_ascii_alphanumeric() || c == '_' || c == '-'
55 }) {
56 push("openai_style_key", &mut kinds);
57 }
58
59 if ["xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-"]
61 .iter()
62 .any(|p| token_after(content, p, 10, |c| c.is_ascii_alphanumeric() || c == '-'))
63 {
64 push("slack_token", &mut kinds);
65 }
66
67 if content.match_indices("eyJ").count() >= 2 {
69 push("jwt", &mut kinds);
70 }
71
72 if assignment_shaped(content) {
75 push("credential_assignment", &mut kinds);
76 }
77
78 kinds
79}
80
81fn token_after(
83 haystack: &str,
84 prefix: &str,
85 min_len: usize,
86 charset: impl Fn(char) -> bool,
87) -> bool {
88 let mut from = 0usize;
89 while let Some(pos) = haystack[from..].find(prefix) {
90 let abs = from + pos + prefix.len();
91 let run = haystack[abs..].chars().take_while(|c| charset(*c)).count();
92 if run >= min_len {
93 return true;
94 }
95 from = abs;
96 }
97 false
98}
99
100fn assignment_shaped(content: &str) -> bool {
103 const KEYS: &[&str] = &[
104 "password",
105 "passwd",
106 "api_key",
107 "api-key",
108 "apikey",
109 "secret",
110 "access_token",
111 ];
112 let lower = content.to_lowercase();
113 for key in KEYS {
114 let mut from = 0usize;
115 while let Some(pos) = lower[from..].find(key) {
116 let abs = from + pos + key.len();
117 let rest = lower[abs..].trim_start();
118 let Some(delim) = rest.chars().next() else {
119 break;
120 };
121 if delim == ':' || delim == '=' {
122 let value = rest[1..].trim_start();
123 let value = value.strip_prefix(['"', '\'']).unwrap_or(value);
124 let is_marker = value
128 .get(..10)
129 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
130 if !is_marker {
131 let run: usize = value
132 .chars()
133 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
134 .map(char::len_utf8)
135 .sum();
136 if run >= 16 {
137 return true;
138 }
139 }
140 }
141 from = abs;
142 }
143 }
144 false
145}
146
147#[must_use]
155pub fn redact_credential_content(content: &str) -> (String, Vec<&'static str>) {
156 let kinds = credential_shaped_content(content);
157 if kinds.is_empty() {
158 return (content.to_string(), kinds);
159 }
160
161 let mut out = content.to_string();
162
163 if kinds.contains(&"private_key_pem") {
164 while let Some((start, end)) = pem_block_span(&out) {
165 out.replace_range(start..end, "[REDACTED:private_key_pem]");
166 }
167 out = out.replace("PRIVATE KEY-----", "[REDACTED:pem-key]");
172 out = out.replace("-----BEGIN", "[REDACTED:pem-begin]");
173 out = out.replace("-----END", "[REDACTED:pem-end]");
174 }
175
176 if kinds.contains(&"credential_assignment") {
177 while let Some((start, end)) = assignment_value_span(&out) {
178 out.replace_range(start..end, "[REDACTED:credential_assignment]");
179 }
180 }
181
182 if kinds.contains(&"jwt") {
186 while let Some(pos) = out.find("eyJ") {
187 out.replace_range(pos..pos + "eyJ".len(), "[REDACTED:jwt]");
188 }
189 }
190
191 type TokenSpec = (&'static str, &'static str, usize, fn(char) -> bool);
192 let token_specs: &[TokenSpec] = &[
193 ("aws_access_key_id", "AKIA", 16, |c: char| {
194 c.is_ascii_uppercase() || c.is_ascii_digit()
195 }),
196 ("github_token", "ghp_", 30, |c: char| {
197 c.is_ascii_alphanumeric() || c == '_'
198 }),
199 ("github_token", "gho_", 30, |c: char| {
200 c.is_ascii_alphanumeric() || c == '_'
201 }),
202 ("github_token", "github_pat_", 20, |c: char| {
203 c.is_ascii_alphanumeric() || c == '_'
204 }),
205 ("openai_style_key", "sk-", 20, |c: char| {
206 c.is_ascii_alphanumeric() || c == '_' || c == '-'
207 }),
208 ("slack_token", "xoxb-", 10, |c: char| {
209 c.is_ascii_alphanumeric() || c == '-'
210 }),
211 ("slack_token", "xoxp-", 10, |c: char| {
212 c.is_ascii_alphanumeric() || c == '-'
213 }),
214 ("slack_token", "xoxa-", 10, |c: char| {
215 c.is_ascii_alphanumeric() || c == '-'
216 }),
217 ("slack_token", "xoxr-", 10, |c: char| {
218 c.is_ascii_alphanumeric() || c == '-'
219 }),
220 ("slack_token", "xoxs-", 10, |c: char| {
221 c.is_ascii_alphanumeric() || c == '-'
222 }),
223 ];
224 for (kind, prefix, min_len, charset) in token_specs {
225 while let Some((start, end)) = prefixed_token_span(&out, prefix, *min_len, *charset) {
226 out.replace_range(start..end, &format!("[REDACTED:{kind}]"));
227 }
228 }
229
230 (out, kinds)
231}
232
233fn pem_block_span(text: &str) -> Option<(usize, usize)> {
235 let begin = text.find("-----BEGIN")?;
236 let key_at = text[begin..].find("PRIVATE KEY-----")? + begin;
237 let end_at = text[key_at..].find("-----END")? + key_at;
238 let marker_at = text[end_at..].find("PRIVATE KEY-----")? + end_at;
239 Some((begin, marker_at + "PRIVATE KEY-----".len()))
240}
241
242fn assignment_value_span(text: &str) -> Option<(usize, usize)> {
244 const KEYS: &[&str] = &[
245 "password",
246 "passwd",
247 "api_key",
248 "api-key",
249 "apikey",
250 "secret",
251 "access_token",
252 ];
253 for key in KEYS {
254 let mut from = 0usize;
255 while let Some(pos) = find_ascii_case_insensitive(text, key, from) {
256 let after = pos + key.len();
257 let rest = &text[after..];
258 let ws = rest.len() - rest.trim_start().len();
259 let delim_pos = after + ws;
260 let delim = text[delim_pos..].chars().next();
261 if matches!(delim, Some(':' | '=')) {
262 let tail = &text[delim_pos + 1..];
263 let vws = tail.len() - tail.trim_start().len();
264 let mut vstart = delim_pos + 1 + vws;
265 if let Some(quote) = text[vstart..].chars().next() {
266 if quote == '"' || quote == '\'' {
267 vstart += quote.len_utf8();
268 }
269 }
270 let mut bytes = 0usize;
271 for c in text[vstart..].chars() {
272 if c.is_whitespace() || c == '"' || c == '\'' {
273 break;
274 }
275 bytes += c.len_utf8();
276 }
277 let is_marker = text[vstart..]
278 .get(..10)
279 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
280 if bytes >= 16 && !is_marker {
281 return Some((vstart, vstart + bytes));
282 }
283 }
284 from = after;
285 }
286 }
287 None
288}
289
290fn prefixed_token_span(
292 text: &str,
293 prefix: &str,
294 min_len: usize,
295 charset: fn(char) -> bool,
296) -> Option<(usize, usize)> {
297 let mut from = 0usize;
298 while let Some(pos) = text[from..].find(prefix) {
299 let start = from + pos;
300 let value_start = start + prefix.len();
301 let mut bytes = 0usize;
302 let mut count = 0usize;
303 for c in text[value_start..].chars() {
304 if !charset(c) {
305 break;
306 }
307 bytes += c.len_utf8();
308 count += 1;
309 }
310 if count >= min_len {
311 return Some((start, value_start + bytes));
312 }
313 from = value_start;
314 }
315 None
316}
317
318fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
320 let h = haystack.as_bytes();
321 let n = needle.as_bytes();
322 if n.is_empty() || from >= h.len() || n.len() > h.len() - from {
323 return None;
324 }
325 (from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn detects_private_keys_aws_and_github() {
334 let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
335 assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
336
337 let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
338 assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
339
340 let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
341 assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
342 }
343
344 #[test]
345 fn detects_sk_slack_jwt_and_assignments() {
346 let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
347 assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
348
349 let slack = format!(
353 "xoxb-{}-{}-{}",
354 "123456789012", "1234567890123", "abcdefghijklmnop"
355 );
356 assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
357
358 let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
359 assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
360
361 let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
362 assert_eq!(
363 credential_shaped_content(assign),
364 vec!["credential_assignment"]
365 );
366 }
367
368 #[test]
369 fn ordinary_prose_stays_clean() {
370 assert!(
371 credential_shaped_content("remember that the password policy requires rotation")
372 .is_empty()
373 );
374 assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
375 assert!(credential_shaped_content("short token: abc123").is_empty());
376 assert!(
377 credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
378 );
379 assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
380 assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
381 }
382
383 #[test]
384 fn dedupes_kinds() {
385 let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
386 assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
387 }
388
389 #[test]
390 fn redacts_private_key_blocks() {
391 let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowSECRET\n-----END RSA PRIVATE KEY-----\nafter";
392 let (redacted, kinds) = redact_credential_content(pem);
393 assert!(kinds.contains(&"private_key_pem"));
394 assert!(!redacted.contains("MIIEowSECRET"), "key body must be gone");
395 assert!(!redacted.contains("BEGIN RSA PRIVATE KEY"));
396 assert_eq!(redacted, "before\n[REDACTED:private_key_pem]\nafter");
397 }
398
399 #[test]
400 fn redacts_assignment_values_and_tokens() {
401 let text = "db password=correct-horse-battery-staple and key sk-proj0123456789abcdefghijklmnopqrstuv";
402 let (redacted, _) = redact_credential_content(text);
403 assert!(redacted.contains("password=[REDACTED:credential_assignment]"));
404 assert!(!redacted.contains("correct-horse-battery-staple"));
405 assert!(!redacted.contains("sk-proj0123456789abcdefghijklmnopqrstuv"));
406 assert!(redacted.contains("[REDACTED:openai_style_key]"));
407
408 let aws = "id AKIAIOSFODNN7EXAMPLE here";
409 let (redacted, _) = redact_credential_content(aws);
410 assert_eq!(redacted, "id [REDACTED:aws_access_key_id] here");
411 }
412
413 #[test]
414 fn clean_content_passes_through_unchanged() {
415 let text = "remember that the password policy requires rotation";
416 let (redacted, kinds) = redact_credential_content(text);
417 assert!(kinds.is_empty());
418 assert_eq!(redacted, text);
419 }
420
421 #[test]
422 fn redaction_is_idempotent() {
423 let text = "key sk-proj0123456789abcdefghijklmnopqrstuv end";
424 let (once, _) = redact_credential_content(text);
425 let (twice, kinds) = redact_credential_content(&once);
426 assert_eq!(once, twice);
427 assert!(
428 kinds.is_empty(),
429 "redacted marker must read clean: {kinds:?}"
430 );
431 }
432
433 #[test]
434 fn assignment_marker_does_not_retrigger_detection() {
435 let text = "db password=correct-horse-battery-staple";
439 let (once, _) = redact_credential_content(text);
440 assert!(
441 credential_shaped_content(&once).is_empty(),
442 "redacted assignment must read clean: {once}"
443 );
444 let (twice, kinds) = redact_credential_content(&once);
445 assert_eq!(once, twice);
446 assert!(kinds.is_empty());
447 }
448
449 #[test]
450 fn short_jwt_fragments_are_redacted_too() {
451 let text = "tokens eyJab and eyJcd appeared in logs";
455 let (once, kinds) = redact_credential_content(text);
456 assert!(kinds.contains(&"jwt"));
457 assert!(
458 credential_shaped_content(&once).is_empty(),
459 "short fragments must read clean after redaction: {once}"
460 );
461 let (twice, _) = redact_credential_content(&once);
462 assert_eq!(once, twice);
463 }
464
465 #[test]
466 fn pem_fragments_are_redacted_too() {
467 let text = "docs explain -----BEGIN PRIVATE KEY----- when truncated";
471 let (once, kinds) = redact_credential_content(text);
472 assert!(kinds.contains(&"private_key_pem"));
473 assert!(
474 credential_shaped_content(&once).is_empty(),
475 "PEM fragments must read clean after redaction: {once}"
476 );
477 let (twice, _) = redact_credential_content(&once);
478 assert_eq!(once, twice);
479 }
480}