From 175f2117d365ef8eabc0a6f09099e73296d8b210 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 13:32:48 +0000 Subject: [PATCH 1/4] security: remove embedded DeepSeek API keys; require user/local model setup Stop desktop packaging from copying maintainer .env into the org sidecar. Never seed or prefill a real cloud API key; prefer local Ollama and block empty-key cloud calls with a clear error. Add a CI scan for DeepSeek-like secrets in packaged config templates. Co-authored-by: XYAI Labs --- CHANGELOG.md | 4 + dashboard/src/locales/en.json | 1 + dashboard/src/locales/zh.json | 1 + desktop/portable/bundle-org-sidecar.sh | 3 + modules/openxyos/.env.example | 4 +- modules/openxyos/backend/llm-providers.ts | 7 + modules/openxyos/backend/routes/settings.ts | 8 +- modules/openxyos/backend/seed.ts | 2 +- modules/openxyos/backend/services/ai.ts | 16 +- .../openxyos/frontend/src/llm-providers.ts | 1 + .../frontend/src/pages/OpenHomePage.tsx | 10 +- .../openxyos/scripts/test-llm-onboarding.ts | 15 ++ src/octop/api/routers/providers.py | 5 +- src/octop/api/routers/setup.py | 13 +- src/octop/i18n/en.json | 1 + src/octop/i18n/zh.json | 1 + src/octop/infra/agents/providers/probe.py | 17 ++ src/octop/infra/errors.py | 2 + tests/integration/test_setup_wizard.py | 52 +++++ tests/unit/desktop/test_packaged_secrets.py | 179 ++++++++++++++++++ tests/unit/test_provider_fetch_models.py | 19 ++ tests/unit/test_provider_probe.py | 23 +++ 22 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 tests/unit/desktop/test_packaged_secrets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4f8617..700feb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 安全 + +- 桌面安装包与配置模板不再允许嵌入云厂商 API Key(含 DeepSeek)。首次运行不会预填真实密钥:优先本机 Ollama,云调用在密钥为空时直接拒绝并提示用户自行填写。打包时排除 `.env`;生产/air-gap sidecar 不再回退 `LLM_API_KEY` 环境变量。已发布的 **0.0.1–0.0.4** 安装包必须下架,并轮换可能泄露的 DeepSeek 密钥。 + ### 文档 - 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index db2e6faf..b4eb82e2 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -376,6 +376,7 @@ "PROVIDER_NOT_VISIBLE": "Provider is not visible.", "PROVIDER_REFERENCED": "Provider is in use and cannot be removed.", "PROVIDER_LOCAL_PROTECTED": "Local runtime providers cannot be deleted.", + "PROVIDER_API_KEY_REQUIRED": "Cloud model API key is empty. Set your own key in Settings → Models, or use a local model such as Ollama.", "STORAGE_BACKEND_NAME_TAKEN": "Storage backend name is already in use.", "STORAGE_BACKEND_REFERENCED": "Storage backend is in use and cannot be removed.", "STORAGE_BACKEND_DEPS_FAILED": "Could not install optional components for this storage backend. Check network access and retry.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 3c05196f..8eef6833 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -376,6 +376,7 @@ "PROVIDER_NOT_VISIBLE": "提供商不可见。", "PROVIDER_REFERENCED": "提供商正在使用中,无法删除。", "PROVIDER_LOCAL_PROTECTED": "本地提供商无法删除。", + "PROVIDER_API_KEY_REQUIRED": "云模型 API Key 为空。请在「设置 → 大模型」中填写你自己的密钥,或改用 Ollama 等本机模型。", "STORAGE_BACKEND_NAME_TAKEN": "存储后端名称已被占用。", "STORAGE_BACKEND_REFERENCED": "存储后端正在使用中,无法删除。", "STORAGE_BACKEND_DEPS_FAILED": "无法安装此后端所需的可选组件。请检查网络后重试。", diff --git a/desktop/portable/bundle-org-sidecar.sh b/desktop/portable/bundle-org-sidecar.sh index 7b262835..4250ab35 100755 --- a/desktop/portable/bundle-org-sidecar.sh +++ b/desktop/portable/bundle-org-sidecar.sh @@ -125,6 +125,7 @@ build_openxyos() { rsync -a --delete \ --exclude node_modules --exclude dist --exclude .git --exclude .github \ --exclude backups --exclude dist-backup-20260626 --exclude V0.5 \ + --include '.env.example' --exclude '.env' --exclude '.env.*' \ "${src}/" "${work}/" else python3 - "$src" "$work" <<'PY' @@ -135,6 +136,8 @@ skip = {"node_modules", "dist", ".git", ".github", "backups", "dist-backup-20260 for item in src.iterdir(): if item.name in skip: continue + if item.name == ".env" or (item.name.startswith(".env.") and item.name != ".env.example"): + continue target = dest / item.name if item.is_dir(): shutil.copytree(item, target, ignore=shutil.ignore_patterns(*skip)) diff --git a/modules/openxyos/.env.example b/modules/openxyos/.env.example index 44d947b4..52f91533 100644 --- a/modules/openxyos/.env.example +++ b/modules/openxyos/.env.example @@ -17,7 +17,9 @@ SEED_ADMIN_EMAIL=admin@example.com SEED_ADMIN_PASSWORD=replace-me-with-a-unique-admin-password SEED_DEMO_PASSWORD=replace-me-with-a-unique-demo-password -# Optional LLM integration. Leave blank to start without a remote provider. +# Optional LLM integration for local development only. Leave blank. +# Desktop / production installers must never ship a real key; users set their +# own cloud key in Settings, or use a local model (Ollama). LLM_API_KEY= LLM_API_BASE= LLM_MODEL= diff --git a/modules/openxyos/backend/llm-providers.ts b/modules/openxyos/backend/llm-providers.ts index 23d4d32c..43121525 100644 --- a/modules/openxyos/backend/llm-providers.ts +++ b/modules/openxyos/backend/llm-providers.ts @@ -1,4 +1,11 @@ export const LLM_PROVIDERS = { + ollama: { + provider: "Ollama", + name: "Ollama (Local)", + baseUrl: "http://127.0.0.1:11434/v1", + model: "llama3.2", + local: true, + }, deepseek: { provider: "DeepSeek", name: "DeepSeek V4 Flash", diff --git a/modules/openxyos/backend/routes/settings.ts b/modules/openxyos/backend/routes/settings.ts index 88b959b4..95d37695 100644 --- a/modules/openxyos/backend/routes/settings.ts +++ b/modules/openxyos/backend/routes/settings.ts @@ -316,7 +316,11 @@ settingsRoutes.put("/ai/onboarding", requireAdmin, (req: AuthRequest, res) => { const apiKey = String(req.body?.apiKey || "").trim(); const preset = LLM_PROVIDERS[providerId]; if (!preset) return res.status(400).json({ success: false, error: settingsError(req, "请选择受支持的大模型", "Choose a supported AI model") }); - if (apiKey.length < 8 || apiKey.length > 512 || /\s/.test(apiKey)) { + const isLocal = providerId === "ollama" || Boolean("local" in preset && preset.local); + if (!isLocal && (apiKey.length < 8 || apiKey.length > 512 || /\s/.test(apiKey))) { + return res.status(400).json({ success: false, error: settingsError(req, "API Key 格式无效", "API key format is invalid") }); + } + if (isLocal && apiKey && (apiKey.length > 512 || /\s/.test(apiKey))) { return res.status(400).json({ success: false, error: settingsError(req, "API Key 格式无效", "API key format is invalid") }); } @@ -324,7 +328,7 @@ settingsRoutes.put("/ai/onboarding", requireAdmin, (req: AuthRequest, res) => { llm_provider: providerId, llm_api_base: preset.baseUrl, llm_model: preset.model, - llm_api_key: apiKey, + llm_api_key: isLocal ? (apiKey || "ollama") : apiKey, ai_reply_enabled: "true", }; for (const [key, value] of Object.entries(values)) { diff --git a/modules/openxyos/backend/seed.ts b/modules/openxyos/backend/seed.ts index 96e64e31..77176a70 100644 --- a/modules/openxyos/backend/seed.ts +++ b/modules/openxyos/backend/seed.ts @@ -966,7 +966,7 @@ function migrateDatabase() { const existingConfig = dbGet("SELECT COUNT(*) as c FROM ai_config") as any; if (existingConfig.c === 0) { - dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_api_key", process.env.LLM_API_KEY || "", "LLM API 密钥", 1]); + dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_api_key", "", "LLM API 密钥", 1]); dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_api_base", process.env.LLM_API_BASE || "", "LLM API 地址", 1]); dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_model", process.env.LLM_MODEL || "", "LLM 模型", 1]); dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["ai_reply_enabled", "true", "是否启用AI自动回复", 1]); diff --git a/modules/openxyos/backend/services/ai.ts b/modules/openxyos/backend/services/ai.ts index 43944ff3..d7f6b899 100644 --- a/modules/openxyos/backend/services/ai.ts +++ b/modules/openxyos/backend/services/ai.ts @@ -28,13 +28,21 @@ export function sanitizeLLMInput(text: string): string { .slice(0, 16_000); } +function allowEnvLlmFallback(): boolean { + const environment = (process.env.NODE_ENV || "").trim().toLowerCase(); + if (environment === "production" || environment === "private-production") return false; + if ((process.env.AIR_GAP_MODE || "").trim().toLowerCase() === "true") return false; + return true; +} + function getLLMConfig(): { apiKey: string; baseUrl: string; model: string } { const tenantId = getRequestTenantId() ?? 1; const getValue = (key: string) => dbGet("SELECT value FROM ai_config WHERE key = ? AND tenant_id = ?", [key, tenantId]) as any; + const envFallback = allowEnvLlmFallback(); return { - apiKey: getValue("llm_api_key")?.value || process.env.LLM_API_KEY || "", - baseUrl: getValue("llm_api_base")?.value || process.env.LLM_API_BASE || "", - model: getValue("llm_model")?.value || process.env.LLM_MODEL || "", + apiKey: getValue("llm_api_key")?.value || (envFallback ? process.env.LLM_API_KEY || "" : ""), + baseUrl: getValue("llm_api_base")?.value || (envFallback ? process.env.LLM_API_BASE || "" : ""), + model: getValue("llm_model")?.value || (envFallback ? process.env.LLM_MODEL || "" : ""), }; } @@ -50,7 +58,7 @@ function endpoint(baseUrl: string): string { export async function callLLM(messages: AIMessage[], temperature = 0.7, maxTokens = 1024): Promise { const { apiKey, baseUrl, model } = getLLMConfig(); if (!aiIsEnabled()) return { content: "[系统] AI 功能已被管理员关闭。", tokens_used: 0, model: "disabled" }; - if (!apiKey || !baseUrl || !model) return { content: "[系统] 请先在系统设置中完成模型服务配置。", tokens_used: 0, model: "unconfigured" }; + if (!apiKey || !baseUrl || !model) return { content: "[系统] 尚未配置云模型 API Key。请在系统设置中填写你自己的密钥,或改用本机模型(如 Ollama)。", tokens_used: 0, model: "unconfigured" }; if (!assertModelEndpointAllowed(baseUrl)) return { content: "[系统] 当前网络策略不允许连接该模型服务。", tokens_used: 0, model: "blocked" }; const controller = new AbortController(); diff --git a/modules/openxyos/frontend/src/llm-providers.ts b/modules/openxyos/frontend/src/llm-providers.ts index 01bba5e3..4d9cdfbe 100644 --- a/modules/openxyos/frontend/src/llm-providers.ts +++ b/modules/openxyos/frontend/src/llm-providers.ts @@ -1,4 +1,5 @@ export const EXPERIENCE_MODELS = [ + { id: "ollama", provider: "Ollama", name: "Ollama (Local)", baseUrl: "http://127.0.0.1:11434/v1", model: "llama3.2", hint: "本机模型,无需云厂商密钥", hintEn: "Local models, no cloud API key", local: true }, { id: "deepseek", provider: "DeepSeek", name: "DeepSeek V4 Flash", baseUrl: "https://api.deepseek.com", model: "deepseek-v4-flash", hint: "高性价比推理与通用对话", hintEn: "Cost-efficient reasoning and general chat" }, { id: "qwen", provider: "阿里云百炼", name: "通义千问 Plus", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen-plus", hint: "中文与企业场景", hintEn: "Chinese and enterprise scenarios" }, { id: "openai", provider: "OpenAI", name: "GPT-5 mini", baseUrl: "https://api.openai.com/v1", model: "gpt-5-mini", hint: "通用智能与工具调用", hintEn: "General intelligence and tool use" }, diff --git a/modules/openxyos/frontend/src/pages/OpenHomePage.tsx b/modules/openxyos/frontend/src/pages/OpenHomePage.tsx index 6a3b4a2b..f160ae10 100644 --- a/modules/openxyos/frontend/src/pages/OpenHomePage.tsx +++ b/modules/openxyos/frontend/src/pages/OpenHomePage.tsx @@ -387,7 +387,7 @@ export default function OpenHomePage() { const tx = (zh: string, en: string) => isEnglish ? en : zh; const navigate = useNavigate(), { login } = useAuthStore(); const [demoOpen, setDemoOpen] = useState(false), [demoPhase, setDemoPhase] = useState<"model" | "account">("model"), [loginState, setLoginState] = useState<"idle" | "admin" | "user">("idle"), [loginError, setLoginError] = useState(""), [copied, setCopied] = useState(false), [navOpen, setNavOpen] = useState(false); - const [providerId, setProviderId] = useState("deepseek"), [apiKey, setApiKey] = useState(""), [showKey, setShowKey] = useState(false); + const [providerId, setProviderId] = useState("ollama"), [apiKey, setApiKey] = useState(""), [showKey, setShowKey] = useState(false); const openDemo = (phase: "model" | "account") => { setDemoPhase(phase); setLoginError(""); setLoginState("idle"); setDemoOpen(true); }; @@ -397,13 +397,15 @@ export default function OpenHomePage() { catch (error) { setLoginError(error instanceof Error ? error.message : tx("演示环境暂时不可用,请使用登录页进入。", "The demo is temporarily unavailable. Please use the sign-in page.")); setLoginState("idle"); } }; const saveModelAndEnter = async () => { - if (apiKey.trim().length < 8 || /\s/.test(apiKey.trim())) { setLoginError(tx("请输入有效的 API Key(不能包含空格)", "Enter a valid API key without spaces.")); return; } + const selected = EXPERIENCE_MODELS.find(model => model.id === providerId); + const isLocal = Boolean(selected && "local" in selected && selected.local); + if (!isLocal && (apiKey.trim().length < 8 || /\s/.test(apiKey.trim()))) { setLoginError(tx("请输入有效的 API Key(不能包含空格)", "Enter a valid API key without spaces.")); return; } setLoginState("admin"); setLoginError(""); try { await login("demo@demo.com", "openxyos-demo-2026"); const response = await authFetch("/api/settings/ai/onboarding", { method: "PUT", - body: JSON.stringify({ providerId, apiKey: apiKey.trim() }), + body: JSON.stringify({ providerId, apiKey: isLocal ? (apiKey.trim() || "ollama") : apiKey.trim() }), }); const body = await response.text(); const result = body ? JSON.parse(body) : null; @@ -463,6 +465,6 @@ export default function OpenHomePage() {
BUILD WITH US

