rover/telemetry/
redact.rs1use std::sync::LazyLock;
18
19use regex::Regex;
20use url::Url;
21
22const TRIGGER_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
23
24static AUTH_HEADER_VALUE: LazyLock<Regex> =
28 LazyLock::new(|| Regex::new(r"(?i)\b(Bearer|Basic)\s+\S+").unwrap());
29
30pub fn redact_authorization(s: &str) -> String {
33 AUTH_HEADER_VALUE
34 .replace_all(s, "$1 <redacted>")
35 .into_owned()
36}
37
38fn is_authorization_field(field_name: &str) -> bool {
41 field_name.eq_ignore_ascii_case("authorization")
42}
43
44pub fn redact_url(s: &str) -> String {
50 if !s.contains('=') && !s.contains('?') {
51 return s.to_string();
52 }
53 let Ok(mut url) = Url::parse(s) else {
54 return s.to_string();
55 };
56 let Some(query) = url.query().map(str::to_string) else {
57 return s.to_string();
58 };
59 let mut rewritten = String::with_capacity(query.len());
60 let mut changed = false;
61 let mut first = true;
62 for pair in query.split('&') {
63 if !first {
64 rewritten.push('&');
65 }
66 first = false;
67 if let Some((k, _v)) = pair.split_once('=') {
68 let k_lower = k.to_lowercase();
69 if TRIGGER_KEYS.iter().any(|t| k_lower.contains(t)) {
70 rewritten.push_str(k);
71 rewritten.push_str("=<redacted>");
72 changed = true;
73 continue;
74 }
75 }
76 rewritten.push_str(pair);
77 }
78 if !changed {
79 return s.to_string();
80 }
81 url.set_query(Some(&rewritten));
82 url.to_string()
83}
84
85use std::fmt;
86use tracing::Subscriber;
87use tracing::field::{Field, Visit};
88use tracing_subscriber::fmt::{
89 FmtContext,
90 format::{FormatEvent, FormatFields, Writer},
91};
92use tracing_subscriber::registry::LookupSpan;
93
94pub struct RedactingFormatEvent;
98
99impl<S, N> FormatEvent<S, N> for RedactingFormatEvent
100where
101 S: Subscriber + for<'a> LookupSpan<'a>,
102 N: for<'a> FormatFields<'a> + 'static,
103{
104 fn format_event(
105 &self,
106 _ctx: &FmtContext<'_, S, N>,
107 mut writer: Writer<'_>,
108 event: &tracing::Event<'_>,
109 ) -> fmt::Result {
110 let metadata = event.metadata();
111 write!(
113 writer,
114 "{} {} {}:",
115 jiff::Timestamp::now(),
116 metadata.level(),
117 metadata.target(),
118 )?;
119 let mut buf = String::new();
120 let mut visitor = RedactingVisitor { out: &mut buf };
121 event.record(&mut visitor);
122 writeln!(writer, "{buf}")?;
123 Ok(())
124 }
125}
126
127struct RedactingVisitor<'a> {
128 out: &'a mut String,
129}
130
131impl Visit for RedactingVisitor<'_> {
132 fn record_str(&mut self, field: &Field, value: &str) {
133 let _ = std::fmt::write(
134 &mut *self.out,
135 format_args!(" {}={}", field.name(), scrub(field.name(), value)),
136 );
137 }
138
139 fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
140 let formatted = format!("{value:?}");
141 let _ = std::fmt::write(
142 &mut *self.out,
143 format_args!(" {}={}", field.name(), scrub(field.name(), &formatted)),
144 );
145 }
146}
147
148fn scrub(field_name: &str, value: &str) -> String {
153 if is_authorization_field(field_name) {
154 return "<redacted>".to_string();
155 }
156 redact_authorization(&redact_url(value))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn redacts_api_key_query_param() {
165 let url = "https://api.example.com/v1/x?api_key=AKIAIOSFODNN7EXAMPLE&page=1";
166 let out = redact_url(url);
167 assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"), "got: {out}");
168 assert!(
169 out.contains("api_key=%3Credacted%3E") || out.contains("api_key=<redacted>"),
170 "got: {out}"
171 );
172 assert!(
173 out.contains("page=1"),
174 "non-secret param should remain: {out}"
175 );
176 }
177
178 #[test]
179 fn redacts_token_substring_match() {
180 let url = "https://x/?access_token=abc";
181 let out = redact_url(url);
182 assert!(!out.contains("abc"), "got: {out}");
183 }
184
185 #[test]
186 fn leaves_non_secret_url_alone() {
187 let url = "https://x/?page=2&size=10";
188 assert_eq!(redact_url(url), url);
189 }
190
191 #[test]
192 fn passes_through_non_url_strings() {
193 let s = "this is not a url";
194 assert_eq!(redact_url(s), s);
195 }
196
197 #[test]
198 fn redact_authorization_strips_bearer_token() {
199 let s = "tried: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig";
200 let out = redact_authorization(s);
201 assert!(!out.contains("eyJhbGciOiJIUzI1NiJ9"), "got: {out}");
202 assert!(out.contains("Bearer <redacted>"), "got: {out}");
203 }
204
205 #[test]
206 fn redact_authorization_strips_basic_token() {
207 let s = "auth=Basic dXNlcjpwYXNzd29yZA==";
208 let out = redact_authorization(s);
209 assert!(!out.contains("dXNlcjpwYXNzd29yZA"), "got: {out}");
210 assert!(out.contains("Basic <redacted>"), "got: {out}");
211 }
212
213 #[test]
214 fn redact_authorization_is_case_insensitive_on_scheme() {
215 for s in [
216 "bearer ABCDEF",
217 "BEARER ABCDEF",
218 "BeArEr ABCDEF",
219 "basic ABCDEF",
220 "BASIC ABCDEF",
221 ] {
222 let out = redact_authorization(s);
223 assert!(!out.contains("ABCDEF"), "input={s:?} got: {out}");
224 }
225 }
226
227 #[test]
228 fn redact_authorization_handles_multiple_matches() {
229 let s = r#"{"first": "Bearer aaa", "second": "Basic bbb"}"#;
231 let out = redact_authorization(s);
232 assert!(!out.contains("aaa"), "got: {out}");
233 assert!(!out.contains("bbb"), "got: {out}");
234 assert!(out.contains("Bearer <redacted>"), "got: {out}");
235 assert!(out.contains("Basic <redacted>"), "got: {out}");
236 }
237
238 #[test]
239 fn redact_authorization_leaves_unrelated_text_alone() {
240 let s = "user logged in via http";
241 assert_eq!(redact_authorization(s), s);
242 }
243
244 #[test]
245 fn is_authorization_field_matches_case_insensitively() {
246 assert!(is_authorization_field("authorization"));
247 assert!(is_authorization_field("Authorization"));
248 assert!(is_authorization_field("AUTHORIZATION"));
249 assert!(!is_authorization_field("auth"));
250 assert!(!is_authorization_field("authorize"));
251 assert!(!is_authorization_field("cookie"));
252 }
253
254 #[test]
255 fn scrub_short_circuits_authorization_field() {
256 let out = scrub("authorization", "Custom-Scheme some-opaque-token");
259 assert_eq!(out, "<redacted>");
260 }
261
262 #[test]
263 fn scrub_redacts_bearer_inside_debug_printed_value() {
264 let value = r#"headers={"authorization":"Bearer abc"}"#;
266 let out = scrub("request", value);
267 assert!(!out.contains("abc"), "got: {out}");
268 assert!(out.contains("Bearer <redacted>"), "got: {out}");
269 }
270
271 #[test]
272 fn scrub_still_redacts_url_query_secrets_for_url_shaped_values() {
273 let url = "https://x.example.com/?api_key=AKIA&page=2";
277 let out = scrub("url", url);
278 assert!(!out.contains("AKIA"), "got: {out}");
279 assert!(out.contains("page=2"), "got: {out}");
280 }
281}