ralph_workflow/cloud/
redaction.rs1#[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 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 out.push_str(scheme);
55 let scheme_end = i + scheme_len;
56
57 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 out.push_str("<redacted>@");
71 out.push_str(&authority[at_pos + 1..]);
72 } else {
73 out.push_str(authority);
74 }
75
76 i = end;
78 }
79
80 out
81}
82
83fn redact_bearer_tokens(input: &str) -> String {
84 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 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 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 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 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}