# SSH 环境转发会泄露本地上下文吗？

SSH 环境转发可能泄露远程任务根本不需要的大量本地上下文。通道虽然经过加密，但加密只保护传输过程，并不会让 `AWS_PROFILE`、`GIT_AUTHOR_EMAIL`、租户名称或内部功能开关自动成为另一台机器上进程的合理输入。

我把每个转发变量都视为远程命令的参数。因此，每个变量都应有明确名称、用途、负责人和测试。从适合个人笔记本的 SSH 配置中复制通配符不符合这个标准，因为只要有人新增一个匹配的本地变量，它的含义就会改变。

安全目标不是空白的远程环境。`sshd`、登录账户、shell 和包装脚本都会建立自己的基础环境。更准确的目标是：客户端只提供书面约定中的工作流变量，服务器不接受更宽的集合，并且测试同时证明必需值能够到达、诱饵值会被拒绝。

## 只有 SSH 两端都同意，转发才会发生

OpenSSH 环境转发有两道门。客户端的 `SendEnv` 从本地进程环境中选择变量名，客户端 `SetEnv` 则直接提供 `NAME=VALUE`。服务器的 `AcceptEnv` 决定哪些客户端变量进入会话环境。通常只有客户端发送且服务器接受的名称才会通过。

RFC 4254 描述了底层机制。shell 或命令启动前，客户端可以发送类型为 `env` 的 SSH 通道请求，其中包含一个变量名和一个值。该 RFC 明确警告，不受控制地设置特权进程环境会带来安全风险，并建议维护允许列表，或等服务器进程降低权限后再设置变量。这不是后来附加的加固建议，而是协议规范本身的提醒。

OpenSSH 手册还有一个常让审计人员困惑的例外：客户端请求伪终端时，协议需要 `TERM`，所以它始终会被发送和接受。自动化不需要终端时应使用 `ssh -T`。这样可从这条路径中去掉终端行为、交互式启动意外和 `TERM`。

这套握手机制带来四个结论。

- 宽泛的 `SendEnv` 面对拒绝所有变量的服务器时暂时不起作用，但管理员一旦扩大 `AcceptEnv`，它就会生效。
- 宽泛的 `AcceptEnv` 面对谨慎客户端时暂时不起作用，但任何获准用户都能换一份客户端配置发送匹配值。
- 当不同人员控制两端配置时，任何一端都无法单独落实完整约定。
- 远程命令成功，并不能说明服务器拒绝过哪些环境请求。

最后一点尤其重要。OpenSSH 可以忽略变量后继续会话。如果部署之所以成功，是因为远程 shell 本来就定义了同名变量，只检查最终值的测试可能把一个从未转发的值误认为转发结果。

## 通配符会把未来的本地状态变成远程输入

`SendEnv WORKFLOW_*` 并不表示“今天审查过的变量”，而是未来每个 SSH 客户端进程环境中所有匹配名称。几周后，开发者可能在 shell 配置中加入 `WORKFLOW_DEBUG_DUMP`、`WORKFLOW_CUSTOMER` 或 `WORKFLOW_TOKEN_FILE`，旧规则会悄悄获得新行为。

区域设置最能说明问题。许多工作站会发送 `LANG` 和 `LC_*`，以便交互式会话正确显示文本。这对人工登录可能合理，却不应自动进入非交互构建或部署账户。区域设置会改变排序、字符分类、日期格式和诊断文本。解析输出的任务可能失败，即使没有任何秘密泄露。

风险较高的模式通常看起来像方便的命名空间：

- `AWS_*` 可能包含配置文件名、区域、凭据相关值和配置路径。
- `GIT_*` 可能包含身份、跟踪设置、备用对象目录或 askpass 行为。
- `CI_*` 常把无害的构建标签、平台上下文和临时路径混在一起。
- `LC_*` 看似只是显示设置，直到脚本依赖固定排序或消息。
- `APP_*` 会随应用增长，通常没有单一安全含义。

不要把整个命名空间归类为安全，应逐个判断名称。工作流可能需要 `DEPLOY_REGION`，但 `AWS_PROFILE` 只是工作站上下文；可能需要 `BUILD_REF`，而 `GIT_CONFIG_COUNT` 会改变 Git 的运行配置。共同前缀便于整理，却不是信任边界。

即使不是凭据，值也会暴露上下文。路径会泄露用户名和仓库布局，配置文件名会指明账户或环境，跟踪开关可能让远程工具把命令数据写入共享日志。代理稍后还可能利用这些信息。泄露包括未经计划的影响和披露，不只包括秘密字符串。

