工程文章

云端 Mac CI 的 Git 凭据隔离与退出清理实战

云端 Mac CI 的 Git 凭据隔离与退出清理实战

一台长期运行的云端 Mac 接连执行多个仓库的构建时,最危险的并不总是令牌直接出现在脚本里,而是上一个作业把凭据悄悄留给了下一个作业。常见残留点包括 Git 全局配置、带认证信息的远程地址、登录钥匙串,以及未删除的临时脚本。处理这类问题的目标不是“构建结束后删一个变量”,而是让凭据从一开始就只能存在于单次作业的边界内。

先确认凭据会落到哪里

不要急着改流水线。先在不输出秘密值的前提下,确认 Git 实际读取了哪些配置。--show-origin 会同时给出配置来源,能区分系统级、用户级与仓库级设置。

set +x

git config --show-origin --get-all credential.helper || true
git config --show-origin --get-regexp '^(credential|http)\.' || true
git config --show-origin --get-regexp '^url\..*\.insteadof$' || true

重点检查三类信号:

  • credential.helper 是否指向持久化钥匙串;
  • http.extraHeader 是否被写进全局或仓库配置;
  • url.*.insteadOf 是否把普通地址改写成包含认证信息的地址。

远程地址也要检查,但不要原样写入日志。可以只判断是否存在 协议://用户信息@主机 结构,发现后立即失败,而不是打印完整地址。

remote_url="$(git remote get-url origin)"
case "$remote_url" in
  *://*@*)
    printf '%s
' "Remote URL contains embedded credentials" >&2
    exit 1
    ;;
esac

审计脚本的第一原则是只报告“凭据可能存在”,不要为了证明问题而把凭据本身输出到构建日志。

为每个作业创建独立 HOME

Git 会从 HOME 推导用户级配置路径。让所有作业共用运行用户的 HOME,等于让它们共用 .gitconfig、凭据 helper 配置和大量工具状态。更稳妥的做法是为每次作业创建权限为 700 的临时 HOME,并显式指定 Git 全局配置文件。

set -eu

ORIGINAL_HOME="$HOME"
JOB_HOME="$(mktemp -d "${TMPDIR%/}/git-job.XXXXXX")"
chmod 700 "$JOB_HOME"

export HOME="$JOB_HOME"
export XDG_CONFIG_HOME="$JOB_HOME/.config"
export GIT_CONFIG_GLOBAL="$JOB_HOME/.gitconfig"
export GIT_TERMINAL_PROMPT=0

mkdir -p "$XDG_CONFIG_HOME"

这样做不会隔离系统级 Git 配置,因此仍要主动重置 helper 链。Git 支持多个 credential.helper 值;先写入空值,可以清除从低优先级配置继承的 helper,再追加本次作业自己的实现。

别把仓库也放进临时 HOME

HOME 是凭据与工具状态边界,不必同时承担工作区职责。源码目录应继续由 runner 的工作区管理,便于容量控制和产物收集。两者分开后,清理 HOME 不会误删构建结果,清理工作区也不会遗漏用户级认证配置。

如果流水线需要并发,每个并发槽都必须调用一次 mktemp,不能按仓库名复用固定目录。固定目录会在异常退出后被下一次作业继承,也可能让两个作业同时修改同一份 .gitconfig

只在 Git 请求时提供令牌

令牌应由 CI 的秘密变量机制注入环境,脚本只引用变量名。不要把令牌拼进 clone 地址,也不要用 git config --global http.extraHeader 长期保存认证头。

下面的 helper 只响应 Git 的 get 请求,配置文件中保存的是变量引用逻辑,不是令牌值:

: "${CI_GIT_USER:?CI_GIT_USER is required}"
: "${CI_GIT_TOKEN:?CI_GIT_TOKEN is required}"

git config --global credential.helper ""
git config --global --add credential.helper \
'!f() {
  if [ "$1" = get ]; then
    printf "username=%s
