| 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: ®ex::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 |