1use regex::Regex;
38use std::sync::OnceLock;
39
40pub fn account_log_fingerprint(account: &str) -> String {
51 futu_core::log_redact::account_log_fingerprint(account)
52}
53
54pub fn device_id_log_fingerprint(device_id: &str) -> String {
56 let digest = md5::compute(device_id.as_bytes());
57 let hex = format!("{:x}", digest);
58 format!("dev-{}", &hex[..8])
59}
60
61pub fn uid_log_fingerprint(uid: u64) -> String {
63 futu_core::log_redact::uid_log_fingerprint(uid)
64}
65
66pub const SENSITIVE_FIELDS: &[&str] = &[
71 "device_verify_sig",
73 "device_code_sig",
74 "device_code",
75 "device_sig_new",
76 "device_sig",
77 "device_id",
78 "moomoo_web_sig_new",
79 "moomoo_client_sig",
80 "moomoo_client_key",
81 "tgtgt_new",
82 "tgtgt",
83 "rand_key_new",
84 "rand_key",
85 "client_sig",
86 "client_key",
87 "web_sig_new",
88 "web_sig",
89 "ci_sig",
90 "salt32",
91 "salt",
92 "pwd_md5",
93 "password",
94 "pwd",
95 "auth_token",
96 "auth_code",
97 "client_token",
98 "session_id",
99 "tgtgt_b64",
101 "aes_key",
102 "s2", "s3", "pass_wd",
105];
106
107const ACCOUNT_IDENTITY_FIELDS: &[&str] = &["account"];
108const UID_IDENTITY_FIELDS: &[&str] = &["uid", "user_id", "customer_id", "real_uid", "origin_uid"];
109
110pub fn redact_auth_body(body: &str) -> String {
122 let mut result = body.to_string();
123 for field in SENSITIVE_FIELDS {
124 result = redact_json_field(&result, field);
126 result = redact_kv_field(&result, field);
128 }
129 for field in ACCOUNT_IDENTITY_FIELDS {
130 result = redact_json_identity_field(&result, field, false);
131 result = redact_kv_identity_field(&result, field, false);
132 }
133 for field in UID_IDENTITY_FIELDS {
134 result = redact_json_identity_field(&result, field, true);
135 result = redact_kv_identity_field(&result, field, true);
136 }
137 result
138}
139
140fn identity_fingerprint(value: &str, uid_namespace: bool) -> String {
141 if uid_namespace {
142 futu_core::log_redact::uid_text_log_fingerprint(value)
143 } else {
144 futu_core::log_redact::account_log_fingerprint(value)
145 }
146}
147
148fn redact_json_identity_field(s: &str, field: &str, uid_namespace: bool) -> String {
149 let quoted_pattern = format!(r#""{}"\s*:\s*"((?:[^"\\]|\\.)*)""#, regex::escape(field));
150 let quoted = match Regex::new("ed_pattern) {
151 Ok(re) => re,
152 Err(err) => return fail_closed_redacted_body(s, field, "json-identity", err),
153 };
154 let result = quoted
155 .replace_all(s, |caps: ®ex::Captures| {
156 let value = caps.get(1).map(|matched| matched.as_str()).unwrap_or("");
157 let fingerprint = identity_fingerprint(value, uid_namespace);
158 format!(r#""{field}":"{fingerprint}""#)
159 })
160 .to_string();
161
162 let numeric_pattern = format!(r#"("{}"\s*:\s*)(-?[0-9]+)"#, regex::escape(field));
163 let numeric = match Regex::new(&numeric_pattern) {
164 Ok(re) => re,
165 Err(err) => return fail_closed_redacted_body(&result, field, "json-identity", err),
166 };
167 numeric
168 .replace_all(&result, |caps: ®ex::Captures| {
169 let prefix = caps.get(1).map(|matched| matched.as_str()).unwrap_or("");
170 let value = caps.get(2).map(|matched| matched.as_str()).unwrap_or("");
171 let fingerprint = identity_fingerprint(value, uid_namespace);
172 format!(r#"{prefix}"{fingerprint}""#)
173 })
174 .to_string()
175}
176
177fn redact_kv_identity_field(s: &str, field: &str, uid_namespace: bool) -> String {
178 let pattern = format!(r"(^|[\s&?]){}=([^&\s,\n\r]+)", regex::escape(field));
179 let re = match Regex::new(&pattern) {
180 Ok(re) => re,
181 Err(err) => return fail_closed_redacted_body(s, field, "kv-identity", err),
182 };
183 re.replace_all(s, |caps: ®ex::Captures| {
184 let prefix = caps.get(1).map(|matched| matched.as_str()).unwrap_or("");
185 let value = caps.get(2).map(|matched| matched.as_str()).unwrap_or("");
186 let fingerprint = identity_fingerprint(value, uid_namespace);
187 format!("{prefix}{field}={fingerprint}")
188 })
189 .to_string()
190}
191
192fn redact_json_field(s: &str, field: &str) -> String {
197 let pattern = format!(r#""{}"\s*:\s*"((?:[^"\\]|\\.)*)""#, regex::escape(field));
199 let re = match Regex::new(&pattern) {
200 Ok(re) => re,
201 Err(err) => return fail_closed_redacted_body(s, field, "json", err),
202 };
203 re.replace_all(s, |caps: ®ex::Captures| {
204 let value_len = caps.get(1).map(|m| m.as_str().len()).unwrap_or(0);
205 format!(r#""{field}":"<REDACTED len={value_len}>""#)
206 })
207 .to_string()
208}
209
210fn redact_kv_field(s: &str, field: &str) -> String {
214 let pattern = format!(r"(^|[\s&?]){}=([^&\s,\n\r]+)", regex::escape(field));
217 let re = match Regex::new(&pattern) {
218 Ok(re) => re,
219 Err(err) => return fail_closed_redacted_body(s, field, "kv", err),
220 };
221 re.replace_all(s, |caps: ®ex::Captures| {
222 let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or("");
223 let value_len = caps.get(2).map(|m| m.as_str().len()).unwrap_or(0);
224 format!("{prefix}{field}=<REDACTED len={value_len}>")
225 })
226 .to_string()
227}
228
229fn fail_closed_redacted_body(s: &str, field: &str, style: &str, err: regex::Error) -> String {
230 tracing::error!(
231 field,
232 style,
233 error = %err,
234 "auth redaction regex compile failed; redacting whole body"
235 );
236 format!("<REDACTED auth body len={}>", s.len())
237}
238
239static DEBUG_WARN_EMITTED: OnceLock<()> = OnceLock::new();
244
245pub fn emit_debug_log_security_warn_once() {
246 DEBUG_WARN_EMITTED.get_or_init(|| {
247 eprintln!(
251 "⚠️ SECURITY (v1.4.84 SEC-001): auth debug log 包含 redacted \
252 credentials (tgtgt / salt / device_sig / pwd_md5 等). Log 文件\n\
253 可能被 malware / 恶意 agent skill 读取. 建议:\n\
254 1. 生产环境使用 --log-level info (不 debug)\n\
255 2. 永远不要分享 debug log (GitHub issue / Slack)\n\
256 3. Log 路径放 0700 目录 (默认 /tmp 是 1777 world-readable)\n\
257 v1.4.84 起 auth body 字段中的 credential 已 redact 为 <REDACTED \
258 len=N>,登录 identity 仅保留 fingerprint. 但 log 分享仍可能泄 IP 等上下文.\n"
259 );
260 });
261}
262
263#[cfg(test)]
264mod tests;