Skip to main content

laminar_connectors/security/
mod.rs

1//! Shared secret classification for catalog and checkpoint metadata.
2
3use 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/// Whether `value` is one exact environment reference without a default.
12#[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/// Whether a connector option name conventionally carries credential material.
28#[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/// Whether a URI query key conventionally carries a signature or credential.
70#[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/// Whether any URI in a comma/whitespace-delimited connector value embeds literal credentials.
124#[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/// Return a stable connector identity value with credential material removed.
199#[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;