feat: 完成T-503敏感信息提示与脱敏
- 保存或变更账号密码、AI API Key 前弹出本地明文保存提示 - 新增敏感值打码、结构化日志脱敏和自由文本替换工具 - 补充 GUI/appconfig 单测,覆盖明文提示和脱敏边界 - 同步任务看板、当前状态、架构、API、路由和编码规则文档
This commit is contained in:
+58
-10
@@ -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)}
|
||||
|
||||
+47
-3
@@ -85,6 +85,17 @@ if QT_IMPORT_ERROR is None:
|
||||
from . import config as account_config
|
||||
|
||||
|
||||
PLAINTEXT_SECRET_TITLE = "本地明文保存提示"
|
||||
PLAINTEXT_API_KEY_WARNING = (
|
||||
"API Key 会以本地明文保存到 config/ai_models.json,仅供本机调用 AI 使用。"
|
||||
"该文件已 gitignore,UI 打码显示,日志/导出不记录明文。"
|
||||
)
|
||||
PLAINTEXT_PASSWORD_WARNING = (
|
||||
"密码会以本地明文保存到本地 SQLite,仅供人工参考,不会自动登录/自动填。"
|
||||
"数据库文件已 gitignore,请勿提交或分享。"
|
||||
)
|
||||
|
||||
|
||||
def _database_path(db_path=None, config=None) -> str:
|
||||
return db_path or appconfig.db_path(config)
|
||||
|
||||
@@ -2496,7 +2507,7 @@ if QT_IMPORT_ERROR is None:
|
||||
|
||||
def execute(self):
|
||||
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
|
||||
payload = dict(result or {})
|
||||
payload = dict(appconfig.sanitize_for_log(result or {}))
|
||||
payload["name"] = self.model_name
|
||||
return payload
|
||||
|
||||
@@ -2787,6 +2798,9 @@ if QT_IMPORT_ERROR is None:
|
||||
model = self._form_values()
|
||||
if model is None:
|
||||
return
|
||||
current = self._current_model()
|
||||
if self._should_warn_plaintext_api_key(model, current):
|
||||
self._show_plaintext_api_key_warning()
|
||||
try:
|
||||
if self.current_model_name is None:
|
||||
appconfig.add_ai_model(model, path=self.ai_models_path)
|
||||
@@ -3141,6 +3155,18 @@ if QT_IMPORT_ERROR is None:
|
||||
QMessageBox.warning(self, "设置", message)
|
||||
self._set_status(message)
|
||||
|
||||
def _should_warn_plaintext_api_key(self, model, current):
|
||||
new_key = str((model or {}).get("api_key") or "")
|
||||
current_key = str((current or {}).get("api_key") or "")
|
||||
return bool(new_key) and new_key != current_key
|
||||
|
||||
def _show_plaintext_api_key_warning(self):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
PLAINTEXT_SECRET_TITLE,
|
||||
PLAINTEXT_API_KEY_WARNING,
|
||||
)
|
||||
|
||||
def _current_model(self):
|
||||
return self._model_by_name(self.current_model_name)
|
||||
|
||||
@@ -3293,11 +3319,14 @@ if QT_IMPORT_ERROR is None:
|
||||
dialog = AccountDialog(self, default_port=default_port, config=self.config)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
values = dialog.values()
|
||||
if self._should_warn_plaintext_password(values):
|
||||
self._show_plaintext_password_warning()
|
||||
try:
|
||||
accounts.create_account(
|
||||
path=self.db_path,
|
||||
config=self.config,
|
||||
**dialog.values(),
|
||||
**values,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
@@ -3317,12 +3346,15 @@ if QT_IMPORT_ERROR is None:
|
||||
)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
values = dialog.values()
|
||||
if self._should_warn_plaintext_password(values, account):
|
||||
self._show_plaintext_password_warning()
|
||||
try:
|
||||
updated = accounts.update_account(
|
||||
account.alias,
|
||||
path=self.db_path,
|
||||
config=self.config,
|
||||
**dialog.values(),
|
||||
**values,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
@@ -3421,6 +3453,18 @@ if QT_IMPORT_ERROR is None:
|
||||
self._set_status(f"快捷方式已生成:{shortcut_path}")
|
||||
QMessageBox.information(self, "账号管理", f"快捷方式已生成:\n{shortcut_path}")
|
||||
|
||||
def _should_warn_plaintext_password(self, values, account=None):
|
||||
new_password = str((values or {}).get("password") or "")
|
||||
current_password = str(getattr(account, "password", None) or "")
|
||||
return bool(new_password) and new_password != current_password
|
||||
|
||||
def _show_plaintext_password_warning(self):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
PLAINTEXT_SECRET_TITLE,
|
||||
PLAINTEXT_PASSWORD_WARNING,
|
||||
)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with the fixed five-tab workflow."""
|
||||
|
||||
Reference in New Issue
Block a user