返回 CodeWhale
sanitize.rs
根目录 / crates / secrets / src / sanitize.rs
1 //! Pure output-sanitization primitives shared by portable command helpers
2 //! (FEAT-025 D4).
3 //!
4 //! These helpers previously lived in the TUI command, client, and OSC8
5 //! modules. Relocating the pure algorithms here gives `/export` and
6 //! `/structcopy` exactly one implementation with no TUI, client, or
7 //! configuration dependency. TUI callers delegate back to these functions so
8 //! behavior cannot drift.
9
10 use std::sync::OnceLock;
11
12 use regex::Regex;
13 use serde_json::Value;
14
15 use crate::redact::redact_secrets;
16
17 /// Strip ANSI/OSC/control sequences from `s` into `out`.
18 ///
19 /// Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), DCS, SOS,
20 /// PM, APC, and standalone two-byte ESC sequences. OSC 8 hyperlink wrappers
21 /// (`ESC ] 8 ; … BEL` / `ESC \`) are stripped along with the rest.
22 pub fn strip_ansi_into(s: &str, out: &mut String) {
23 strip_ansi_impl(s, out, false);
24 }
25
26 /// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold,
27 /// underline, reset) pass through untouched so a renderer that understands
28 /// them can paint the output as the tool emitted it. Everything else — OSC
29 /// (including OSC 8 hyperlink wrappers), cursor movement, DCS, lone control
30 /// bytes — is still removed; only the styling survives.
31 pub fn strip_ansi_keep_sgr_into(s: &str, out: &mut String) {
32 strip_ansi_impl(s, out, true);
33 }
34
35 /// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back
36 /// to `1` for continuation bytes / invalid leads so callers always make
37 /// forward progress.
38 pub fn utf8_seq_len(lead: u8) -> usize {
39 if lead < 0xc0 {
40 1
41 } else if lead < 0xe0 {
42 2
43 } else if lead < 0xf0 {
44 3
45 } else {
46 4
47 }
48 }
49
50 fn strip_ansi_impl(s: &str, out: &mut String, keep_sgr: bool) {
51 let bytes = s.as_bytes();
52 let mut i = 0;
53 while i < bytes.len() {
54 if bytes[i] == 0x1b && i + 1 < bytes.len() {
55 let next = bytes[i + 1];
56 match next {
57 // CSI: ESC [ ... <final byte 0x40..=0x7E>
58 b'[' => {
59 let mut j = i + 2;
60 let mut final_byte = 0u8;
61 while j < bytes.len() {
62 let b = bytes[j];
63 if (0x40..=0x7e).contains(&b) {
64 final_byte = b;
65 j += 1;
66 break;
67 }
68 j += 1;
69 }
70 if keep_sgr
71 && final_byte == b'm'
72 && let Ok(seq) = std::str::from_utf8(&bytes[i..j])
73 {
74 out.push_str(seq);
75 }
76 i = j;
77 continue;
78 }
79 // OSC / DCS / SOS / PM / APC: ESC ] | P | X | ^ | _ ... ST(ESC \) or BEL
80 b']' | b'P' | b'X' | b'^' | b'_' => {
81 let mut j = i + 2;
82 while j < bytes.len() {
83 if bytes[j] == 0x07 {
84 j += 1;
85 break;
86 }
87 if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
88 j += 2;
89 break;
90 }
91 j += 1;
92 }
93 i = j;
94 continue;
95 }
96 // Standalone two-byte ESC sequence (RIS, charset selection, etc.)
97 _ => {
98 i += 2;
99 continue;
100 }
101 }
102 }
103 // Strip lone control bytes that ratatui would otherwise drop (and which
104 // mean nothing in transcript output) but keep \n, \r, \t as legitimate
105 // formatting.
106 let b = bytes[i];
107 if b < 0x80 {
108 if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' {
109 i += 1;
110 continue;
111 }
112 out.push(b as char);
113 i += 1;
114 } else {
115 // UTF-8 multi-byte sequence: copy the whole code point intact.
116 // Pushing `b as char` would mis-decode it as Latin-1 and mangle
117 // non-ASCII text (CJK, accented Latin, emoji, …).
118 let len = utf8_seq_len(b);
119 let end = (i + len).min(bytes.len());
120 if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
121 out.push_str(chunk);
122 }
123 i = end;
124 }
125 }
126 }
127
128 /// Mask credentials in a URL so it can appear in output or a report.
129 ///
130 /// Userinfo is replaced with `***` and query values under sensitive keys are
131 /// masked. A URL that does not parse is returned unchanged.
132 pub fn redact_url_for_display(url: &str) -> String {
133 let Ok(mut parsed) = url::Url::parse(url) else {
134 return url.to_string();
135 };
136 if !parsed.username().is_empty() || parsed.password().is_some() {
137 let _ = parsed.set_username("***");
138 let _ = parsed.set_password(Some("***"));
139 }
140 if parsed.query().is_none() {
141 return parsed.to_string();
142 }
143 let pairs: Vec<(String, String)> = parsed
144 .query_pairs()
145 .map(|(key, value)| {
146 let value = if is_sensitive_url_query_key(&key) {
147 "***".to_string()
148 } else {
149 value.into_owned()
150 };
151 (key.into_owned(), value)
152 })
153 .collect();
154 parsed.set_query(None);
155 let mut query = parsed.query_pairs_mut();
156 for (key, value) in pairs {
157 query.append_pair(&key, &value);
158 }
159 drop(query);
160 parsed.to_string()
161 }
162
163 fn is_sensitive_url_query_key(key: &str) -> bool {
164 let normalized = key.trim().replace(['-', '.'], "_").to_ascii_lowercase();
165 matches!(
166 normalized.as_str(),
167 "api_key"
168 | "apikey"
169 | "access_token"
170 | "auth_token"
171 | "authorization"
172 | "bearer"
173 | "client_secret"
174 | "credential"
175 | "id_token"
176 | "password"
177 | "refresh_token"
178 | "secret"
179 | "token"
180 ) || normalized.ends_with("_api_key")
181 || normalized.ends_with("_authorization")
182 || normalized.ends_with("_password")
183 || normalized.ends_with("_secret")
184 || normalized.ends_with("_token")
185 }
186
187 /// True when `role` names an internal, non-user-visible message role.
188 pub fn is_internal_role(role: &str) -> bool {
189 matches!(
190 role.trim().to_ascii_lowercase().as_str(),
191 "system" | "developer" | "internal"
192 )
193 }
194
195 /// True when a JSON/assignment key names a credential-bearing value.
196 ///
197 /// Classification normalizes separators and quotes so obfuscated variants are
198 /// still caught; the vocabulary is shared with `/structcopy`.
199 pub fn is_sensitive_key(key: &str) -> bool {
200 let normalized = key
201 .trim()
202 .trim_matches(['\'', '"'])
203 .replace(['-', '.', ' '], "_")
204 .to_ascii_lowercase();
205 [
206 "api_key",
207 "apikey",
208 "secret",
209 "token",
210 "password",
211 "passwd",
212 "authorization",
213 "access_key",
214 "client_secret",
215 "private_key",
216 "cookie",
217 "session_key",
218 ]
219 .iter()
220 .any(|hint| normalized.contains(hint))
221 }
222
223 /// Sanitize arbitrary text for safe export output.
224 ///
225 /// Strips ANSI/control bytes, normalizes newlines, then applies private-key,
226 /// bearer-token, JWT, URL-credential, and keyed-secret redaction in the exact
227 /// established order.
228 pub fn sanitize_text(input: &str) -> String {
229 let mut visible = String::with_capacity(input.len());
230 strip_ansi_into(input, &mut visible);
231 let visible = visible.replace("\r\n", "\n").replace('\r', "\n");
232 let visible: String = visible
233 .chars()
234 .filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control())
235 .collect();
236 let private_keys = private_key_regex().replace_all(&visible, "[redacted private key]");
237 let bearer = bearer_regex().replace_all(&private_keys, "Bearer [redacted]");
238 let jwt = jwt_regex().replace_all(&bearer, "[redacted token]");
239 let urls = url_regex().replace_all(&jwt, |captures: &regex::Captures<'_>| {
240 redact_url_match(captures.get(0).map_or("", |value| value.as_str()))
241 });
242 redact_secrets(&urls)
243 }
244
245 /// Recursively redact a JSON value.
246 ///
247 /// A value under a sensitive key is replaced wholesale; other strings pass
248 /// through [`sanitize_text`], and arrays/objects are traversed in place.
249 pub fn redact_json(value: &mut Value, key: Option<&str>) {
250 if key.is_some_and(is_sensitive_key) {
251 *value = Value::String("[redacted]".to_string());
252 return;
253 }
254 match value {
255 Value::String(text) => *text = sanitize_text(text),
256 Value::Array(items) => {
257 for item in items {
258 redact_json(item, None);
259 }
260 }
261 Value::Object(map) => {
262 for (key, value) in map {
263 redact_json(value, Some(key));
264 }
265 }
266 Value::Null | Value::Bool(_) | Value::Number(_) => {}
267 }
268 }
269
270 /// Collapse whitespace and neutralize inline backticks for a single-line
271 /// export field.
272 pub fn inline_text(input: &str) -> String {
273 sanitize_text(input)
274 .split_whitespace()
275 .collect::<Vec<_>>()
276 .join(" ")
277 .replace('`', "'")
278 }
279
280 fn redact_url_match(raw: &str) -> String {
281 let trimmed = raw.trim_end_matches(['.', ',', ';', '!']);
282 let suffix = &raw[trimmed.len()..];
283 format!("{}{}", redact_url_for_display(trimmed), suffix)
284 }
285
286 fn private_key_regex() -> &'static Regex {
287 static RE: OnceLock<Regex> = OnceLock::new();
288 RE.get_or_init(|| {
289 Regex::new(
290 r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----",
291 )
292 .expect("private-key redaction regex")
293 })
294 }
295
296 fn bearer_regex() -> &'static Regex {
297 static RE: OnceLock<Regex> = OnceLock::new();
298 RE.get_or_init(|| {
299 Regex::new(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{6,}").expect("bearer redaction regex")
300 })
301 }
302
303 fn jwt_regex() -> &'static Regex {
304 static RE: OnceLock<Regex> = OnceLock::new();
305 RE.get_or_init(|| {
306 Regex::new(r"\beyJ[a-zA-Z0-9_-]{5,}\.[a-zA-Z0-9_-]{5,}(?:\.[a-zA-Z0-9_-]{5,})?\b")
307 .expect("JWT redaction regex")
308 })
309 }
310
311 fn url_regex() -> &'static Regex {
312 static RE: OnceLock<Regex> = OnceLock::new();
313 RE.get_or_init(|| {
314 Regex::new(r#"https?://[^\s<>\"'`\]\[\)\(\}\{]+"#).expect("URL redaction regex")
315 })
316 }
317
318 #[cfg(test)]
319 mod tests {
320 use super::*;
321
322 #[test]
323 fn strip_ansi_removes_control_sequences_but_keeps_text() {
324 let mut out = String::new();
325 strip_ansi_into("a\u{1b}[31mred\u{1b}[0m b", &mut out);
326 assert_eq!(out, "ared b");
327 }
328
329 #[test]
330 fn url_redaction_masks_userinfo_and_sensitive_query_values() {
331 assert_eq!(
332 redact_url_for_display(
333 "https://alice:password@example.com/path?token=very-secret&ok=1"
334 ),
335 "https://***:***@example.com/path?token=***&ok=1"
336 );
337 }
338
339 #[test]
340 fn sensitive_keys_normalize_separators_and_quotes() {
341 assert!(is_sensitive_key("API-KEY"));
342 assert!(is_sensitive_key("\"client secret\""));
343 assert!(!is_sensitive_key("monkey"));
344 }
345
346 #[test]
347 fn sanitize_text_redacts_private_keys_bearer_and_jwt() {
348 // Assemble the PEM markers and the provider-token prefix at runtime so
349 // this source file never contains a literal private-key header (or a
350 // literal token) for a secret scanner to match. The runtime strings are
351 // identical to the real shapes, and this mirrors the convention the
352 // pre-move test used in `config::persistence` - moving that test into
353 // this crate silently dropped it, which is what GitGuardian caught.
354 let begin = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat();
355 let end = ["-----END RSA", " PRIVATE KEY-----"].concat();
356 let bearer = format!("{} abcdefghijklmnop", "Bearer");
357 let opaque = ["sk-", "abcdef1234567890"].concat();
358 let text = format!("{begin}\nMII\n{end}\nAuthorization: {bearer}\n{opaque}");
359
360 let out = sanitize_text(&text);
361 assert!(!out.contains("MII"), "{out}");
362 assert!(!out.contains("abcdefghijklmnop"), "{out}");
363 assert!(out.contains("[redacted]"), "{out}");
364 }
365 }
366
366 lines RUST