Files
dsh_shenxian/dsh-server-docs/04-调整方案/14-实例会话敏感信息暴露面审计与加固.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

240 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/[email protected]`:
| 能力 | 实测 |
|---|---|
| `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 |