diff --git a/docs/审计报告-中控实例全方位-2026-10-07.md b/docs/审计报告-中控实例全方位-2026-10-07.md new file mode 100644 index 0000000..9be5ec6 --- /dev/null +++ b/docs/审计报告-中控实例全方位-2026-10-07.md @@ -0,0 +1,143 @@ +# 审计报告:中控 Bridge + 实例全方位(2026-10-07) + +> 基线提交:`a23fb7c`(含数据统计页与实盘说明同步) +> 对照:`docs/审计报告-高危修复-2026-10-07.md`(币本位高危闭环) + +## 1. 范围 + +| 范围 | 内容 | +| --- | --- | +| 本仓库实例侧 | `app.py`、`lib/options/**`、`lib/instance/**`、`lib/hub/**`(Bridge)、`lib/sim/**`、`lib/env/**`、备份/远程更新 | +| 本仓库中控侧 | 仅有 **实例 Bridge**(`/api/hub/*`、`/hub_sso`);**中控前端/常驻进程**在独立仓库(如 `crypto_okx_hub`),本轮未审其 SSRF/SSE 实现 | +| 方法 | 三路并行静态审阅(Bridge 权限、资金路径回归、Web 鉴权/XSS/CSRF)+ 人工复核关键代码 + 对照 10-07 高危修复项 | + +## 2. 总览 + +| 级别 | 数量 | 说明 | +| --- | --- | --- | +| Critical | 1 | Bridge Token 明文进 env 设置页 → 等价远程 root | +| High | 6 | 平仓后卖币回归;settings 认 Bridge Token;7 天 embed_sso;备份 tar slip;CSRF/破坏性 GET;`/options/guide` 缺模块 500 | +| Medium | 9 | 监控/条件单失败语义;close_preview 时延;Webhook;登录无限速;复盘图;journal 删路径;frame-ancestors=*;version 泄路径 | +| Low | 若干 | 密码策略、进程内 SECRET 回退、局部 XSS、SSO 可重放至过期、settings Token 用 `==` | + +**币本位 10-07 Critical/High 主路径**:开仓增量、卖回封顶、粉尘桥、`sweep_spot_after_options_flat`、中控条件单持仓上限等 **仍在**;本地相关单测此前已通过。 +**回归**:`maybe_sell_spot_after_close` 未与 sweep 对齐。 + +## 3. Critical + +### C1 · `HUB_BRIDGE_TOKEN` 对已登录会话明文可见 + +| | | +| --- | --- | +| 位置 | `lib/env/env_schema.py`(`SENSITIVE_EXACT` 未含 `HUB_BRIDGE_TOKEN`);`/api/settings/env`;设置页 JS 明文输入框 | +| 证据 | `_is_sensitive` 仅匹配 `APP_PASSWORD` / `*_SECRET` / `*_API_KEY` 等;`HUB_BRIDGE_TOKEN` 走明文 `current` | +| 影响 | 任意已登录或持有 7 天 `embed_sso` 的会话可复制 Token,进而无 Cookie 调用 `/api/hub/update`(git pull+重启)、`/api/hub/backup/download`(含 `.env`)、平仓/条件单等 | +| 建议 | 将 `HUB_BRIDGE_TOKEN`(及 `WECHAT_WEBHOOK`)列入 `SENSITIVE_EXACT`;UI 仅显示掩码;复制走一次性确认;拆分 read/trade/admin Token | + +## 4. High + +### H1 · 平仓后卖币:持仓查询失败/`None` 当空仓(回归) + +| | | +| --- | --- | +| 位置 | `lib/options/options_coin_open_lib.py` · `maybe_sell_spot_after_close` | +| 证据 | `fetch_option_positions(ex) or []`;`except: pass` 后仍调用 `sell_residual_after_option_flat` | +| 对照 | 同文件 `sweep_spot_after_options_flat` 已正确拒绝 `None`/异常 | +| 调用链 | 买一平仓 / close_exec / monitor 同步卖币 | +| 影响 | API 抖动时可能市价卖掉桥/抵押币,期权仓仍在 | +| 建议 | 与 sweep 对齐;加单测 | + +### H2 · Bridge Token 可认证大部分 `/api/settings/*` + +| | | +| --- | --- | +| 位置 | `lib/instance/instance_settings_register.py` · `_api_login_required` | +| 证据 | `X-Hub-Token == HUB_BRIDGE_TOKEN` → `allowed=True`;仅拦截 env **写入**,不拦 restart / backup restore / download | +| 影响 | Token 即可恢复备份(含 `.env`)、PM2 重启,无需实例密码 | +| 建议 | Bridge 只走 `/api/hub/**`;设置类 API 仅 session/embed_sso | + +### H3 · `embed_sso` 默认 7 天且可进 URL + +| | | +| --- | --- | +| 位置 | `hub_bridge_register.py` `/hub_sso`;`app.py` `login_required` | +| 影响 | Referer/历史/日志泄露 → 长期全权 UI(含开仓、改密、恢复备份) | +| 建议 | TTL 缩至数小时;禁止 query 传参(仅 header/sessionStorage);绑定 Origin | + +### H4 · 备份恢复 `tar.extractall` 无成员路径校验 + +| | | +| --- | --- | +| 位置 | `lib/instance/backup_lib.py` · `restore_backup` | +| 影响 | 已认证用户恢复恶意包时可写穿临时目录(tar slip) | +| 建议 | Python 3.12+ `filter="data"`;或校验成员路径不越出 `tmp` | + +### H5 · CSRF:`SameSite=None` + 无 CSRF Token;破坏性 GET + +| | | +| --- | --- | +| 位置 | `app.py` Cookie SameSite 默认 None;`/del_key/` GET 删除 | +| 影响 | 跨站可借会话触发恢复/改密等;`` 可删关键位 | +| 建议 | 状态变更一律 POST + CSRF/Origin 校验;删除改 POST | + +### H6 · `/options/guide` 引用不存在的 `lib.hub.hub_strategy_lib` + +| | | +| --- | --- | +| 位置 | `lib/options/options_register.py`(约 441 行) | +| 证据 | 仓库内无 `hub_strategy_lib.py`;同能力已在 `lib/common/markdown_html_lib.py` | +| 影响 | 打开期权说明页直接 ImportError/500(可用性 High,非资金) | +| 建议 | 改为 `from lib.common.markdown_html_lib import render_markdown_html` | + +## 5. Medium + +| ID | 主题 | 位置 | 要点 | +| --- | --- | --- | --- | +| M1 | 监控持仓拉取失败 → `[]` | `options_monitor_lib.py` | 目标监控可能被标 expired,自动化出场中断 | +| M2 | 条件单 exit `or []` / `machine_has_option_position` 视 `None` 为空 | `options_cond_order_lib.py` | 查询失败可能误结束条件单 | +| M3 | 中控快照仍算 `close_preview` | `options_hub_lib.py` | 利于流动性展示,加重超时风险(相对 10-07「关闭 preview」有回退) | +| M4 | `WECHAT_WEBHOOK` 未脱敏 | env schema | 登录用户可读完整 webhook | +| M5 | `/login` 无限速 | `app.py` | 密码爆破 | +| M6 | 复盘静态图无 login | `options_review_register.py` | 依赖不可猜测文件名 | +| M7 | journal 删除路径未限制在 upload 根下 | `journal_images_lib.py` | DB 被投毒时可删库外文件 | +| M8 | `HUB_EMBED_FRAME_ANCESTORS=*` | `hub_bridge_auth.py` | 任意父页可嵌实例(点击劫持面) | +| M9 | `/api/hub/version` 返回本机 `repo` 绝对路径 | `instance_update_lib.py` | 信息泄露(需 Bridge Token) | + +## 6. Low / 运维注意 + +- Bootstrap 空密码时写入 `admin123`(`scripts/bootstrap_deploy_secrets.py`)— 上线后必须改密 +- `APP_AUTH_DISABLED=true` 关闭全部鉴权 — 仅本地调试 +- `FLASK_SECRET_KEY` 未配置时进程随机 — 重启会话失效 +- 改密最短 6 位、明文比对 `.env` +- 复盘/统计主路径有 `escapeHtml`;`options_panel.js` 等对交易所字段拼接仍有局部 XSS 面 +- 远程更新依赖固定脚本 + `git pull`:Token 泄露即供应链风险(与 C1/H2 叠加) +- SSO 签名令牌在过期前可重放(无一次性消费);settings 侧 Token 比对用 `==` 而非 `hmac.compare_digest` +- `lib.hub.hub_auth` 在本仓不存在,settings 鉴权依赖 ImportError 回落(行为易混淆) + +## 7. 已验证仍稳健(摘要) + +- Bridge 写路径:禁市价平、中控仅全平、条件单创建有持仓上限与本机有仓拒绝 +- SSO `next` 防开放重定向(`_safe_next_path`) +- 备份文件名白名单 + 路径限制在 backup 目录内 +- Bridge Token 校验用 `hmac.compare_digest` +- 币本位:买币增量、卖回 `coin_amount` 封顶、粉尘桥 `positions_ok`、sweep 失败不卖 +- SQLite WAL + busy_timeout;条件单/目标出场多数已「先关连接再网络」 +- env 对 API Key / Passphrase / App 密码有脱敏 + +## 8. 建议修复优先级 + +1. **立刻**:C1 脱敏 `HUB_BRIDGE_TOKEN`;H1 对齐 `maybe_sell_spot_after_close`;H6 guide 导入修正 +2. **本周**:H2 收紧 settings 对 Bridge Token;H3 缩短 embed_sso;H4 tar filter;H5 CSRF + `/del_key` POST +3. **随后**:M1–M3 失败语义统一;M4–M9 加固;评估中控侧独立仓库 SSRF/实例 URL 白名单 + +## 9. 结论 + +- **交易资金主路径**:10-07 高危大多仍成立,但 **平仓后卖币入口存在明确 High 回归**,应优先修。 +- **中控/实例权限模型**:单一长效 `HUB_BRIDGE_TOKEN` + 明文进设置页 + 设置 API 认 Token,是本轮 **最大系统性风险**(Critical)。 +- **中控 UI 仓库**未纳入本轮;实例 Bridge 与中控控制面需分开再审一轮。 + +修复后建议:再跑一轮 Bugbot(未提交变更)+ `tests.test_spot_dust_and_sell_cap` 等币本位单测,并人工测「持仓 API 失败时不卖币」。 + +## 10. 审阅来源 + +并行静态审阅(结果已并入上文):[Bridge 权限](f31789fc-cb2a-4838-ba28-777fbdc07de0)、[资金路径回归](b0c41cf8-fa1c-4a7c-8840-b2ba60fceb15)、[Web 鉴权/CSRF](6c69483e-2340-47c0-bdb8-0d02473dbfbf)。 diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index 5883819..a546255 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -574,22 +574,27 @@ def maybe_sell_spot_after_close( inst_id: str, close_result: dict[str, Any] | None = None, ) -> dict[str, Any] | None: - """期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.""" + """期权平仓后若该合约为币本位且已空仓,卖回本桥残留币. + + 持仓查询失败/空响应时不卖,等下次监控再查(与 sweep_spot_after_options_flat 一致). + """ + del close_result # 预留字段;卖回只认交易所持仓实况 if margin_mode_from_inst_id(inst_id) != "coin": return None - # 仍有仓则不卖 try: - rows = cfg["fetch_option_positions"](ex) or [] - for p in rows: - if str(p.get("instId") or p.get("inst_id") or "") != inst_id: - continue - try: - if abs(float(p.get("pos") or 0)) > 1e-12: - return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"} - except (TypeError, ValueError): - pass - except Exception: - pass + rows = cfg["fetch_option_positions"](ex) + except Exception as e: + return {"ok": False, "msg": f"持仓查询失败,暂不卖币: {e}", "skipped": True} + if rows is None: + return {"ok": False, "msg": "持仓查询失败: 空响应,暂不卖币", "skipped": True} + for p in rows or []: + if str(p.get("instId") or p.get("inst_id") or "") != inst_id: + continue + try: + if abs(float(p.get("pos") or 0)) > 1e-12: + return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"} + except (TypeError, ValueError): + pass parts = inst_id.split("-") underlying = (parts[0] if parts else "ETH").upper() return _spot_bridge.sell_residual_after_option_flat( diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 92fc3f9..4694649 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -438,7 +438,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: from flask import render_template_string - from lib.hub.hub_strategy_lib import render_markdown_html + from lib.common.markdown_html_lib import render_markdown_html md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md" try: diff --git a/tests/test_spot_dust_and_sell_cap.py b/tests/test_spot_dust_and_sell_cap.py index b0b4b19..0f1488d 100644 --- a/tests/test_spot_dust_and_sell_cap.py +++ b/tests/test_spot_dust_and_sell_cap.py @@ -35,5 +35,65 @@ class TestSellCapLogic(unittest.TestCase): self.assertAlmostEqual(sell_amt, 0.006) +class TestMaybeSellAfterCloseGuard(unittest.TestCase): + """平仓后卖回:持仓查询失败/空响应绝不卖币.""" + + def test_none_positions_no_sell(self): + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + called = {"sell": 0} + + def _fetch(_ex): + return None + + cfg = { + "fetch_option_positions": _fetch, + "get_db": lambda: (_ for _ in ()).throw(AssertionError("不应开库卖币")), + } + out = maybe_sell_spot_after_close( + cfg, object(), inst_id="ETH-USD-260701-2500-C" + ) + self.assertIsInstance(out, dict) + self.assertFalse(out.get("ok")) + self.assertTrue(out.get("skipped")) + self.assertIn("空响应", str(out.get("msg") or "")) + self.assertEqual(called["sell"], 0) + + def test_fetch_raises_no_sell(self): + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + def _fetch(_ex): + raise RuntimeError("network down") + + cfg = { + "fetch_option_positions": _fetch, + "get_db": lambda: (_ for _ in ()).throw(AssertionError("不应开库卖币")), + } + out = maybe_sell_spot_after_close( + cfg, object(), inst_id="ETH-USD-260701-2500-C" + ) + self.assertIsInstance(out, dict) + self.assertFalse(out.get("ok")) + self.assertTrue(out.get("skipped")) + self.assertIn("持仓查询失败", str(out.get("msg") or "")) + + def test_still_open_skips_sell(self): + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + inst = "ETH-USD-260701-2500-C" + + def _fetch(_ex): + return [{"instId": inst, "pos": "1"}] + + cfg = { + "fetch_option_positions": _fetch, + "get_db": lambda: (_ for _ in ()).throw(AssertionError("有仓不应卖币")), + } + out = maybe_sell_spot_after_close(cfg, object(), inst_id=inst) + self.assertTrue(out.get("ok")) + self.assertTrue(out.get("skipped")) + self.assertIn("仍有持仓", str(out.get("msg") or "")) + + if __name__ == "__main__": unittest.main()