laminar_connectors/security/
mod.rs1use laminar_core::storage_location::{StorageLocation, StorageProvider};
4
5const REDACTED: &str = "<redacted>";
6
7fn normalized_key(key: &str) -> String {
8 key.to_ascii_lowercase().replace(['.', '-'], "_")
9}
10
11#[must_use]
13pub fn is_env_reference(value: &str) -> bool {
14 let Some(variable) = value
15 .strip_prefix("${")
16 .and_then(|value| value.strip_suffix('}'))
17 else {
18 return false;
19 };
20 let mut chars = variable.chars();
21 chars
22 .next()
23 .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
24 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
25}
26
27#[must_use]
29pub fn is_secret_option_key(key: &str) -> bool {
30 let key = normalized_key(key);
31 key.contains("password")
32 || key.contains("passwd")
33 || key.contains("secret")
34 || key == "token"
35 || key.ends_with("_token")
36 || key.contains("credential")
37 || key.contains("private_key")
38 || key.contains("sasl_jaas")
39 || key.contains("oauthbearer")
40 || key.contains("api_key")
41 || key.contains("account_key")
42 || key.contains("session_key")
43}
44
45fn percent_decode_key(key: &str) -> String {
46 let bytes = key.as_bytes();
47 let mut decoded = Vec::with_capacity(bytes.len());
48 let mut index = 0;
49 while index < bytes.len() {
50 if bytes[index] == b'%' && index + 2 < bytes.len() {
51 let hex = |byte: u8| match byte {
52 b'0'..=b'9' => Some(byte - b'0'),
53 b'a'..=b'f' => Some(byte - b'a' + 10),
54 b'A'..=b'F' => Some(byte - b'A' + 10),
55 _ => None,
56 };
57 if let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2])) {
58 decoded.push((high << 4) | low);
59 index += 3;
60 continue;
61 }
62 }
63 decoded.push(bytes[index]);
64 index += 1;
65 }
66 String::from_utf8_lossy(&decoded).into_owned()
67}
68
69#[must_use]
71pub fn is_sensitive_uri_query_key(key: &str) -> bool {
72 let key = normalized_key(&percent_decode_key(key));
73 key == "sig"
74 || key.contains("signature")
75 || key.contains("password")
76 || key.contains("token")
77 || key.contains("secret")
78 || key.contains("credential")
79 || key == "key"
80 || key.ends_with("_key")
81}
82
83fn uri_segment_contains_secret(segment: &str, allow_reference: bool) -> bool {
84 let Some((_, after_scheme)) = segment.split_once("://") else {
85 return false;
86 };
87 let authority = after_scheme
88 .split(['/', '?', '#'])
89 .next()
90 .unwrap_or_default();
91 if !is_qualified_azure_location(segment) {
92 if let Some((userinfo, _)) = authority.rsplit_once('@') {
93 let secret = userinfo
94 .split_once(':')
95 .map_or(userinfo, |(_, password)| password);
96 if !(secret.is_empty() || allow_reference && is_env_reference(secret)) {
97 return true;
98 }
99 }
100 }
101 after_scheme
102 .split_once('?')
103 .map(|(_, query)| query.split('#').next().unwrap_or_default())
104 .into_iter()
105 .flat_map(|query| query.split(['&', ';']))
106 .filter_map(|parameter| parameter.split_once('='))
107 .any(|(key, secret)| {
108 is_sensitive_uri_query_key(key)
109 && !secret.is_empty()
110 && !(allow_reference && is_env_reference(secret))
111 })
112}
113
114fn is_qualified_azure_location(segment: &str) -> bool {
115 let location = segment
116 .find(['?', '#'])
117 .map_or(segment, |index| &segment[..index]);
118 StorageLocation::parse(location).is_ok_and(|location| {
119 location.provider == StorageProvider::AzureAdls && location.filesystem.is_some()
120 })
121}
122
123#[must_use]
125pub fn value_contains_uri_secret(value: &str, allow_reference: bool) -> bool {
126 value
127 .split(|ch: char| ch == ',' || ch.is_ascii_whitespace())
128 .filter(|segment| segment.contains("://"))
129 .any(|segment| uri_segment_contains_secret(segment, allow_reference))
130}
131
132fn sanitize_query(query: &str) -> String {
133 let mut output = String::with_capacity(query.len());
134 let mut start = 0;
135 for (index, separator) in query.match_indices(['&', ';']) {
136 sanitize_parameter(&query[start..index], &mut output);
137 output.push_str(separator);
138 start = index + separator.len();
139 }
140 sanitize_parameter(&query[start..], &mut output);
141 output
142}
143
144fn sanitize_parameter(parameter: &str, output: &mut String) {
145 let Some((key, value)) = parameter.split_once('=') else {
146 output.push_str(parameter);
147 return;
148 };
149 output.push_str(key);
150 output.push('=');
151 if is_sensitive_uri_query_key(key) && !value.is_empty() {
152 output.push_str(REDACTED);
153 } else {
154 output.push_str(value);
155 }
156}
157
158fn sanitize_uri_segment(segment: &str) -> String {
159 let Some(scheme_end) = segment.find("://").map(|index| index + 3) else {
160 return segment.to_string();
161 };
162 let authority_end = segment[scheme_end..]
163 .find(['/', '?', '#'])
164 .map_or(segment.len(), |index| scheme_end + index);
165 let authority = &segment[scheme_end..authority_end];
166 let mut output = String::with_capacity(segment.len());
167 output.push_str(&segment[..scheme_end]);
168 if is_qualified_azure_location(segment) {
169 output.push_str(authority);
170 } else if let Some((_, host)) = authority.rsplit_once('@') {
171 output.push_str(REDACTED);
172 output.push('@');
173 output.push_str(host);
174 } else {
175 output.push_str(authority);
176 }
177
178 let suffix = &segment[authority_end..];
179 let (suffix, had_fragment) = suffix
180 .split_once('#')
181 .map_or((suffix, false), |(before, _)| (before, true));
182 let Some(query_start) = suffix.find('?') else {
183 output.push_str(suffix);
184 if had_fragment {
185 output.push_str("#<redacted>");
186 }
187 return output;
188 };
189 output.push_str(&suffix[..=query_start]);
190 let query = &suffix[query_start + 1..];
191 output.push_str(&sanitize_query(query));
192 if had_fragment {
193 output.push_str("#<redacted>");
194 }
195 output
196}
197
198#[must_use]
200pub fn sanitize_identity_value(key: &str, value: &str) -> String {
201 if is_secret_option_key(key) {
202 return REDACTED.to_string();
203 }
204 let mut output = String::with_capacity(value.len());
205 let mut start = 0;
206 for (index, delimiter) in value
207 .char_indices()
208 .filter(|(_, ch)| *ch == ',' || ch.is_ascii_whitespace())
209 {
210 output.push_str(&sanitize_uri_segment(&value[start..index]));
211 output.push(delimiter);
212 start = index + delimiter.len_utf8();
213 }
214 output.push_str(&sanitize_uri_segment(&value[start..]));
215 output
216}
217
218#[cfg(test)]
219mod tests;