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 if uri_userinfo_span(content).is_some() {
84 push("credential_uri", &mut kinds);
85 }
86
87 kinds
88}
89
90fn token_after(
92 haystack: &str,
93 prefix: &str,
94 min_len: usize,
95 charset: impl Fn(char) -> bool,
96) -> bool {
97 let mut from = 0usize;
98 while let Some(pos) = haystack[from..].find(prefix) {
99 let abs = from + pos + prefix.len();
100 let run = haystack[abs..].chars().take_while(|c| charset(*c)).count();
101 if run >= min_len {
102 return true;
103 }
104 from = abs;
105 }
106 false
107}
108
109const ASSIGNMENT_KEYS: &[&str] = &[
120 "password",
121 "passwd",
122 "passphrase",
123 "api_key",
124 "api-key",
125 "apikey",
126 "secret",
127 "token",
128 "access_token",
129 "secret_access_key",
130 "aws_secret_access_key",
131 "secret_key",
132 "client_secret",
133 "private_key",
134 "auth_token",
135 "refresh_token",
136];
137
138fn assignment_shaped(content: &str) -> bool {
141 let lower = content.to_lowercase();
142 for key in ASSIGNMENT_KEYS {
143 let mut from = 0usize;
144 while let Some(pos) = lower[from..].find(key) {
145 let abs = from + pos + key.len();
146 let rest = lower[abs..].trim_start();
147 let rest = rest.strip_prefix('"').unwrap_or(rest).trim_start();
149 let Some(delim) = rest.chars().next() else {
150 break;
151 };
152 if delim == ':' || delim == '=' {
153 let value = rest[1..].trim_start();
154 let value = value.strip_prefix(['"', '\'']).unwrap_or(value);
155 let is_marker = value
159 .get(..10)
160 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
161 if !is_marker {
162 let run: usize = value
163 .chars()
164 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
165 .map(char::len_utf8)
166 .sum();
167 if run >= 16 {
168 return true;
169 }
170 }
171 }
172 from = abs;
173 }
174 }
175 false
176}
177
178#[must_use]
186pub fn redact_credential_content(content: &str) -> (String, Vec<&'static str>) {
187 let kinds = credential_shaped_content(content);
188 if kinds.is_empty() {
189 return (content.to_string(), kinds);
190 }
191
192 let mut out = content.to_string();
193
194 if kinds.contains(&"private_key_pem") {
195 while let Some((start, end)) = pem_block_span(&out) {
196 out.replace_range(start..end, "[REDACTED:private_key_pem]");
197 }
198 out = out.replace("PRIVATE KEY-----", "[REDACTED:pem-key]");
203 out = out.replace("-----BEGIN", "[REDACTED:pem-begin]");
204 out = out.replace("-----END", "[REDACTED:pem-end]");
205 }
206
207 if kinds.contains(&"credential_assignment") {
208 while let Some((start, end)) = assignment_value_span(&out) {
209 out.replace_range(start..end, "[REDACTED:credential_assignment]");
210 }
211 }
212
213 if kinds.contains(&"credential_uri") {
214 while let Some((start, end)) = uri_userinfo_span(&out) {
215 out.replace_range(start..end, "[REDACTED:credential_uri]");
216 }
217 }
218
219 if kinds.contains(&"jwt") {
223 while let Some(pos) = out.find("eyJ") {
224 out.replace_range(pos..pos + "eyJ".len(), "[REDACTED:jwt]");
225 }
226 }
227
228 type TokenSpec = (&'static str, &'static str, usize, fn(char) -> bool);
229 let token_specs: &[TokenSpec] = &[
230 ("aws_access_key_id", "AKIA", 16, |c: char| {
231 c.is_ascii_uppercase() || c.is_ascii_digit()
232 }),
233 ("github_token", "ghp_", 30, |c: char| {
234 c.is_ascii_alphanumeric() || c == '_'
235 }),
236 ("github_token", "gho_", 30, |c: char| {
237 c.is_ascii_alphanumeric() || c == '_'
238 }),
239 ("github_token", "github_pat_", 20, |c: char| {
240 c.is_ascii_alphanumeric() || c == '_'
241 }),
242 ("openai_style_key", "sk-", 20, |c: char| {
243 c.is_ascii_alphanumeric() || c == '_' || c == '-'
244 }),
245 ("slack_token", "xoxb-", 10, |c: char| {
246 c.is_ascii_alphanumeric() || c == '-'
247 }),
248 ("slack_token", "xoxp-", 10, |c: char| {
249 c.is_ascii_alphanumeric() || c == '-'
250 }),
251 ("slack_token", "xoxa-", 10, |c: char| {
252 c.is_ascii_alphanumeric() || c == '-'
253 }),
254 ("slack_token", "xoxr-", 10, |c: char| {
255 c.is_ascii_alphanumeric() || c == '-'
256 }),
257 ("slack_token", "xoxs-", 10, |c: char| {
258 c.is_ascii_alphanumeric() || c == '-'
259 }),
260 ];
261 for (kind, prefix, min_len, charset) in token_specs {
262 while let Some((start, end)) = prefixed_token_span(&out, prefix, *min_len, *charset) {
263 out.replace_range(start..end, &format!("[REDACTED:{kind}]"));
264 }
265 }
266
267 (out, kinds)
268}
269
270fn pem_block_span(text: &str) -> Option<(usize, usize)> {
272 let begin = text.find("-----BEGIN")?;
273 let key_at = text[begin..].find("PRIVATE KEY-----")? + begin;
274 let end_at = text[key_at..].find("-----END")? + key_at;
275 let marker_at = text[end_at..].find("PRIVATE KEY-----")? + end_at;
276 Some((begin, marker_at + "PRIVATE KEY-----".len()))
277}
278
279fn assignment_value_span(text: &str) -> Option<(usize, usize)> {
281 for key in ASSIGNMENT_KEYS {
282 let mut from = 0usize;
283 while let Some(pos) = find_ascii_case_insensitive(text, key, from) {
284 let after = pos + key.len();
285 let rest_raw = &text[after..];
286 let quoted = rest_raw.strip_prefix('"').is_some();
288 let rest = rest_raw.strip_prefix('"').unwrap_or(rest_raw);
289 let ws = rest.len() - rest.trim_start().len();
290 let delim_pos = after + usize::from(quoted) + ws;
291 let delim = text[delim_pos..].chars().next();
292 if matches!(delim, Some(':' | '=')) {
293 let tail = &text[delim_pos + 1..];
294 let vws = tail.len() - tail.trim_start().len();
295 let mut vstart = delim_pos + 1 + vws;
296 if let Some(quote) = text[vstart..].chars().next() {
297 if quote == '"' || quote == '\'' {
298 vstart += quote.len_utf8();
299 }
300 }
301 let mut bytes = 0usize;
302 for c in text[vstart..].chars() {
303 if c.is_whitespace() || c == '"' || c == '\'' {
304 break;
305 }
306 bytes += c.len_utf8();
307 }
308 let is_marker = text[vstart..]
309 .get(..10)
310 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
311 if bytes >= 16 && !is_marker {
312 return Some((vstart, vstart + bytes));
313 }
314 }
315 from = after;
316 }
317 }
318 None
319}
320
321fn uri_userinfo_span(text: &str) -> Option<(usize, usize)> {
332 let mut from = 0usize;
333 while let Some(rel) = text[from..].find("://") {
334 let sep = from + rel;
335 let authority_start = sep + 3;
336 let scheme_start = text[..sep]
337 .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-'))
338 .map_or(0, |i| {
339 i + text[i..].chars().next().map_or(1, char::len_utf8)
344 });
345 let scheme = &text[scheme_start..sep];
346 let scheme_ok = scheme.starts_with(|c: char| c.is_ascii_alphabetic());
347 if scheme_ok {
348 let authority_end = text[authority_start..]
349 .find(|c: char| c == '/' || c == '?' || c == '#' || c.is_whitespace())
350 .map_or(text.len(), |i| authority_start + i);
351 if let Some(at_rel) = text[authority_start..authority_end].rfind('@') {
352 let at = authority_start + at_rel;
353 let userinfo = &text[authority_start..at];
354 if let Some(colon_rel) = userinfo.rfind(':') {
355 let password = &userinfo[colon_rel + 1..];
356 let is_marker = userinfo
359 .get(..10)
360 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
361 if !password.is_empty() && !is_marker {
362 return Some((authority_start, at));
363 }
364 }
365 }
366 }
367 from = authority_start;
368 }
369 None
370}
371
372fn prefixed_token_span(
374 text: &str,
375 prefix: &str,
376 min_len: usize,
377 charset: fn(char) -> bool,
378) -> Option<(usize, usize)> {
379 let mut from = 0usize;
380 while let Some(pos) = text[from..].find(prefix) {
381 let start = from + pos;
382 let value_start = start + prefix.len();
383 let mut bytes = 0usize;
384 let mut count = 0usize;
385 for c in text[value_start..].chars() {
386 if !charset(c) {
387 break;
388 }
389 bytes += c.len_utf8();
390 count += 1;
391 }
392 if count >= min_len {
393 return Some((start, value_start + bytes));
394 }
395 from = value_start;
396 }
397 None
398}
399
400fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
402 let h = haystack.as_bytes();
403 let n = needle.as_bytes();
404 if n.is_empty() || from >= h.len() || n.len() > h.len() - from {
405 return None;
406 }
407 (from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn detects_private_keys_aws_and_github() {
416 let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
417 assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
418
419 let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
420 assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
421
422 let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
423 assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
424 }
425
426 #[test]
427 fn detects_sk_slack_jwt_and_assignments() {
428 let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
429 assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
430
431 let slack = format!(
435 "xoxb-{}-{}-{}",
436 "123456789012", "1234567890123", "abcdefghijklmnop"
437 );
438 assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
439
440 let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
441 assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
442
443 let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
444 assert_eq!(
445 credential_shaped_content(assign),
446 vec!["credential_assignment"]
447 );
448 }
449
450 #[test]
451 fn detects_aws_secret_and_compound_assignment_keys() {
452 let aws = "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
457 assert_eq!(
458 credential_shaped_content(aws),
459 vec!["credential_assignment"]
460 );
461 let (redacted, kinds) = redact_credential_content(aws);
462 assert!(kinds.contains(&"credential_assignment"));
463 assert_eq!(
464 redacted,
465 "AWS_SECRET_ACCESS_KEY=[REDACTED:credential_assignment]"
466 );
467 assert!(
468 credential_shaped_content(&redacted).is_empty(),
469 "redacted AWS secret must read clean: {redacted}"
470 );
471 let (twice, _) = redact_credential_content(&redacted);
472 assert_eq!(redacted, twice);
473
474 for text in [
476 "secret_access_key=0123456789abcdef",
477 "secret_key: 0123456789abcdef",
478 "refresh_token=0123456789abcdef",
479 "auth_token=0123456789abcdef",
480 ] {
481 assert_eq!(
482 credential_shaped_content(text),
483 vec!["credential_assignment"],
484 "{text}"
485 );
486 }
487
488 assert!(
490 credential_shaped_content("the aws secret access key rotation policy was updated")
491 .is_empty()
492 );
493 }
494
495 #[test]
496 fn ordinary_prose_stays_clean() {
497 assert!(
498 credential_shaped_content("remember that the password policy requires rotation")
499 .is_empty()
500 );
501 assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
502 assert!(credential_shaped_content("short token: abc123").is_empty());
503 assert!(
504 credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
505 );
506 assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
507 assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
508 }
509
510 #[test]
511 fn dedupes_kinds() {
512 let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
513 assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
514 }
515
516 #[test]
517 fn redacts_private_key_blocks() {
518 let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowSECRET\n-----END RSA PRIVATE KEY-----\nafter";
519 let (redacted, kinds) = redact_credential_content(pem);
520 assert!(kinds.contains(&"private_key_pem"));
521 assert!(!redacted.contains("MIIEowSECRET"), "key body must be gone");
522 assert!(!redacted.contains("BEGIN RSA PRIVATE KEY"));
523 assert_eq!(redacted, "before\n[REDACTED:private_key_pem]\nafter");
524 }
525
526 #[test]
527 fn redacts_assignment_values_and_tokens() {
528 let text = "db password=correct-horse-battery-staple and key sk-proj0123456789abcdefghijklmnopqrstuv";
529 let (redacted, _) = redact_credential_content(text);
530 assert!(redacted.contains("password=[REDACTED:credential_assignment]"));
531 assert!(!redacted.contains("correct-horse-battery-staple"));
532 assert!(!redacted.contains("sk-proj0123456789abcdefghijklmnopqrstuv"));
533 assert!(redacted.contains("[REDACTED:openai_style_key]"));
534
535 let aws = "id AKIAIOSFODNN7EXAMPLE here";
536 let (redacted, _) = redact_credential_content(aws);
537 assert_eq!(redacted, "id [REDACTED:aws_access_key_id] here");
538 }
539
540 #[test]
544 fn redacts_json_style_assignment_values() {
545 let json = r#"{"api_key": "supersecretvalue12345", "note": "plain"}"#;
546 let (redacted, kinds) = redact_credential_content(json);
547 assert!(
548 kinds.contains(&"credential_assignment"),
549 "JSON assignment must be detected: {kinds:?}"
550 );
551 assert!(
552 !redacted.contains("supersecretvalue12345"),
553 "JSON assignment value must be scrubbed: {redacted}"
554 );
555 assert!(
556 redacted.contains("plain"),
557 "non-secret values stay: {redacted}"
558 );
559 }
560
561 #[test]
562 fn clean_content_passes_through_unchanged() {
563 let text = "remember that the password policy requires rotation";
564 let (redacted, kinds) = redact_credential_content(text);
565 assert!(kinds.is_empty());
566 assert_eq!(redacted, text);
567 }
568
569 #[test]
570 fn redaction_is_idempotent() {
571 let text = "key sk-proj0123456789abcdefghijklmnopqrstuv end";
572 let (once, _) = redact_credential_content(text);
573 let (twice, kinds) = redact_credential_content(&once);
574 assert_eq!(once, twice);
575 assert!(
576 kinds.is_empty(),
577 "redacted marker must read clean: {kinds:?}"
578 );
579 }
580
581 #[test]
582 fn assignment_marker_does_not_retrigger_detection() {
583 let text = "db password=correct-horse-battery-staple";
587 let (once, _) = redact_credential_content(text);
588 assert!(
589 credential_shaped_content(&once).is_empty(),
590 "redacted assignment must read clean: {once}"
591 );
592 let (twice, kinds) = redact_credential_content(&once);
593 assert_eq!(once, twice);
594 assert!(kinds.is_empty());
595 }
596
597 #[test]
598 fn short_jwt_fragments_are_redacted_too() {
599 let text = "tokens eyJab and eyJcd appeared in logs";
603 let (once, kinds) = redact_credential_content(text);
604 assert!(kinds.contains(&"jwt"));
605 assert!(
606 credential_shaped_content(&once).is_empty(),
607 "short fragments must read clean after redaction: {once}"
608 );
609 let (twice, _) = redact_credential_content(&once);
610 assert_eq!(once, twice);
611 }
612
613 #[test]
614 fn pem_fragments_are_redacted_too() {
615 let text = "docs explain -----BEGIN PRIVATE KEY----- when truncated";
619 let (once, kinds) = redact_credential_content(text);
620 assert!(kinds.contains(&"private_key_pem"));
621 assert!(
622 credential_shaped_content(&once).is_empty(),
623 "PEM fragments must read clean after redaction: {once}"
624 );
625 let (twice, _) = redact_credential_content(&once);
626 assert_eq!(once, twice);
627 }
628
629 #[test]
633 fn reviewer_fixtures_are_detected_and_redacted() {
634 let token = "TOKEN=generic_token_value_0123456789abcdef";
635 assert!(
636 credential_shaped_content(token).contains(&"credential_assignment"),
637 "plain token assignments must fire"
638 );
639 let (red, kinds) = redact_credential_content(token);
640 assert!(kinds.contains(&"credential_assignment"));
641 assert!(!red.contains("generic_token_value_0123456789abcdef"));
642 assert!(
643 credential_shaped_content(&red).is_empty(),
644 "redacted token must read clean: {red}"
645 );
646
647 let uri = "DATABASE_URL=postgres://alice:fakepassword123456@db.example.com/prod";
648 assert!(
649 credential_shaped_content(uri).contains(&"credential_uri"),
650 "URI userinfo must be detected independently of the key name"
651 );
652 let (red, kinds) = redact_credential_content(uri);
653 assert!(kinds.contains(&"credential_uri"));
654 assert!(!red.contains("fakepassword123456"), "password must be gone");
655 assert!(
656 !red.contains("alice"),
657 "userinfo over-redaction is deliberate"
658 );
659 assert!(red.contains("db.example.com"), "host stays: {red}");
660 assert!(
661 credential_shaped_content(&red).is_empty(),
662 "redacted URI must read clean: {red}"
663 );
664 let (twice, _) = redact_credential_content(&red);
665 assert_eq!(red, twice, "URI redaction must be idempotent");
666
667 let redis = "REDIS_URL=redis://:hunter2hunter2@cache.internal:6379/0";
669 let (red, kinds) = redact_credential_content(redis);
670 assert!(kinds.contains(&"credential_uri"));
671 assert!(!red.contains("hunter2hunter2"));
672 assert!(credential_shaped_content(&red).is_empty());
673
674 let json = r#"{"Database_Url": "Postgres://Alice:Passw0rd123456@Db.Example.com/prod"}"#;
676 let (red, kinds) = redact_credential_content(json);
677 assert!(
678 kinds.contains(&"credential_uri"),
679 "JSON URI must fire: {kinds:?}"
680 );
681 assert!(!red.contains("Passw0rd123456"));
682 assert!(credential_shaped_content(&red).is_empty());
683
684 for text in [
687 "BOT_TOKEN=0123456789abcdef",
688 "SESSION_TOKEN: 0123456789abcdef",
689 "bearer_token=0123456789abcdef",
690 "PASSPHRASE=correct-horse-battery-staple",
691 ] {
692 assert!(
693 credential_shaped_content(text).contains(&"credential_assignment"),
694 "{text}"
695 );
696 }
697 }
698
699 #[test]
700 fn uri_lookalikes_stay_clean() {
701 for text in [
702 "see https://example.com/path for details",
703 "ssh://git@github.com:22/repo",
704 "connect to http://127.0.0.1:8080/status",
705 "https://user@example.com/profile",
706 "the scheme: separator is not a URL",
707 "note:// just a label",
708 ] {
709 assert!(
710 credential_shaped_content(text).is_empty(),
711 "lookalike must stay clean: {text}"
712 );
713 }
714 }
715
716 #[test]
721 fn uri_scan_is_char_boundary_safe_next_to_multibyte_text() {
722 let text = "†††https://alice:fakepassword123456@db.example.com/prod";
724 assert_eq!(
725 credential_shaped_content(text),
726 vec!["credential_uri".to_string()],
727 "multibyte neighbors must not break URI detection"
728 );
729 let (redacted, _) = redact_credential_content(text);
730 assert!(redacted.contains("[REDACTED:"), "{redacted}");
731 assert!(!redacted.contains("fakepassword123456"), "{redacted}");
732
733 let lookalike = "†".repeat(64) + "https://example.com/path";
735 assert!(credential_shaped_content(&lookalike).is_empty());
736 }
737}