browser_commander/traces/
redaction.rs1use std::fmt;
9use std::sync::Arc;
10
11use regex::{NoExpand, Regex};
12use url::{form_urlencoded, Url};
13
14use super::jsonfmt::{Json, JsonObject};
15
16pub const REDACTED: &str = "[redacted]";
18
19pub const DEFAULT_REDACT_SELECTORS: [&str; 3] =
21 ["input[type=password]", "[data-private]", "[data-bc-redact]"];
22
23pub const DEFAULT_REDACT_ATTRIBUTES: [&str; 7] = [
25 "authorization",
26 "proxy-authorization",
27 "cookie",
28 "set-cookie",
29 "x-api-key",
30 "x-auth-token",
31 "x-csrf-token",
32];
33
34pub const DEFAULT_REDACT_QUERY_PARAMS: [&str; 12] = [
36 "access_token",
37 "api_key",
38 "apikey",
39 "auth",
40 "code",
41 "id_token",
42 "password",
43 "refresh_token",
44 "secret",
45 "session",
46 "signature",
47 "token",
48];
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct RedactContext<'a> {
53 pub kind: &'a str,
55 pub name: Option<&'a str>,
57 pub value: &'a str,
59}
60
61pub type RedactCallback = Arc<dyn Fn(&RedactContext<'_>) -> Option<String> + Send + Sync>;
63
64#[derive(Clone)]
66pub struct TracePrivacyOptions {
67 pub redact_selectors: Vec<String>,
69 pub redact_attributes: Vec<String>,
71 pub redact_query_params: Vec<String>,
73 pub redact_patterns: Vec<String>,
75 pub redact: Option<RedactCallback>,
77 pub use_defaults: bool,
79}
80
81impl Default for TracePrivacyOptions {
82 fn default() -> Self {
83 Self {
84 redact_selectors: Vec::new(),
85 redact_attributes: Vec::new(),
86 redact_query_params: Vec::new(),
87 redact_patterns: Vec::new(),
88 redact: None,
89 use_defaults: true,
90 }
91 }
92}
93
94impl fmt::Debug for TracePrivacyOptions {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.debug_struct("TracePrivacyOptions")
97 .field("redact_selectors", &self.redact_selectors)
98 .field("redact_attributes", &self.redact_attributes)
99 .field("redact_query_params", &self.redact_query_params)
100 .field("redact_patterns", &self.redact_patterns)
101 .field("redact", &self.redact.as_ref().map(|_| "<callback>"))
102 .field("use_defaults", &self.use_defaults)
103 .finish()
104 }
105}
106
107#[derive(Clone)]
109pub struct NormalizedPrivacy {
110 pub redact_selectors: Vec<String>,
112 pub redact_attributes: Vec<String>,
114 pub redact_query_params: Vec<String>,
116 pub redact_patterns: Vec<Regex>,
118 pub redact: Option<RedactCallback>,
120}
121
122impl fmt::Debug for NormalizedPrivacy {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 f.debug_struct("NormalizedPrivacy")
125 .field("redact_selectors", &self.redact_selectors)
126 .field("redact_attributes", &self.redact_attributes)
127 .field("redact_query_params", &self.redact_query_params)
128 .field("redact_patterns", &self.redact_patterns)
129 .field("redact", &self.redact.as_ref().map(|_| "<callback>"))
130 .finish()
131 }
132}
133
134impl Default for NormalizedPrivacy {
135 fn default() -> Self {
136 normalize_privacy_options(&TracePrivacyOptions::default())
137 .expect("the default privacy options have no patterns to fail")
138 }
139}
140
141fn lower_set(defaults: &[&str], use_defaults: bool, extra: &[String]) -> Vec<String> {
142 let mut seen = Vec::<String>::new();
143 let defaults = defaults.iter().copied().filter(|_| use_defaults);
144 for value in defaults.chain(extra.iter().map(String::as_str)) {
145 let lowered = value.to_lowercase();
146 if !seen.contains(&lowered) {
147 seen.push(lowered);
148 }
149 }
150 seen
151}
152
153pub fn normalize_privacy_options(
159 privacy: &TracePrivacyOptions,
160) -> Result<NormalizedPrivacy, regex::Error> {
161 let use_defaults = privacy.use_defaults;
162 Ok(NormalizedPrivacy {
163 redact_selectors: lower_set(
164 &DEFAULT_REDACT_SELECTORS,
165 use_defaults,
166 &privacy.redact_selectors,
167 ),
168 redact_attributes: lower_set(
169 &DEFAULT_REDACT_ATTRIBUTES,
170 use_defaults,
171 &privacy.redact_attributes,
172 ),
173 redact_query_params: lower_set(
174 &DEFAULT_REDACT_QUERY_PARAMS,
175 use_defaults,
176 &privacy.redact_query_params,
177 ),
178 redact_patterns: privacy
179 .redact_patterns
180 .iter()
181 .map(|pattern| Regex::new(pattern))
182 .collect::<Result<_, _>>()?,
183 redact: privacy.redact.clone(),
184 })
185}
186
187pub fn redact_text(
189 value: &str,
190 privacy: &NormalizedPrivacy,
191 kind: &str,
192 name: Option<&str>,
193) -> String {
194 if value.is_empty() {
195 return String::new();
196 }
197 let mut text = value.to_string();
198 for pattern in &privacy.redact_patterns {
199 text = pattern.replace_all(&text, NoExpand(REDACTED)).into_owned();
200 }
201 if let Some(callback) = &privacy.redact {
202 let context = RedactContext {
203 kind,
204 name,
205 value: &text,
206 };
207 if let Some(replaced) = callback(&context) {
208 text = replaced;
209 }
210 }
211 text
212}
213
214fn redact_params(body: &str, privacy: &NormalizedPrivacy) -> Option<String> {
217 let mut pairs: Vec<(String, String)> = form_urlencoded::parse(body.as_bytes())
218 .map(|(key, value)| (key.into_owned(), value.into_owned()))
219 .collect();
220 let keys: Vec<String> = pairs.iter().map(|(key, _)| key.clone()).collect();
221 let mut changed = false;
222 for key in keys {
223 if !privacy.redact_query_params.contains(&key.to_lowercase()) {
224 continue;
225 }
226 changed = true;
227 let mut found = false;
228 pairs.retain_mut(|(name, value)| {
229 if *name != key {
230 return true;
231 }
232 if found {
233 return false;
234 }
235 found = true;
236 *value = REDACTED.to_string();
237 true
238 });
239 if !found {
240 pairs.push((key, REDACTED.to_string()));
241 }
242 }
243 if !changed {
244 return None;
245 }
246 Some(
247 form_urlencoded::Serializer::new(String::new())
248 .extend_pairs(pairs.iter())
249 .finish(),
250 )
251}
252
253pub fn redact_url(url: &str, privacy: &NormalizedPrivacy) -> String {
257 if url.is_empty() {
258 return String::new();
259 }
260 let mut text = url.to_string();
261 if let Ok(mut parsed) = Url::parse(url) {
262 let has_user = !parsed.username().is_empty();
263 let has_password = parsed
264 .password()
265 .is_some_and(|password| !password.is_empty());
266 if has_user || has_password {
267 let _ = parsed.set_username(if has_user { REDACTED } else { "" });
268 let _ = parsed.set_password(if has_password { Some(REDACTED) } else { None });
269 }
270 if let Some(query) = parsed.query().map(str::to_string) {
271 if let Some(redacted) = redact_params(&query, privacy) {
272 parsed.set_query(Some(&redacted));
273 }
274 }
275 if let Some(fragment) = parsed.fragment().map(str::to_string) {
276 if !fragment.is_empty() {
277 let body = fragment.strip_prefix('?').unwrap_or(&fragment);
278 if let Some(redacted) = redact_params(body, privacy) {
279 parsed.set_fragment(Some(&redacted));
280 }
281 }
282 }
283 text = parsed.to_string().replace("%5Bredacted%5D", REDACTED);
284 }
285 redact_text(&text, privacy, "url", None)
286}
287
288fn names_a_url(key: &str) -> bool {
291 key.len() >= 3
292 && key
293 .get(key.len() - 3..)
294 .is_some_and(|tail| tail.eq_ignore_ascii_case("url"))
295}
296
297pub fn redact_value(value: &Json, privacy: &NormalizedPrivacy, key: &str) -> Json {
299 match value {
300 Json::String(text) => {
301 if privacy.redact_attributes.contains(&key.to_lowercase()) {
302 Json::from(REDACTED)
303 } else if names_a_url(key) {
304 Json::String(redact_url(text, privacy))
305 } else {
306 Json::String(redact_text(text, privacy, "field", Some(key)))
307 }
308 }
309 Json::Array(items) => Json::Array(
310 items
311 .iter()
312 .map(|item| redact_value(item, privacy, key))
313 .collect(),
314 ),
315 Json::Object(object) => Json::Object(redact_object(object, privacy)),
316 other => other.clone(),
317 }
318}
319
320pub fn redact_object(object: &JsonObject, privacy: &NormalizedPrivacy) -> JsonObject {
322 object
323 .iter()
324 .map(|(name, entry)| (name.clone(), redact_value(entry, privacy, name)))
325 .collect()
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 fn privacy() -> NormalizedPrivacy {
333 normalize_privacy_options(&TracePrivacyOptions {
334 redact_patterns: vec!["sk-[a-z0-9]+".to_string()],
335 redact_query_params: vec!["Ticket".to_string()],
336 ..TracePrivacyOptions::default()
337 })
338 .unwrap()
339 }
340
341 #[test]
342 fn defaults_are_additive_and_lower_cased() {
343 let privacy = privacy();
344 assert_eq!(privacy.redact_query_params.last().unwrap(), "ticket");
345 assert_eq!(privacy.redact_query_params.len(), 13);
346 }
347
348 #[test]
349 fn urls_lose_credentials_and_listed_parameters() {
350 let privacy = privacy();
351 assert_eq!(
352 redact_url(
353 "https://user:pw@example.com/start?ticket=t-1&lang=en#access_token=abc&view=1",
354 &privacy
355 ),
356 "https://[redacted]:[redacted]@example.com/start?ticket=[redacted]&lang=en#access_token=[redacted]&view=1"
357 );
358 assert_eq!(
359 redact_url("not a url sk-abc", &privacy),
360 "not a url [redacted]"
361 );
362 }
363
364 #[test]
365 fn values_are_redacted_by_field_name() {
366 let privacy = privacy();
367 let value = Json::parse(
368 r#"{"authorization":"Bearer x","pageUrl":"https://a.example/?token=1","list":["sk-a1"]}"#,
369 )
370 .unwrap();
371 assert_eq!(
372 redact_value(&value, &privacy, "").to_compact(),
373 r#"{"authorization":"[redacted]","pageUrl":"https://a.example/?token=[redacted]","list":["[redacted]"]}"#
374 );
375 }
376}