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
100const ASSIGNMENT_KEYS: &[&str] = &[
105 "password",
106 "passwd",
107 "api_key",
108 "api-key",
109 "apikey",
110 "secret",
111 "access_token",
112 "secret_access_key",
113 "aws_secret_access_key",
114 "secret_key",
115 "client_secret",
116 "private_key",
117 "auth_token",
118 "refresh_token",
119];
120
121fn assignment_shaped(content: &str) -> bool {
124 let lower = content.to_lowercase();
125 for key in ASSIGNMENT_KEYS {
126 let mut from = 0usize;
127 while let Some(pos) = lower[from..].find(key) {
128 let abs = from + pos + key.len();
129 let rest = lower[abs..].trim_start();
130 let rest = rest.strip_prefix('"').unwrap_or(rest).trim_start();
132 let Some(delim) = rest.chars().next() else {
133 break;
134 };
135 if delim == ':' || delim == '=' {
136 let value = rest[1..].trim_start();
137 let value = value.strip_prefix(['"', '\'']).unwrap_or(value);
138 let is_marker = value
142 .get(..10)
143 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
144 if !is_marker {
145 let run: usize = value
146 .chars()
147 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
148 .map(char::len_utf8)
149 .sum();
150 if run >= 16 {
151 return true;
152 }
153 }
154 }
155 from = abs;
156 }
157 }
158 false
159}
160
161#[must_use]
169pub fn redact_credential_content(content: &str) -> (String, Vec<&'static str>) {
170 let kinds = credential_shaped_content(content);
171 if kinds.is_empty() {
172 return (content.to_string(), kinds);
173 }
174
175 let mut out = content.to_string();
176
177 if kinds.contains(&"private_key_pem") {
178 while let Some((start, end)) = pem_block_span(&out) {
179 out.replace_range(start..end, "[REDACTED:private_key_pem]");
180 }
181 out = out.replace("PRIVATE KEY-----", "[REDACTED:pem-key]");
186 out = out.replace("-----BEGIN", "[REDACTED:pem-begin]");
187 out = out.replace("-----END", "[REDACTED:pem-end]");
188 }
189
190 if kinds.contains(&"credential_assignment") {
191 while let Some((start, end)) = assignment_value_span(&out) {
192 out.replace_range(start..end, "[REDACTED:credential_assignment]");
193 }
194 }
195
196 if kinds.contains(&"jwt") {
200 while let Some(pos) = out.find("eyJ") {
201 out.replace_range(pos..pos + "eyJ".len(), "[REDACTED:jwt]");
202 }
203 }
204
205 type TokenSpec = (&'static str, &'static str, usize, fn(char) -> bool);
206 let token_specs: &[TokenSpec] = &[
207 ("aws_access_key_id", "AKIA", 16, |c: char| {
208 c.is_ascii_uppercase() || c.is_ascii_digit()
209 }),
210 ("github_token", "ghp_", 30, |c: char| {
211 c.is_ascii_alphanumeric() || c == '_'
212 }),
213 ("github_token", "gho_", 30, |c: char| {
214 c.is_ascii_alphanumeric() || c == '_'
215 }),
216 ("github_token", "github_pat_", 20, |c: char| {
217 c.is_ascii_alphanumeric() || c == '_'
218 }),
219 ("openai_style_key", "sk-", 20, |c: char| {
220 c.is_ascii_alphanumeric() || c == '_' || c == '-'
221 }),
222 ("slack_token", "xoxb-", 10, |c: char| {
223 c.is_ascii_alphanumeric() || c == '-'
224 }),
225 ("slack_token", "xoxp-", 10, |c: char| {
226 c.is_ascii_alphanumeric() || c == '-'
227 }),
228 ("slack_token", "xoxa-", 10, |c: char| {
229 c.is_ascii_alphanumeric() || c == '-'
230 }),
231 ("slack_token", "xoxr-", 10, |c: char| {
232 c.is_ascii_alphanumeric() || c == '-'
233 }),
234 ("slack_token", "xoxs-", 10, |c: char| {
235 c.is_ascii_alphanumeric() || c == '-'
236 }),
237 ];
238 for (kind, prefix, min_len, charset) in token_specs {
239 while let Some((start, end)) = prefixed_token_span(&out, prefix, *min_len, *charset) {
240 out.replace_range(start..end, &format!("[REDACTED:{kind}]"));
241 }
242 }
243
244 (out, kinds)
245}
246
247fn pem_block_span(text: &str) -> Option<(usize, usize)> {
249 let begin = text.find("-----BEGIN")?;
250 let key_at = text[begin..].find("PRIVATE KEY-----")? + begin;
251 let end_at = text[key_at..].find("-----END")? + key_at;
252 let marker_at = text[end_at..].find("PRIVATE KEY-----")? + end_at;
253 Some((begin, marker_at + "PRIVATE KEY-----".len()))
254}
255
256fn assignment_value_span(text: &str) -> Option<(usize, usize)> {
258 for key in ASSIGNMENT_KEYS {
259 let mut from = 0usize;
260 while let Some(pos) = find_ascii_case_insensitive(text, key, from) {
261 let after = pos + key.len();
262 let rest_raw = &text[after..];
263 let quoted = rest_raw.strip_prefix('"').is_some();
265 let rest = rest_raw.strip_prefix('"').unwrap_or(rest_raw);
266 let ws = rest.len() - rest.trim_start().len();
267 let delim_pos = after + usize::from(quoted) + ws;
268 let delim = text[delim_pos..].chars().next();
269 if matches!(delim, Some(':' | '=')) {
270 let tail = &text[delim_pos + 1..];
271 let vws = tail.len() - tail.trim_start().len();
272 let mut vstart = delim_pos + 1 + vws;
273 if let Some(quote) = text[vstart..].chars().next() {
274 if quote == '"' || quote == '\'' {
275 vstart += quote.len_utf8();
276 }
277 }
278 let mut bytes = 0usize;
279 for c in text[vstart..].chars() {
280 if c.is_whitespace() || c == '"' || c == '\'' {
281 break;
282 }
283 bytes += c.len_utf8();
284 }
285 let is_marker = text[vstart..]
286 .get(..10)
287 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
288 if bytes >= 16 && !is_marker {
289 return Some((vstart, vstart + bytes));
290 }
291 }
292 from = after;
293 }
294 }
295 None
296}
297
298fn prefixed_token_span(
300 text: &str,
301 prefix: &str,
302 min_len: usize,
303 charset: fn(char) -> bool,
304) -> Option<(usize, usize)> {
305 let mut from = 0usize;
306 while let Some(pos) = text[from..].find(prefix) {
307 let start = from + pos;
308 let value_start = start + prefix.len();
309 let mut bytes = 0usize;
310 let mut count = 0usize;
311 for c in text[value_start..].chars() {
312 if !charset(c) {
313 break;
314 }
315 bytes += c.len_utf8();
316 count += 1;
317 }
318 if count >= min_len {
319 return Some((start, value_start + bytes));
320 }
321 from = value_start;
322 }
323 None
324}
325
326fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
328 let h = haystack.as_bytes();
329 let n = needle.as_bytes();
330 if n.is_empty() || from >= h.len() || n.len() > h.len() - from {
331 return None;
332 }
333 (from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn detects_private_keys_aws_and_github() {
342 let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
343 assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
344
345 let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
346 assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
347
348 let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
349 assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
350 }
351
352 #[test]
353 fn detects_sk_slack_jwt_and_assignments() {
354 let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
355 assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
356
357 let slack = format!(
361 "xoxb-{}-{}-{}",
362 "123456789012", "1234567890123", "abcdefghijklmnop"
363 );
364 assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
365
366 let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
367 assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
368
369 let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
370 assert_eq!(
371 credential_shaped_content(assign),
372 vec!["credential_assignment"]
373 );
374 }
375
376 #[test]
377 fn detects_aws_secret_and_compound_assignment_keys() {
378 let aws = "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
383 assert_eq!(
384 credential_shaped_content(aws),
385 vec!["credential_assignment"]
386 );
387 let (redacted, kinds) = redact_credential_content(aws);
388 assert!(kinds.contains(&"credential_assignment"));
389 assert_eq!(
390 redacted,
391 "AWS_SECRET_ACCESS_KEY=[REDACTED:credential_assignment]"
392 );
393 assert!(
394 credential_shaped_content(&redacted).is_empty(),
395 "redacted AWS secret must read clean: {redacted}"
396 );
397 let (twice, _) = redact_credential_content(&redacted);
398 assert_eq!(redacted, twice);
399
400 for text in [
402 "secret_access_key=0123456789abcdef",
403 "secret_key: 0123456789abcdef",
404 "refresh_token=0123456789abcdef",
405 "auth_token=0123456789abcdef",
406 ] {
407 assert_eq!(
408 credential_shaped_content(text),
409 vec!["credential_assignment"],
410 "{text}"
411 );
412 }
413
414 assert!(
416 credential_shaped_content("the aws secret access key rotation policy was updated")
417 .is_empty()
418 );
419 }
420
421 #[test]
422 fn ordinary_prose_stays_clean() {
423 assert!(
424 credential_shaped_content("remember that the password policy requires rotation")
425 .is_empty()
426 );
427 assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
428 assert!(credential_shaped_content("short token: abc123").is_empty());
429 assert!(
430 credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
431 );
432 assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
433 assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
434 }
435
436 #[test]
437 fn dedupes_kinds() {
438 let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
439 assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
440 }
441
442 #[test]
443 fn redacts_private_key_blocks() {
444 let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowSECRET\n-----END RSA PRIVATE KEY-----\nafter";
445 let (redacted, kinds) = redact_credential_content(pem);
446 assert!(kinds.contains(&"private_key_pem"));
447 assert!(!redacted.contains("MIIEowSECRET"), "key body must be gone");
448 assert!(!redacted.contains("BEGIN RSA PRIVATE KEY"));
449 assert_eq!(redacted, "before\n[REDACTED:private_key_pem]\nafter");
450 }
451
452 #[test]
453 fn redacts_assignment_values_and_tokens() {
454 let text = "db password=correct-horse-battery-staple and key sk-proj0123456789abcdefghijklmnopqrstuv";
455 let (redacted, _) = redact_credential_content(text);
456 assert!(redacted.contains("password=[REDACTED:credential_assignment]"));
457 assert!(!redacted.contains("correct-horse-battery-staple"));
458 assert!(!redacted.contains("sk-proj0123456789abcdefghijklmnopqrstuv"));
459 assert!(redacted.contains("[REDACTED:openai_style_key]"));
460
461 let aws = "id AKIAIOSFODNN7EXAMPLE here";
462 let (redacted, _) = redact_credential_content(aws);
463 assert_eq!(redacted, "id [REDACTED:aws_access_key_id] here");
464 }
465
466 #[test]
470 fn redacts_json_style_assignment_values() {
471 let json = r#"{"api_key": "supersecretvalue12345", "note": "plain"}"#;
472 let (redacted, kinds) = redact_credential_content(json);
473 assert!(
474 kinds.contains(&"credential_assignment"),
475 "JSON assignment must be detected: {kinds:?}"
476 );
477 assert!(
478 !redacted.contains("supersecretvalue12345"),
479 "JSON assignment value must be scrubbed: {redacted}"
480 );
481 assert!(
482 redacted.contains("plain"),
483 "non-secret values stay: {redacted}"
484 );
485 }
486
487 #[test]
488 fn clean_content_passes_through_unchanged() {
489 let text = "remember that the password policy requires rotation";
490 let (redacted, kinds) = redact_credential_content(text);
491 assert!(kinds.is_empty());
492 assert_eq!(redacted, text);
493 }
494
495 #[test]
496 fn redaction_is_idempotent() {
497 let text = "key sk-proj0123456789abcdefghijklmnopqrstuv end";
498 let (once, _) = redact_credential_content(text);
499 let (twice, kinds) = redact_credential_content(&once);
500 assert_eq!(once, twice);
501 assert!(
502 kinds.is_empty(),
503 "redacted marker must read clean: {kinds:?}"
504 );
505 }
506
507 #[test]
508 fn assignment_marker_does_not_retrigger_detection() {
509 let text = "db password=correct-horse-battery-staple";
513 let (once, _) = redact_credential_content(text);
514 assert!(
515 credential_shaped_content(&once).is_empty(),
516 "redacted assignment must read clean: {once}"
517 );
518 let (twice, kinds) = redact_credential_content(&once);
519 assert_eq!(once, twice);
520 assert!(kinds.is_empty());
521 }
522
523 #[test]
524 fn short_jwt_fragments_are_redacted_too() {
525 let text = "tokens eyJab and eyJcd appeared in logs";
529 let (once, kinds) = redact_credential_content(text);
530 assert!(kinds.contains(&"jwt"));
531 assert!(
532 credential_shaped_content(&once).is_empty(),
533 "short fragments must read clean after redaction: {once}"
534 );
535 let (twice, _) = redact_credential_content(&once);
536 assert_eq!(once, twice);
537 }
538
539 #[test]
540 fn pem_fragments_are_redacted_too() {
541 let text = "docs explain -----BEGIN PRIVATE KEY----- when truncated";
545 let (once, kinds) = redact_credential_content(text);
546 assert!(kinds.contains(&"private_key_pem"));
547 assert!(
548 credential_shaped_content(&once).is_empty(),
549 "PEM fragments must read clean after redaction: {once}"
550 );
551 let (twice, _) = redact_credential_content(&once);
552 assert_eq!(once, twice);
553 }
554}