云端 Mac CI 进程环境变量泄露审计与密钥隔离

云端 Mac CI 进程环境变量泄露审计与密钥隔离

一台云端 Mac 同时承担拉取代码、编译、测试和发布时,最容易被忽略的边界不是目录,而是进程环境。流水线把令牌写入环境变量后,xcodebuild、脚本、测试宿主和后台子进程都会默认继承它。即使主任务已经结束,一个未退出的辅助进程仍可能保留完整变量。正确做法不是禁止环境变量,而是把密钥的可见时间和可见进程压缩到最小。

先画出变量传播路径

从 runner 启动的每个进程都应归入构建、测试、发布或辅助工具四类。构建和测试通常不需要发布令牌,辅助工具也不应继承它。先记录变量名、注入阶段、使用命令和预期销毁点,不记录真实值。

检查对象 常见风险 验收方法
runner 父进程 启动时获得全部密钥 仅检查变量名是否存在
构建子进程 无条件继承父环境 使用随机标记执行继承测试
launchd 域 setenv 后跨任务保留 任务前后分别检查并清除
临时文件 权限过宽或异常退出后残留 权限设为 600,并用 trap 删除
日志 Shell 跟踪打印命令参数 密钥阶段关闭 set -x

审计脚本只需要回答“某个标记是否可见”,不要把环境快照保存为构建附件。完整的 env、export 或 ps eww 输出本身就可能成为新的泄露源。

用随机标记验证继承

不要拿真实令牌做实验。生成一次性标记,启动短生命周期子进程,再静默判断它能否看到标记。下面的检查不会把标记内容打印到日志,但退出码能告诉流水线继承是否发生。

set -euo pipefail

marker="$(/usr/bin/uuidgen)"
export CI_SECRET_PROBE="$marker"

/bin/sleep 20 &
child_pid=$!

if /bin/ps eww -p "$child_pid" -o command= | /usr/bin/grep -q "$marker"; then
  result=1
else
  result=0
fi

kill "$child_pid" 2>/dev/null || true
wait "$child_pid" 2>/dev/null || true
unset CI_SECRET_PROBE

test "$result" -eq 1

这个测试证明默认继承确实存在,不代表应该在生产日志中反复扫描所有进程。正式验收可使用固定变量名配合随机值,只记录“通过”或“失败”。

检查 launchd 残留

不应使用 launchctl setenv 注入任务密钥,因为它的生命周期可能超过单次 Shell。若历史脚本使用过这种方式,先在不输出值的前提下检查状态,随后执行:

launchctl unsetenv CI_RELEASE_TOKEN
test -z "$(launchctl getenv CI_RELEASE_TOKEN)"

检查应放在任务开始和清理阶段各一次。若 runner 作为独立用户运行,要在该用户对应的 launchd 域内执行,而不是只检查当前远程会话。

为构建建立最小环境

env -i 可以从空环境启动命令,但不能盲目清空。Xcode 工具链通常需要 HOME、PATH、TMPDIR、区域设置和明确的开发者目录。先用版本查询验收,再运行完整构建。

set -euo pipefail

job_tmp="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mac-ci.XXXXXX")"
trap '/bin/rm -rf "$job_tmp"' EXIT HUP INT TERM

/usr/bin/env -i \
  HOME="$HOME" \
  PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
  TMPDIR="$job_tmp/" \
  LANG="en_US.UTF-8" \
  DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" \
  /usr/bin/xcrun xcodebuild -version

真实项目若依赖 Ruby、Node.js 或包管理器,应把它们的可执行目录逐项加入 PATH,不要恢复整个登录 Shell 环境。每增加一个变量,都要写明哪个命令需要它。

把构建与发布拆成两个权限域

编译阶段只产出归档和校验文件,不接触发布凭据。发布阶段读取已经验收的产物,并只向上传命令注入所需变量。不要在任务开头 export CI_RELEASE_TOKEN,因为后续所有脚本都会继承它。

set +x
CI_RELEASE_TOKEN="$release_token" \
  /usr/bin/env \
  RELEASE_ARTIFACT="$artifact_path" \
  ./ci/publish.sh
unset release_token
set -x

更稳妥的做法是让 publish.sh 只启动一个上传进程,等待它退出后立即结束。若工具要求凭据文件,使用任务专属临时目录,创建前设置 umask 077,写入后确认权限为 600。不要把文件放进仓库、用户桌面或长期缓存目录。

限制失败处理程序

失败钩子经常收集环境、进程和工作区信息。它不应执行无过滤的 env,也不应把完整命令行打包。允许收集退出码、工具版本、产物路径、磁盘余量和经过白名单筛选的变量名。需要检查密钥时,只比较哈希或随机探针是否出现,不保存原值。

把清理结果设为任务门禁

任务成功不等于清理成功。结束阶段至少检查:发布变量已 unset、launchd 中没有对应名称、临时目录不存在、测试与上传子进程已经退出、日志未开启命令跟踪。任何一项失败,都应把当前 runner 标记为需要检查,停止继续接收带密钥的发布任务。

建议为每次任务生成独立的 TMPDIR,并把进程组作为清理单位。只杀主脚本可能留下模拟器辅助程序、上传器或自定义守护进程。清理后再运行一次不含真实值的随机探针,确认新的子进程无法观察到上一阶段的标记。

最终目标不是让流水线完全不使用环境变量,而是建立清晰边界:构建阶段没有发布密钥,发布阶段只有一个必要进程能看到密钥,任务结束后进程、launchd 和磁盘都不再保留它。把这套检查写进 runner 初始化与收尾脚本后,后续项目无需依赖操作者记忆。

常见问题

CI 密钥只放在环境变量里就足够安全吗?

不够。环境变量会默认传给子进程,还可能被诊断命令、调试输出或长驻服务读取。应把构建与发布拆开,只向确实需要凭据的单个命令注入变量。

怎样确认任务结束后没有环境变量残留?

检查 runner 父进程、launchd 域和仍在运行的子进程是否包含变量名,同时确认任务临时目录已删除。检查过程只匹配变量名或随机标记,不应把真实值写入日志。

使用 env -i 会不会导致 xcodebuild 无法运行?

会清空默认环境,因此必须显式传入 HOME、PATH、TMPDIR、LANG 和 DEVELOPER_DIR 等必要变量。先运行版本检查和小型构建,再把白名单应用到完整流水线。

独享物理节点

把下一次构建放到云端 Mac mini

选择配置、计费周期与节点区域,使用独享物理机运行开发、构建和自动化任务。

立即租用 Mac mini