Skip to main content

rskit_util/
sensitive.rs

1//! Matching helpers for names that commonly carry secret values.
2
3/// Default key names that should usually be treated as secret-bearing.
4pub 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/// Matches normalized key names that commonly carry secret values.
23///
24/// Names are compared case-insensitively after trimming leading dashes and
25/// normalizing `-` to `_`. A key matches when it equals a configured name or
26/// ends with `_<name>`, allowing names such as `db_password` while avoiding
27/// false positives such as `author`.
28#[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    /// Create a matcher from a set of secret-bearing names.
41    #[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    /// Add another secret-bearing key name.
51    #[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    /// Add multiple secret-bearing key names.
62    #[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    /// Return true when `name` should be treated as secret-bearing.
71    #[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}