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 一律写「远程服务器」。
13 KiB
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 为什么不能简单隔离
- 该密钥是浏览器 Web 客户端连接实例的会话密钥——实例进程要写,浏览器要读。
- 实例进程与 agent 工具调用同 uid,OS 层无法区分。
- dsh 沙箱不提供"读路径 deny"。
- 真正隔离需要改 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 幂等,表可撤可恢复 |
踩坑记录(已固化进脚本注释):
- 阿里云 DNS 是
100.100.2.136/100.100.2.138,绝不能封100.100.0.0/16整段——只精确封元数据端点/32。 - Docker 用 iptables-nft,不要往它的链里塞规则,独立建 nft 表最干净。
nft -f遇已存在的表会报File exists→ 单元必须加ExecStartPre=-nft delete table ip dsh_egress(-忽略失败)才幂等。- 观测要排除 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 |