## 看似安全的例外可能在几个月后突然扩大

这类泄露往往由配置漂移造成，而不是一次明显冒险的修改。开发者先为需要 `APP_COLOR=0` 的测试加入 `SendEnv APP_*`，服务器当时拒绝它。后来管理员为同一共享主机上的另一支团队加入 `AcceptEnv APP_*`。两次评审都只看到握手的一半，因此各自看起来无害。

下一次连接会把两条沉睡规则接起来。开发者环境中已有 `APP_CUSTOMER=acme-lab`、`APP_TRACE=1` 和 `APP_CONFIG=/Users/lee/work/private/config`。客户端发送全部变量，守护进程全部接受。发布失败后，诊断包装脚本执行 `env`，并把输出写入组内可读日志。没有令牌被复制，但本地身份、客户上下文、路径和跟踪开关都未经批准越过边界。

删除服务器通配符只能修复后续会话，不能删除旧日志，也不能确定哪些命令看过这些值。应把发现当作一个小型事件处理：

1. 停止接受宽泛模式，并验证守护进程配置。
2. 找出两端模式同时存在期间涉及的账户、客户端和时间范围。
3. 在获准的远程日志中搜索变量名，不要搜索敏感值。
4. 判断每个值是否改变了命令行为，或向其他用户披露了上下文。
5. 用精确名称替换通配符，并为被拒绝类别增加诱饵。

因此，“服务器目前会拒绝它”不能为宽泛客户端规则辩护。沉睡配置在生效时通常没有明确负责人。即使服务器很严格，也要删掉客户端无用的发送规则；即使今天的客户端很谨慎，也要删掉服务器无用的接受规则。

反向漂移也会发生。服务器长期为交互用户接受 `LC_*`，后来自动化镜像更新加入发送 `LC_*` 的系统配置。部署任务继承开发者选择的 `LC_COLLATE`，文件顺序随之改变。这可能不是披露事件，却是同一握手导致的完整性故障。应把保密性和命令行为放在同一次审查中。

## 检查 SSH 实际采用的配置

编辑配置文件前，先读取客户端的最终配置。OpenSSH 的 `ssh -G` 会应用 `Host`、`Match`、include、用户配置和系统配置，然后打印指定目标的结果。看起来最权威的文件可能被更早的值覆盖，也可能从 include 中获得额外规则。

请在与工作流相同的账户和执行环境中运行：

```sh
ssh -G deploy-prod |
  awk '$1 == "sendenv" || $1 == "setenv" { print }'
```

一种典型的不安全结果如下：

```text
sendenv LANG
sendenv LC_*
sendenv AWS_*
setenv WORKFLOW_KIND=deploy
```

`ssh -G` 打印的是有效选择规则，而不是 `SendEnv` 选中的当前值，因此不会在审查输出中倾倒凭据。但只有把这些名称或模式与真正启动 SSH 的进程环境对照，审查才算完整。

只收集名称，不收集值：

```sh
env | sed 's/=.*//' | LC_ALL=C sort > local-env.names
grep -E '^(LANG|LC_|AWS_|WORKFLOW_)' local-env.names
```

上面的正则只是检查工具，不是可复用策略。应按 `ssh -G` 中的所有模式调整它。如果 SSH 由监督进程、IDE、代理或计划任务启动，请检查那个进程的环境，而不是交互式 shell。环境沿进程树继承，从错误的父进程测试会得到干净却无关的答案。

服务器必须单独检查。OpenSSH 服务器可用 `sshd -T` 验证并打印有效设置；如果 `Match` 块影响该账户，还要提供连接条件：

```sh
sudo sshd -T \
  -C user=deploybot,host=deploy.example,addr=192.0.2.44 |
  grep '^acceptenv'
```

审查时换成真实用户、主机和客户端地址，示例中的文档地址只应留在示例里。重新加载前还要运行 `sudo sshd -t`。语法检查代价很低，收紧环境规则不值得以远程访问中断为代价。

## 专用客户端文件让允许列表一目了然

自动化最可靠的做法，是用 `-F` 指定一份小型专用 SSH 配置。`ssh` 手册说明，显式文件会替代常规用户配置，并使系统级客户端文件被忽略。这样，工作站软件或集中配置日后新增的区域通配符不会进入工作流。

