# 14 · 实例会话敏感信息暴露面审计与加固 - 日期:2026-09-10 - 触发:一份"dsh 实例会话读取是否会涉及服务关键信息泄密"的安全审计报告 - 结论一句话:**跨租户/提权不成立(门户资产全部拒绝访问),真正的缺口是"实例出网完全不受限"+"云元数据端点可达"+"会话无保留期";而 `.credentials.yaml` 的暴露无法靠 dsh 机制消除,只能降值与观测。** - 状态:审计已完成 → **加固项待用户确认后执行**(见 §五) > **TL;DR**|**结论**:实例会话敏感信息暴露面审计:**跨租户 / 提权不成立**(门户资产全部拒绝访问)。 > **关键**:真缺口 = 实例**出网完全不受限** + 云元数据端点可达 + **会话无保留期**;`.credentials.yaml` 只能降值与观测。 > **状态**:🔄 审计完成;加固项后续由档案 39 / 28 等落地 --- ## 一、原审计报告要点(待核实) 报告给出了 dsh 实例内 agent 会话的可读清单、被拒清单,以及"读取 → 落盘 → 外传"三段风险链,并给出 5 条建议: | # | 建议 | 本文判定 | |---|---|---| | 1 | 对话中不放真实密钥 | ✅ 成立(本次审计全程未输出任何密钥正文) | | 2 | 收紧沙箱出网,加白名单 | ⚠️ 方向成立,但**硬白名单会直接打断业务**,需分层 | | 3 | 会话记录保留期与清理 | ✅ 成立,当前**无保留期**,应立即做 | | 4 | `.credentials.yaml` 当密钥文件管理 | ✅ 成立,但**无法靠 dsh 机制隔离**(见 §四) | | 5 | 保持 `approval: ask` | ✅ 成立,且应**显式固定**,不依赖内置默认 | --- ## 二、实测核验(四轮只读审计,2026-09-10) 审计脚本:`scripts/audit-secret-exposure.sh` / `-2.sh` / `-3.sh`(工作区根目录)。 **红线:脚本只取 stat 元数据与键名,严禁输出任何密钥正文。** ### 2.1 实例身份与自家目录可见性 | 项 | 实测结果 | |---|---| | 实例进程 | `node /usr/local/bin/dsh --profile web --host 127.0.0.1 --port 41427`,运行 uid = `dsh-cce6d1cdb376430480f0` | | 家目录 | `drwx------` 属主即实例 uid(100002 / 114801) | | `.credentials.yaml` | **161 B,mode 600,属主 = 实例运行 uid** → agent 会话可读 | | 该文件结构 | `records["client-connection/browser-session"].payload.secret` = 43 字符 base64url | | **其性质** | **不是 LLM provider API Key**,是**浏览器 Web 客户端连接实例的会话密钥** | | `sessions/` | mode 700,2 个会话,488 K | | `storages/session_projcache/sessions/*.json` | mode 600,3 个,共约 14 K | | `settings.yaml` | mode 600,33 B,内容仅 `agent-presets: default: cordis` | ### 2.2 门户资产(越权/跨租户是否成立) 以实例 uid 实测 `test -r`: | 路径 | 结果 | |---|---| | `/opt/dshs` | ✅ 拒绝 | | `/opt/dsh` | ✅ 拒绝 | | `/var/lib/dshs` | ✅ 拒绝 | | `/var/lib/dshs/dshs.db`(mode 600 root) | ✅ 拒绝 | | `/etc/shadow` | ✅ 拒绝 | | `/root` | ✅ 拒绝 | | `/home/admin`、`/home/www` | ✅ 拒绝 | | `/home`(仅列目录名) | ⚠️ 可列(泄漏两个账号名,信息量极小,可接受) | > **账户隔离(ISOLATION=account + setpriv)有效,跨租户与提权路径均不成立。** ### 2.3 出网能力(关键缺口) 以实例 uid(`setpriv --reuid`)实测: | 目标 | 结果 | |---|---| | `https://www.baidu.com` | **200** | | `https://httpbin.org/get` | **200** | | `https://api.deepseek.com` | 401(可达) | | DNS 解析 | ✅ 可用 | | `iptables -P OUTPUT` | **ACCEPT,无任何针对 dsh uid 的限制** | ### 2.4 云元数据端点(本报告最高危项,实测降级) | 端点 | 结果 | |---|---| | `http://169.254.169.254/...` | 不可达 | | `http://metadata.google.internal/...` | 不可达 | | **`http://100.100.100.200/latest/meta-data/`** | **200 可达** | 进一步只取**目录列表**(不取任何凭据正文): ``` disks/ dns-conf/ eipv4 hibernation/ hostname image-id image/ instance-id instance/ mac network-type network/ ntp-conf/ owner-account-id private-ipv4 region-id serial-number source-address sub-private-ipv4-list vpc-cidr-block ``` > **无 `ram/` 目录 → 该 ECS 未挂载 RAM 角色 → 无 STS 临时凭据可窃取。** > 因此从"云凭据失窃(P0)"降级为"**云上资产画像 + 内网拓扑泄露(P2)**": > 可读取 `instance-id` / `owner-account-id` / `vpc-cidr-block` / `private-ipv4` / `region-id` / `serial-number`。 ### 2.5 dsh 沙箱能力边界(决定哪些加固可行) 只读查阅官方包 `@deepseek-ai/dsh@0.1.2-rc.1`: | 能力 | 实测 | |---|---| | `dsh-sandbox-policy` 模式 | 仅 `read-only` / `workspace-write` / `danger-full-access` 三档 | | **约束对象** | **只约束"文件修改",不约束读取**(README 原文:`cannot modify files` / `may modify files under ... workspaceRoot`) | | **网络/出网控制** | **完全没有**(包内无 `network` / `egress` 相关实现) | | `dsh-user-approval` | 仅 `ask` / `never` 两档 | | `dsh-permission-presets` | 把 sandbox + approval 捆绑为命名预设,默认提供 `workspace-write`(sandbox=workspace-write, approval=ask) 与 `danger-full-access`(…, never) | | 当前实例配置 | `profiles/web/cordis.yml` 与 `cordis.patch.yml` **均为 `[]`**,未配置 permission-presets → 走内置默认(schema `.default("read-only")`) | > **结论:dsh 沙箱解决不了"读",也解决不了"外传"。这两段只能在 OS 层(门户/防火墙)处理。** ### 2.6 uid 段(防火墙规则用) ``` hashUid(userId, baseUid) = baseUid + (hash % 100000) // 存量用户 新用户 uid = baseUid + row_id // DB 自增,无冲突 观测值:100002、114801 → 统一落在 [baseUid, baseUid + 99999] ``` ### 2.7 规模 - 用户数:2 - `/var/lib/dshs` 占用:17 M - 磁盘:40 G 用 8.5 G(23%) --- ## 三、风险定性(对照"读 → 落盘 → 外传"三段链) | 段 | 现状 | 可控性 | |---|---|---| | **读** | agent 以自身 uid 读自家 `.credentials.yaml` / 会话记录,属"自己读自己",非越权 | ❌ dsh 沙箱不支持读限制;OS 层也无法区分"dsh 主程序读"与"agent 工具读"(同 uid) | | **落盘** | 默认 sandbox mode = read-only,写入已被限制;但未显式固定,存在内置默认漂移风险 | ✅ 可显式固定 preset | | **外传** | **完全开放**,且元数据端点可达 | ✅ **唯一真正有效的控制点** | **因此加固重心应放在"外传"段,其次"降值"(让读到的东西不值钱),"读"段只能接受。** --- ## 四、`.credentials.yaml` 为什么不能简单隔离 1. 该密钥是**浏览器 Web 客户端连接实例的会话密钥**——实例进程要**写**,浏览器要**读**。 2. 实例进程与 agent 工具调用**同 uid**,OS 层无法区分。 3. dsh 沙箱不提供"读路径 deny"。 4. 真正隔离需要改 dsh 主程序(**红线 R2 禁止**)或把 credentials 搬到 root-only 路径 + dsh 侧适配(同样需改官方行为)。 → **降级处理**:承认其可被会话读取,改为**降值 + 观测**(短生命周期 / 可轮换 / 读行为留痕),并在文档中标注"任何时刻不要把该密钥或等价物贴进对话"。 --- ## 五、加固方案对比(待确认) | # | 措施 | 收益 | 代价 / 风险 | 可逆性 | 建议 | |---|---|---|---|---|---| | **H1** | **封禁元数据端点**:nft `meta skuid <段>` + `ip daddr 100.100.100.200` → reject | 消除云上资产画像与内网拓扑泄露 | 极低(仅 dsh uid 段,root 与云监控不受影响) | 一条命令删除规则 | ✅ **立即执行** | | **H2** | **显式固定 permission preset**(`workspace-write` + `approval: ask`),走官方 cordis patch 机制 | 消除"内置默认漂移";不碰红线 R2 | 低(官方机制,需重启实例生效) | 改回 patch 即可 | ✅ **立即执行** | | **H3** | **会话保留期 GC**:`sessions/` 与 `session_projcache/` 超 N 天清理(默认 30 天,首跑 dry-run) | 收敛"落盘"段的存量 | 用户会丢失 N 天前的聊天记录 | 脚本可停;删除不可恢复 | ✅ 执行(**需你确认保留天数**) | | **H4** | **出网观测**:记录实例外联目标域名(先记录不拦截) | 为后续白名单提供事实依据 | 低 | 停服务即可 | ✅ **立即执行** | | **H5** | **出网严格白名单**:仅放行 LLM API、npm/pip 等 | 真正切断外传 | **高**:用户自有服务、各类 API 会被打断,需长期维护白名单 | 可回滚 | ❌ **暂缓**,等 H4 观测数据出来再评估 | | **H6** | `.credentials.yaml` 隔离 | — | **技术上不可行**(§四) | — | ⬇️ 降级为"降值 + 文档标注" | ### H1 实施要点(两个必踩坑) ``` # 坑 1:阿里云 DNS 是 100.100.2.136 / 100.100.2.138,绝不能把 100.100.0.0/16 整段封掉 # —— 只封元数据端点的 /32:100.100.100.200 # 坑 2:Docker 用 iptables-nft,别往它的链里塞;单独建 nft table 最干净 ``` 建议落地形式:`/etc/nftables-dsh-egress.nft` + systemd oneshot 服务(持久化,重启不丢)。 --- ## 六、执行记录(2026-09-10 12:03) 已执行 **H1 + H4**(用户确认范围);H2 经核实转为待确认;H3 用户未选、暂缓。 ### H1 + H4 · 出网护栏(已完成并验证) 落地物: | 文件 | 作用 | |---|---| | `/etc/nftables-dsh-egress.nft` | 规则源文件(独立 `table ip dsh_egress`,不碰 Docker 链) | | `/etc/systemd/system/dsh-egress.service` | oneshot 单元,开机自动加载 | | `scripts/harden-h1-h4-egress.sh`(本机) | 一键复现脚本(幂等) | 规则(只作用于 uid 100000–199999): ``` # H1 · 阻断云元数据端点(先记录再拒绝) meta skuid 100000-199999 ip daddr 100.100.100.200 log prefix "dsh-egress-BLOCK " level warn meta skuid 100000-199999 ip daddr 100.100.100.200 counter reject # H4 · 观测实例新建外联(先记录不拦截;排除 loopback 与 DNS 降噪) meta skuid 100000-199999 ip daddr != 127.0.0.0/8 tcp dport != 53 ct state new counter log prefix "dsh-egress " level info meta skuid 100000-199999 ip daddr != 127.0.0.0/8 udp dport != 53 ct state new counter log prefix "dsh-egress " level info ``` 验证结果: | 检查 | 结果 | |---|---| | 实例 uid → `100.100.100.200` | **HTTP 000(阻断)**;reject 计数器命中 2 次,BLOCK 日志 2 条 | | 实例 uid → `https://www.baidu.com` | HTTP 200(业务不受影响) | | 实例 uid DNS 解析 | ✅ 正常(`100.100.2.136` 未被误封) | | root → `100.100.100.200` | HTTP 200(云监控/运维不受影响) | | 门户 `127.0.0.1:3080` | HTTP 200 | | 降噪效果 | DNS 日志行 0 条(原占绝大多数) | | systemd | `enabled` + `active`;stop→start 幂等,表可撤可恢复 | **踩坑记录(已固化进脚本注释)**: 1. 阿里云 DNS 是 `100.100.2.136` / `100.100.2.138`,**绝不能封 `100.100.0.0/16` 整段**——只精确封元数据端点 `/32`。 2. Docker 用 iptables-nft,**不要往它的链里塞规则**,独立建 nft 表最干净。 3. `nft -f` 遇已存在的表会报 `File exists` → 单元必须加 `ExecStartPre=-nft delete table ip dsh_egress`(`-` 忽略失败)才幂等。 4. 观测要排除 53 端口:nft 日志**不记录查询域名**,DNS 行是纯噪音且量最大。 ### H2 · 固定 permission preset(改为待确认) 核实结果: - `@deepseek-ai/dsh-permission-presets` **已在 `@deepseek-ai/dsh-base` 的默认 bundle 清单中**(host 侧),客户端侧 `@deepseek-ai/dsh-client-ui-permission-presets` 在 `dsh-web-app` 中 → **插件已随默认 bundle 加载**。 - 当前 `profiles/web/cordis.patch.yml` 内容为默认空(`[]`),未覆盖;官方包逻辑为 `defaultPreset = config.defaultPreset ?? inferredDefault`。 - 即:**当前生效值走官方推断默认,从服务端无法直接读出**(客户端 Web UI 有 Permissions 选择器可直接查看)。 未执行的理由:注入 `cordis.patch.yml` 需**重启用户实例**才生效,会打断当前会话;且可能与 `ensure-role-profile-patch.cjs`(按角色隐藏「模型」分区)的管理标记冲突。 → 处理方式:先由用户在 dsh Web UI 的 Permissions 选择器确认当前预设;若不是 `workspace-write + ask`,再备份 `cordis.patch.yml` → 注入 → 重启实例 → 验证。 ### H3 · 会话保留期(用户未选,暂缓) 触发条件:用户数 ≥ 5 或 `sessions/` 总量 > 1 GB 时再启用。 ## 七、回滚 | 项 | 回滚方式 | |---|---| | H1 + H4 | `systemctl disable --now dsh-egress`,或 `nft delete table ip dsh_egress` | | H2 | 备份还原 `profiles/web/cordis.patch.yml` 并重启实例 | | H3 | 停用 timer;**已删除数据不可恢复**,故首跑必须 dry-run |