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

13 KiB
Raw Blame History

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