{tx("不只使用系统,一起定义人机组织的未来", "Do more than use the system—define the future of human–AI organizations")}

{tx("欢迎从组织模型、智能体协作、治理策略、模块生态和工程质量开始贡献。", "Contribute through organization models, agent collaboration, governance policy, module ecosystems, and engineering quality.")}

{GITHUB_URL ? {tx("查看 Issues", "View issues")} : {tx("阅读源码边界", "Read source boundary")}}
openXYOS

Open-source operating system for human–agent organizations.

Apache License 2.0 · Built in the open
- {demoOpen &&
setDemoOpen(false)}>
e.stopPropagation()}>{demoPhase === "model" ? <>

{tx("接入大模型,体验完整智能能力", "Connect a model to experience full intelligence")}

{tx("选择你已有 API Key 的模型服务。密钥保存到当前本地演示租户,页面不会回显原文;之后可在“系统设置 → AI 大模型”中更换。", "Choose a model service for which you have an API key. The key is stored only in the current demo tenant and is never shown back in plain text; change it later in System Settings → AI Models.")}

{EXPERIENCE_MODELS.map(model => )}
{tx("Key 仅发送到当前 openXYOS 后端,不写入浏览器日志,也不会再次明文返回。", "The key is sent only to the current openXYOS backend, never logged in the browser, and never returned in plain text.")}
{loginState !== "idle" &&

{tx("正在登录演示租户并保存模型配置…", "Signing in to the demo tenant and saving model configuration…")}

}{loginError &&

{loginError}

}
: <>

{tx("选择测试视角", "Choose a demo perspective")}

{tx("使用本地演示租户进入真实系统。管理员可体验模块开关和名称编辑,普通员工只看到已授权能力。", "Enter the live system with the local demo tenant. Administrators can manage modules and labels; users see only authorized capabilities.")}

{loginState !== "idle" &&

{tx("正在连接演示环境…", "Connecting to the demo environment…")}

}{loginError &&

{loginError}

}}
} + {demoOpen &&
setDemoOpen(false)}>
e.stopPropagation()}>{demoPhase === "model" ? <>

{tx("接入大模型,体验完整智能能力", "Connect a model to experience full intelligence")}

{tx("优先使用本机 Ollama;云厂商需要你自己的 API Key,安装包不会预填密钥。密钥保存到当前本地演示租户,页面不会回显原文;之后可在“系统设置 → AI 大模型”中更换。", "Prefer local Ollama first. Cloud providers need your own API key — installers never prefill one. The key is stored only in the current demo tenant and is never shown back in plain text; change it later in System Settings → AI Models.")}

{EXPERIENCE_MODELS.map(model => )}
{tx("Key 仅发送到当前 openXYOS 后端,不写入浏览器日志,也不会再次明文返回。", "The key is sent only to the current openXYOS backend, never logged in the browser, and never returned in plain text.")}
{loginState !== "idle" &&

{tx("正在登录演示租户并保存模型配置…", "Signing in to the demo tenant and saving model configuration…")}

}{loginError &&

{loginError}

}
: <>

{tx("选择测试视角", "Choose a demo perspective")}

{tx("使用本地演示租户进入真实系统。管理员可体验模块开关和名称编辑,普通员工只看到已授权能力。", "Enter the live system with the local demo tenant. Administrators can manage modules and labels; users see only authorized capabilities.")}

{loginState !== "idle" &&

{tx("正在连接演示环境…", "Connecting to the demo environment…")}

}{loginError &&

{loginError}

}}
} ; } diff --git a/modules/openxyos/scripts/test-llm-onboarding.ts b/modules/openxyos/scripts/test-llm-onboarding.ts index e6e93628..a3941956 100644 --- a/modules/openxyos/scripts/test-llm-onboarding.ts +++ b/modules/openxyos/scripts/test-llm-onboarding.ts @@ -71,6 +71,21 @@ async function main() { }); assert.equal(invalid.status, 400); + const emptyCloud = await request(tokens.admin, "/api/settings/ai/onboarding", { + method: "PUT", + body: JSON.stringify({ providerId: "deepseek", apiKey: "" }), + }); + assert.equal(emptyCloud.status, 400); + + const local = await request(tokens.admin, "/api/settings/ai/onboarding", { + method: "PUT", + body: JSON.stringify({ providerId: "ollama", apiKey: "" }), + }); + assert.equal(local.status, 200); + const localRows = dbAll("SELECT key, value FROM ai_config WHERE tenant_id = ?", [tenantA]) as any[]; + assert.equal(localRows.find(row => row.key === "llm_api_key")?.value, "ollama"); + assert.equal(localRows.find(row => row.key === "llm_api_base")?.value, "http://127.0.0.1:11434/v1"); + const context = await request(tokens.tenantB, "/api/context"); assert.equal((await context.json() as any).tenantId, tenantB); console.log("LLM onboarding, key masking, and tenant isolation integration tests passed"); diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index 6e6ab633..2c20e862 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -20,6 +20,7 @@ from octop.infra.agents.providers.probe import ( fetch_openai_compatible_models, make_probe_provider_row, + missing_cloud_api_key_result, probe_provider_row, provider_headers, ) @@ -311,7 +312,7 @@ async def admin_test_provider_draft( base_url=body.base_url, ) if not api_key: - return {"ok": False, "error": "api_key is required"} + return missing_cloud_api_key_result(resolve_request_locale(request)) model_id = body.model_id.strip() if not model_id: return {"ok": False, "error": "model_id is required"} @@ -350,7 +351,7 @@ async def admin_fetch_provider_models( base_url=body.base_url, ) if not api_key: - return {"ok": False, "error": "api_key is required"} + return missing_cloud_api_key_result(resolve_request_locale(request)) draft = SimpleNamespace(extra_json=body.extra_json) return await fetch_openai_compatible_models( base_url=base_url or None, diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index b38afc9c..ca63b209 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -13,7 +13,11 @@ from octop.api.deps import get_server, require_database, resolve_user_from_token, sign_token from octop.infra.agents.providers.model_flags import resolve_provider_credentials from octop.infra.agents.providers.presets import load_provider_presets -from octop.infra.agents.providers.probe import make_probe_provider_row, probe_provider_row +from octop.infra.agents.providers.probe import ( + make_probe_provider_row, + missing_cloud_api_key_result, + probe_provider_row, +) from octop.infra.errors import ErrorCode, OctopError from octop.infra.setup import password_file as _wizard from octop.infra.setup.wizard_tokens import RateLimited @@ -183,7 +187,10 @@ async def _apply_provider_draft(server: Any, draft: ProviderDraftBody) -> None: base_url=draft.base_url, ) if not api_key: - raise OctopError(ErrorCode.INTERNAL_ERROR, "api_key is required", status=400) + raise OctopError( + ErrorCode.PROVIDER_API_KEY_REQUIRED, + "cloud model API key is required", + ) if not base_url: raise OctopError(ErrorCode.INTERNAL_ERROR, "base_url is required", status=400) @@ -419,7 +426,7 @@ async def test_provider_draft( base_url=body.base_url, ) if not api_key: - return {"ok": False, "error": "api_key is required"} + return missing_cloud_api_key_result(resolve_request_locale(request)) row = make_probe_provider_row( name=body.name, kind=body.type, diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index e863c5b4..ab42ce25 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -338,6 +338,7 @@ "PROVIDER_NOT_VISIBLE": "Provider is not visible.", "PROVIDER_REFERENCED": "Provider is in use and cannot be removed.", "PROVIDER_LOCAL_PROTECTED": "Local runtime providers cannot be deleted.", + "PROVIDER_API_KEY_REQUIRED": "Cloud model API key is empty. Set your own key in Settings → Models, or use a local model such as Ollama.", "STORAGE_BACKEND_NAME_TAKEN": "Storage backend name is already in use.", "STORAGE_BACKEND_REFERENCED": "Storage backend is in use and cannot be removed.", "STORAGE_BACKEND_DEPS_FAILED": "Could not install optional components for this storage backend. Check network access and retry.", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index e3887de2..9ed4b607 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -338,6 +338,7 @@ "PROVIDER_NOT_VISIBLE": "提供商不可见。", "PROVIDER_REFERENCED": "提供商正在使用中,无法删除。", "PROVIDER_LOCAL_PROTECTED": "本地提供商无法删除。", + "PROVIDER_API_KEY_REQUIRED": "云模型 API Key 为空。请在「设置 → 大模型」中填写你自己的密钥,或改用 Ollama 等本机模型。", "STORAGE_BACKEND_NAME_TAKEN": "存储后端名称已被占用。", "STORAGE_BACKEND_REFERENCED": "存储后端正在使用中,无法删除。", "STORAGE_BACKEND_DEPS_FAILED": "无法安装此后端所需的可选组件。请检查网络后重试。", diff --git a/src/octop/infra/agents/providers/probe.py b/src/octop/infra/agents/providers/probe.py index 695c146a..d44d6c71 100644 --- a/src/octop/infra/agents/providers/probe.py +++ b/src/octop/infra/agents/providers/probe.py @@ -21,6 +21,13 @@ _EMBEDDING_PROBE_TEXT = "ping" +def missing_cloud_api_key_result(locale: str = "en") -> dict[str, Any]: + """User-visible probe/fetch error when a cloud provider has no API key.""" + from octop.i18n import error_message + + return {"ok": False, "error": error_message("PROVIDER_API_KEY_REQUIRED", locale)} + + def provider_headers(row: Any) -> dict[str, str]: raw = getattr(row, "extra_json", None) if not raw: @@ -230,6 +237,11 @@ async def probe_provider_row( result["error"] = _friendly_probe_error(str(result["error"]), locale=locale) return result + from octop.infra.utils.local_endpoint import placeholder_api_key + + if not placeholder_api_key(getattr(row, "base_url", None), getattr(row, "api_key", None)): + return missing_cloud_api_key_result(locale) + mid = _probe_model_id(row, model_id) if _should_probe_embedding(row, model_id=mid, embedding=embedding): return await _probe_embedding_endpoint(row, model_id=mid, locale=locale) @@ -258,6 +270,11 @@ async def fetch_openai_compatible_models( locale: str = "en", ) -> dict[str, Any]: """List models via OpenAI-compatible ``GET {base}/models``.""" + from octop.infra.utils.local_endpoint import placeholder_api_key + + api_key = placeholder_api_key(base_url, api_key) + if not api_key: + return missing_cloud_api_key_result(locale) url = _models_list_url(base_url) headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"} extra_headers = ensure_opencode_session_header(base_url, extra_headers) diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index f11ffcd0..23344cc8 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -42,6 +42,7 @@ class ErrorCode(StrEnum): PROVIDER_NOT_VISIBLE = "PROVIDER_NOT_VISIBLE" PROVIDER_REFERENCED = "PROVIDER_REFERENCED" PROVIDER_LOCAL_PROTECTED = "PROVIDER_LOCAL_PROTECTED" + PROVIDER_API_KEY_REQUIRED = "PROVIDER_API_KEY_REQUIRED" STORAGE_BACKEND_NAME_TAKEN = "STORAGE_BACKEND_NAME_TAKEN" STORAGE_BACKEND_REFERENCED = "STORAGE_BACKEND_REFERENCED" STORAGE_BACKEND_DEPS_FAILED = "STORAGE_BACKEND_DEPS_FAILED" @@ -142,6 +143,7 @@ class ErrorCode(StrEnum): ErrorCode.PROVIDER_NOT_VISIBLE: 400, ErrorCode.PROVIDER_REFERENCED: 409, ErrorCode.PROVIDER_LOCAL_PROTECTED: 409, + ErrorCode.PROVIDER_API_KEY_REQUIRED: 400, ErrorCode.STORAGE_BACKEND_NAME_TAKEN: 409, ErrorCode.STORAGE_BACKEND_REFERENCED: 409, ErrorCode.STORAGE_BACKEND_DEPS_FAILED: 503, diff --git a/tests/integration/test_setup_wizard.py b/tests/integration/test_setup_wizard.py index bc3f51e2..43ed0ea8 100644 --- a/tests/integration/test_setup_wizard.py +++ b/tests/integration/test_setup_wizard.py @@ -430,3 +430,55 @@ async def test_finish_rejects_invalid_token_after_admin_exists(env: Any) -> None headers={"Authorization": "Bearer faketoken"}, ) assert r.status_code == 401 + + +async def test_finish_rejects_empty_cloud_api_key(env: Any) -> None: + c, srv, home = env + pw = read_password(Path.home()) + tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] + admin = ( + await c.post( + "/api/setup/initial-admin", + json={"username": "admin", "password": "TestPass12"}, + headers={"Authorization": f"Bearer {tok}"}, + ) + ).json() + srv.wizard_tokens.clear() + r = await c.post( + "/api/setup/finish", + json={ + "provider_draft": { + "name": "DeepSeek", + "type": "openai", + "api_key": "", + "base_url": "https://api.deepseek.com", + "models": [{"id": "deepseek-chat", "name": "DeepSeek", "enabled": True}], + } + }, + headers={"Authorization": f"Bearer {admin['access_token']}"}, + ) + assert r.status_code == 400, r.text + body = r.json() + assert body["error"]["code"] == "PROVIDER_API_KEY_REQUIRED" + assert srv.services.provider_repo.list_all() == [] + + +async def test_test_provider_rejects_empty_cloud_api_key(env: Any) -> None: + c, _srv, home = env + pw = read_password(Path.home()) + tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] + r = await c.post( + "/api/setup/test-provider", + json={ + "name": "DeepSeek", + "type": "openai", + "api_key": "", + "base_url": "https://api.deepseek.com", + "model_id": "deepseek-chat", + }, + headers={"Authorization": f"Bearer {tok}"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is False + assert "API key" in body["error"] or "Ollama" in body["error"] diff --git a/tests/unit/desktop/test_packaged_secrets.py b/tests/unit/desktop/test_packaged_secrets.py new file mode 100644 index 00000000..8ecca32b --- /dev/null +++ b/tests/unit/desktop/test_packaged_secrets.py @@ -0,0 +1,179 @@ +"""Fail CI if packaged config templates embed cloud API keys.""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] + +# Real DeepSeek keys are ``sk-`` + 32+ hex. Generic vendor keys are longer ``sk-`` tokens. +_DEEPSEEK_LIKE = re.compile(r"\bsk-[a-fA-F0-9]{32,}\b") +_GENERIC_SK = re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b") +_ENV_KEY_ASSIGN = re.compile( + r"(?im)^[ \t]*(?:export[ \t]+)?(?:LLM_API_KEY|DEEPSEEK_API_KEY|OPENAI_API_KEY|DASHSCOPE_API_KEY)" + r"[ \t]*=[ \t]*([^\s#]+)" +) +_JSON_API_KEY = re.compile( + r'(?i)"(?:api_key|llm_api_key|secret_key)"\s*:\s*"(sk-[A-Za-z0-9_-]{16,})"' +) + +_SKIP_DIR_NAMES = frozenset( + { + "node_modules", + "dist", + "backend-dist", + ".git", + "coverage", + "__pycache__", + } +) +_SCAN_SUFFIXES = frozenset( + { + ".json", + ".yml", + ".yaml", + ".env", + ".example", + ".nsi", + ".nsh", + ".ps1", + ".sh", + ".bat", + ".cmd", + ".ts", + ".tsx", + ".js", + ".go", + ".py", + ".txt", + ".toml", + ".md", + } +) +# Documented placeholders / test fakes — never a live quota key. +_ALLOWED_SK_PREFIXES = ( + "sk-test", + "sk-draft", + "sk-new", + "sk-secret", + "sk-keep", + "sk-user", + "sk-invalid", + "sk-other", + "sk-xxx", + "sk-lf-", + "sk-ap-", + "sk-orphan", + "sk-disconnected", +) +_ALLOWED_ENV_VALUES = frozenset( + { + "", + '""', + "''", + "${OPENAI_API_KEY:-}", + "${LLM_API_KEY:-}", + "${DEEPSEEK_API_KEY:-}", + } +) + + +def _packaged_roots() -> list[Path]: + return [ + REPO / "desktop", + REPO / "modules" / "openxyos" / ".env.example", + REPO / "modules" / "openxyos" / "deploy", + REPO / "modules" / "openxyos" / "backend", + REPO / "modules" / "openxyos" / "frontend" / "src" / "llm-providers.ts", + REPO / "modules" / "openxyos" / "frontend" / "src" / "pages" / "OpenHomePage.tsx", + REPO / "src" / "octop" / "infra" / "agents" / "providers" / "presets.py", + REPO / "scripts" / "org-export" / "template", + REPO / ".env.example", + ] + + +def _iter_scan_files() -> list[Path]: + files: list[Path] = [] + for root in _packaged_roots(): + if root.is_file(): + files.append(root) + continue + if not root.is_dir(): + continue + for path in root.rglob("*"): + if not path.is_file(): + continue + if any(part in _SKIP_DIR_NAMES for part in path.parts): + continue + if path.suffix.lower() not in _SCAN_SUFFIXES and path.name not in { + ".env", + ".env.example", + }: + continue + files.append(path) + return files + + +def _is_allowed_sk(token: str) -> bool: + lower = token.lower() + return any(lower.startswith(prefix) for prefix in _ALLOWED_SK_PREFIXES) + + +def _is_placeholder_env_value(raw: str) -> bool: + value = raw.strip().strip("\"'") + if value in _ALLOWED_ENV_VALUES or value == "": + return True + lowered = value.lower() + if lowered.startswith("${") or lowered.startswith("<"): + return True + return any(part in lowered for part in ("replace-me", "changeme", "your-key", "example")) + + +def _findings_in(path: Path, text: str) -> list[str]: + hits: list[str] = [] + for match in _DEEPSEEK_LIKE.finditer(text): + hits.append(f"{path}: DeepSeek-like key {match.group(0)[:8]}…") + for match in _GENERIC_SK.finditer(text): + token = match.group(0) + if _is_allowed_sk(token) or _DEEPSEEK_LIKE.fullmatch(token): + continue + hits.append(f"{path}: credential-like token {token[:8]}…") + for match in _ENV_KEY_ASSIGN.finditer(text): + if _is_placeholder_env_value(match.group(1)): + continue + hits.append(f"{path}: non-empty {match.group(0).split('=', 1)[0].strip()} assignment") + for match in _JSON_API_KEY.finditer(text): + token = match.group(1) + if _is_allowed_sk(token): + continue + hits.append(f"{path}: json api_key looks like a live secret") + return hits + + +def test_packaged_templates_have_no_embedded_cloud_keys() -> None: + findings: list[str] = [] + scanned = 0 + for path in _iter_scan_files(): + scanned += 1 + text = path.read_text(encoding="utf-8", errors="replace") + findings.extend(_findings_in(path, text)) + assert scanned > 0 + assert findings == [] + + +def test_sidecar_packaging_excludes_dotenv() -> None: + script = (REPO / "desktop" / "portable" / "bundle-org-sidecar.sh").read_text(encoding="utf-8") + assert "--exclude '.env'" in script + assert ".env.example" in script + assert 'item.name == ".env"' in script + + +def test_secret_patterns_catch_deepseek_like_defaults() -> None: + fake = ( + 'LLM_API_KEY=sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' + '{"api_key": "sk-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}\n' + ) + hits = _findings_in(Path("fixture.env"), fake) + assert hits + assert any("DeepSeek-like" in hit or "json api_key" in hit or "LLM_API_KEY" in hit for hit in hits) diff --git a/tests/unit/test_provider_fetch_models.py b/tests/unit/test_provider_fetch_models.py index 949d0fee..bf85bdd7 100644 --- a/tests/unit/test_provider_fetch_models.py +++ b/tests/unit/test_provider_fetch_models.py @@ -174,3 +174,22 @@ async def test_fetch_models_server_error_friendly() -> None: assert result["ok"] is False assert "temporarily unavailable" in result["error"].lower() assert "503" not in result["error"] + + +@pytest.mark.asyncio +async def test_fetch_models_blocks_empty_cloud_api_key() -> None: + mock_client = AsyncMock() + mock_client.get = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client): + result = await fetch_openai_compatible_models( + base_url="https://api.deepseek.com", + api_key="", + locale="en", + ) + + assert result["ok"] is False + assert "API key" in result["error"] + mock_client.get.assert_not_called() diff --git a/tests/unit/test_provider_probe.py b/tests/unit/test_provider_probe.py index 19ec9989..4fd145e9 100644 --- a/tests/unit/test_provider_probe.py +++ b/tests/unit/test_provider_probe.py @@ -193,3 +193,26 @@ async def test_chat_probe_skips_embeddings_endpoint() -> None: assert result["ok"] is True client.assert_not_called() fake.ainvoke.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cloud_probe_blocks_empty_api_key_without_http() -> None: + row = SimpleNamespace( + name="DeepSeek", + kind="openai", + base_url="https://api.deepseek.com", + api_key="", + extra_json=None, + get_models=lambda: [{"id": "deepseek-chat", "name": "DeepSeek"}], + ) + with ( + patch("octop.infra.agents.providers.probe.httpx.AsyncClient") as client, + patch("octop.infra.agents.providers.probe.build_probe_chat_model") as chat, + ): + result = await probe_provider_row(row, locale="en") + + assert result["ok"] is False + assert "API key" in result["error"] + assert "Ollama" in result["error"] + client.assert_not_called() + chat.assert_not_called() From 8624ae850ab4ea167d888482537cb7ccc53d71d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 13:34:22 +0000 Subject: [PATCH 2/4] style: ruff-format packaged secrets CI guard Co-authored-by: XYAI Labs --- tests/unit/desktop/test_packaged_secrets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/desktop/test_packaged_secrets.py b/tests/unit/desktop/test_packaged_secrets.py index 8ecca32b..d3c7920f 100644 --- a/tests/unit/desktop/test_packaged_secrets.py +++ b/tests/unit/desktop/test_packaged_secrets.py @@ -171,9 +171,11 @@ def test_sidecar_packaging_excludes_dotenv() -> None: def test_secret_patterns_catch_deepseek_like_defaults() -> None: fake = ( - 'LLM_API_KEY=sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' + "LLM_API_KEY=sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" '{"api_key": "sk-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}\n' ) hits = _findings_in(Path("fixture.env"), fake) assert hits - assert any("DeepSeek-like" in hit or "json api_key" in hit or "LLM_API_KEY" in hit for hit in hits) + assert any( + "DeepSeek-like" in hit or "json api_key" in hit or "LLM_API_KEY" in hit for hit in hits + ) From 944dcc082150392c73d11e87f941004f3179655f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 13:47:39 +0000 Subject: [PATCH 3/4] test: match localized empty-key error in fetch-models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin fetch-models now returns PROVIDER_API_KEY_REQUIRED copy (API Key / 密钥), not a literal api_key substring. Co-authored-by: XYAI Labs --- tests/integration/test_provider_fetch_models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_provider_fetch_models.py b/tests/integration/test_provider_fetch_models.py index 9c01b6c5..97b474e1 100644 --- a/tests/integration/test_provider_fetch_models.py +++ b/tests/integration/test_provider_fetch_models.py @@ -20,7 +20,8 @@ async def test_admin_fetch_models_requires_api_key(env: Any) -> None: assert r.status_code == 200 body = r.json() assert body["ok"] is False - assert "api_key" in body["error"] + err = body["error"] + assert "api key" in err.lower() or "密钥" in err async def test_admin_fetch_models_rejects_non_openai_kind(env: Any) -> None: From ef5840a00d3675cf77d98892b5a0bffd7ed938be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 13:51:38 +0000 Subject: [PATCH 4/4] security: block packaging of home DB/.env; ignore cloud keys in env First-run must not persist DeepSeek from DEEPSEEK_API_KEY/OPENAI_API_KEY. Refuse to zip staging trees that contain .env, octop.db, or .freeos. Desktop CI workflow does not inject LLM secrets. Co-authored-by: XYAI Labs --- CHANGELOG.md | 2 +- desktop/portable/bundle-org-sidecar.sh | 5 +- desktop/portable/package.sh | 13 +++ desktop/portable/scan_packaged_secrets.py | 87 +++++++++++++++++++++ tests/integration/test_setup_wizard.py | 38 +++++++++ tests/unit/desktop/test_packaged_secrets.py | 38 +++++++++ 6 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 desktop/portable/scan_packaged_secrets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 700feb59..33dac748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 安全 -- 桌面安装包与配置模板不再允许嵌入云厂商 API Key(含 DeepSeek)。首次运行不会预填真实密钥:优先本机 Ollama,云调用在密钥为空时直接拒绝并提示用户自行填写。打包时排除 `.env`;生产/air-gap sidecar 不再回退 `LLM_API_KEY` 环境变量。已发布的 **0.0.1–0.0.4** 安装包必须下架,并轮换可能泄露的 DeepSeek 密钥。 +- 桌面安装包与配置模板不再允许嵌入云厂商 API Key(含 DeepSeek)。首次运行不会预填真实密钥:优先本机 Ollama,云调用在密钥为空时直接拒绝并提示用户自行填写(即使环境里有 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_API_KEY` 也不写入 `providers`)。打包排除 `.env`、`octop.db`、`.freeos`,打 zip 前扫描 staging。生产/air-gap sidecar 不再回退 `LLM_API_KEY`。已发布的 **0.0.1–0.0.4** 安装包须下架(已从 Release 删除),并轮换可能泄露的 DeepSeek 密钥。 ### 文档 diff --git a/desktop/portable/bundle-org-sidecar.sh b/desktop/portable/bundle-org-sidecar.sh index 4250ab35..d275c458 100755 --- a/desktop/portable/bundle-org-sidecar.sh +++ b/desktop/portable/bundle-org-sidecar.sh @@ -126,18 +126,21 @@ build_openxyos() { --exclude node_modules --exclude dist --exclude .git --exclude .github \ --exclude backups --exclude dist-backup-20260626 --exclude V0.5 \ --include '.env.example' --exclude '.env' --exclude '.env.*' \ + --exclude '.freeos' --exclude '.octop' --exclude 'octop.db' --exclude '*.sqlite' \ "${src}/" "${work}/" else python3 - "$src" "$work" <<'PY' import shutil, sys from pathlib import Path src, dest = Path(sys.argv[1]), Path(sys.argv[2]) -skip = {"node_modules", "dist", ".git", ".github", "backups", "dist-backup-20260626", "V0.5"} +skip = {"node_modules", "dist", ".git", ".github", "backups", "dist-backup-20260626", "V0.5", ".freeos", ".octop"} for item in src.iterdir(): if item.name in skip: continue if item.name == ".env" or (item.name.startswith(".env.") and item.name != ".env.example"): continue + if item.name == "octop.db" or item.suffix in {".sqlite", ".sqlite3", ".db"}: + continue target = dest / item.name if item.is_dir(): shutil.copytree(item, target, ignore=shutil.ignore_patterns(*skip)) diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh index d1bb95e5..0a74e52f 100755 --- a/desktop/portable/package.sh +++ b/desktop/portable/package.sh @@ -306,6 +306,19 @@ assemble_one() { stamp="${stamp:-local}-$(date -u +%Y%m%d%H%M%S)" printf '%s\n' "$stamp" > "${staging}/FREEOS_STAMP" + echo "[package] ${plat}: scanning staging for secrets / user home data" >&2 + py_scan="" + if command -v python3 >/dev/null 2>&1; then + py_scan=python3 + elif command -v python >/dev/null 2>&1; then + py_scan=python + fi + if [[ -z "$py_scan" ]]; then + echo "[package] need python to scan packaged secrets" >&2 + exit 1 + fi + "$py_scan" "${REPO_ROOT}/desktop/portable/scan_packaged_secrets.py" "$staging" + rm -f "$zip_path" echo "[package] ${plat}: zipping → ${zip_path}" local zip_name zip_stem diff --git a/desktop/portable/scan_packaged_secrets.py b/desktop/portable/scan_packaged_secrets.py new file mode 100644 index 00000000..f97094cf --- /dev/null +++ b/desktop/portable/scan_packaged_secrets.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Refuse to ship a portable tree that contains user secrets or home data.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +_DEEPSEEK_LIKE = re.compile(r"\bsk-[a-fA-F0-9]{32,}\b") +_SKIP_DIR_NAMES = frozenset( + { + "node_modules", + "dist", + "backend-dist", + "packages", + "runtime", + "__pycache__", + } +) +_SECRET_FILENAMES = frozenset({".env", "octop.db"}) +_SECRET_DIRNAMES = frozenset({".freeos", ".octop"}) + + +def iter_findings(root: Path) -> list[str]: + hits: list[str] = [] + if not root.is_dir(): + return [f"{root}: staging directory missing"] + for path in root.rglob("*"): + parts = set(path.parts) + if parts & _SKIP_DIR_NAMES: + continue + if path.is_dir(): + if path.name in _SECRET_DIRNAMES: + hits.append(f"{path}: user-home data directory must not be packaged") + continue + if path.name in _SECRET_FILENAMES or ( + path.name.startswith(".env.") and path.name != ".env.example" + ): + hits.append(f"{path}: secret/user-data file must not be packaged") + continue + if path.suffix.lower() in {".db", ".sqlite", ".sqlite3"}: + hits.append(f"{path}: sqlite database must not be packaged") + continue + if ( + path.suffix.lower() + not in { + ".env", + ".example", + ".json", + ".txt", + ".md", + ".sh", + ".bat", + ".cmd", + ".ps1", + ".yml", + ".yaml", + } + and path.name != "env" + ): + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if _DEEPSEEK_LIKE.search(text): + hits.append(f"{path}: DeepSeek-like API key must not be packaged") + return hits + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + sys.stderr.write("usage: scan_packaged_secrets.py \n") + return 2 + root = Path(argv[1]) + hits = iter_findings(root) + if hits: + sys.stderr.write("packaged tree contains secrets / user data:\n") + for hit in hits: + sys.stderr.write(f" {hit}\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/integration/test_setup_wizard.py b/tests/integration/test_setup_wizard.py index 43ed0ea8..e05b9d80 100644 --- a/tests/integration/test_setup_wizard.py +++ b/tests/integration/test_setup_wizard.py @@ -463,6 +463,44 @@ async def test_finish_rejects_empty_cloud_api_key(env: Any) -> None: assert srv.services.provider_repo.list_all() == [] +async def test_finish_ignores_cloud_api_key_environment( + env: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty wizard draft must not pick up maintainer/cloud keys from the process env.""" + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-" + "a" * 32) + monkeypatch.setenv("OPENAI_API_KEY", "sk-" + "b" * 32) + monkeypatch.setenv("LLM_API_KEY", "sk-" + "c" * 32) + c, srv, home = env + pw = read_password(Path.home()) + tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] + admin = ( + await c.post( + "/api/setup/initial-admin", + json={"username": "admin2", "password": "TestPass12"}, + headers={"Authorization": f"Bearer {tok}"}, + ) + ).json() + srv.wizard_tokens.clear() + r = await c.post( + "/api/setup/finish", + json={ + "provider_draft": { + "name": "DeepSeek", + "type": "openai", + "api_key": "", + "base_url": "https://api.deepseek.com/v1", + "models": [ + {"id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash", "enabled": True} + ], + } + }, + headers={"Authorization": f"Bearer {admin['access_token']}"}, + ) + assert r.status_code == 400, r.text + assert r.json()["error"]["code"] == "PROVIDER_API_KEY_REQUIRED" + assert srv.services.provider_repo.list_all() == [] + + async def test_test_provider_rejects_empty_cloud_api_key(env: Any) -> None: c, _srv, home = env pw = read_password(Path.home()) diff --git a/tests/unit/desktop/test_packaged_secrets.py b/tests/unit/desktop/test_packaged_secrets.py index d3c7920f..256ca563 100644 --- a/tests/unit/desktop/test_packaged_secrets.py +++ b/tests/unit/desktop/test_packaged_secrets.py @@ -165,10 +165,48 @@ def test_packaged_templates_have_no_embedded_cloud_keys() -> None: def test_sidecar_packaging_excludes_dotenv() -> None: script = (REPO / "desktop" / "portable" / "bundle-org-sidecar.sh").read_text(encoding="utf-8") assert "--exclude '.env'" in script + assert "--exclude '.freeos'" in script + assert "--exclude 'octop.db'" in script assert ".env.example" in script assert 'item.name == ".env"' in script +def test_package_sh_scans_staging_before_zip() -> None: + script = (REPO / "desktop" / "portable" / "package.sh").read_text(encoding="utf-8") + assert "scan_packaged_secrets.py" in script + workflow = (REPO / ".github" / "workflows" / "octop-desktop.yml").read_text(encoding="utf-8") + assert "LLM_API_KEY" not in workflow + assert "DEEPSEEK_API_KEY" not in workflow + assert "LIVE_OPENAI" not in workflow + + +def test_staging_scanner_rejects_home_db_and_dotenv(tmp_path: Path) -> None: + import importlib.util + + scan_path = REPO / "desktop" / "portable" / "scan_packaged_secrets.py" + spec = importlib.util.spec_from_file_location("scan_packaged_secrets", scan_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + iter_findings = module.iter_findings + + clean = tmp_path / "clean" + clean.mkdir() + (clean / "README.txt").write_text("ok\n", encoding="utf-8") + assert iter_findings(clean) == [] + + dirty = tmp_path / "dirty" + dirty.mkdir() + (dirty / ".env").write_text("LLM_API_KEY=sk-" + "a" * 32 + "\n", encoding="utf-8") + (dirty / "octop.db").write_bytes(b"sqlite") + (dirty / ".freeos").mkdir() + hits = iter_findings(dirty) + assert hits + assert any(".env" in hit for hit in hits) + assert any("octop.db" in hit for hit in hits) + assert any(".freeos" in hit for hit in hits) + + def test_secret_patterns_catch_deepseek_like_defaults() -> None: fake = ( "LLM_API_KEY=sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n"