password=%s
" \
      "$CI_GIT_USER" "$CI_GIT_TOKEN"
  fi
}; f'

关闭命令回显与交互回退

接触秘密变量前必须执行 set +x。否则 shell 会把变量展开后的命令写入日志。GIT_TERMINAL_PROMPT=0 同样重要:令牌缺失或失效时,作业应明确失败,不能停在不可见的交互提示上。

还要检查构建包装器是否会自动转储环境变量。诊断信息只应输出变量是否已设置,例如检查字符串长度是否大于零,不应输出变量内容、认证头或完整远程地址。

对于需要写权限的自动化任务,应把读取源码与推送产物拆成不同凭据。只执行 checkout 的作业不需要写权限;发布作业也不应获得超出目标仓库和必要操作范围的权限。

用 trap 覆盖所有退出路径

只在脚本最后写 rm -rf,无法覆盖中途失败、超时终止或人工取消。应在创建临时目录后立即注册 trap,并在清理函数中先清空变量,再删除作业 HOME。

cleanup_git_credentials() {
  set +e
  unset CI_GIT_TOKEN CI_GIT_USER
  if [ -n "${JOB_HOME:-}" ] && [ -d "$JOB_HOME" ]; then
    rm -rf "$JOB_HOME"
  fi
}

trap cleanup_git_credentials EXIT HUP INT TERM

清理函数必须允许重复执行,目录已经不存在时也不能报错。删除目标还要满足两个条件:变量非空,并且确实指向目录。不要使用宽泛的钥匙串删除命令,也不要清空整个登录钥匙串,因为其中可能包含同一运行用户的其他必要项目。

若历史流水线曾使用持久化 helper,应先按主机与账户精确盘点,再安排一次性迁移。新旧方案并行期间,每次作业都要验证当前 helper 来源,避免临时 HOME 已启用,但系统包装脚本又把旧配置复制回来。

把残留检查变成构建门禁

清理是否成功不能只靠脚本没有报错。建议在 runner 外层增加退出后检查,或者让执行器在回收作业时验证以下项目:

  • 临时 HOME 已删除;
  • 工作区 .git/config 中没有认证头或含用户信息的远程地址;
  • 作业日志没有秘密变量的已知指纹;
  • Git 配置来源只包含预期的系统配置和本次临时配置;
  • 后续空白作业无法读取上一次作业的仓库凭据。

可以为测试令牌设置一个无权限的固定标记值,在隔离环境中运行失败用例,再扫描日志与文件系统。这里检查的是“标记是否泄漏”,而不是验证真实令牌。失败用例至少覆盖 clone 失败、构建命令退出、收到终止信号和清理函数重复执行。

当多个团队共用一台物理节点时,还应把运行用户作为第二道边界。临时 HOME 解决的是作业级残留;独立用户解决的是不同信任域之间的进程、文件权限和钥匙串边界。两层同时存在,才不会把脚本清理当成唯一防线。

最终验收标准很简单:作业开始前没有可继承凭据,运行中只按需提供,任何退出路径都会清理,下一次作业无法证明上一枚令牌曾经存在。做到这一点,Git 凭据才真正属于作业,而不是属于那台长期运行的云端 Mac。

常见问题

为什么只在作业结束时 unset 令牌变量还不够?

因为凭据可能已经被 Git helper、远程地址、全局配置或钥匙串持久化。必须同时检查配置来源、远程地址和临时目录,并用 trap 覆盖异常退出。

长期运行的 CI 节点可以直接共用登录钥匙串吗?

不建议让多个仓库或作业共用同一组持久凭据。更稳妥的做法是按作业注入最小权限令牌,并为不同信任边界使用独立运行用户或独立钥匙串。

独享云端 Mac

为开发、构建与远程桌面选择一台独享物理机

比较三档 Apple Silicon 配置,并在下单时选择节点、租期与存储附加项。

选择租用方案