Skip to main content

rover/telemetry/
redact.rs

1//! Tracing layer that redacts secret material from event field values
2//! before they hit any log destination. Two redactors run in sequence:
3//!
4//! 1. URL query-string values for keys matching a denylist
5//!    (`api_key`, `token`, `secret`, `password`).
6//! 2. HTTP `Authorization`-style credentials:
7//!    - A field literally named `authorization` (case-insensitive) has its
8//!      entire value replaced with `<redacted>`.
9//!    - Any value that embeds a `Bearer <token>` or `Basic <token>` shape
10//!      (regardless of field name — covers debug-printed `HeaderMap`s and
11//!      similar) has the credential portion replaced with `<redacted>`.
12//!
13//! HAR debug output is deliberately NOT scrubbed (see `docs/security.md`):
14//! the user opts in via `[debug] har_path` to capture raw traffic and the
15//! file is meant to be protected with filesystem permissions.
16
17use std::sync::LazyLock;
18
19use regex::Regex;
20use url::Url;
21
22const TRIGGER_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
23
24/// Matches `Bearer <token>` or `Basic <token>` (case-insensitive scheme;
25/// credential is any run of non-whitespace). Used to scrub credential
26/// substrings out of debug-printed header maps and similar.
27static AUTH_HEADER_VALUE: LazyLock<Regex> =
28    LazyLock::new(|| Regex::new(r"(?i)\b(Bearer|Basic)\s+\S+").unwrap());
29
30/// Replace every `Bearer <token>` / `Basic <token>` substring in `s` with
31/// `<scheme> <redacted>`. Returns `s` unchanged if no match.
32pub fn redact_authorization(s: &str) -> String {
33    AUTH_HEADER_VALUE
34        .replace_all(s, "$1 <redacted>")
35        .into_owned()
36}
37
38/// True if `field_name` is an HTTP-authorization-shaped key whose entire
39/// value should be scrubbed (rather than just the credential portion).
40fn is_authorization_field(field_name: &str) -> bool {
41    field_name.eq_ignore_ascii_case("authorization")
42}
43
44/// Redact secret query-string values from `s`. If `s` is not a URL or has
45/// no triggering keys, returns the input unchanged.
46///
47/// Fast path: short-circuit when the string contains neither `=` nor `?`.
48/// Otherwise parse, walk pairs, only allocate if at least one rewrite happens.
49pub 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
94/// Custom event formatter that redacts URL query-string secrets in every
95/// field value before writing. Replaces the default formatter installed in
96/// `telemetry::init`.
97pub 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        // Plain-text line format: <timestamp> <LEVEL> <target>: <fields>
112        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
148/// Run every redactor over `value`. If `field_name` is itself an
149/// authorization-shaped key, short-circuit with the literal `<redacted>`
150/// (cheaper and stops the value from leaking even when it doesn't match
151/// the Bearer/Basic shape — e.g. a custom token format).
152fn 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        // Debug-printed map with two Authorization-bearing entries.
230        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        // The field-name shortcut emits the literal `<redacted>` regardless
257        // of value shape, so even an opaque custom-token format won't leak.
258        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        // Generic field name → goes through the substring redactor.
265        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        // When the field value is itself a URL, the URL-query redactor
274        // still fires. Composition is preserved by the auth-redactor pass
275        // not corrupting URL syntax.
276        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}