Skip to content

Commit 8040fc1

Browse files
committed
fix(bundle): redact private support bundle details (2026.6.16.2-23BD)
1 parent 2bcdcd9 commit 8040fc1

3 files changed

Lines changed: 259 additions & 11 deletions

File tree

bridge/src/cmd_bundle/manifest.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,9 @@ pub(super) async fn build_manifest(
192192
redaction_policy: vec![
193193
"Wi-Fi passwords are not included.",
194194
"SSID values are redacted from bundle text.",
195-
"Key/value fields named password, pass, ssid, token, secret, authorization, api_key, or apikey are redacted.",
196-
"Lengths, failure codes, timings, local IPs, device names, driver names, firmware IDs, and filesystem paths may be included for diagnosis.",
195+
"Key/value fields named password, pass, ssid, token, secret, authorization, api_key, apikey, hostname, computername, username, user_name, or userprofile are redacted.",
196+
"Private LAN IP addresses, MAC addresses, Windows user profile paths, hostnames, and usernames are redacted from bundle text.",
197+
"Lengths, ports, failure codes, timings, device names, driver names, and firmware IDs may be included for diagnosis.",
197198
"Raw per-key keyboard traces require trace logging and are not enabled by default.",
198199
"Raw USB packet dumps are only captured when the Pico is deliberately switched into debug input mode.",
199200
"Retained debug packet logs are redacted by the same bundle filter before they are written into the ZIP.",

bridge/src/cmd_bundle/mod.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ pub async fn build_bundle(out_path: PathBuf) -> Result<BundleSummary> {
436436
.unix_permissions(0o644);
437437

438438
zip.start_file("manifest.json", opts)?;
439-
zip.write_all(manifest_json.as_bytes())?;
439+
zip.write_all(redact_bundle_text(&manifest_json).as_bytes())?;
440440

441441
zip.start_file("bundle-capture.txt", opts)?;
442442
zip.write_all(redact_bundle_text(&capture_log.text()).as_bytes())?;
@@ -646,7 +646,8 @@ pub async fn build_bundle(out_path: PathBuf) -> Result<BundleSummary> {
646646
format!("crashes/{}", name.to_string_lossy()),
647647
opts,
648648
)?;
649-
zip.write_all(&bytes)?;
649+
let text = String::from_utf8_lossy(&bytes);
650+
zip.write_all(redact_bundle_text(&text).as_bytes())?;
650651
}
651652
Err(e) => {
652653
tracing::debug!(
@@ -685,7 +686,8 @@ pub async fn build_bundle(out_path: PathBuf) -> Result<BundleSummary> {
685686
match std::fs::read(&jp) {
686687
Ok(bytes) => {
687688
zip.start_file("state-journal.log", opts)?;
688-
zip.write_all(&bytes)?;
689+
let text = String::from_utf8_lossy(&bytes);
690+
zip.write_all(redact_bundle_text(&text).as_bytes())?;
689691
}
690692
Err(e) => {
691693
tracing::debug!("bundle: could not read state journal: {e}");
@@ -2350,7 +2352,7 @@ pub async fn run(output: Option<PathBuf>) -> Result<()> {
23502352
);
23512353
}
23522354
println!();
2353-
println!("Wi-Fi password and SSID are not included. Safe to share.");
2355+
println!("Wi-Fi credentials, local addresses, and profile paths are redacted. Safe to share.");
23542356
println!();
23552357
println!("Report this bundle at: {issue_url}");
23562358

bridge/src/cmd_bundle/redact.rs

Lines changed: 250 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
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

57
const 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+
1729
pub(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

4153
fn 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+
64257
fn 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)]
158357
mod 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

Comments
 (0)