```sshconfig
Host deploy-prod
    HostName deploy.example
    User deploybot
    IdentityFile ~/.ssh/deploy_ed25519
    IdentitiesOnly yes
    RequestTTY no
    SendEnv DEPLOY_REGION
    SendEnv BUILD_REF
    SetEnv WORKFLOW_KIND=deploy
```

明确指定它：

```sh
ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

`SendEnv` 从本地 `ssh` 进程环境读取值，适合每次运行都会合理变化的输入。客户端 `SetEnv` 在配置中写死一个值，适合非秘密常量标签，不适合凭据，也不适合容易忘记更新的值。

OpenSSH 也允许用带 `-` 前缀的 `SendEnv` 模式清除之前选择的名称，可用于修复已有的分层配置：

```sshconfig
Host deploy-prod
    SendEnv -*
    SendEnv DEPLOY_REGION BUILD_REF
```

清理只影响当时已累积的选择，后续 include 或系统规则仍可能重新加入名称。因此我不会把减法当作最终边界。通过 `-F` 使用的专用文件组成更少，其他工程师一屏就能理解。

凭据不应进入这份约定。转发的令牌会变成普通远程进程状态，可能进入子进程、调试输出、崩溃报告、权限允许时的 `/proc` 检查，或意外的 `env` 输出。SSH 保护传输，不保护值到达后的整个生命周期。

## 服务器应只为有限账户接受精确名称

服务器是会话启动前拒绝客户端变量的最后位置。除伪终端下的 `TERM` 外，OpenSSH 默认不接受变量。应保留这个全局默认值，只为工作流专用账户增加精确名称。

```sshdconfig
Match User deploybot
    AcceptEnv DEPLOY_REGION
    AcceptEnv BUILD_REF
    AcceptEnv WORKFLOW_KIND
```

不要使用 `AcceptEnv APP_*`、`AcceptEnv AWS_*` 或裸通配符。OpenSSH 的 `sshd_config` 手册明确警告，某些环境变量可能绕过受限用户环境。今天的名称看起来无害并不能消除风险，因为通配符会在没有服务器变更的情况下批准未来名称。

宽泛的全局 `AcceptEnv` 会削弱所有匹配账户。新增更窄的 `Match User` 块不会删除全局已接受名称，因为这些条目可以累积。如果交互用户需要区域设置而部署账户不需要，不要从全局区域规则开始；应只为目标用户或组开放，并用 `sshd -T -C` 检查每类连接。

服务器 `SetEnv` 是另一项控制。它为子会话设置值，并覆盖默认值以及来自 `AcceptEnv` 或 `PermitUserEnvironment` 的值。服务器拥有某个常量时应使用它。如果每次运行都必须采用同一个服务器批准值，就不要再从客户端接受该值。

`PermitUserEnvironment` 又是另一条来源。它控制 `~/.ssh/environment` 和 authorized_keys 中的 `environment=`，OpenSSH 默认关闭它。只检查 `AcceptEnv` 的审计会漏掉这条路径。除非账户设计明确需要，否则保持关闭；若启用，则把相关模式纳入同一份约定。

精确名称允许列表只控制名称，不验证值。允许的 `DEPLOY_REGION` 仍可能包含空格、shell 元字符、换行或禁止的区域。SSH 协议把它作为数据传输，但接收脚本可能通过 `eval`、未加引号的展开、生成配置或命令字符串把它变成语法。

应在远程入口验证每个允许值：

```sh
case ${DEPLOY_REGION-} in
    us-east-1|us-west-2) ;;
    *)
        printf 'invalid DEPLOY_REGION\n' >&2
        exit 64
        ;;
esac

case ${BUILD_REF-} in
    [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*)
        ;;
    *)
        printf 'invalid BUILD_REF\n' >&2
        exit 64
        ;;
esac
```

验证必须符合真实约定。这个短 shell 模式只演示形状检查；如果发布要求完整提交标识，就应检查精确长度并确认仓库确实包含它。不要让验证器宽松到只会拒绝空字符串。

修改后应检查有效结果，而不是相信文件缩进：

```sh
sudo sshd -t
sudo sshd -T \
  -C user=deploybot,host=deploy.example,addr=192.0.2.44 |
  grep -E '^(acceptenv|permituserenvironment|setenv)'
