feat: 完成T-503敏感信息提示与脱敏

- 保存或变更账号密码、AI API Key 前弹出本地明文保存提示

- 新增敏感值打码、结构化日志脱敏和自由文本替换工具

- 补充 GUI/appconfig 单测,覆盖明文提示和脱敏边界

- 同步任务看板、当前状态、架构、API、路由和编码规则文档
This commit is contained in:
chengma
2026-06-29 10:05:16 +08:00
parent 82d390c25a
commit 01e319cad8
13 changed files with 250 additions and 40 deletions
+58 -10
View File
@@ -116,9 +116,7 @@ def _assert_no_secrets(config):
if isinstance(value, dict):
for key, child in value.items():
lowered = str(key).lower()
if lowered in SECRET_FIELD_NAMES or lowered.endswith(
("_key", "_token", "_password")
):
if _is_secret_field_name(lowered):
raise ConfigError(
f"config.json 不允许保存敏感字段: {'.'.join(path + [str(key)])}"
)
@@ -130,6 +128,57 @@ def _assert_no_secrets(config):
visit(config, [])
def _is_secret_field_name(name) -> bool:
lowered = str(name).lower()
return lowered in SECRET_FIELD_NAMES or lowered.endswith(
("_key", "_token", "_password")
)
def mask_secret(secret) -> str:
"""Return a display-safe representation of a secret value."""
if secret is None:
return ""
if isinstance(secret, (dict, list, tuple, set)):
return "***" if secret else ""
text = str(secret or "")
if not text:
return ""
if len(text) <= 8:
return "***"
return f"{text[:4]}***{text[-4:]}"
def sanitize_for_log(value):
"""Return a copy of value with secret fields masked for logs/status/export."""
if isinstance(value, dict):
sanitized = {}
for key, child in value.items():
if _is_secret_field_name(key):
sanitized[key] = mask_secret(child)
else:
sanitized[key] = sanitize_for_log(child)
return sanitized
if isinstance(value, list):
return [sanitize_for_log(item) for item in value]
if isinstance(value, tuple):
return tuple(sanitize_for_log(item) for item in value)
return value
def redact_secrets(text, secret_values=None) -> str:
"""Replace known secret values inside free-form text."""
redacted = str(text)
for secret in secret_values or []:
secret_text = str(secret or "")
if secret_text:
redacted = redacted.replace(secret_text, "***")
return redacted
def save_config(config, path=CONFIG_PATH) -> dict:
"""Persist config to JSON and return the normalized config."""
@@ -281,11 +330,7 @@ def _model_index(models, name):
def _mask_api_key(api_key):
if not api_key:
return ""
if len(api_key) <= 8:
return "***"
return f"{api_key[:4]}***{api_key[-4:]}"
return mask_secret(api_key)
def _public_model(model):
@@ -393,6 +438,7 @@ def _test_request_payload(model):
def test_ai_model(name, path=AI_MODELS_PATH) -> dict:
"""Send a minimal request to the configured model endpoint."""
model = None
try:
model = get_model(name, path=path)
missing = [
@@ -422,6 +468,8 @@ def test_ai_model(name, path=AI_MODELS_PATH) -> dict:
except urllib.error.HTTPError as exc:
return {"ok": False, "status": exc.code, "error": f"HTTP {exc.code}"}
except urllib.error.URLError as exc:
return {"ok": False, "error": str(exc.reason)}
secret_values = [model.get("api_key")] if model else []
return {"ok": False, "error": redact_secrets(str(exc.reason), secret_values)}
except Exception as exc:
return {"ok": False, "error": str(exc)}
secret_values = [model.get("api_key")] if model else []
return {"ok": False, "error": redact_secrets(str(exc), secret_values)}