1use regex::Regex;
7use std::sync::OnceLock;
8
9pub struct SecretPattern {
10 pub name: &'static str,
11 pub re: Regex,
12}
13
14const PATTERNS: &[(&str, &str)] = &[
15 ("private key", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----"),
16 ("AWS access key id", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
17 ("GitHub token", r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b"),
18 ("GitLab token", r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
19 ("Slack token", r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
20 ("Google API key", r"\bAIza[0-9A-Za-z_-]{35}\b"),
21 ("Stripe live key", r"\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b"),
22 ("npm token", r"\bnpm_[A-Za-z0-9]{36}\b"),
23 ("Anthropic API key", r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"),
24 ("OpenAI API key", r"\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}\b"),
25 ("Hugging Face token", r"\bhf_[A-Za-z]{34}\b"),
26 (
27 CONNECTION,
28 r#"\b[A-Za-z][A-Za-z0-9+.-]{1,30}://([^\s:/?#@'"<>\[\]]+):([^\s/?#@'"<>]{4,})@(\[[0-9A-Fa-f:.]+\]|[^\s/?#@:'"<>]+)"#,
29 ),
30 ("JWT", r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}"),
31];
32
33const CONNECTION: &str = "connection string with a password";
34
35const JWT_SAMPLE_SIGNATURE: &str = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
37
38fn example_host(host: &str) -> bool {
40 let h = host.trim_end_matches('.').to_ascii_lowercase();
41 let h = h.trim_start_matches('[').trim_end_matches(']');
42 let under = |d: &str| h == d || h.ends_with(&format!(".{d}"));
43 ["example.com", "example.org", "example.net", "example", "test", "invalid", "localhost"].iter().any(|d| under(d))
44 || h.starts_with("127.")
45 || h == "0.0.0.0"
46 || h == "::1"
47}
48
49fn placeholder(pw: &str, user: &str) -> bool {
52 const WORDS: &[&str] = &[
53 "password",
54 "passwd",
55 "pass",
56 "pwd",
57 "changeme",
58 "change_me",
59 "change-me",
60 "secret",
61 "example",
62 "placeholder",
63 "dummy",
64 "redacted",
65 "your_password",
66 "yourpassword",
67 "your-password",
68 "mypassword",
69 "my_password",
70 "pass123",
71 ];
72 let l = pw.to_ascii_lowercase();
73 let mut chars = pw.chars();
74 let first = chars.next().unwrap_or(' ');
75 pw.starts_with('$')
76 || pw.starts_with("{{")
77 || pw.starts_with('<')
78 || (pw.starts_with('%') && pw.ends_with('%'))
79 || chars.all(|c| c == first)
80 || WORDS.contains(&l.as_str())
81 || l == user.to_ascii_lowercase()
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Hit {
87 pub start: usize,
88 pub end: usize,
89 pub name: &'static str,
90}
91
92pub fn find(text: &str) -> Vec<Hit> {
94 let mut out = vec![];
95 for p in all() {
96 for c in p.re.captures_iter(text) {
97 let m = c.get(0).expect("group 0 is the match");
98 let keep = match p.name {
99 CONNECTION => {
100 let (user, pw, host) = (&c[1], &c[2], &c[3]);
101 !example_host(host) && !placeholder(pw, user)
102 }
103 "JWT" => !m.as_str().ends_with(JWT_SAMPLE_SIGNATURE),
104 _ => true,
105 };
106 if keep {
107 out.push(Hit { start: m.start(), end: m.end(), name: p.name });
108 }
109 }
110 }
111 out.sort_by_key(|h| (h.start, std::cmp::Reverse(h.end)));
112 out
113}
114
115pub fn redact(text: &str) -> (String, usize) {
117 let hits = find(text);
118 if hits.is_empty() {
119 return (text.to_string(), 0);
120 }
121 let (mut out, mut at, mut n) = (String::with_capacity(text.len()), 0, 0);
122 for h in &hits {
123 if h.start < at {
124 continue;
125 }
126 out.push_str(&text[at..h.start]);
127 out.push_str(&format!("{MARKER}{}]", h.name));
128 at = h.end;
129 n += 1;
130 }
131 out.push_str(&text[at..]);
132 (out, n)
133}
134
135pub const MARKER: &str = "[redacted: ";
137
138pub fn all() -> &'static [SecretPattern] {
139 static P: OnceLock<Vec<SecretPattern>> = OnceLock::new();
140 P.get_or_init(|| {
141 PATTERNS.iter().filter_map(|(name, re)| Regex::new(re).ok().map(|re| SecretPattern { name, re })).collect()
142 })
143}
144
145pub fn scanned_attachments(root: &std::path::Path, d: &crate::model::Doc, max: u64) -> Vec<String> {
149 attachment_scan(root, d, max).0
150}
151
152pub fn attachment_scan(root: &std::path::Path, d: &crate::model::Doc, max: u64) -> (Vec<String>, Vec<String>) {
155 let base = d.base_dir();
156 let mut paths: Vec<String> = d.bundle_files.clone();
157 for e in d.files().into_iter().filter(|e| e.store != "store") {
158 let p = if base.is_empty() { e.path } else { format!("{base}/{}", e.path) };
159 if !paths.contains(&p) {
160 paths.push(p);
161 }
162 }
163 let (mut read, mut skipped) = (vec![], vec![]);
164 for p in paths {
165 let full = root.join(&p);
166 let Ok(meta) = std::fs::metadata(&full) else { continue };
167 if !meta.is_file() {
168 continue;
169 }
170 if meta.len() > max {
171 skipped.push(p);
172 continue;
173 }
174 let Ok(bytes) = std::fs::read(&full) else { continue };
175 if bytes[..bytes.len().min(8192)].contains(&0) {
176 skipped.push(p);
177 } else {
178 read.push(p);
179 }
180 }
181 (read, skipped)
182}
183
184pub fn skipped_note(root: &std::path::Path, skipped: &[String], max: u64) -> Option<String> {
187 if skipped.is_empty() {
188 return None;
189 }
190 let list = |v: &[&String]| {
191 let mut names: Vec<&str> = v.iter().map(|s| s.as_str()).take(5).collect();
192 names.sort_unstable();
193 let more = v.len().saturating_sub(5);
194 let more = if more > 0 { format!(" (+{more} more)") } else { String::new() };
195 format!("{}{more}", names.join(", "))
196 };
197 let (big, binary): (Vec<&String>, Vec<&String>) =
198 skipped.iter().partition(|p| std::fs::metadata(root.join(p)).is_ok_and(|m| m.len() > max));
199 let mut parts = vec![];
200 if !big.is_empty() {
201 parts.push(format!(
202 "{} over [files] scan_max_bytes: {} (raise scan_max_bytes in spec.toml to read them)",
203 big.len(),
204 list(&big)
205 ));
206 }
207 if !binary.is_empty() {
208 parts.push(format!("{} binary: {}", binary.len(), list(&binary)));
209 }
210 Some(format!("{} attachment(s) not scanned for secrets: {}", skipped.len(), parts.join("; ")))
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
220 fn detects_known_formats_only() {
221 let hit = |s: &str| all().iter().find(|p| p.re.is_match(s)).map(|p| p.name);
222 assert_eq!(hit(&format!("-----BEGIN OPENSSH {}-----", "PRIVATE KEY")), Some("private key"));
223 assert_eq!(hit(&format!("key {}{} here", "AK", "IA".to_string() + &"X".repeat(16))), Some("AWS access key id"));
224 assert_eq!(hit(&format!("{}_{}", "ghp", "a".repeat(36))), Some("GitHub token"));
225 assert_eq!(hit("the word secret and sk_test_123"), None);
226 }
227
228 fn names(s: &str) -> Vec<&'static str> {
229 find(s).into_iter().map(|h| h.name).collect()
230 }
231
232 #[test]
233 fn connection_strings_with_real_hosts_hit() {
234 let pw = format!("{}{}", "s3cr", "Et-77x");
235 for scheme in ["postgres", "postgresql", "mysql", "mongodb+srv", "redis", "amqp"] {
236 let s = format!("url = \"{scheme}://app:{pw}@db.internal.acme.io:5432/main\"");
237 assert_eq!(names(&s), [CONNECTION], "{s}");
238 }
239 assert_eq!(names(&format!("redis:{}default:{pw}@10.0.4.2:6379", "//")), [CONNECTION]);
240 }
241
242 #[test]
243 fn connection_string_false_positives_pass() {
244 let must_pass = [
245 "postgres://app:hunter2x@example.com/db",
246 "postgres://app:hunter2x@db.example.org/db",
247 "mysql://root:hunter2x@localhost:3306/x",
248 "mysql://root:hunter2x@127.0.0.1/x",
249 "redis://u:hunter2x@[::1]:6379",
250 "redis://u:hunter2x@cache.test",
251 "mongodb://u:hunter2x@db.invalid",
252 "postgres://app:${DB_PASSWORD}@db.acme.io/x",
253 "postgres://app:$DB_PASSWORD@db.acme.io/x",
254 "postgres://app:{{ db_password }}@db.acme.io/x",
255 "postgres://app:<password>@db.acme.io/x",
256 "postgres://app:%PGPASS%@db.acme.io/x",
257 "postgres://user:password@db.acme.io/x",
258 "postgres://user:changeme@db.acme.io/x",
259 "postgres://postgres:postgres@db.acme.io/x",
260 "postgres://u:xxxx@db.acme.io/x",
261 "postgres://u:****@db.acme.io/x",
262 "postgres://u:abc@db.acme.io/x",
263 "postgres://app@db.acme.io/x",
264 "https://db.acme.io/x?u=a:b@c",
265 "see a:b@c and user@example.com",
266 ];
267 for s in must_pass {
268 assert!(names(s).is_empty(), "{s} -> {:?}", names(s));
269 }
270 }
271
272 #[test]
273 fn jwt_hits_but_not_the_jwt_io_sample() {
274 let part = |n: usize| "a".repeat(n);
275 let jwt =
276 format!("{}{}.{}{}.{}", "ey", "J".to_string() + &part(20), "ey", "J".to_string() + &part(30), part(43));
277 assert_eq!(names(&format!("Authorization: Bearer {jwt}")), ["JWT"]);
278 let sample = format!(
279 "{}{}.{}{}.{JWT_SAMPLE_SIGNATURE}",
280 "ey",
281 "J".to_string() + &part(20),
282 "ey",
283 "J".to_string() + &part(30)
284 );
285 assert!(names(&sample).is_empty());
286 assert!(names("eyJhbGciOi only one part").is_empty());
287 }
288
289 #[test]
290 fn redact_replaces_each_hit() {
291 let pw = format!("{}{}", "s3cr", "Et-77x");
292 let t = format!("a postgres:{}app:{pw}@db.acme.io/x b {}_{} c", "//", "ghp", "a".repeat(36));
293 let (r, n) = redact(&t);
294 assert_eq!(n, 2);
295 assert_eq!(r, format!("a {MARKER}{CONNECTION}]/x b {MARKER}GitHub token] c"));
296 assert_eq!(redact("nothing here"), ("nothing here".to_string(), 0));
297 }
298}