fix(aibox): never fall back to deepseek.key for model profile endpoints (H1)
~/.cert/deepseek.key is a DeepSeek-only credential; when a models profile specified its own api_key_env (e.g. MOONSHOT_API_KEY) but the env var was unset, resolve_api_key silently read that file and sent the DeepSeek key as Bearer to a third-party endpoint (cross-vendor credential leak). resolve_api_key gains allow_key_file (default True for the legacy flat api section); create_llm_client passes allow_key_file=profile is None so any profile path raises a clear RuntimeError instead, pointing users to set the env var or use "" for local keyless endpoints. Tests: new TestH1KeyIsolation (4 cases) with a mocked readable deepseek.key proving the profile path errors out while the flat api section still reads the file; full suite 276 passed.
This commit is contained in:
1 parent
58725c8a12
commit
4480bce684
3 files changed
+68
-3
No files matched your search
+1
-1
@@ -548,7 +548,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
|---|---|
|
||||
| `api_url` | 端点 URL(OpenAI Chat Completions 协议) |
|
||||
| `model` | 该档案使用的模型名 |
|
||||
| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM) |
|
||||
| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM)。档案端点环境变量取不到时**直接报错,绝不回退 `~/.cert/deepseek.key`**——该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏(key 文件回退仅对无档案的旧 `api` 扁平节生效) |
|
||||
| `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) |
|
||||
| `timeout` | 请求超时秒数 |
|
||||
|
||||
|
||||
+11
-2
@@ -221,13 +221,17 @@ class DeepSeekClient(LLMClient):
|
||||
|
||||
def resolve_api_key(api_key: Optional[str], config=None,
|
||||
env_name: Optional[str] = None,
|
||||
allow_missing: bool = False) -> str:
|
||||
allow_missing: bool = False,
|
||||
allow_key_file: bool = True) -> str:
|
||||
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。
|
||||
|
||||
env_name: 档案级环境变量名(来自 models.<name>.api_key_env);
|
||||
None 时回退 config 的 api.api_key_env。
|
||||
allow_missing: 本地免密端点(models.<name>.api_key_env 为空串)时,
|
||||
允许无 Key,返回空串(Bearer 空头,Ollama/vLLM 忽略)。
|
||||
allow_key_file: 是否允许回退读取 ~/.cert/deepseek.key。该文件是 DeepSeek
|
||||
专属凭据,模型档案端点(其他厂商)必须传 False——环境变量取不到
|
||||
时直接报错,绝不把 A 厂商 Key 发给 B 厂商(跨厂商凭据泄漏)。
|
||||
"""
|
||||
if api_key:
|
||||
return api_key
|
||||
@@ -244,6 +248,10 @@ def resolve_api_key(api_key: Optional[str], config=None,
|
||||
return env_key
|
||||
if allow_missing:
|
||||
return ""
|
||||
if not allow_key_file:
|
||||
raise RuntimeError(
|
||||
f"模型档案端点缺少 API Key:请设置环境变量 {env_name},"
|
||||
f"或将该档案的 api_key_env 配置为空串以启用本地免密端点")
|
||||
try:
|
||||
home = os.environ.get("HOME", os.environ["USERPROFILE"])
|
||||
key_path = os.path.join(home, ".cert", "deepseek.key")
|
||||
@@ -314,7 +322,8 @@ def create_llm_client(config, api_key: Optional[str] = None,
|
||||
timeout = config.get("api.timeout", 30)
|
||||
|
||||
resolved_key = resolve_api_key(api_key, config, env_name=env_name,
|
||||
allow_missing=allow_missing)
|
||||
allow_missing=allow_missing,
|
||||
allow_key_file=profile is None)
|
||||
|
||||
if profile is not None:
|
||||
provider = "openai_compatible"
|
||||
|
||||
@@ -193,6 +193,62 @@ class TestResolveApiKey:
|
||||
assert resolve_api_key("sk-explicit", _cfg()) == "sk-explicit"
|
||||
|
||||
|
||||
def _key_file_readable(path, *args, **kwargs):
|
||||
"""mock open:deepseek.key 存在且可读(返回假 key),证明泄漏路径是否被触发。"""
|
||||
if "deepseek.key" in str(path):
|
||||
import io
|
||||
m = io.StringIO("sk-deepseek-leaked")
|
||||
m.name = str(path)
|
||||
return m
|
||||
raise OSError(path)
|
||||
|
||||
|
||||
class TestH1KeyIsolation:
|
||||
"""H1:跨厂商 API Key 兜底泄漏——档案端点绝不回退读 ~/.cert/deepseek.key。
|
||||
|
||||
场景:deepseek.key 文件存在且可读(mock),若修复缺失,
|
||||
档案端点会静默拿到该 DeepSeek Key 发往其他厂商。
|
||||
"""
|
||||
|
||||
def test_profile_env_missing_never_reads_key_file(self):
|
||||
"""档案显式 env 名 + 环境变量未设置 + key 文件存在 → 报错而非读文件"""
|
||||
env = {k: "" for k in ("DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")}
|
||||
with mock.patch.dict(os.environ, env, clear=False), \
|
||||
mock.patch("builtins.open", side_effect=_key_file_readable):
|
||||
with pytest.raises(RuntimeError, match="MOONSHOT_API_KEY"):
|
||||
resolve_api_key(None, _cfg(), env_name="MOONSHOT_API_KEY",
|
||||
allow_key_file=False)
|
||||
|
||||
def test_profile_via_create_client_never_reads_key_file(self):
|
||||
"""端到端:create_llm_client 档案命中 + env 缺失 + key 文件存在 → 报错"""
|
||||
env = {k: "" for k in ("DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")}
|
||||
with mock.patch.dict(os.environ, env, clear=False), \
|
||||
mock.patch("builtins.open", side_effect=_key_file_readable):
|
||||
with pytest.raises(RuntimeError, match="MOONSHOT_API_KEY"):
|
||||
create_llm_client(_cfg(), model="kimi")
|
||||
|
||||
def test_profile_no_env_fallback_missing_raises(self):
|
||||
"""档案未配 api_key_env + 回退变量也未设置 + key 文件存在 → 报错"""
|
||||
models = dict(MODELS)
|
||||
models["noenv"] = {
|
||||
"api_url": "https://ark.example/v1/chat/completions",
|
||||
"model": "doubao-pro",
|
||||
}
|
||||
env = {k: "" for k in ("ARK_API_KEY", "DEEPSEEK_API_KEY")}
|
||||
with mock.patch.dict(os.environ, env, clear=False), \
|
||||
mock.patch("builtins.open", side_effect=_key_file_readable):
|
||||
with pytest.raises(RuntimeError, match="ARK_API_KEY"):
|
||||
create_llm_client(_cfg(models=models, api_key_env="ARK_API_KEY"),
|
||||
model="noenv")
|
||||
|
||||
def test_flat_api_still_reads_key_file(self):
|
||||
"""回归保证:无档案旧扁平节 + 环境变量未设置 → 仍回退读 key 文件"""
|
||||
env = {k: "" for k in ("DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")}
|
||||
with mock.patch.dict(os.environ, env, clear=False), \
|
||||
mock.patch("builtins.open", side_effect=_key_file_readable):
|
||||
assert resolve_api_key(None, _cfg(models=None)) == "sk-deepseek-leaked"
|
||||
|
||||
|
||||
# ==================== DeepSeekChat 档案级 reasoning 读取 ====================
|
||||
|
||||
class TestChatReasoningFlag:
|
||||
|
||||
Reference in new issue
Block a user