```

把输出随配置评审保存。它记录守护进程对特定连接真正解析出的设置，比 include 目录中某个片段的截图更有用。

## 在远程证明必需值到达且诱饵被拒绝

有效测试应在远程进程内同时检查正例和反例。为每个必需值设置不会混淆的标记，再设置能匹配旧宽泛模式的诱饵，通过专用文件启动非交互会话，并让远程 shell 在约定不符时失败。

下面只允许客户端提供 `DEPLOY_REGION`、`BUILD_REF` 和固定的 `WORKFLOW_KIND`：

```sh
export DEPLOY_REGION='env-audit-region'
export BUILD_REF='env-audit-ref'
export AWS_PROFILE='must-not-cross'
export LC_AUDIT_CANARY='must-not-cross'
export APP_PRIVATE_PATH='must-not-cross'

ssh -F ./deploy-ssh.conf -T deploy-prod 'sh -s' <<'REMOTE'
set -eu

test "$(printenv DEPLOY_REGION)" = 'env-audit-region'
test "$(printenv BUILD_REF)" = 'env-audit-ref'
test "$(printenv WORKFLOW_KIND)" = 'deploy'

for name in AWS_PROFILE LC_AUDIT_CANARY APP_PRIVATE_PATH; do
    if printenv "$name" >/dev/null 2>&1; then
        printf 'unexpected forwarded variable: %s\n' "$name" >&2
        exit 1
    fi
done

printf '%s\n' 'SSH environment contract passed'
REMOTE
```

预期输出只有一行：

```text
SSH environment contract passed
```

再取消一个必需的本地值进行反向测试。`SendEnv` 无法发送本地不存在的名称，因此远程断言应该失败：

```sh
unset BUILD_REF
if ssh -F ./deploy-ssh.conf -T deploy-prod 'test -n "$BUILD_REF"'; then
    printf '%s\n' 'test failed: BUILD_REF appeared unexpectedly' >&2
    exit 1
fi
```

这能区分可选转发值与必需输入。如果没有 `BUILD_REF` 就必须停止，还应在 SSH 前用 `${BUILD_REF:?BUILD_REF is required}` 让启动器失败。远程检查仍不能省，因为它验证传输约定并能发现服务器拒绝。

诱饵应覆盖审计发现的每个通配符或可疑类别，并使用显然是假的值。不要把真实凭据放进泄露测试，因为失败可能一边证明问题，一边把凭据写进日志。

一次成功不能证明所有想象得到的变量都无法通过。它只证明在某份配置和某个进程环境下，列出的反例被拒绝。还要结合两项静态检查：`ssh -G` 中只能有精确 `SendEnv` 名称，`sshd -T -C` 中只能有预期 `AcceptEnv` 名称。只要出现 `*` 或 `?`，测试矩阵就无法列举未来环境新增的名称。

应从每种不同启动器运行测试。开发者 shell、CI 工作器、编辑器任务和自治代理即使调用同一二进制，也可能使用不同环境与配置路径。把专用文件路径写进启动器，不要依赖只存在于交互 shell 的别名。

观察最终环境和证明 SSH 的贡献也不同。假设 `/etc/profile` 已设置 `DEPLOY_REGION=us-east-1`，客户端又发送同样值，即使服务器拒绝请求，正向断言也会通过。应使用远程基础环境不可能已有的唯一标记，并执行缺失输入测试，才能同时发现被拒绝的必需值与意外的远程默认值。

这项测试并不声称远程环境只有三个变量。正常 OpenSSH 会话还包含服务器建立的 `HOME`、`USER`、`SHELL`、`PATH` 和 SSH 连接元数据，启动脚本或包装层也可能增加其他值。断言证明的是客户端贡献部分符合约定。若命令还需要最小化整个环境，应另行审计服务器基础环境。

## 命令构造可以完全绕过 SendEnv

清空 `SendEnv` 并不能阻止本地 shell 把变量展开到远程命令字符串中。这是另一条数据路径：

```sh
ssh deploy-prod "release '$TENANT' '$TOKEN'"
```

本地 shell 在 `ssh` 启动前替换两个值。它们位于加密的命令请求中，而不是 `env` 请求中，因此 `AcceptEnv` 无法拒绝。根据启动器如何构造和记录命令，它们还可能出现在进程检查、shell 历史、CI 日志或错误消息中。

下面的前缀会改变本地 SSH 进程环境，但只有配置选择该名称时才会跨越：

```sh
DEPLOY_REGION=west ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

团队经常混淆这两种情况。“SSH 运行时它在环境里”不能证明环境转发发送了它；“我们关闭了 `SendEnv`”也不能证明包装脚本没有把它插入参数或标准输入。必须追踪真实通道。

