devboy_core/
sentry_integration.rs1use crate::config::SentryConfig;
15
16const SENSITIVE_KEYS: &[&str] = &[
18 "authorization",
19 "x-gitlab-token",
20 "x-api-key",
21 "cookie",
22 "private-token",
23 "token",
24 "api_key",
25 "apikey",
26 "secret",
27 "password",
28 "private_key",
29];
30
31pub fn init_sentry(
42 config: Option<&SentryConfig>,
43 release: &str,
44) -> Option<sentry::ClientInitGuard> {
45 let default_config = SentryConfig::default();
46 let config = config.unwrap_or(&default_config);
47
48 let dsn = std::env::var("DEVBOY_SENTRY_DSN")
50 .ok()
51 .map(|s| s.trim().to_string())
52 .filter(|s| !s.is_empty())
53 .or_else(|| config.dsn.as_ref().map(|s| s.trim().to_string()))
54 .filter(|s| !s.is_empty())?;
55
56 let parsed_dsn = match dsn.parse::<sentry::types::Dsn>() {
60 Ok(d) => Some(d),
61 Err(e) => {
62 eprintln!("[devboy] Invalid Sentry DSN: {e}. Sentry will be disabled.");
63 return None;
64 }
65 };
66
67 let environment = std::env::var("DEVBOY_SENTRY_ENVIRONMENT")
69 .ok()
70 .map(|s| s.trim().to_string())
71 .filter(|s| !s.is_empty())
72 .or_else(|| config.environment.clone());
73
74 let sample_rate = std::env::var("DEVBOY_SENTRY_SAMPLE_RATE")
76 .ok()
77 .and_then(|s| s.trim().parse::<f32>().ok())
78 .or(config.sample_rate)
79 .unwrap_or(1.0)
80 .clamp(0.0, 1.0);
81
82 let traces_sample_rate = std::env::var("DEVBOY_SENTRY_TRACES_SAMPLE_RATE")
84 .ok()
85 .and_then(|s| s.trim().parse::<f32>().ok())
86 .or(config.traces_sample_rate)
87 .unwrap_or(0.0)
88 .clamp(0.0, 1.0);
89
90 let guard = sentry::init(sentry::ClientOptions {
91 dsn: parsed_dsn,
92 release: Some(release.to_string().into()),
93 environment: environment.map(Into::into),
94 sample_rate,
95 traces_sample_rate,
96 before_send: Some(std::sync::Arc::new(scrub_sensitive_data)),
97 before_breadcrumb: Some(std::sync::Arc::new(scrub_breadcrumb)),
98 ..Default::default()
99 });
100
101 if guard.is_enabled() {
102 eprintln!("[devboy] Sentry error reporting enabled");
104 }
105
106 Some(guard)
107}
108
109fn scrub_sensitive_data(
111 mut event: sentry::protocol::Event<'static>,
112) -> Option<sentry::protocol::Event<'static>> {
113 if let Some(ref mut message) = event.message {
115 *message = scrub_url_credentials(message);
116 }
117
118 for exception in &mut event.exception.values {
120 if let Some(ref mut value) = exception.value {
121 *value = scrub_url_credentials(value);
122 }
123 }
124
125 if let Some(ref mut request) = event.request {
127 scrub_map(&mut request.headers);
128 if let Some(ref url) = request.url {
129 let scrubbed = scrub_url_credentials(url.as_str());
130 if let Ok(new_url) = scrubbed.parse() {
131 request.url = Some(new_url);
132 }
133 }
134 if let Some(ref mut query) = request.query_string {
135 *query = scrub_url_credentials(query);
136 }
137 }
138
139 let keys_to_scrub: Vec<String> = event
141 .extra
142 .keys()
143 .filter(|k| is_sensitive_key(k))
144 .cloned()
145 .collect();
146 for key in keys_to_scrub {
147 event.extra.insert(
148 key,
149 sentry::protocol::Value::String("[Filtered]".to_string()),
150 );
151 }
152
153 Some(event)
154}
155
156fn scrub_breadcrumb(
161 mut breadcrumb: sentry::protocol::Breadcrumb,
162) -> Option<sentry::protocol::Breadcrumb> {
163 if let Some(ref mut message) = breadcrumb.message {
164 *message = scrub_url_credentials(message);
165 }
166 for value in breadcrumb.data.values_mut() {
168 if let sentry::protocol::Value::String(s) = value {
169 *s = scrub_url_credentials(s);
170 }
171 }
172 Some(breadcrumb)
173}
174
175fn scrub_url_credentials(input: &str) -> String {
183 let mut result = input.to_string();
184 if let Some(start) = result.find("://") {
186 let after_scheme = start + 3;
187 if let Some(at_pos) = result[after_scheme..].find('@') {
188 let abs_at = after_scheme + at_pos;
189 if result[after_scheme..abs_at].contains(':') {
191 result = format!("{}[Filtered]{}", &result[..after_scheme], &result[abs_at..]);
192 }
193 }
194 }
195 for param in &["token", "key", "secret", "password", "api_key", "apikey"] {
198 let pat = format!("{param}=");
199 let pat_bytes = pat.as_bytes();
200 let mut search_from = 0;
201 while search_from + pat_bytes.len() <= result.len() {
202 let haystack = &result.as_bytes()[search_from..];
203 let found = haystack
204 .windows(pat_bytes.len())
205 .position(|w| w.eq_ignore_ascii_case(pat_bytes));
206 let Some(rel_pos) = found else { break };
207 let pos = search_from + rel_pos;
208 let value_start = pos + pat.len();
209 let value_end = result[value_start..]
210 .find(['&', '#', ' '])
211 .map(|i| value_start + i)
212 .unwrap_or(result.len());
213 let original_param = &result[pos..value_start];
215 let replacement = format!(
216 "{}{}[Filtered]{}",
217 &result[..pos],
218 original_param,
219 &result[value_end..]
220 );
221 search_from = pos + original_param.len() + "[Filtered]".len();
222 result = replacement;
223 }
224 }
225 result
226}
227
228fn is_sensitive_key(key: &str) -> bool {
230 let lower = key.to_lowercase();
231 SENSITIVE_KEYS.iter().any(|&k| lower.contains(k))
232}
233
234fn scrub_map(map: &mut std::collections::BTreeMap<String, String>) {
236 let keys_to_scrub: Vec<String> = map
237 .keys()
238 .filter(|k| is_sensitive_key(k))
239 .cloned()
240 .collect();
241 for key in keys_to_scrub {
242 map.insert(key, "[Filtered]".to_string());
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_is_sensitive_key() {
252 assert!(is_sensitive_key("Authorization"));
253 assert!(is_sensitive_key("x-gitlab-token"));
254 assert!(is_sensitive_key("X-API-KEY"));
255 assert!(is_sensitive_key("my_secret_field"));
256 assert!(is_sensitive_key("PRIVATE-TOKEN"));
257 assert!(!is_sensitive_key("content-type"));
258 assert!(!is_sensitive_key("user-agent"));
259 assert!(!is_sensitive_key("tool_name"));
260 }
261
262 #[test]
263 fn test_scrub_url_credentials() {
264 assert_eq!(
266 scrub_url_credentials("https://user:pass@sentry.io/123"),
267 "https://[Filtered]@sentry.io/123"
268 );
269 assert_eq!(
271 scrub_url_credentials("https://host.com/api?token=secret123&foo=bar"),
272 "https://host.com/api?token=[Filtered]&foo=bar"
273 );
274 assert_eq!(
276 scrub_url_credentials("https://host.com?key=abc&password=xyz"),
277 "https://host.com?key=[Filtered]&password=[Filtered]"
278 );
279 assert_eq!(
281 scrub_url_credentials("https://host.com/path?page=1"),
282 "https://host.com/path?page=1"
283 );
284 assert_eq!(
286 scrub_url_credentials("Connected to proxy at host:8080"),
287 "Connected to proxy at host:8080"
288 );
289 assert_eq!(
291 scrub_url_credentials("https://host.com?TOKEN=secret"),
292 "https://host.com?TOKEN=[Filtered]"
293 );
294 assert_eq!(
295 scrub_url_credentials("https://host.com?Token=abc123"),
296 "https://host.com?Token=[Filtered]"
297 );
298 assert_eq!(
300 scrub_url_credentials("https://host.com?api_key=xyz&page=1"),
301 "https://host.com?api_key=[Filtered]&page=1"
302 );
303 }
304
305 #[test]
306 fn test_scrub_url_credentials_non_ascii() {
307 assert_eq!(
309 scrub_url_credentials("https://host.com/путь?token=secret"),
310 "https://host.com/путь?token=[Filtered]"
311 );
312 assert_eq!(
314 scrub_url_credentials("https://host.com?key=ключ&page=1"),
315 "https://host.com?key=[Filtered]&page=1"
316 );
317 assert_eq!(scrub_url_credentials("Привет мир"), "Привет мир");
319 }
320
321 #[test]
322 fn test_scrub_url_credentials_multiple_same_param() {
323 assert_eq!(
325 scrub_url_credentials("https://h.com?token=a&other=b&token=c"),
326 "https://h.com?token=[Filtered]&other=b&token=[Filtered]"
327 );
328 }
329
330 #[test]
331 fn test_scrub_url_credentials_fragment() {
332 assert_eq!(
334 scrub_url_credentials("https://host.com?token=secret#section"),
335 "https://host.com?token=[Filtered]#section"
336 );
337 }
338
339 #[test]
340 fn test_scrub_map() {
341 let mut map = std::collections::BTreeMap::new();
342 map.insert("Authorization".to_string(), "Bearer xyz".to_string());
343 map.insert("Content-Type".to_string(), "application/json".to_string());
344 map.insert("x-api-key".to_string(), "secret123".to_string());
345
346 scrub_map(&mut map);
347
348 assert_eq!(map["Authorization"], "[Filtered]");
349 assert_eq!(map["Content-Type"], "application/json");
350 assert_eq!(map["x-api-key"], "[Filtered]");
351 }
352
353 #[test]
354 fn test_sentry_config_default() {
355 let config = SentryConfig::default();
356 assert!(config.dsn.is_none());
357 assert!(config.environment.is_none());
358 assert!(config.sample_rate.is_none());
359 assert!(config.traces_sample_rate.is_none());
360 }
361}