11//! Bundle text redaction. Keep diagnostic structure, counters, lengths,
2- //! timings, and failure codes while stripping values that identify Wi-Fi
3- //! networks or credentials.
2+ //! timings, and failure codes while stripping values that identify the
3+ //! operator, local network, Wi-Fi network, or credentials.
4+
5+ use std:: net:: Ipv4Addr ;
46
57const SENSITIVE_KEYS : & [ & str ] = & [
68 "ssid" ,
@@ -12,8 +14,18 @@ const SENSITIVE_KEYS: &[&str] = &[
1214 "authorization" ,
1315 "api_key" ,
1416 "apikey" ,
17+ "hostname" ,
18+ "computername" ,
19+ "username" ,
20+ "user_name" ,
21+ "userprofile" ,
1522] ;
1623
24+ const REDACTED : & str = "<redacted>" ;
25+ const REDACTED_IP : & str = "<redacted-ip>" ;
26+ const REDACTED_MAC : & str = "<redacted-mac>" ;
27+ const USERPROFILE : & str = "%USERPROFILE%" ;
28+
1729pub ( super ) fn redact_bundle_text ( input : & str ) -> String {
1830 let mut out = String :: with_capacity ( input. len ( ) ) ;
1931 for segment in input. split_inclusive ( '\n' ) {
@@ -40,6 +52,10 @@ pub(super) fn redact_bundle_text(input: &str) -> String {
4052
4153fn redact_line ( line : & str ) -> String {
4254 let line = redact_wifi_join_line ( line) ;
55+ let line = redact_windows_user_paths ( & line) ;
56+ let line = redact_private_ipv4 ( & line) ;
57+ let line = redact_mac_addresses ( & line) ;
58+ let line = redact_hostname_label ( & line) ;
4359 redact_key_values ( & line)
4460}
4561
@@ -56,11 +72,188 @@ fn redact_wifi_join_line(line: &str) -> String {
5672 let value_end = value_start + rel_end;
5773 let mut out = String :: with_capacity ( line. len ( ) ) ;
5874 out. push_str ( & line[ ..value_start] ) ;
59- out. push_str ( "<redacted>" ) ;
75+ out. push_str ( REDACTED ) ;
6076 out. push_str ( & line[ value_end..] ) ;
6177 out
6278}
6379
80+ fn redact_windows_user_paths ( line : & str ) -> String {
81+ let mut out = String :: with_capacity ( line. len ( ) ) ;
82+ let mut index = 0usize ;
83+ while index < line. len ( ) {
84+ let Some ( ( start, prefix) ) = find_next_user_path_prefix ( line, index) else {
85+ out. push_str ( & line[ index..] ) ;
86+ break ;
87+ } ;
88+ out. push_str ( & line[ index..start] ) ;
89+ let mut user_end = start + prefix. len ( ) ;
90+ let bytes = line. as_bytes ( ) ;
91+ while user_end < bytes. len ( ) && bytes[ user_end] != b'\\' && bytes[ user_end] != b'/' {
92+ user_end += 1 ;
93+ }
94+ out. push_str ( USERPROFILE ) ;
95+ index = user_end;
96+ }
97+ out
98+ }
99+
100+ fn find_next_user_path_prefix ( line : & str , offset : usize ) -> Option < ( usize , & ' static str ) > {
101+ const PREFIXES : & [ & str ] = & [ "C:\\ Users\\ " , "C:/Users/" , "C:\\ \\ Users\\ \\ " ] ;
102+ let lower = line[ offset..] . to_ascii_lowercase ( ) ;
103+ PREFIXES
104+ . iter ( )
105+ . filter_map ( |prefix| {
106+ lower
107+ . find ( & prefix. to_ascii_lowercase ( ) )
108+ . map ( |pos| ( offset + pos, * prefix) )
109+ } )
110+ . min_by_key ( |( pos, _) | * pos)
111+ }
112+
113+ fn redact_private_ipv4 ( line : & str ) -> String {
114+ let bytes = line. as_bytes ( ) ;
115+ let mut out = String :: with_capacity ( line. len ( ) ) ;
116+ let mut index = 0usize ;
117+ let mut last = 0usize ;
118+ while index < bytes. len ( ) {
119+ if !bytes[ index] . is_ascii_digit ( ) {
120+ index += 1 ;
121+ continue ;
122+ }
123+ let start = index;
124+ while index < bytes. len ( ) && ( bytes[ index] . is_ascii_digit ( ) || bytes[ index] == b'.' ) {
125+ index += 1 ;
126+ }
127+ let end = index;
128+ if is_private_ipv4_token ( line, start, end) {
129+ out. push_str ( & line[ last..start] ) ;
130+ out. push_str ( REDACTED_IP ) ;
131+ last = end;
132+ }
133+ }
134+ if last == 0 {
135+ return line. to_string ( ) ;
136+ }
137+ out. push_str ( & line[ last..] ) ;
138+ out
139+ }
140+
141+ fn is_private_ipv4_token ( line : & str , start : usize , end : usize ) -> bool {
142+ if start > 0 && is_token_char ( line. as_bytes ( ) [ start - 1 ] ) {
143+ return false ;
144+ }
145+ if end < line. len ( ) && is_token_char ( line. as_bytes ( ) [ end] ) {
146+ return false ;
147+ }
148+ let candidate = & line[ start..end] ;
149+ if candidate. as_bytes ( ) . iter ( ) . filter ( |b| * * b == b'.' ) . count ( ) != 3 {
150+ return false ;
151+ }
152+ let Ok ( ip) = candidate. parse :: < Ipv4Addr > ( ) else {
153+ return false ;
154+ } ;
155+ is_private_or_local_ipv4 ( ip)
156+ }
157+
158+ fn is_private_or_local_ipv4 ( ip : Ipv4Addr ) -> bool {
159+ let octets = ip. octets ( ) ;
160+ octets[ 0 ] == 10
161+ || ( octets[ 0 ] == 172 && ( 16 ..=31 ) . contains ( & octets[ 1 ] ) )
162+ || ( octets[ 0 ] == 192 && octets[ 1 ] == 168 )
163+ || ( octets[ 0 ] == 169 && octets[ 1 ] == 254 )
164+ || octets[ 0 ] == 127
165+ }
166+
167+ fn is_token_char ( byte : u8 ) -> bool {
168+ byte. is_ascii_alphanumeric ( ) || byte == b'.' || byte == b'_' || byte == b'-'
169+ }
170+
171+ fn redact_mac_addresses ( line : & str ) -> String {
172+ let bytes = line. as_bytes ( ) ;
173+ let mut out = String :: with_capacity ( line. len ( ) ) ;
174+ let mut index = 0usize ;
175+ let mut last = 0usize ;
176+ while index + 17 <= bytes. len ( ) {
177+ if is_mac_at ( bytes, index) {
178+ out. push_str ( & line[ last..index] ) ;
179+ out. push_str ( REDACTED_MAC ) ;
180+ index += 17 ;
181+ last = index;
182+ } else {
183+ index += 1 ;
184+ }
185+ }
186+ if last == 0 {
187+ return line. to_string ( ) ;
188+ }
189+ out. push_str ( & line[ last..] ) ;
190+ out
191+ }
192+
193+ fn is_mac_at ( bytes : & [ u8 ] , start : usize ) -> bool {
194+ if start > 0 && is_token_char ( bytes[ start - 1 ] ) {
195+ return false ;
196+ }
197+ let sep = bytes[ start + 2 ] ;
198+ if sep != b':' && sep != b'-' {
199+ return false ;
200+ }
201+ for group in 0 ..6 {
202+ let pos = start + group * 3 ;
203+ if !bytes[ pos] . is_ascii_hexdigit ( ) || !bytes[ pos + 1 ] . is_ascii_hexdigit ( ) {
204+ return false ;
205+ }
206+ if group < 5 && bytes[ pos + 2 ] != sep {
207+ return false ;
208+ }
209+ }
210+ let end = start + 17 ;
211+ end >= bytes. len ( ) || !is_token_char ( bytes[ end] )
212+ }
213+
214+ fn redact_hostname_label ( line : & str ) -> String {
215+ const LABELS : & [ & str ] = & [
216+ "hostname" ,
217+ "host name" ,
218+ "computername" ,
219+ "computer name" ,
220+ "username" ,
221+ "user name" ,
222+ ] ;
223+ let trimmed = line. trim_start ( ) ;
224+ let prefix_len = line. len ( ) - trimmed. len ( ) ;
225+ let lower = trimmed. to_ascii_lowercase ( ) ;
226+ for label in LABELS {
227+ if !lower. starts_with ( label) {
228+ continue ;
229+ }
230+ let mut value_start = prefix_len + label. len ( ) ;
231+ let bytes = line. as_bytes ( ) ;
232+ if value_start < bytes. len ( )
233+ && !bytes[ value_start] . is_ascii_whitespace ( )
234+ && bytes[ value_start] != b':'
235+ && bytes[ value_start] != b'='
236+ {
237+ continue ;
238+ }
239+ while value_start < bytes. len ( )
240+ && ( bytes[ value_start] . is_ascii_whitespace ( )
241+ || bytes[ value_start] == b':'
242+ || bytes[ value_start] == b'=' )
243+ {
244+ value_start += 1 ;
245+ }
246+ if value_start >= bytes. len ( ) {
247+ return line. to_string ( ) ;
248+ }
249+ let mut out = String :: with_capacity ( line. len ( ) ) ;
250+ out. push_str ( & line[ ..value_start] ) ;
251+ out. push_str ( REDACTED ) ;
252+ return out;
253+ }
254+ line. to_string ( )
255+ }
256+
64257fn redact_key_values ( line : & str ) -> String {
65258 let mut out = String :: with_capacity ( line. len ( ) ) ;
66259 let mut index = 0usize ;
@@ -72,7 +265,7 @@ fn redact_key_values(line: &str) -> String {
72265 out. push_str ( & line[ index..value_start] ) ;
73266 let value_end = find_value_end ( line, value_start) ;
74267 if value_end > value_start {
75- out. push_str ( "<redacted>" ) ;
268+ out. push_str ( REDACTED ) ;
76269 }
77270 index = value_end;
78271 if key_start == index {
@@ -137,7 +330,7 @@ fn find_value_end(line: &str, value_start: usize) -> usize {
137330 let quote = bytes[ value_start] ;
138331 let mut i = value_start + 1 ;
139332 while i < bytes. len ( ) {
140- if bytes[ i] == quote {
333+ if bytes[ i] == quote && quote_ends_value ( bytes , i + 1 ) {
141334 return i + 1 ;
142335 }
143336 i += 1 ;
@@ -154,6 +347,12 @@ fn find_value_end(line: &str, value_start: usize) -> usize {
154347 i
155348}
156349
350+ fn quote_ends_value ( bytes : & [ u8 ] , next : usize ) -> bool {
351+ next >= bytes. len ( )
352+ || bytes[ next] . is_ascii_whitespace ( )
353+ || matches ! ( bytes[ next] , b',' | b';' | b'}' | b']' )
354+ }
355+
157356#[ cfg( test) ]
158357mod tests {
159358 use super :: redact_bundle_text;
@@ -181,4 +380,50 @@ mod tests {
181380 assert ! ( !redacted. contains( "abc123" ) ) ;
182381 assert ! ( !redacted. contains( "Cafe WiFi" ) ) ;
183382 }
383+
384+ #[ test]
385+ fn redacts_quoted_ssid_with_apostrophe ( ) {
386+ let input = "ssid='Landen's_Router_2.4G' pass_len=12" ;
387+ let redacted = redact_bundle_text ( input) ;
388+
389+ assert_eq ! ( redacted, "ssid=<redacted> pass_len=12" ) ;
390+ assert ! ( !redacted. contains( "Landen" ) ) ;
391+ assert ! ( !redacted. contains( "Router" ) ) ;
392+ }
393+
394+ #[ test]
395+ fn redacts_local_network_and_machine_identity ( ) {
396+ let input = concat ! (
397+ "ack from 192.168.50.227:4242 via 10.1.2.3\n " ,
398+ "public dns 8.8.8.8 remains visible\n " ,
399+ "path=C:\\ Users\\ Landen\\ AppData\\ Local\\ ParsecCouchLink\\ data\\ logs\n " ,
400+ "mac=AA-BB-CC-DD-EE-FF host=02E22DA9\n " ,
401+ "hostname DESKTOP-12345\n "
402+ ) ;
403+ let redacted = redact_bundle_text ( input) ;
404+
405+ assert ! ( redacted. contains( "<redacted-ip>:4242" ) ) ;
406+ assert ! ( redacted. contains( "via <redacted-ip>" ) ) ;
407+ assert ! ( redacted. contains( "public dns 8.8.8.8 remains visible" ) ) ;
408+ assert ! (
409+ redacted. contains( "path=%USERPROFILE%\\ AppData\\ Local\\ ParsecCouchLink\\ data\\ logs" )
410+ ) ;
411+ assert ! ( redacted. contains( "mac=<redacted-mac> host=02E22DA9" ) ) ;
412+ assert ! ( redacted. contains( "hostname <redacted>" ) ) ;
413+ assert ! ( !redacted. contains( "192.168.50.227" ) ) ;
414+ assert ! ( !redacted. contains( "10.1.2.3" ) ) ;
415+ assert ! ( !redacted. contains( "C:\\ Users\\ Landen" ) ) ;
416+ assert ! ( !redacted. contains( "AA-BB-CC-DD-EE-FF" ) ) ;
417+ assert ! ( !redacted. contains( "DESKTOP-12345" ) ) ;
418+ }
419+
420+ #[ test]
421+ fn redacts_json_escaped_windows_profile_paths ( ) {
422+ let input =
423+ r#"{ "config_path": "C:\\Users\\Landen\\AppData\\Roaming\\ParsecCouchLink\\config" }"# ;
424+ let redacted = redact_bundle_text ( input) ;
425+
426+ assert ! ( redacted. contains( r#""config_path": "%USERPROFILE%\\AppData\\Roaming"# ) ) ;
427+ assert ! ( !redacted. contains( r#"C:\\Users\\Landen"# ) ) ;
428+ }
184429}
0 commit comments