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| i + 1);
339 let scheme = &text[scheme_start..sep];
340 let scheme_ok = scheme.starts_with(|c: char| c.is_ascii_alphabetic());
341 if scheme_ok {
342 let authority_end = text[authority_start..]
343 .find(|c: char| c == '/' || c == '?' || c == '#' || c.is_whitespace())
344 .map_or(text.len(), |i| authority_start + i);
345 if let Some(at_rel) = text[authority_start..authority_end].rfind('@') {
346 let at = authority_start + at_rel;
347 let userinfo = &text[authority_start..at];
348 if let Some(colon_rel) = userinfo.rfind(':') {
349 let password = &userinfo[colon_rel + 1..];
350 let is_marker = userinfo
353 .get(..10)
354 .is_some_and(|p| p.eq_ignore_ascii_case("[REDACTED:"));
355 if !password.is_empty() && !is_marker {
356 return Some((authority_start, at));
357 }
358 }
359 }
360 }
361 from = authority_start;
362 }
363 None
364}
365
366fn prefixed_token_span(
368 text: &str,
369 prefix: &str,
370 min_len: usize,
371 charset: fn(char) -> bool,
372) -> Option<(usize, usize)> {
373 let mut from = 0usize;
374 while let Some(pos) = text[from..].find(prefix) {
375 let start = from + pos;
376 let value_start = start + prefix.len();
377 let mut bytes = 0usize;
378 let mut count = 0usize;
379 for c in text[value_start..].chars() {
380 if !charset(c) {
381 break;
382 }
383 bytes += c.len_utf8();
384 count += 1;
385 }
386 if count >= min_len {
387 return Some((start, value_start + bytes));
388 }
389 from = value_start;
390 }
391 None
392}
393
394fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option<usize> {
396 let h = haystack.as_bytes();
397 let n = needle.as_bytes();
398 if n.is_empty() || from >= h.len() || n.len() > h.len() - from {
399 return None;
400 }
401 (from..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn detects_private_keys_aws_and_github() {
410 let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
411 assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
412
413 let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
414 assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
415
416 let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
417 assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
418 }
419
420 #[test]
421 fn detects_sk_slack_jwt_and_assignments() {
422 let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
423 assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
424
425 let slack = format!(
429 "xoxb-{}-{}-{}",
430 "123456789012", "1234567890123", "abcdefghijklmnop"
431 );
432 assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
433
434 let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
435 assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
436
437 let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
438 assert_eq!(
439 credential_shaped_content(assign),
440 vec!["credential_assignment"]
441 );
442 }
443
444 #[test]
445 fn detects_aws_secret_and_compound_assignment_keys() {
446 let aws = "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
451 assert_eq!(
452 credential_shaped_content(aws),
453 vec!["credential_assignment"]
454 );
455 let (redacted, kinds) = redact_credential_content(aws);
456 assert!(kinds.contains(&"credential_assignment"));
457 assert_eq!(
458 redacted,
459 "AWS_SECRET_ACCESS_KEY=[REDACTED:credential_assignment]"
460 );
461 assert!(
462 credential_shaped_content(&redacted).is_empty(),
463 "redacted AWS secret must read clean: {redacted}"
464 );
465 let (twice, _) = redact_credential_content(&redacted);
466 assert_eq!(redacted, twice);
467
468 for text in [
470 "secret_access_key=0123456789abcdef",
471 "secret_key: 0123456789abcdef",
472 "refresh_token=0123456789abcdef",
473 "auth_token=0123456789abcdef",
474 ] {
475 assert_eq!(
476 credential_shaped_content(text),
477 vec!["credential_assignment"],
478 "{text}"
479 );
480 }
481
482 assert!(
484 credential_shaped_content("the aws secret access key rotation policy was updated")
485 .is_empty()
486 );
487 }
488
489 #[test]
490 fn ordinary_prose_stays_clean() {
491 assert!(
492 credential_shaped_content("remember that the password policy requires rotation")
493 .is_empty()
494 );
495 assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
496 assert!(credential_shaped_content("short token: abc123").is_empty());
497 assert!(
498 credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
499 );
500 assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
501 assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
502 }
503
504 #[test]
505 fn dedupes_kinds() {
506 let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
507 assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
508 }
509
510 #[test]
511 fn redacts_private_key_blocks() {
512 let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIEowSECRET\n-----END RSA PRIVATE KEY-----\nafter";
513 let (redacted, kinds) = redact_credential_content(pem);
514 assert!(kinds.contains(&"private_key_pem"));
515 assert!(!redacted.contains("MIIEowSECRET"), "key body must be gone");
516 assert!(!redacted.contains("BEGIN RSA PRIVATE KEY"));
517 assert_eq!(redacted, "before\n[REDACTED:private_key_pem]\nafter");
518 }
519
520 #[test]
521 fn redacts_assignment_values_and_tokens() {
522 let text = "db password=correct-horse-battery-staple and key sk-proj0123456789abcdefghijklmnopqrstuv";
523 let (redacted, _) = redact_credential_content(text);
524 assert!(redacted.contains("password=[REDACTED:credential_assignment]"));
525 assert!(!redacted.contains("correct-horse-battery-staple"));
526 assert!(!redacted.contains("sk-proj0123456789abcdefghijklmnopqrstuv"));
527 assert!(redacted.contains("[REDACTED:openai_style_key]"));
528
529 let aws = "id AKIAIOSFODNN7EXAMPLE here";
530 let (redacted, _) = redact_credential_content(aws);
531 assert_eq!(redacted, "id [REDACTED:aws_access_key_id] here");
532 }
533
534 #[test]
538 fn redacts_json_style_assignment_values() {
539 let json = r#"{"api_key": "supersecretvalue12345", "note": "plain"}"#;
540 let (redacted, kinds) = redact_credential_content(json);
541 assert!(
542 kinds.contains(&"credential_assignment"),
543 "JSON assignment must be detected: {kinds:?}"
544 );
545 assert!(
546 !redacted.contains("supersecretvalue12345"),
547 "JSON assignment value must be scrubbed: {redacted}"
548 );
549 assert!(
550 redacted.contains("plain"),
551 "non-secret values stay: {redacted}"
552 );
553 }
554
555 #[test]
556 fn clean_content_passes_through_unchanged() {
557 let text = "remember that the password policy requires rotation";
558 let (redacted, kinds) = redact_credential_content(text);
559 assert!(kinds.is_empty());
560 assert_eq!(redacted, text);
561 }
562
563 #[test]
564 fn redaction_is_idempotent() {
565 let text = "key sk-proj0123456789abcdefghijklmnopqrstuv end";
566 let (once, _) = redact_credential_content(text);
567 let (twice, kinds) = redact_credential_content(&once);
568 assert_eq!(once, twice);
569 assert!(
570 kinds.is_empty(),
571 "redacted marker must read clean: {kinds:?}"
572 );
573 }
574
575 #[test]
576 fn assignment_marker_does_not_retrigger_detection() {
577 let text = "db password=correct-horse-battery-staple";
581 let (once, _) = redact_credential_content(text);
582 assert!(
583 credential_shaped_content(&once).is_empty(),
584 "redacted assignment must read clean: {once}"
585 );
586 let (twice, kinds) = redact_credential_content(&once);
587 assert_eq!(once, twice);
588 assert!(kinds.is_empty());
589 }
590
591 #[test]
592 fn short_jwt_fragments_are_redacted_too() {
593 let text = "tokens eyJab and eyJcd appeared in logs";
597 let (once, kinds) = redact_credential_content(text);
598 assert!(kinds.contains(&"jwt"));
599 assert!(
600 credential_shaped_content(&once).is_empty(),
601 "short fragments must read clean after redaction: {once}"
602 );
603 let (twice, _) = redact_credential_content(&once);
604 assert_eq!(once, twice);
605 }
606
607 #[test]
608 fn pem_fragments_are_redacted_too() {
609 let text = "docs explain -----BEGIN PRIVATE KEY----- when truncated";
613 let (once, kinds) = redact_credential_content(text);
614 assert!(kinds.contains(&"private_key_pem"));
615 assert!(
616 credential_shaped_content(&once).is_empty(),
617 "PEM fragments must read clean after redaction: {once}"
618 );
619 let (twice, _) = redact_credential_content(&once);
620 assert_eq!(once, twice);
621 }
622
623 #[test]
627 fn reviewer_fixtures_are_detected_and_redacted() {
628 let token = "TOKEN=generic_token_value_0123456789abcdef";
629 assert!(
630 credential_shaped_content(token).contains(&"credential_assignment"),
631 "plain token assignments must fire"
632 );
633 let (red, kinds) = redact_credential_content(token);
634 assert!(kinds.contains(&"credential_assignment"));
635 assert!(!red.contains("generic_token_value_0123456789abcdef"));
636 assert!(
637 credential_shaped_content(&red).is_empty(),
638 "redacted token must read clean: {red}"
639 );
640
641 let uri = "DATABASE_URL=postgres://alice:fakepassword123456@db.example.com/prod";
642 assert!(
643 credential_shaped_content(uri).contains(&"credential_uri"),
644 "URI userinfo must be detected independently of the key name"
645 );
646 let (red, kinds) = redact_credential_content(uri);
647 assert!(kinds.contains(&"credential_uri"));
648 assert!(!red.contains("fakepassword123456"), "password must be gone");
649 assert!(
650 !red.contains("alice"),
651 "userinfo over-redaction is deliberate"
652 );
653 assert!(red.contains("db.example.com"), "host stays: {red}");
654 assert!(
655 credential_shaped_content(&red).is_empty(),
656 "redacted URI must read clean: {red}"
657 );
658 let (twice, _) = redact_credential_content(&red);
659 assert_eq!(red, twice, "URI redaction must be idempotent");
660
661 let redis = "REDIS_URL=redis://:hunter2hunter2@cache.internal:6379/0";
663 let (red, kinds) = redact_credential_content(redis);
664 assert!(kinds.contains(&"credential_uri"));
665 assert!(!red.contains("hunter2hunter2"));
666 assert!(credential_shaped_content(&red).is_empty());
667
668 let json = r#"{"Database_Url": "Postgres://Alice:Passw0rd123456@Db.Example.com/prod"}"#;
670 let (red, kinds) = redact_credential_content(json);
671 assert!(
672 kinds.contains(&"credential_uri"),
673 "JSON URI must fire: {kinds:?}"
674 );
675 assert!(!red.contains("Passw0rd123456"));
676 assert!(credential_shaped_content(&red).is_empty());
677
678 for text in [
681 "BOT_TOKEN=0123456789abcdef",
682 "SESSION_TOKEN: 0123456789abcdef",
683 "bearer_token=0123456789abcdef",
684 "PASSPHRASE=correct-horse-battery-staple",
685 ] {
686 assert!(
687 credential_shaped_content(text).contains(&"credential_assignment"),
688 "{text}"
689 );
690 }
691 }
692
693 #[test]
694 fn uri_lookalikes_stay_clean() {
695 for text in [
696 "see https://example.com/path for details",
697 "ssh://git@github.com:22/repo",
698 "connect to http://127.0.0.1:8080/status",
699 "https://user@example.com/profile",
700 "the scheme: separator is not a URL",
701 "note:// just a label",
702 ] {
703 assert!(
704 credential_shaped_content(text).is_empty(),
705 "lookalike must stay clean: {text}"
706 );
707 }
708 }
709}