Skip to main content

laminar_connectors/
security.rs

1//! Shared secret classification for catalog and checkpoint metadata.
2
3const REDACTED: &str = "<redacted>";
4
5fn normalized_key(key: &str) -> String {
6    key.to_ascii_lowercase().replace(['.', '-'], "_")
7}
8
9/// Whether `value` is one exact environment reference without a default.
10#[must_use]
11pub fn is_env_reference(value: &str) -> bool {
12    let Some(variable) = value
13        .strip_prefix("${")
14        .and_then(|value| value.strip_suffix('}'))
15    else {
16        return false;
17    };
18    let mut chars = variable.chars();
19    chars
20        .next()
21        .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
22        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
23}
24
25/// Whether a connector option name conventionally carries credential material.
26#[must_use]
27pub fn is_secret_option_key(key: &str) -> bool {
28    let key = normalized_key(key);
29    key.contains("password")
30        || key.contains("passwd")
31        || key.contains("secret")
32        || key == "token"
33        || key.ends_with("_token")
34        || key.contains("credential")
35        || key.contains("private_key")
36        || key.contains("sasl_jaas")
37        || key.contains("oauthbearer")
38        || key.contains("api_key")
39        || key.contains("account_key")
40        || key.contains("session_key")
41}
42
43fn percent_decode_key(key: &str) -> String {
44    let bytes = key.as_bytes();
45    let mut decoded = Vec::with_capacity(bytes.len());
46    let mut index = 0;
47    while index < bytes.len() {
48        if bytes[index] == b'%' && index + 2 < bytes.len() {
49            let hex = |byte: u8| match byte {
50                b'0'..=b'9' => Some(byte - b'0'),
51                b'a'..=b'f' => Some(byte - b'a' + 10),
52                b'A'..=b'F' => Some(byte - b'A' + 10),
53                _ => None,
54            };
55            if let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2])) {
56                decoded.push((high << 4) | low);
57                index += 3;
58                continue;
59            }
60        }
61        decoded.push(bytes[index]);
62        index += 1;
63    }
64    String::from_utf8_lossy(&decoded).into_owned()
65}
66
67/// Whether a URI query key conventionally carries a signature or credential.
68#[must_use]
69pub fn is_sensitive_uri_query_key(key: &str) -> bool {
70    let key = normalized_key(&percent_decode_key(key));
71    key == "sig"
72        || key.contains("signature")
73        || key.contains("password")
74        || key.contains("token")
75        || key.contains("secret")
76        || key.contains("credential")
77        || key == "key"
78        || key.ends_with("_key")
79}
80
81fn uri_segment_contains_secret(segment: &str, allow_reference: bool) -> bool {
82    let Some((_, after_scheme)) = segment.split_once("://") else {
83        return false;
84    };
85    let authority = after_scheme
86        .split(['/', '?', '#'])
87        .next()
88        .unwrap_or_default();
89    if let Some((userinfo, _)) = authority.rsplit_once('@') {
90        let secret = userinfo
91            .split_once(':')
92            .map_or(userinfo, |(_, password)| password);
93        if !(secret.is_empty() || allow_reference && is_env_reference(secret)) {
94            return true;
95        }
96    }
97    after_scheme
98        .split_once('?')
99        .map(|(_, query)| query.split('#').next().unwrap_or_default())
100        .into_iter()
101        .flat_map(|query| query.split(['&', ';']))
102        .filter_map(|parameter| parameter.split_once('='))
103        .any(|(key, secret)| {
104            is_sensitive_uri_query_key(key)
105                && !secret.is_empty()
106                && !(allow_reference && is_env_reference(secret))
107        })
108}
109
110/// Whether any URI in a comma/whitespace-delimited connector value embeds literal credentials.
111#[must_use]
112pub fn value_contains_uri_secret(value: &str, allow_reference: bool) -> bool {
113    value
114        .split(|ch: char| ch == ',' || ch.is_ascii_whitespace())
115        .filter(|segment| segment.contains("://"))
116        .any(|segment| uri_segment_contains_secret(segment, allow_reference))
117}
118
119fn sanitize_query(query: &str) -> String {
120    let mut output = String::with_capacity(query.len());
121    let mut start = 0;
122    for (index, separator) in query.match_indices(['&', ';']) {
123        sanitize_parameter(&query[start..index], &mut output);
124        output.push_str(separator);
125        start = index + separator.len();
126    }
127    sanitize_parameter(&query[start..], &mut output);
128    output
129}
130
131fn sanitize_parameter(parameter: &str, output: &mut String) {
132    let Some((key, value)) = parameter.split_once('=') else {
133        output.push_str(parameter);
134        return;
135    };
136    output.push_str(key);
137    output.push('=');
138    if is_sensitive_uri_query_key(key) && !value.is_empty() {
139        output.push_str(REDACTED);
140    } else {
141        output.push_str(value);
142    }
143}
144
145fn sanitize_uri_segment(segment: &str) -> String {
146    let Some(scheme_end) = segment.find("://").map(|index| index + 3) else {
147        return segment.to_string();
148    };
149    let authority_end = segment[scheme_end..]
150        .find(['/', '?', '#'])
151        .map_or(segment.len(), |index| scheme_end + index);
152    let authority = &segment[scheme_end..authority_end];
153    let mut output = String::with_capacity(segment.len());
154    output.push_str(&segment[..scheme_end]);
155    if let Some((_, host)) = authority.rsplit_once('@') {
156        output.push_str(REDACTED);
157        output.push('@');
158        output.push_str(host);
159    } else {
160        output.push_str(authority);
161    }
162
163    let suffix = &segment[authority_end..];
164    let Some(query_start) = suffix.find('?') else {
165        output.push_str(suffix);
166        return output;
167    };
168    output.push_str(&suffix[..=query_start]);
169    let query_and_fragment = &suffix[query_start + 1..];
170    let (query, fragment) = query_and_fragment
171        .split_once('#')
172        .map_or((query_and_fragment, None), |(query, fragment)| {
173            (query, Some(fragment))
174        });
175    output.push_str(&sanitize_query(query));
176    if let Some(fragment) = fragment {
177        output.push('#');
178        output.push_str(fragment);
179    }
180    output
181}
182
183/// Return a stable connector identity value with credential material removed.
184#[must_use]
185pub fn sanitize_identity_value(key: &str, value: &str) -> String {
186    if is_secret_option_key(key) {
187        return REDACTED.to_string();
188    }
189    let mut output = String::with_capacity(value.len());
190    let mut start = 0;
191    for (index, delimiter) in value
192        .char_indices()
193        .filter(|(_, ch)| *ch == ',' || ch.is_ascii_whitespace())
194    {
195        output.push_str(&sanitize_uri_segment(&value[start..index]));
196        output.push(delimiter);
197        start = index + delimiter.len_utf8();
198    }
199    output.push_str(&sanitize_uri_segment(&value[start..]));
200    output
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn uri_secret_detection_covers_userinfo_lists_and_sas_signatures() {
209        assert!(value_contains_uri_secret(
210            "wss://public.test, wss://user:pass@private.test",
211            true
212        ));
213        assert!(value_contains_uri_secret(
214            "https://blob.test/file?sv=1&sig=signed",
215            true
216        ));
217        assert!(!value_contains_uri_secret(
218            "mongodb://user:${MONGO_PASSWORD}@db.test/data",
219            true
220        ));
221    }
222
223    #[test]
224    fn oauth_bearer_options_are_classified_without_provider_specific_callers() {
225        assert!(is_secret_option_key("sasl.oauthbearer.config"));
226        assert!(is_secret_option_key("oauthbearer-token"));
227    }
228
229    #[test]
230    fn durable_identity_preserves_endpoint_but_removes_uri_credentials() {
231        let sanitized = sanitize_identity_value(
232            "connection.uri",
233            "mongodb://user:pass@db.test/data?replicaSet=rs0&token=abc \
234             mongodb://next:secret@db2.test/data",
235        );
236        assert_eq!(
237            sanitized,
238            "mongodb://<redacted>@db.test/data?replicaSet=rs0&token=<redacted> \
239             mongodb://<redacted>@db2.test/data"
240        );
241        assert!(!sanitized.contains("user"));
242        assert!(!sanitized.contains("pass"));
243        assert!(!sanitized.contains("abc"));
244    }
245}