futu_backend/auth/redact.rs
1//! v1.4.84 SEC-001 fix: Auth credential redaction for debug log output.
2//!
3//! **背景**: eli security report SEC-001 证实 daemon `--log-level debug` 时
4//! `/tmp/*.log` 明文写入 `tgtgt` / `salt` / `device_sig` 等完整 auth credentials.
5//! 80+ 历史 log 文件暴露 tgtgt. agent 时代, 恶意 skill 一行 `cat /tmp/*.log
6//! | grep tgtgt` 就能拿凭据.
7//!
8//! **修法**: 这个模块提供 `redact_auth_body(&str) -> String` / `redact_kv(&str,
9//! key) -> String` / `redact_auth_json_value(&mut Value)` 三个 helper, 替换
10//! 敏感字段 value 为 `"<REDACTED len=N>"`. 所有 auth debug / info log 打印
11//! body / response 前必须过这些 helper.
12//!
13//! **敏感字段清单** (redact 时替换 value):
14//! - `tgtgt` / `tgtgt_new` — 172 byte base64 AES-256 auth payload
15//! - `salt` / `salt32` — 16/32 char server-provided nonce (TGTGT key 派生)
16//! - `client_sig` / `client_key` / `rand_key` / `rand_key_new` — session keys
17//! - `device_sig` / `device_sig_new` — device 签名 (credentials 持久化)
18//! - `device_verify_sig` / `device_code` / `device_code_sig` — SMS 2FA 凭据
19//! - `pwd` / `pwd_md5` / `password` — 密码 / MD5
20//! - `auth_token` / `session_id` — session-level tokens
21//! - `web_sig_new` / `ci_sig` — web session tokens
22//!
23//! **非敏感字段**保留明文: account / device_id / device_alias / device_type
24//! / os_ver / sens_state / uid / svr_time / user_attribution / region_no
25//! / is_phone — 这些是**identity / context** 不是 credential, 保留方便 debug.
26//!
27//! **何时调用**:
28//! - `crates/futu-backend/src/auth/mod.rs`: L765 salt / L883 raw response /
29//! L1071 verify_response / L1203 POST body
30//! - 其他任何 `tracing::debug!` / `info!` 打印 response / body / header 的
31//! log point — 改为 `redact_auth_body` 包装
32
33use regex::Regex;
34use std::sync::OnceLock;
35
36// ============================================================================
37// v1.4.106 codex 0558 F2+F3: PII fingerprint helpers for log fields
38// ============================================================================
39//
40// SEC-001 (v1.4.84) 已 redact auth body 中的 credentials, 但**结构化
41// log 字段** (e.g. `tracing::info!(account = %config.account, ...)`,
42// `device_id = %device_id`, `uid = cred.uid`) 仍以**明文**写入 stdout / 文件.
43// 这些 helper 把 raw PII 哈希成短指纹, 让 log 仍能跨行关联但不可反推真值.
44
45/// v1.4.106 F2: account 字符串 -> 12-char fingerprint `acc-{8-hex}`.
46pub fn account_log_fingerprint(account: &str) -> String {
47 let digest = md5::compute(account.as_bytes());
48 let hex = format!("{:x}", digest);
49 format!("acc-{}", &hex[..8])
50}
51
52/// v1.4.106 F2: device_id (16-hex) -> 12-char fingerprint `dev-{8-hex}`.
53pub fn device_id_log_fingerprint(device_id: &str) -> String {
54 let digest = md5::compute(device_id.as_bytes());
55 let hex = format!("{:x}", digest);
56 format!("dev-{}", &hex[..8])
57}
58
59/// v1.4.106 F3: uid (u64) -> 12-char fingerprint `uid-{8-hex}`.
60pub fn uid_log_fingerprint(uid: u64) -> String {
61 let digest = md5::compute(uid.to_string().as_bytes());
62 let hex = format!("{:x}", digest);
63 format!("uid-{}", &hex[..8])
64}
65
66/// v1.4.84 SEC-001: 敏感字段名清单 (case-sensitive, 匹配 JSON key 或 URL param).
67///
68/// 按字母顺序 + 长 name 优先(避免 `tgtgt_new` 被 `tgtgt` 先 match 误 redact 成
69/// `<REDACTED len=X>_new` 这种怪格式).
70pub const SENSITIVE_FIELDS: &[&str] = &[
71 // 长 name 优先 (sub-string 避免)
72 "device_verify_sig",
73 "device_code_sig",
74 "device_code",
75 "device_sig_new",
76 "device_sig",
77 "tgtgt_new",
78 "tgtgt",
79 "rand_key_new",
80 "rand_key",
81 "client_sig",
82 "client_key",
83 "web_sig_new",
84 "web_sig",
85 "ci_sig",
86 "salt32",
87 "salt",
88 "pwd_md5",
89 "password",
90 "pwd",
91 "auth_token",
92 "session_id",
93 // Phase 4 补强 (v1.4.84 增)
94 "tgtgt_b64",
95 "aes_key",
96 "s2", // S2 key 派生中间状态 (auth_cryptor)
97 "s3", // S3 key 派生中间状态
98 "pass_wd",
99];
100
101/// v1.4.84 SEC-001: 给一段 JSON-like / key=value text 做 redaction.
102///
103/// 支持两种格式:
104/// 1. JSON body: `{"tgtgt":"abc","account":"xxx"}` → `{"tgtgt":"<REDACTED
105/// len=3>","account":"xxx"}`
106/// 2. URL-encoded form: `tgtgt=abc&account=xxx` → `tgtgt=<REDACTED len=3>&account=xxx`
107/// 3. 自由格式 (key=value 空格分隔): `salt=abc svr_time=123` → `salt=<REDACTED
108/// len=3> svr_time=123`
109///
110/// 非敏感字段 (account / device_id / os_ver / uid / svr_time / ...)
111/// 保留原值不动.
112pub fn redact_auth_body(body: &str) -> String {
113 let mut result = body.to_string();
114 for field in SENSITIVE_FIELDS {
115 // JSON style: "field":"value"
116 result = redact_json_field(&result, field);
117 // URL / form style: field=value& or field=value (end/whitespace)
118 result = redact_kv_field(&result, field);
119 }
120 result
121}
122
123/// Redact JSON style: `"field":"value"` → `"field":"<REDACTED len=N>"`.
124///
125/// Greedy match value until closing quote (escaped quotes handled by regex
126/// `\\.`). Re-uses compiled regex via OnceLock per field.
127fn redact_json_field(s: &str, field: &str) -> String {
128 // Escape field name for regex literal
129 let pattern = format!(r#""{}"\s*:\s*"((?:[^"\\]|\\.)*)""#, regex::escape(field));
130 let re = match Regex::new(&pattern) {
131 Ok(re) => re,
132 Err(err) => return fail_closed_redacted_body(s, field, "json", err),
133 };
134 re.replace_all(s, |caps: ®ex::Captures| {
135 let value_len = caps.get(1).map(|m| m.as_str().len()).unwrap_or(0);
136 format!(r#""{field}":"<REDACTED len={value_len}>""#)
137 })
138 .to_string()
139}
140
141/// Redact key=value style (URL query / key=value text):
142/// `field=value&` or `field=value(end)` or `field=value(whitespace)` →
143/// `field=<REDACTED len=N>`.
144fn redact_kv_field(s: &str, field: &str) -> String {
145 // Match: (^|[\s&?]) field = value(终止:& 或 空白 或 EOL)
146 // value 截止到 `&` / space / tab / newline / `,` / EOL
147 let pattern = format!(r"(^|[\s&?]){}=([^&\s,\n\r]+)", regex::escape(field));
148 let re = match Regex::new(&pattern) {
149 Ok(re) => re,
150 Err(err) => return fail_closed_redacted_body(s, field, "kv", err),
151 };
152 re.replace_all(s, |caps: ®ex::Captures| {
153 let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or("");
154 let value_len = caps.get(2).map(|m| m.as_str().len()).unwrap_or(0);
155 format!("{prefix}{field}=<REDACTED len={value_len}>")
156 })
157 .to_string()
158}
159
160fn fail_closed_redacted_body(s: &str, field: &str, style: &str, err: regex::Error) -> String {
161 tracing::error!(
162 field,
163 style,
164 error = %err,
165 "auth redaction regex compile failed; redacting whole body"
166 );
167 format!("<REDACTED auth body len={}>", s.len())
168}
169
170/// v1.4.84 SEC-001: 单例 shared emit 打开 `--log-level debug` 时 stderr warn.
171///
172/// Caller 在 `auth::authenticate` 入口第一次进来时调. OnceLock 保证只打一次
173/// 避免每次 auth retry 重复 spam.
174static DEBUG_WARN_EMITTED: OnceLock<()> = OnceLock::new();
175
176pub fn emit_debug_log_security_warn_once() {
177 DEBUG_WARN_EMITTED.get_or_init(|| {
178 // Check if debug-level tracing is enabled. Use tracing LevelFilter check
179 // through a runtime hook. For simplicity we just always emit — auth
180 // path 只在 startup 走一次, cost negligible.
181 eprintln!(
182 "⚠️ SECURITY (v1.4.84 SEC-001): auth debug log 包含 redacted \
183 credentials (tgtgt / salt / device_sig / pwd_md5 等). Log 文件\n\
184 可能被 malware / 恶意 agent skill 读取. 建议:\n\
185 1. 生产环境使用 --log-level info (不 debug)\n\
186 2. 永远不要分享 debug log (GitHub issue / Slack)\n\
187 3. Log 路径放 0700 目录 (默认 /tmp 是 1777 world-readable)\n\
188 v1.4.84 起 auth body 字段中的 credential 已 redact 为 <REDACTED \
189 len=N>. 但 log 分享仍可能泄 account / device_id / IP.\n"
190 );
191 });
192}
193
194#[cfg(test)]
195mod tests;