1pub const DEFAULT_SECRET_KEY_NAMES: &[&str] = &[
5 "password",
6 "passwd",
7 "pwd",
8 "token",
9 "secret",
10 "credential",
11 "credentials",
12 "apikey",
13 "api_key",
14 "auth",
15 "authorization",
16 "auth_token",
17 "access_token",
18 "refresh_token",
19 "client_secret",
20];
21
22#[derive(Debug, Clone, Eq, PartialEq)]
29pub struct SecretKeyMatcher {
30 names: Vec<String>,
31}
32
33impl Default for SecretKeyMatcher {
34 fn default() -> Self {
35 Self::new(DEFAULT_SECRET_KEY_NAMES)
36 }
37}
38
39impl SecretKeyMatcher {
40 #[must_use]
42 pub fn new(names: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
43 let names = names
44 .into_iter()
45 .filter_map(|name| normalize_name(name.as_ref()))
46 .collect();
47 Self { names }
48 }
49
50 #[must_use]
52 pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
53 if let Some(name) = normalize_name(name.as_ref())
54 && !self.names.iter().any(|existing| existing == &name)
55 {
56 self.names.push(name);
57 }
58 self
59 }
60
61 #[must_use]
63 pub fn with_names(mut self, names: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
64 for name in names {
65 self = self.with_name(name);
66 }
67 self
68 }
69
70 #[must_use]
72 pub fn is_secret_key(&self, name: &str) -> bool {
73 let Some(normalized) = normalize_name(name) else {
74 return false;
75 };
76
77 self.names
78 .iter()
79 .any(|secret| normalized == secret.as_str() || has_secret_suffix(&normalized, secret))
80 }
81}
82
83fn has_secret_suffix(normalized: &str, secret: &str) -> bool {
84 normalized
85 .strip_suffix(secret)
86 .is_some_and(|prefix| prefix.ends_with('_'))
87}
88
89fn normalize_name(name: &str) -> Option<String> {
90 let normalized = name
91 .trim_start_matches('-')
92 .replace('-', "_")
93 .to_ascii_lowercase();
94
95 (!normalized.is_empty()).then_some(normalized)
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn default_matches_common_secret_key_names() {
104 let matcher = SecretKeyMatcher::default();
105
106 assert!(matcher.is_secret_key("--token"));
107 assert!(matcher.is_secret_key("--auth-token"));
108 assert!(matcher.is_secret_key("db_password"));
109 assert!(matcher.is_secret_key("CLIENT_SECRET"));
110 }
111
112 #[test]
113 fn default_does_not_match_non_secret_substrings() {
114 let matcher = SecretKeyMatcher::default();
115
116 assert!(!matcher.is_secret_key("--author"));
117 assert!(!matcher.is_secret_key("tokenizer"));
118 }
119
120 #[test]
121 fn custom_names_extend_matching() {
122 let matcher = SecretKeyMatcher::default().with_name("license-key");
123
124 assert!(matcher.is_secret_key("--license-key"));
125 assert!(matcher.is_secret_key("vendor_license_key"));
126 }
127}