fix(aibox): never persist SOCKS5 proxy password to disk (F1/N1, +N11)

- config.set_proxy gains password_env (aligned with api_key_env
  convention): config file persists only the env var name; plaintext
  password lives in memory for the current session only
- _save_config deep-copies and nulls proxy.socks5.password before
  json.dump, so every save path is sanitized (defense in depth)
- get_proxy resolves password as in-memory plaintext first, then via
  password_env from os.environ; downstream consumers (llm_client,
  web tool curl args) unchanged
- legacy migration on load: plaintext found on disk is kept in memory
  for the session, file is immediately rewritten sanitized
- SetProxyTool exposes password_env and reports password source
- registry debug logs mask password/api_key/token/secret/authorization
  kwargs (N11)

Tests: new tests/test_config.py (3 cases: no plaintext on save,
password_env resolution, legacy migration); full suite 267 passed.
Audit report updated with Batch F progress.
This commit is contained in:
qing committed 2026-08-17 22:37:30 +08:00
1 parent a833cd0d5f
commit eb88a77238
5 files changed
+179 -12

No files matched your search

+46 -8
View File
@@ -8,6 +8,7 @@ common.config - aibox 配置管理器
import os import os
import json import json
import copy
from typing import Dict, Optional from typing import Dict, Optional
from .i18n import i18n from .i18n import i18n
@@ -187,6 +188,7 @@ class ConfigManager:
print(i18n.get('config_not_found', config_path)) print(i18n.get('config_not_found', config_path))
self._config = self._get_default_config() self._config = self._get_default_config()
self._save_default_config(config_path) self._save_default_config(config_path)
self._migrate_legacy_proxy_password()
def _merge_configs(self, default: Dict, user: Dict) -> Dict: def _merge_configs(self, default: Dict, user: Dict) -> Dict:
result = default.copy() result = default.copy()
@@ -220,6 +222,28 @@ class ConfigManager:
self._load_config() self._load_config()
def _resolve_proxy_password(self, proxy_conf: Dict) -> Optional[str]:
"""解析代理密码:内存明文优先,否则按 password_env 查环境变量。
对齐 api_key_env 惯例:配置文件只持久化环境变量名,明文密码不落盘。
"""
password = proxy_conf.get("password")
if password:
return password
env_name = proxy_conf.get("password_env")
if env_name:
return os.environ.get(env_name) or None
return None
def _migrate_legacy_proxy_password(self):
"""一次性迁移:旧版把代理密码明文落盘。加载后明文保留在内存供本会话
使用,同时立即重写脱敏配置文件,清除盘上存量明文。"""
socks = (self._config or {}).get("proxy", {}).get("socks5")
if socks and socks.get("password"):
print("⚠️ 检测到配置文件中的代理明文密码:已保留供本次会话使用,不再落盘。"
"跨重启请将密码存入环境变量,并把 proxy.socks5.password_env 指向该变量名")
self._save_config()
def get_proxy(self) -> Optional[Dict[str, str]]: def get_proxy(self) -> Optional[Dict[str, str]]:
"""获取当前代理配置(返回 requests 可用的 proxies 字典)""" """获取当前代理配置(返回 requests 可用的 proxies 字典)"""
proxy_conf = self.get("proxy.socks5") proxy_conf = self.get("proxy.socks5")
@@ -228,15 +252,20 @@ class ConfigManager:
host = proxy_conf.get("host", "127.0.0.1") host = proxy_conf.get("host", "127.0.0.1")
port = proxy_conf.get("port", 1080) port = proxy_conf.get("port", 1080)
username = proxy_conf.get("username") username = proxy_conf.get("username")
password = proxy_conf.get("password") password = self._resolve_proxy_password(proxy_conf)
if username and password: if username and password:
proxy_url = f"socks5://{username}:{password}@{host}:{port}" proxy_url = f"socks5://{username}:{password}@{host}:{port}"
else: else:
proxy_url = f"socks5://{host}:{port}" proxy_url = f"socks5://{host}:{port}"
return {"http": proxy_url, "https": proxy_url} return {"http": proxy_url, "https": proxy_url}
def set_proxy(self, host: str, port: int, username: str = None, password: str = None, enabled: bool = True, save: bool = True): def set_proxy(self, host: str, port: int, username: str = None, password: str = None,
"""动态设置代理配置(内存中更新,并可选择写入配置文件)""" password_env: str = None, enabled: bool = True, save: bool = True):
"""动态设置代理配置(内存中更新,并可选择写入配置文件)。
password 明文仅存内存(_save_config 落盘时强制脱敏);
跨重启持久化请用 password_env 指向存有密码的环境变量名。
"""
# 确保 proxy 节点存在 # 确保 proxy 节点存在
if "proxy" not in self._config: if "proxy" not in self._config:
self._config["proxy"] = {} self._config["proxy"] = {}
@@ -245,31 +274,40 @@ class ConfigManager:
"host": host, "host": host,
"port": port, "port": port,
"username": username, "username": username,
"password": password "password": password,
"password_env": password_env
} }
if save: if save:
self._save_config() self._save_config()
return True return True
def _save_config(self): def _save_config(self):
"""将当前配置保存到配置文件(覆盖)""" """将当前配置保存到配置文件(覆盖)。
落盘前对代理密码做纵深防御脱敏:无论调用路径如何,明文密码一律不写入文件。
"""
config_path = self._config_path config_path = self._config_path
if config_path: if config_path:
try: try:
safe_config = copy.deepcopy(self._config)
socks = safe_config.get("proxy", {}).get("socks5")
if socks and socks.get("password"):
socks["password"] = None
os.makedirs(os.path.dirname(config_path), exist_ok=True) os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f: with open(config_path, 'w', encoding='utf-8') as f:
json.dump(self._config, f, ensure_ascii=False, indent=2) json.dump(safe_config, f, ensure_ascii=False, indent=2)
print(f"✅ 配置已保存到 {config_path}") print(f"✅ 配置已保存到 {config_path}")
except Exception as e: except Exception as e:
print(f"⚠️ 保存配置失败: {e}") print(f"⚠️ 保存配置失败: {e}")
def get_proxy_info(self) -> Dict: def get_proxy_info(self) -> Dict:
"""获取代理配置的详细信息(用于显示)""" """获取代理配置的详细信息(用于显示,不回传任何密码值)"""
proxy_conf = self.get("proxy.socks5", {}) proxy_conf = self.get("proxy.socks5", {})
return { return {
"enabled": proxy_conf.get("enabled", False), "enabled": proxy_conf.get("enabled", False),
"host": proxy_conf.get("host", ""), "host": proxy_conf.get("host", ""),
"port": proxy_conf.get("port", 0), "port": proxy_conf.get("port", 0),
"username": proxy_conf.get("username"), "username": proxy_conf.get("username"),
"password": proxy_conf.get("password") is not None # 只显示是否存在 "password": self._resolve_proxy_password(proxy_conf) is not None, # 只显示是否存在
"password_env": proxy_conf.get("password_env") or ""
} }
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""F1(N1 高危)修复回归测试:代理密码不落盘。
覆盖三点:
1. set_proxy(password=..., save=True) 后配置文件无明文密码(_save_config 脱敏)
2. password_env 解析:get_proxy() 从环境变量取密码拼 socks5:// URL
3. 旧版明文配置迁移:加载后盘上明文被清除,内存中本会话仍可用
"""
import json
import pytest
from common.config import ConfigManager
@pytest.fixture
def fresh_config(tmp_path, monkeypatch):
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
cfg_file = tmp_path / "config.json"
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
def _reset():
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
_reset()
yield ConfigManager
_reset()
def _read_cfg_file() -> dict:
path = ConfigManager()._config_path
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def test_set_proxy_sanitizes_password_on_save(fresh_config, tmp_path):
"""save=True 落盘后,文件中不得出现明文密码(纵深防御脱敏)"""
cfg = fresh_config()
cfg.set_proxy("127.0.0.1", 1080, username="u1", password="secret123",
password_env="MY_SOCKS_PW", save=True)
on_disk = _read_cfg_file()
socks = on_disk["proxy"]["socks5"]
assert socks["password"] in (None, ""), "落盘文件不得含明文密码"
assert socks["password_env"] == "MY_SOCKS_PW", "password_env 应持久化"
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "secret123" not in raw, "整个配置文件不得出现明文密码字符串"
# 内存中本会话仍可用
proxies = cfg.get_proxy()
assert proxies is not None
assert "u1:secret123@" in proxies["https"]
def test_password_env_resolution(fresh_config, monkeypatch):
"""password 为空时按 password_env 从环境变量解析"""
monkeypatch.setenv("TEST_SOCKS_PW", "env-secret-42")
cfg = fresh_config()
cfg.set_proxy("10.0.0.8", 7890, username="u2", password=None,
password_env="TEST_SOCKS_PW", save=True)
proxies = cfg.get_proxy()
assert proxies is not None
assert "u2:env-secret-42@10.0.0.8:7890" in proxies["https"]
# get_proxy_info 不泄漏密码值,只给布尔 + 变量名
info = cfg.get_proxy_info()
assert info["password"] is True
assert info["password_env"] == "TEST_SOCKS_PW"
def test_legacy_plaintext_migration(fresh_config, tmp_path):
"""旧版明文配置:加载后盘上清除,内存保留供本会话使用"""
cfg_file = tmp_path / "config.json"
legacy = {
"proxy": {
"socks5": {
"enabled": True, "host": "1.2.3.4", "port": 1080,
"username": "old_u", "password": "legacy_plain_pw"
}
}
}
cfg_file.write_text(json.dumps(legacy, ensure_ascii=False), encoding="utf-8")
cfg = fresh_config() # 触发 _load_config → 迁移
# 盘上已脱敏
on_disk = _read_cfg_file()
assert on_disk["proxy"]["socks5"]["password"] in (None, "")
raw = cfg_file.read_text(encoding="utf-8")
assert "legacy_plain_pw" not in raw
# 内存中本会话仍可解析出旧密码(代理不断)
proxies = cfg.get_proxy()
assert proxies is not None
assert "old_u:legacy_plain_pw@1.2.3.4:1080" in proxies["https"]
+11 -3
View File
@@ -34,7 +34,8 @@ class SetProxyTool(Tool):
"host": {"type": "string", "description": "代理服务器地址,如 127.0.0.1"}, "host": {"type": "string", "description": "代理服务器地址,如 127.0.0.1"},
"port": {"type": "integer", "description": "代理端口,如 1080"}, "port": {"type": "integer", "description": "代理端口,如 1080"},
"username": {"type": "string", "description": "用户名(可选)"}, "username": {"type": "string", "description": "用户名(可选)"},
"password": {"type": "string", "description": "密码(可选)"}, "password": {"type": "string", "description": "密码(可选,明文仅本次会话有效,不落盘)"},
"password_env": {"type": "string", "description": "存有密码的环境变量名(可选,跨重启持久的推荐方式)"},
"enabled": {"type": "boolean", "description": "是否启用代理,默认 true"}, "enabled": {"type": "boolean", "description": "是否启用代理,默认 true"},
"save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"} "save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"}
}, },
@@ -42,14 +43,21 @@ class SetProxyTool(Tool):
} }
def execute(self, host: str, port: int, username: str = None, password: str = None, def execute(self, host: str, port: int, username: str = None, password: str = None,
enabled: bool = True, save: bool = True, **kwargs) -> str: password_env: str = None, enabled: bool = True, save: bool = True, **kwargs) -> str:
config = ConfigManager() config = ConfigManager()
config.set_proxy(host, port, username, password, enabled, save) config.set_proxy(host, port, username, password, password_env, enabled, save)
proxy_info = config.get_proxy_info() proxy_info = config.get_proxy_info()
if proxy_info.get("password_env"):
pw_source = "环境变量 " + proxy_info["password_env"]
elif proxy_info.get("password"):
pw_source = "仅本次会话(不落盘)"
else:
pw_source = "无"
data = { data = {
"代理状态": "启用" if proxy_info["enabled"] else "禁用", "代理状态": "启用" if proxy_info["enabled"] else "禁用",
"服务器": f"{proxy_info['host']}:{proxy_info['port']}", "服务器": f"{proxy_info['host']}:{proxy_info['port']}",
"认证": "有" if proxy_info.get("username") else "无", "认证": "有" if proxy_info.get("username") else "无",
"密码来源": pw_source,
"持久化": "已保存" if save else "仅内存" "持久化": "已保存" if save else "仅内存"
} }
return self.format_result(True, "代理设置成功", data) return self.format_result(True, "代理设置成功", data)
+12 -1
View File
@@ -15,6 +15,17 @@ from common import Logger, i18n
from .base import Tool from .base import Tool
# 工具参数名命中这些关键词时,debug 日志里打码(防密码/密钥进日志文件)
_SENSITIVE_PARAM_KEYS = {"password", "api_key", "token", "secret", "authorization"}
def _sanitize_kwargs(kwargs: Dict) -> Dict:
"""复制一份参数并把敏感键的值替换为 ***(仅用于日志输出)"""
return {
k: ("***" if str(k).lower() in _SENSITIVE_PARAM_KEYS else v)
for k, v in kwargs.items()
}
class ToolRegistry: class ToolRegistry:
"""工具注册器 - 支持技能热重载""" """工具注册器 - 支持技能热重载"""
@@ -63,7 +74,7 @@ class ToolRegistry:
if not tool: if not tool:
return f"❌ 错误:找不到工具 '{name}'" return f"❌ 错误:找不到工具 '{name}'"
self.logger.debug(f"工具调用: {name}({kwargs})") self.logger.debug(f"工具调用: {name}({_sanitize_kwargs(kwargs)})")
start_time = time.time() start_time = time.time()
try: try:
+10
View File
@@ -209,3 +209,13 @@ aibox 全量单测 **264 passed**(零回归)。
4. **F4(中)**:N2 drain_thread 加 sentinel 退出 + N3 reset_conversation 移出事件循环(run_in_executor) 4. **F4(中)**:N2 drain_thread 加 sentinel 退出 + N3 reset_conversation 移出事件循环(run_in_executor)
5. **F5(中)**:N7 FTS5 转义 + N8 流式截断 + N10 同事务读写 + 顺带修 L1(两处 LIMIT 参数化)+ L3(finally wait) 5. **F5(中)**:N7 FTS5 转义 + N8 流式截断 + N10 同事务读写 + 顺带修 L1(两处 LIMIT 参数化)+ L3(finally wait)
6. **F6(低)**:N9 prompt 内容过滤/确认门槛 + N11 日志脱敏 + N12 persona 路径校验 + N13/N14/N15/N16/N17 择机处理 + M3/M4 视部署平台决定 6. **F6(低)**:N9 prompt 内容过滤/确认门槛 + N11 日志脱敏 + N12 persona 路径校验 + N13/N14/N15/N16/N17 择机处理 + M3/M4 视部署平台决定
### 8.8 Batch F 修复进展
**F1 ✅ 已落地(2026-08-18)**——N1 + N11 一并修复:
- `common/config.py`:`set_proxy` 新增 `password_env` 参数;`_save_config` 落盘前 deepcopy 脱敏(明文密码强制置 None,纵深防御,任何保存路径均拦截);`get_proxy` 按"内存明文优先 → password_env 查环境变量"解析,下游(llm_client / web 工具 `_proxy_curl_args`)无感知;加载时检测旧版明文自动迁移(内存保留供本会话 + 立即重写脱敏文件,一次性清除存量)
- `tools/proxy_tools.py`:`SetProxyTool` 新增 `password_env` 参数,结果显示"密码来源"(环境变量 / 仅本次会话 / 无)
- `tools/registry.py`(N11):debug 日志对 `password/api_key/token/secret/authorization` 参数打码
- 测试:新增 `tests/test_config.py` 3 例(落盘无明文 / password_env 解析 / 旧配置迁移),全量 267 passed
- 行为变化:已有明文密码的配置重启后代理需重设密码或改用环境变量(迁移日志有提示)——清除存量明文的必然代价