远程启动代码还会形成其他来源。登录 shell、`/etc/environment`、PAM、强制命令、`sudo`、服务管理器或容器启动器，都可能在 SSH 接受变量后删除、替换或新增值。标记缺失时，可用 `ssh -vvv` 查看客户端发送决定，在获准范围查看服务器日志，并运行位于应用包装层之前的微型命令。不要立刻扩大 `AcceptEnv`，那通常会掩盖真正修改值的层。

`sudo` 需要单独检查。sudoers 可以重置大多数环境、保留指定名称或允许调用者请求保留，但它只影响特权子进程收到的内容。值此前已经进入非特权 SSH 会话，shell、审计钩子和包装脚本都可能看到它。应按特权命令需要配置 sudoers，但在这一层之前保持 SSH 允许列表最窄。

远程主机也不是被动管道。控制远程账户的人通常可以打印进程环境，管理员更控制整台机器。转发本地值从设计上就是把它披露给远程信任域。如果安全要求是远程主机绝不能知道某个值，就不要以任何形式通过 SSH 发送。

不要把完整 `env` 倾倒到日常日志中。获准审计时先记录名称，只有假标记才显示值。完整输出会把无关变量变成审计材料，甚至制造本来要避免的事件。

## 代理运行的 SSH 需要更小的进程边界

自治代理常继承启动它的终端、编辑器或编排器环境。这个环境为跨仓库、跨账户工作的人准备，并非为一项远程动作准备。把其中的命名空间发送到远程主机，会给代理更多上下文，也会给远程工具提供无人审查的输入。

启动工作流时既要指定明确的本地环境，也要指定明确的 SSH 配置。一个最小包装脚本可以要求两个变化值，丢弃无关继承状态，只恢复 SSH 客户端确实需要的内容：

```sh
#!/bin/sh
set -eu
: "${DEPLOY_REGION:?DEPLOY_REGION is required}"
: "${BUILD_REF:?BUILD_REF is required}"

exec env -i \
  HOME="$HOME" \
  PATH='/usr/bin:/bin:/usr/sbin:/sbin' \
  DEPLOY_REGION="$DEPLOY_REGION" \
  BUILD_REF="$BUILD_REF" \
  ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

应按操作系统调整 `PATH` 和所需输入，并保持列表显式。示例保留 `HOME`，因为 OpenSSH 可能需要 known_hosts 和身份文件路径。如果托管运行器通过其他选项提供这些文件，也应移除 `HOME`。

在 Mac 上让代理操作 SSH 时，Sallyport 可以把 SSH 密钥保存在加密保管库中，并通过附带的 `sp-ssh` 执行，因此代理不会拿到凭据。这改变的是凭据保管方式，并不能替代明确的远程环境约定；仍应围绕代理获准运行的命令保留精确名称测试。

不要为方便而转发本地云凭据。如果远程动作需要访问其他服务，应给远程工作负载自己的窄权限身份，或让保存凭据的网关执行动作。把操作者的环境凭据复制进远程进程会捆绑两个信任边界，也让撤销难以解释。

## 配置变化后也要让约定保持可执行

环境规则会漂移，因为客户端包加入默认值、管理员合并配置片段、工作流获得新输入。文字规范发现不了这类变化。专用客户端文件、服务器账户配置和诱饵断言应一起进入评审。

以下任一方面变化后都要运行断言：

- SSH 客户端或操作系统软件包
- 用户与系统 SSH 配置 include
- `sshd_config`、PAM、shell 启动文件或强制命令
- 代理启动器、CI 运行器、服务管理器或容器镜像
- 工作流必需输入列表

把新增变量当作接口变更。记录远程命令为什么需要它、客户端还是服务器拥有它、是否包含敏感上下文，以及哪项测试证明它不会出现在其他地方。如果没人能回答，应把值作为经过验证的命令输入，或重新设计远程动作，而不是扩大通配符。

失败必须清楚。转发出错后悄悄使用远程默认值的部署，比因缺少标记直接停止更难诊断，也更容易误用。必需输入应同时在本地启动器和远程入口处失败。

最后还要从远程侧审查账户。完美的 `SendEnv` 列表也保护不了能运行任意 shell、读取其他用户环境或重写启动文件的部署账户。环境转发只是一条输入通道。让它精确、用测试证明行为，并把远程命令权限限制到即使出现意外值也无法转化为无关动作。
