laminar_connectors/
security.rs1const REDACTED: &str = "<redacted>";
4
5fn normalized_key(key: &str) -> String {
6 key.to_ascii_lowercase().replace(['.', '-'], "_")
7}
8
9#[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#[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#[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#[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#[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}