Skip to main content

ralph_workflow/cloud/
redaction.rs

1//! Redaction utilities for cloud-mode logging/payloads.
2//!
3//! Cloud mode must never log or report secrets. Git and HTTP error strings can
4//! contain embedded credentials (for example, URLs with `user:pass@host`).
5//!
6//! This module provides a conservative sanitizer for untrusted error strings.
7
8/// Redact likely secrets from an untrusted, user-controlled string.
9///
10/// This is intentionally conservative. It may redact non-secret strings if they
11/// resemble tokens.
12#[must_use]
13pub fn redact_secrets(input: &str) -> String {
14    let mut s = input.to_string();
15    s = redact_http_url_userinfo(&s);
16    s = redact_common_query_params(&s);
17    s = redact_bearer_tokens(&s);
18    s = redact_token_like_substrings(&s);
19    truncate_redacted(&s)
20}
21
22fn truncate_redacted(input: &str) -> String {
23    const MAX_LEN: usize = 4096;
24
25    if input.len() <= MAX_LEN {
26        return input.to_string();
27    }
28
29    let mut out = input[..MAX_LEN].to_string();
30    out.push_str("...<truncated>");
31    out
32}
33
34fn redact_http_url_userinfo(input: &str) -> String {
35    // Replace `http(s)://user[:pass]@host` with `http(s)://<redacted>@host`.
36    // This is conservative: we only redact when an '@' appears in the URL authority.
37    let mut out = String::with_capacity(input.len());
38    let bytes = input.as_bytes();
39    let mut i = 0;
40
41    while i < bytes.len() {
42        let rest = &input[i..];
43        let (scheme, scheme_len) = if rest.starts_with("https://") {
44            ("https://", 8usize)
45        } else if rest.starts_with("http://") {
46            ("http://", 7usize)
47        } else {
48            out.push(bytes[i] as char);
49            i += 1;
50            continue;
51        };
52
53        // Copy the scheme.
54        out.push_str(scheme);
55        let scheme_end = i + scheme_len;
56
57        // URL authority ends at '/' or whitespace (or end-of-string).
58        let mut end = scheme_end;
59        while end < bytes.len() {
60            let b = bytes[end];
61            if b == b'/' || b.is_ascii_whitespace() {
62                break;
63            }
64            end += 1;
65        }
66
67        let authority = &input[scheme_end..end];
68        if let Some(at_pos) = authority.rfind('@') {
69            // Keep only the host portion after the last '@'.
70            out.push_str("<redacted>@");
71            out.push_str(&authority[at_pos + 1..]);
72        } else {
73            out.push_str(authority);
74        }
75
76        // Continue copying the remainder (including the slash/whitespace that stopped us).
77        i = end;
78    }
79
80    out
81}
82
83fn redact_bearer_tokens(input: &str) -> String {
84    // Replace `Bearer <token>` with `Bearer <redacted>` (case-insensitive match on "bearer").
85    let mut out = String::with_capacity(input.len());
86    let bytes = input.as_bytes();
87    let mut i = 0;
88    while i < bytes.len() {
89        let rest = &input[i..];
90        if starts_with_ignore_ascii_case(rest, "bearer ") {
91            out.push_str("Bearer ");
92            out.push_str("<redacted>");
93            i += "bearer ".len();
94            // Skip token characters (up to whitespace).
95            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
96                i += 1;
97            }
98            continue;
99        }
100
101        out.push(bytes[i] as char);
102        i += 1;
103    }
104    out
105}
106
107fn redact_common_query_params(input: &str) -> String {
108    // Redact common credential-bearing query params and key/value fragments.
109    // We intentionally handle both '&' separated and whitespace terminated values.
110    const KEYS: [&str; 5] = [
111        "access_token=",
112        "token=",
113        "password=",
114        "passwd=",
115        "oauth_token=",
116    ];
117
118    let mut out = String::with_capacity(input.len());
119    let bytes = input.as_bytes();
120    let mut i = 0;
121    while i < bytes.len() {
122        let mut matched: Option<&'static str> = None;
123        for key in KEYS {
124            if input[i..].starts_with(key) {
125                matched = Some(key);
126                break;
127            }
128        }
129
130        if let Some(key) = matched {
131            out.push_str(key);
132            out.push_str("<redacted>");
133            i += key.len();
134            while i < bytes.len() {
135                let b = bytes[i];
136                if b == b'&' || b.is_ascii_whitespace() {
137                    break;
138                }
139                i += 1;
140            }
141            continue;
142        }
143
144        out.push(bytes[i] as char);
145        i += 1;
146    }
147
148    out
149}
150
151fn redact_token_like_substrings(input: &str) -> String {
152    // Redact substrings that look like common tokens, even if not in a URL.
153    // Examples: GitHub PATs, GitLab PATs, Slack tokens, Google OAuth tokens.
154    const PREFIXES: [&str; 6] = ["ghp_", "github_pat_", "glpat-", "xoxb-", "xapp-", "ya29."];
155
156    let mut out = String::with_capacity(input.len());
157    let bytes = input.as_bytes();
158    let mut i = 0;
159
160    while i < bytes.len() {
161        let mut matched_prefix: Option<&'static str> = None;
162        for p in PREFIXES {
163            if input[i..].starts_with(p) {
164                matched_prefix = Some(p);
165                break;
166            }
167        }
168
169        if let Some(prefix) = matched_prefix {
170            // Consume token characters.
171            let mut end = i + prefix.len();
172            while end < bytes.len() {
173                let b = bytes[end];
174                let c = b as char;
175                if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
176                    end += 1;
177                    continue;
178                }
179                break;
180            }
181
182            out.push_str("<redacted>");
183            i = end;
184            continue;
185        }
186
187        out.push(bytes[i] as char);
188        i += 1;
189    }
190
191    out
192}
193
194fn starts_with_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
195    haystack
196        .get(0..needle.len())
197        .is_some_and(|p| p.eq_ignore_ascii_case(needle))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::redact_secrets;
203
204    #[test]
205    fn redacts_http_url_userinfo() {
206        let s = "fatal: could not read Username for 'https://token@github.com/org/repo.git': terminal prompts disabled";
207        let out = redact_secrets(s);
208        assert!(
209            !out.contains("token@github.com"),
210            "should remove userinfo from URL authority"
211        );
212        assert!(
213            out.contains("https://<redacted>@github.com"),
214            "should preserve scheme and host"
215        );
216    }
217
218    #[test]
219    fn redacts_http_url_user_and_password() {
220        let s = "remote: https://user:pass@github.com/org/repo.git";
221        let out = redact_secrets(s);
222        assert!(!out.contains("user:pass@"));
223        assert!(out.contains("https://<redacted>@github.com"));
224    }
225
226    #[test]
227    fn redacts_bearer_tokens() {
228        let s = "Authorization: Bearer abcdef123456";
229        let out = redact_secrets(s);
230        assert_eq!(out, "Authorization: Bearer <redacted>");
231    }
232
233    #[test]
234    fn redacts_common_query_token_params() {
235        let s = "GET /?access_token=abc123&other=ok";
236        let out = redact_secrets(s);
237        assert!(out.contains("access_token=<redacted>"));
238        assert!(out.contains("other=ok"));
239    }
240
241    #[test]
242    fn redacts_github_like_tokens() {
243        let s = "error: ghp_abcdefghijklmnopqrstuvwxyz0123456789";
244        let out = redact_secrets(s);
245        assert!(!out.contains("ghp_"));
246        assert!(out.contains("<redacted>"));
247    }
248
249    #[test]
250    fn truncates_very_long_messages() {
251        let input = "x".repeat(10_000);
252        let out = redact_secrets(&input);
253        assert!(out.len() < input.len());
254        assert!(out.ends_with("...<truncated>"));
255    }
256}