fix(official-sync): 脱敏代理凭据避免泄漏到日志与状态输出

两处代理凭据泄漏:
- redact_proxy_url 对无 scheme 的代理串(user:secret@host:port,curl 默认按
  http 处理)完全不脱敏,明文凭据会进入 progress 事件、daemon 日志和
  bat-events.jsonl。现对无 scheme 但含 userinfo 的情况一并脱敏。
- daemon 保存的 command 含原始代理 URL,经 RPC status 和 bat status --json
  回显到 stdout(会被监控采集)。新增 redact_command_proxy_credentials,在
  build_daemon_status_report 的对外拷贝里脱敏 --proxy 值;磁盘状态文件
  (0600)仍保留原始 command 供 restart/reload 复用。

残留:子进程 argv 仍可经 /proc/<pid>/cmdline 读到代理凭据,以及 0600 状态
文件内的 at-rest 凭据,二者受进程属主与文件权限约束,后续可改由环境变量下
传子进程进一步收敛。

对应 issue #18 维护清单 1-2。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 04:31:10 -07:00
co-authored by Claude Fable 5
parent 7c2f6824a6
commit eef8c52497
2 changed files with 97 additions and 7 deletions
+68 -2
View File
@@ -1,6 +1,6 @@
use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state,
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state, redact_proxy_url,
resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, write_file_atomic,
CurlProxyConfig, CurlProxyMode, OfficialFailedVersionRecord, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
@@ -1668,10 +1668,28 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
next_forced_refresh_unix_seconds: status_file
.as_ref()
.and_then(|status| status.next_forced_refresh_unix_seconds),
command: status_file.map(|status| status.command),
command: status_file.map(|status| redact_command_proxy_credentials(status.command)),
})
}
/// Redacts proxy credentials in a saved daemon command before it is surfaced to
/// callers (RPC `status`, `bat status --json`, human output).
///
/// The on-disk status file keeps the raw command because `restart`/`reload`
/// relaunch the daemon from it; only the externally visible copy is redacted so
/// credentials never reach stdout, monitoring, or log collection.
fn redact_command_proxy_credentials(command: Vec<String>) -> Vec<String> {
let mut redacted = command;
for index in 0..redacted.len() {
if redacted[index] == "--proxy" {
if let Some(value) = redacted.get_mut(index + 1) {
*value = redact_proxy_url(value);
}
}
}
redacted
}
#[derive(Debug, Serialize)]
struct DaemonControlReport {
command: &'static str,
@@ -5044,6 +5062,54 @@ mod tests {
assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded");
}
#[test]
fn redacts_proxy_credentials_in_surfaced_command() {
let command = vec![
"/opt/bat".to_string(),
"--auto-discover".to_string(),
"--proxy".to_string(),
"http://user:secret@127.0.0.1:7890".to_string(),
"--output".to_string(),
"/var/lib/bat".to_string(),
];
let redacted = redact_command_proxy_credentials(command);
assert_eq!(redacted[3], "http://<redacted>@127.0.0.1:7890");
assert!(!redacted.join(" ").contains("secret"));
// 非代理参数保持原样。
assert_eq!(redacted[1], "--auto-discover");
assert_eq!(redacted[5], "/var/lib/bat");
}
#[test]
fn redacts_schemeless_proxy_credentials_in_surfaced_command() {
let command = vec![
"/opt/bat".to_string(),
"--proxy".to_string(),
"user:secret@127.0.0.1:7890".to_string(),
];
let redacted = redact_command_proxy_credentials(command);
assert_eq!(redacted[2], "<redacted>@127.0.0.1:7890");
assert!(!redacted.join(" ").contains("secret"));
}
#[test]
fn leaves_command_without_proxy_untouched() {
let command = vec![
"/opt/bat".to_string(),
"--auto-discover".to_string(),
"--proxy".to_string(),
"auto".to_string(),
];
let redacted = redact_command_proxy_credentials(command.clone());
assert_eq!(redacted, command);
}
fn beijing_time(day: u64, hour: u64, minute: u64, second: u64) -> SystemTime {
system_time_from_beijing_local_seconds(
day.saturating_mul(SECONDS_PER_DAY)