服务账号、PAT 与 CI 凭据:让自动化身份可轮换、可撤销
一次常规离职让发布流水线突然全部失败。排查后发现,构建系统用的是前维护者创建的个人 PAT:token 名叫 ci-token,没有到期日,拥有整个组织的仓库与包权限;值被复制到三个项目和一台自托管 runner。管理员临时生成另一个个人 token 替换主项目,另外两个项目仍在使用旧值,审计记录里所有发布动作也继续显示为那名离职员工。事故表面是凭据过期,根因却是自动化没有独立身份、权限和轮换协议。
CI 能读到一个 secret,不代表它拥有一个可治理的身份。身份回答“谁在执行”,授权回答“可以访问哪些资源和动作”,凭据只是证明身份的材料,secret store 只是保存材料的位置。个人访问令牌绑定人员生命周期;服务账号或应用身份适合持续自动化;OIDC 等工作负载身份联合可以在每次作业时换取短期凭据。架构目标不是把 token 藏得更深,而是让每条自动化链都能指出主体、用途、资源范围、到期时间、轮换 owner、使用证据和撤销结果。
先从一次失败调用反推身份链
收到 401、403 或仓库拉取失败时,不要立刻生成更大 scope 的新 token。先确定调用由哪个 runner、workflow、环境和主体发起,凭据从哪个 secret 键注入,访问哪个资源,目标平台为何拒绝。401 多表示材料无效、过期或格式错误;403 常表示身份有效但 scope、角色、组织策略或资源选择不足;404 有时是平台为避免资源枚举而对无权主体隐藏对象。
workflow: release-demo
job: publish-package
runner: hosted-linux
identity_kind: application
principal: app-ci-publisher
credential_source: environment/release/PUBLISH_TOKEN
target: package-registry/demo-service
required_actions: package:write, package:read
forbidden_actions: repo:admin, members:write, secrets:read
owner: platform-release
expires_or_federates: short-lived先查元数据,不打印 secret 值。可信证据包括凭据 ID 或末四位、创建者、到期时间、最后使用时间、目标资源、workflow run ID、HTTP 状态和审计主体。日志里出现完整 Authorization header、命令回显或 token 是二次事故,应立即停止作业、保存脱敏证据、撤销凭据并启动泄漏响应。
在 PAT、服务账号和应用身份之间选型
PAT 是用户授权给脚本的 token。它的上限由用户自身权限决定,再被 token 的 scope、细粒度权限和资源选择收窄。它不会把普通成员变成管理员,但人员升权、转组、离职或被禁用都会改变自动化行为。GitHub 的 PAT 文档建议可用时优先细粒度 token,并说明 token 与创建它的用户绑定;细粒度 token 可限制到单一资源所有者、指定仓库和具体权限,但仍存在功能边界。还要区分“撤销组织访问”和“撤销令牌本体”:组织所有者撤销某个细粒度 PAT 对本组织的访问后,该 token 仍可能读取公开资源,由它创建的 SSH key 也不会随之自动失效。若退出目标是让全部旧能力失效,必须同时处理 token 本体、SSH key、应用授权和其他资源所有者上的访问,不能把组织管理页上的一次 Revoke 当成全局销毁。
服务账号是明确用于非交互任务的账号,应该禁止日常人工登录或设置受控登录路径,有独立 owner、角色、用途和离职无关的生命周期。它比个人 PAT 稳定,但如果仍签发永久宽权限 token,只是把风险从“个人”改名为“机器人”。目标产品支持原生 service account 时优先使用产品对象;不支持时,合成机器人用户必须在台账和命名上清楚区分,并遵守许可、座席和 MFA 规则。产品对象的语义也不能类推:GitLab 当前的原生 service account 不通过 UI 登录、以 PAT 调用 API 或 Git,实例级、群组级和项目级对象的可加入范围不同;其他产品的“service account”可能仍占座席或允许交互登录。准入时应把登录能力、成员范围、计费、令牌类型和删除后归属逐项实测并登记。
禁用服务账号、移出群组与撤销它签发的 token 是三个动作。若产品没有明确承诺级联撤销,停用账号后仍要枚举 PAT、部署密钥、应用私钥、OAuth grant 和活跃 session,逐一撤销并在私有资源上验证失败。删除账号前先转移制品、仓库、告警和审计归属;直接删除可能失去调查所需的主体关联,也可能让低频任务到下一周期才暴露故障。
应用身份通常优于模拟用户。以 GitHub App 为例,安装范围、仓库选择和权限可独立控制,应用可换取短期 installation token,审计主体也不是某个员工。云资源访问则优先使用 CI 的 OIDC 身份联合:workflow 请求带 issuer、subject、audience 等 claims 的 token,目标云按信任策略签发短期访问凭据。GitHub Actions 的 OIDC 说明指出,这能避免把长期云密钥复制进 CI secret,并让凭据随单次作业过期。
| 形态 | 适合场景 | 主要风险 | 退出动作 |
|---|---|---|---|
| 个人 PAT | 短期个人脚本、产品暂不支持应用身份 | 绑定人员、容易过宽、被多处复制 | 撤销 token,迁移到非人身份 |
| 服务账号 + token | 长期集成、旧系统 API | 永久凭据、共享登录、座席成本 | 撤销 token、禁用账号、转移 owner |
| 应用身份 | 仓库、组织或 SaaS 自动化 | 安装范围和私钥管理不当 | 卸载应用、撤销私钥和授权 |
| OIDC 联合 | CI 访问云、Vault 或支持联合的服务 | trust claim 过宽、fork/环境边界错误 | 删除信任关系,不保存长期密钥 |
| 部署密钥 | 单仓库只读/写 Git 访问 | 私钥复制、写权限过大 | 删除 key 并清理 runner 副本 |
给每个非人身份建立最小台账
一个名为 automation 的共享账号无法说明用途。按工作负载拆主体,例如制品发布、依赖读取、基础设施计划和文档部署分别使用身份。拆分增加少量配置成本,却能限制泄漏半径、单独轮换并从审计判断是哪条链路执行了动作。
principal_id: app-ci-publisher
identity_type: application
purpose: publish demo packages from protected release workflow
owners:
primary: platform-release
backup: developer-productivity
source:
repository: org-demo/service-a
workflow: .github/workflows/release.yml
environment: release
targets:
- resource: package-registry/org-demo/service-a
permissions: [packages:read, packages:write]
denied_by_design:
- repository_administration
- organization_members
- actions_secrets_read
credential:
mode: short_lived_installation_token
maximum_ttl_minutes: 60
evidence:
audit_subject: app-ci-publisher
run_id_required: true字段必须能驱动动作。owner 接收到期和异常使用告警;targets 与权限用于生成授权;source 限定允许调用的仓库、分支或环境;denied_by_design 为反向实验提供预期;credential.mode 决定轮换方式;evidence 规定审计关联。如果一个字段没有系统读取或定期复核,它迟早会变成过时说明。
PAT 的 scope 不能凭名称猜测。先列出 API 或 Git 操作,再查平台的权限映射。例如发布包可能需要包写权限和仓库内容读取,却不需要组织管理员。GitHub 提供细粒度 PAT 权限与 REST endpoint 对照,组织还可以设置访问限制、最大生命周期和细粒度 token 审批策略。策略值会随产品与租户变化,实施时从组织设置和当前官方页面确认,不把历史默认值写进自动化假设。
把 secret 放在正确层级并限制触发路径
CI secret 常有组织、仓库、环境层级。共享层级越高,维护越省事,暴露半径也越大。只服务一个生产发布流程的凭据应放在受保护环境,限制到指定仓库、分支或 tag,并在取用前通过环境审批;多个仓库确需共享时,组织 secret 也要使用仓库 allowlist,而不是默认所有仓库。
以 GitHub Actions 为例,Actions secret只有在 workflow 显式引用时才进入作业;平台会对已登记 secret 尝试日志脱敏,但变换、拼接、编码或第三方程序输出可能绕过遮罩。遮罩不是数据防泄漏边界。fork PR、Dependabot、可修改 workflow 的人员、自托管 runner 的持久磁盘和第三方 action 都会影响 secret 是否安全。
name: publish-demo
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@<pinned-commit-sha>
- name: Publish package
env:
PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN_ACTIVE }}
run: |
set +x
./scripts/publish.shpermissions 把自动生成的 GITHUB_TOKEN 收窄到只读;第三方 action 固定到审查过的 commit SHA,避免 tag 漂移;secret 只注入需要的 step,不放在 job 全局;脚本关闭命令回显,也不得把环境整体转储。环境审批能减少误触发,却不能修复恶意 workflow:有权改 workflow 的人可能把 secret 发送出去,因此 CODEOWNERS、分支保护和 action allowlist 必须一起工作。
GitLab 的 CI/CD variables提供 Variable/File 类型、environment scope、Protected、Masked 和 Hidden 等属性。Masked 主要降低日志意外显示,Protected 决定变量是否只进入受保护 ref 的 pipeline,environment scope 限制目标环境;这些属性解决不同问题,不能互相替代。审查者看到未信任代码读取变量时应停止运行,因为恶意脚本仍可能外传已遮罩变量。
同名键会让正确的 secret 变成错误的值
secret 的存储层级不仅决定谁能读,也决定同名冲突时作业实际拿到哪个值。GitHub Actions 的密钥参考规定同名时环境级覆盖仓库级、仓库级覆盖组织级;组织和仓库 secret 在 run 排队时读取,环境 secret 在引用该环境的 job 启动时读取。这意味着轮换期间只更新组织值,不能保证已经排队的 run 或存在同名仓库值的 workflow 使用新凭据。
团队应禁止不同语义复用同一个键名,并把来源写入台账,例如 RELEASE_REGISTRY_TOKEN 只能由 release 环境提供。不要用“在脚本里打印末四位”识别 secret;为每把凭据登记不敏感的 credential ID 或 version,并让目标系统审计返回该 ID。合成验证可以让组织、仓库和环境分别保存三把只允许读取不同测试对象的凭据,运行引用生产环境的 job,预期只有环境凭据对应的测试对象可读;随后移除 job 的环境引用,预期访问失败或转为仓库级的明确受限身份。若它意外获得更宽权限,说明同名覆盖和 fallback 已经扩大授权面。
GitLab 的变量优先级更长:执行策略、扫描策略、pipeline 变量、项目、群组、实例、dotenv、job 与顶层 YAML 等来源可以相互覆盖,其中手工、API、触发器和下游传递的 pipeline 变量优先于项目与群组变量。把敏感键写成可由 pipeline 参数覆盖的名字,会让受保护变量表面存在、运行时却被调用者替换。高敏任务应限制谁能使用 pipeline variables,避免向下游传递同名敏感键,并在 job 开始时只比较预期的非敏感 credential ID、issuer 和 audience;来源不符立即退出,绝不回退到另一个同名值。
credential_contract:
key: RELEASE_REGISTRY_TOKEN
expected_source: protected_environment
expected_credential_id: release-publisher-next
fallback_allowed: false
consumers:
- protected-release-job
denied_contexts:
- fork
- merge-request
- manual-unprotected-ref反向实验从未受保护 ref 触发同一 job,并尝试以 pipeline variable 覆盖 RELEASE_REGISTRY_TOKEN。预期 job 在接触目标资源前被环境保护或来源断言拒绝,目标侧也没有该主体的访问审计。若替换后的值进入发布步骤,即使发布最终返回 403,门禁仍然失败,因为不可信输入已经越过 secret 选择边界。
用正反实验证明权限恰好够用
正向实验选一个无生产数据的合成制品仓库:CI 使用新身份上传 demo-package-0.0.0-test,随后读取校验和并删除测试制品。预期证据包括成功状态、审计主体为非人身份、资源范围正确、run ID 可关联,日志无 token。清理动作必须使用同一受限身份;如果清理需要管理员,说明权限设计或测试资源边界有问题。
反向实验至少包含两个拒绝。第一,尝试读取未授权仓库,预期 403 或平台的隐藏式 404;第二,尝试修改组织成员或仓库设置,预期明确拒绝。若反向动作成功,立即撤销测试凭据、保存审计事件并收窄权限,不要因为正向发布成功就接受过宽身份。
set -euo pipefail
# 正向:目标仓库应允许读取
status_allowed=$(curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer ${CI_TOKEN}" \
'https://api.example.invalid/repos/org-demo/service-a')
test "${status_allowed}" = '200'
# 反向:非目标仓库必须被拒绝;404 可能是防枚举语义
status_denied=$(curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer ${CI_TOKEN}" \
'https://api.example.invalid/repos/org-demo/private-admin')
case "${status_denied}" in
403|404) ;;
*) echo "unexpected access: ${status_denied}" >&2; exit 1 ;;
esac脚本不能输出 ${CI_TOKEN},也不要使用 curl -v 上传带 Authorization 的完整调试日志。真实平台的状态码和 endpoint 按官方 API 调整;判断标准是允许集与拒绝集都稳定,而不是机械追求某个状态码。
双凭据轮换避免一刀切中断
许多系统支持一次存在多把 key 或多个 token,这为无中断轮换提供了窗口。若目标只允许单 token,先建设应用身份、代理或可回退发布窗口,不要把“旋转”理解为直接让旧值失效。轮换需要两个独立 secret 键,例如 PUBLISH_TOKEN_ACTIVE 与 PUBLISH_TOKEN_NEXT,不能在同一键上覆盖后失去回退证据。
T0 盘点消费者:workflow、runner、外部集成、定时任务
T1 签发 NEXT:同一主体、相同或更小权限、明确到期
T2 单独验证 NEXT:正向允许 + 反向拒绝
T3 小流量切换:一个非关键消费者改用 NEXT
T4 全量切换:其余消费者按清单迁移
T5 静默观察:ACTIVE 最后使用时间不再增长
T6 撤销 ACTIVE:平台端 revoke,不只是删除 CI secret
T7 失效验证:旧值调用返回 401/403,新值继续成功
T8 清理:删除旧 secret、runner 文件、缓存和临时副本关键判断是“旧凭据最后使用时间归零”,不是“所有配置都说已更新”。若旧 token 在 T4 后仍被调用,通过审计的来源 IP、主体、API、时间和流水线记录寻找遗漏消费者;找不到来源时不要无限延长双凭据窗口,应暂停高风险操作并缩小授权范围。重叠过久意味着两把有效钥匙同时存在,泄漏面翻倍。
撤销后要主动运行旧凭据反向验证,但测试端点必须要求认证并位于原授权资源内。公开资源返回 200 不能证明 token 仍有效,组织级撤权后的 403 也不能证明 token 本体已经销毁;应同时查询或调用一个能识别当前认证主体的受控端点,并在其他登记资源所有者上验证访问边界。预期结果要与撤销层级一致:组织撤权只要求该组织私有资源拒绝,令牌本体撤销才要求所有认证调用失败。只删除 CI 平台中的 secret 不会让平台端 token 失效;只在平台端 revoke 也不会清除 runner 工作区、容器层、缓存、备份或外部 secret manager 中的副本。两端都要清理,并复核命令历史、SSH key、诊断包和应用授权。
部分产品提供原子 rotate:GitLab 的服务账号文档说明服务账号 token 可以创建、轮换和撤销,轮换会使当前 token 失效并生成新值。这样的操作不等同于双凭据重叠,消费者没有及时更新就会中断;执行前要确认产品语义、所有消费者和可接受停机窗口。
从个人 PAT 迁移到非人身份
迁移从发现开始。搜索 CI secret 的元数据、workflow 引用、部署平台集成和审计主体,不扫描或集中导出 token 明文。为每个个人 PAT 建立用途、资源、scope、消费者、最后使用、到期和持有人状态;未知用途不能简单续期,也不能在没有影响评估时立即撤销。
先为每种工作负载选择替代身份:GitHub App、GitLab service account、部署 key、云工作负载身份或目标产品的 OAuth client。为替代身份配置更小资源范围,执行正反实验,再用双凭据或灰度消费者切换。审计主体从个人变为应用后,观察一个完整业务周期,包括定时任务、月末任务和低频发布,最后撤销个人 PAT。
migration_item: legacy-personal-pat-07
current_principal: former-maintainer
consumers:
- repo: org-demo/service-a
workflow: release.yml
- scheduler: nightly-metadata-sync
replacement:
principal: app-ci-publisher
credential: installation-token
repositories: [org-demo/service-a]
permissions: [contents:read, packages:write]
verification:
positive: publish synthetic package
negative: deny organization administration
cutover:
owner: platform-release
rollback_until: '<approved-window>'
retirement:
revoke_old_pat: required
verify_old_pat_denied: required迁移完成不是“新流水线绿了”,而是旧 PAT 已撤销、旧主体不再出现在自动化审计、所有消费者都使用新身份、权限更小且 owner 已交接。若目标能力只能由 classic PAT 完成,记录产品限制、补偿控制和迁出触发条件,设置短到期与组织策略;不要把例外升级成默认模板。
优先用短期身份替代长期 secret
OIDC 联合把轮换从“人工更换一串值”变成“每次作业获取短期 token”。信任策略必须绑定具体组织、仓库、分支、tag 或 environment,验证 iss、aud、sub 等 claims。只判断 issuer 而允许任意仓库换取生产角色,等于把整个 CI 平台都设为可信发布者。
permissions:
contents: read
id-token: write
jobs:
deploy:
environment: release
steps:
- uses: actions/checkout@<pinned-commit-sha>
- name: Exchange OIDC token for short-lived cloud credentials
uses: cloud-provider/login-action@<pinned-commit-sha>
with:
audience: api://demo-cloud-exchange
role: demo-release-writerid-token: write 允许 workflow 请求 OIDC token,不自动授予云权限;真正权限由目标侧 trust policy 和 role 决定。GitHub OIDC reference列出了 claims 与工作流权限。aud 只说明令牌面向哪个接收方,不代表该主体有权承担生产角色;目标侧仍要同时校验 iss、aud、sub 和可用的稳定标识。GitHub job 引用 environment 时,默认 sub 表达 environment,而不是同时表达 branch;若策略还要求分支或可复用 workflow 约束,需要使用目标支持的其他 claims、自定义 subject 或环境保护规则补齐,不能凭想象拼接默认 subject。GitLab ID token 同样应以明确 aud 限定接收方,并在目标支持时把稳定的 project_id、namespace_id 与路径型 sub 共同用于信任条件。接入前先用只读角色检查实际签发的 claims,再将 subject 限制到受保护环境;fork、可复用 workflow、仓库转移、重命名和信任模板变更都需要专门回归。
OIDC 反向实验不能只篡改一个无关 claim。至少从未授权仓库或项目、未受保护分支、fork/合并请求上下文和错误 audience 各请求一次角色交换,预期目标端拒绝且不签发下游凭据;正向实验只允许受保护环境中的指定 workflow 获得短期角色。若旧 workflow 文件、旧仓库路径或已删除环境仍能换取角色,说明 trust policy 存在通配符、路径重命名残留或稳定标识缺失。修复后还要等待已签发的下游短期凭据过期,或按云平台能力撤销会话;删除 OIDC 信任关系只能阻止新的 token exchange,不会让已经签发的临时会话必然立即失效。
短期凭据减少静态 secret,却没有消除治理。应用私钥、OIDC 信任关系、runner 身份和可修改 workflow 的权限仍需审计。目标侧应记录 token exchange、role assumption 和资源操作,CI 侧记录 run ID、commit、环境批准和调用主体,两边通过不可伪造的关联字段连接。
防止 secret 经日志、缓存和 runner 扩散
自托管 runner 的风险通常高于托管 runner:工作区、进程环境、Docker layer、缓存、临时文件和诊断包可能跨 job 残留。高敏发布任务使用一次性或强隔离 runner,作业后销毁;不要让不可信 PR 与生产发布共享同一常驻主机。容器任务避免把 secret 写进 Dockerfile ARG、镜像层或构建缓存,优先使用 BuildKit secret mount 等不会持久化到层的机制。
脚本必须避免 set -x、环境全量打印、异常对象序列化和 URL query 携带 token。命令失败时只记录 HTTP 状态、request ID 和脱敏响应。平台自动遮罩通常要求值满足特定格式,短值、编码值或分段值可能不被识别;不要用遮罩测试把真实 token 打进日志。
secret 扫描发现泄漏后,顺序是撤销、评估使用、清理暴露面、签发替代、验证和复盘。仅从 Git 历史删除字符串不能让凭据失效;仅旋转凭据也不能消除历史中携带的其他敏感信息。若日志已进入外部存储,按保留和删除流程处理副本,并保留必要的脱敏事件证据。
用审计和指标管理长期运行
每个凭据事件都应记录主体、凭据 ID、动作、资源范围、执行人或自动化、时间、结果和关联工单,但不记录凭据值。至少关注创建、审批、scope 变更、最后使用、失败调用、轮换、撤销、组织策略拒绝和 secret 读取触发。审计保留时间应覆盖凭据最长生命周期、低频任务周期和事故调查需要;具体能力与保留期限依目标产品订阅和设置确认。
运营指标比“有多少 secret”更有判断力:无 owner 凭据数、个人 PAT 驱动的 CI 数、无到期 token 数、90 天未使用凭据数、宽 scope 凭据数、轮换逾期数、旧凭据撤销后仍成功的调用数、长期 secret 可被多少仓库读取、OIDC trust 可匹配多少主体。高权限凭据即使只有一个,也比大量只读短期 token 更值得优先处理。
成本同样进入身份设计。服务账号可能占座席,外部 secret manager、审计导出和专用 runner 会产生费用;但用一个全局高权限账号节约座席,会把故障和泄漏半径扩大。选型时比较的不只是许可证,而是轮换人工、事故恢复、审计可解释性和迁出成本。不能导出身份配置、无法撤销单个凭据或无法区分审计主体的产品,会形成长期治理债务。
把凭据轮换变成团队常规能力
每次新增自动化,先选择非人主体,按允许动作生成最小权限,限定源 workflow 和目标资源,在隔离资源上完成正反实验,再接入受保护环境。每次签发长期材料,都同时登记到期、轮换 owner、替补、消费者清单和撤销命令。每次角色、仓库或 workflow 变化,都重新判断原权限是否仍恰好够用。
周期复查要能回答:个人 PAT 是否还在驱动共享流水线;服务账号是否有交互登录;secret 是否被过多仓库共享;旧 token 最后使用是否异常;OIDC trust 是否因仓库迁移变宽;自托管 runner 是否留下材料;审计主体是否能关联到 run 与 commit。发现未知消费者时先降低权限和隔离范围,再安排迁移,不用永久延期撤销来换取表面稳定。
一次完整演练应从签发 NEXT 开始,验证允许与拒绝动作,灰度切换消费者,观察 ACTIVE 停止使用,撤销旧值,再用旧值确认失败并清理所有副本。另一次迁出演练则把个人 PAT 替换为应用或服务账号,确认人员离职不会中断任务,且审计主体发生预期变化。团队能在原维护者不在场时完成这两次演练,自动化凭据才算从“藏起来的字符串”升级为可维护、可证明退出的工程身份。
