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 run: usize = value
125 .chars()
126 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
127 .map(char::len_utf8)
128 .sum();
129 if run >= 16 {
130 return true;
131 }
132 }
133 from = abs;
134 }
135 }
136 false
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn detects_private_keys_aws_and_github() {
145 let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----";
146 assert_eq!(credential_shaped_content(pem), vec!["private_key_pem"]);
147
148 let aws = "access id AKIAIOSFODNN7EXAMPLE found in logs";
149 assert_eq!(credential_shaped_content(aws), vec!["aws_access_key_id"]);
150
151 let gh = "token ghp_0123456789abcdefghijklmnopqrstuvwxyzABC pasted";
152 assert_eq!(credential_shaped_content(gh), vec!["github_token"]);
153 }
154
155 #[test]
156 fn detects_sk_slack_jwt_and_assignments() {
157 let sk = "key: sk-proj0123456789abcdefghijklmnopqrstuv";
158 assert_eq!(credential_shaped_content(sk), vec!["openai_style_key"]);
159
160 let slack = format!(
164 "xoxb-{}-{}-{}",
165 "123456789012", "1234567890123", "abcdefghijklmnop"
166 );
167 assert_eq!(credential_shaped_content(&slack), vec!["slack_token"]);
168
169 let jwt = "header eyJhbGciOiJIUzI1NiJ9.payload eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig";
170 assert_eq!(credential_shaped_content(jwt), vec!["jwt"]);
171
172 let assign = "connect with DATABASE_PASSWORD=correct-horse-battery-staple-1 tomorrow";
173 assert_eq!(
174 credential_shaped_content(assign),
175 vec!["credential_assignment"]
176 );
177 }
178
179 #[test]
180 fn ordinary_prose_stays_clean() {
181 assert!(
182 credential_shaped_content("remember that the password policy requires rotation")
183 .is_empty()
184 );
185 assert!(credential_shaped_content("api_key rotation happens quarterly").is_empty());
186 assert!(credential_shaped_content("short token: abc123").is_empty());
187 assert!(
188 credential_shaped_content("the sk- prefix marks OpenAI keys in general").is_empty()
189 );
190 assert!(credential_shaped_content("AKIA is the AWS key prefix").is_empty());
191 assert!(credential_shaped_content("we discussed jwt sessions at length").is_empty());
192 }
193
194 #[test]
195 fn dedupes_kinds() {
196 let both = "AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPLE again";
197 assert_eq!(credential_shaped_content(both), vec!["aws_access_key_id"]);
198 }
199}