在多数团队里,CI/CD 流水线是唯一一个既持有生产凭证、又能执行任意代码、却很少被当作生产系统来审查的地方。出问题的地方往往不是扫描器漏报,而是一条从模板继承来的配置:构建容器带了 --privileged,密钥以环境变量铺满整个 job,构建任务和外部贡献者的代码跑在同一批节点上。这三件事单看都有“当时必须这么干”的理由,叠在一起就构成了一条从一次提交直通生产凭证的短路径。

三个被默认接受的假设
第一条假设是“构建容器是临时的,权限给大一点无所谓”。临时性只说明它活得短,不说明它拿不到东西;一个能访问宿主机设备的容器,在它存活的几分钟里足够读走节点上的凭证、改写缓存,或者留下一个下次构建才触发的东西。
第二条假设是“变量被掩码了就等于安全”。掩码做的是日志字符串替换,它降低的是密钥出现在构建日志里的概率,不改变进程能读到密钥这个事实。子进程、调试输出、异常堆栈、第三方 Action 的错误上报,都是掩码管不到的地方。
第三条假设是“构建节点只是跑任务的机器”。它通常还持有部署凭证、包仓库令牌、构建缓存和上一次构建的产物;一旦不可信代码和这些内容共处一机,节点本身就成了攻击面。
OWASP CI/CD 安全备忘录与 NIST SP 800-204D 处理这三件事的思路是一致的:把流水线定义当作代码来审,凭证按最小权限和短时化处理,构建环境与不可信输入隔离。下面按这三条展开,每条给出可核查的配置点、改动影响和回退方式。
特权标志:先找出谁在申请宿主机能力
一个典型的起点是这样的配置:
# .gitlab-ci.yml 片段:为了在 job 内再跑一次 docker build
build-image:
script:
- docker run --privileged
-v /var/run/docker.sock:/var/run/docker.sock
builder:latest ./build.sh这里叠了两个高危项。--privileged 关掉了设备访问、capability、seccomp/AppArmor 的大部分限制;挂载 docker.sock 则把宿主机 daemon 的控制权交给了容器内进程,单这一项就近似于宿主机 root。而 build.sh 来自当前分支,意味着任何能推分支或提 PR 的人,都能借这行配置在构建节点上执行操作。
审计:把声明找全,而不是只看主文件
直接在仓库里搜关键词能覆盖大部分情况,但要接受两类误差。建议同时做字符串扫描和结构化解析:
- 字符串层:
privileged、CAP_SYS_ADMIN、--cap-add、docker.sock、hostPath、hostNetwork、hostPID、--network host、/dev/。 - 结构层:先展开
include、extends、模板和共享库,再判断实际生效的字段。很多特权声明藏在组织级模板里,只审仓库主文件会漏掉。 - 变量拼接:
privileged: $PRIVILEGED_FLAG这类写法要追到变量定义处,否则扫描结果永远是“未发现”。
这一步的误报主要来自三类位置:文档和注释里的示例、测试固件里的 fixture、以及镜像名或分支名里恰好含有关键词。判断标准不是“有没有这个词”,而是“这个词是否出现在会被运行时消费的字段上”。把规则先设为只告警不阻断,跑一到两周,看清楚噪声分布再收紧。
替代方案与代价
不需要 daemon 和特权的构建方式已经比较成熟:rootless BuildKit、buildah、kaniko 都能在普通容器里完成镜像构建,代价是不再复用宿主机的 Docker 缓存,首次构建明显变慢。如果确实需要 DinD,优先用 rootless 模式,并按需添加单个 capability,而不是整体 --privileged。
对现有流水线的影响通常集中在三处:构建时间上升;依赖 mount、fuse、rpm 打包或内核模块的步骤直接失败;原先靠宿主 docker 缓存“顺带变快”的 job 需要重新设计缓存层。回退方式我建议这样设计:保留旧的构建 job,但从主流程摘出,改为手动触发,加上审批和超时限制,并限定只能由受保护分支调用。这样回退是可用的,但不再是被默认路径。
密钥:从“注入环境变量”到“按需、短时、窄作用域”
# GitHub Actions 片段:仓库级 secret 铺到整个 job
jobs:
deploy:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.PROD_AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.PROD_AWS_SECRET }}
steps:
- run: ./deploy.sh问题不在用了 secret,而在这份密钥的作用域。job 级 env 对该 job 的每个 step 可见,包括那些只是装依赖、跑测试、调用第三方 Action 的 step;任何一次 set -x、env 打印、崩溃时的环境转储,都可能把它带到日志里。掩码只是让它在日志中显示为 ***,值本身依旧在进程环境里。
更值得单独检查的是触发条件。使用 pull_request_target 之类在目标仓库上下文运行、又 checkout 了 PR 代码的 workflow,外部贡献者不需要改动受保护分支就能让代码拿到这些变量。这类路径在生产事故里出现的频率,比“日志泄露密钥”高得多。
改造顺序
- 盘点:列出所有持有生产或类生产凭证的 job,标出触发条件(push、tag、手动、外部 PR)和密钥的静态有效期。
- 换短期凭证:云上优先用 OIDC 换取短期令牌,把信任策略限定到具体仓库、分支或 environment;数据库和第三方 API 若没有 OIDC,至少改为按 job 领取、有效期以小时计。
- 缩小注入范围:从 job 级
env下沉到真正需要它的那一个 step,并避免把密钥传给会打印环境的脚本。 - 加人工卡点:对生产发布使用受保护的 environment,要求审批,并限制可触发分支。
- 最后再轮换旧密钥,确认没有遗留调用方之后再吊销。
影响主要落在开发体验上:本地无法再用同一份长期密钥复现发布,需要在测试环境保留一套低权限凭证;OIDC 需要在云侧配置信任策略,改动涉及平台团队。回退路径建议是双轨而不是回滚:新旧两套凭证并行一段时间,旧密钥保留但缩短有效期并加上审批,确认新路径稳定后再下线。日志脱敏仍然要做,但要把它当成降低可见性,而不是访问控制。
构建节点:把不可信代码和发布凭证分开
共享节点最典型的问题是状态残留。缓存目录、工作区、后台进程、被改写的 ~/.docker/config.json,都会跨构建传递。上一个 job 能写进去的东西,下一个 job 会读到。
分层的最小做法
- 把 job 按可信度分成三类:外部贡献者或 fork 代码(完全不可信)、内部分支(半可信)、发布与签名(高可信)。
- 不可信类只跑在临时节点上,任务结束即销毁,节点上不注入任何凭证,出站网络只放行镜像仓库和包代理。
- 发布类使用独立节点或独立 runner 标签,只接受受保护分支的任务,并记录谁触发了哪次发布。
- 半可信类可以复用节点,但要求每次构建前清理工作区,并把缓存按分支隔离。
Kubernetes 上的 runner pod 至少应该显式收紧到这个程度:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
seccompProfile:
type: RuntimeDefault
capabilities:
drop: ["ALL"]这段只是下限,不是全部;它挡的是提权和默认 capability,不解决网络可达性和凭证暴露。
网络与入口
节点隔离如果没有网络侧的配合,效果有限。两条最容易落地的限制:入站只允许 CI 编排器与运维跳板机的 IP 段访问,其余一律拒绝;出站默认拒绝,按需放行镜像仓库、包索引和内部制品库。出站白名单能显著抬高“构建中偷偷拉一个载荷”的成本。

检查清单
| 检查项 | 定位方法 | 通过标准 | 失败时处置 |
|---|---|---|---|
| 特权声明 | 展开模板后匹配 privileged、cap-add | 生产分支无 --privileged | 换 rootless 构建器,或降级为按需 capability |
| 宿主机挂载 | 搜 docker.sock、hostPath、/dev | 构建容器不接触宿主 daemon | 用 BuildKit 或独立构建服务替代 |
| 密钥作用域 | 查 job 级 env 与变量继承 | 密钥只出现在单个 step | 下沉注入点,改为运行时领取 |
| 静态凭证 | 列出所有长期 access key | 生产凭证为短期或 OIDC 换取 | 双轨并行后吊销旧密钥 |
| 外部 PR 触发 | 审 pull_request_target 等工作流 | 外部代码路径无凭证、无发布权限 | 拆成两个 workflow,权限分离 |
| 节点共享 | 对照 runner 标签与 job 类型 | 发布与不可信构建不同节点 | 新增专用 runner 或临时节点 |
| 入站暴露 | 查节点安全组与监听端口 | 只允许编排器与跳板机 | 收紧安全组,关闭直连调试端口 |
| 出站访问 | 观察构建期网络流量 | 默认拒绝、按需放行 | 先告警收集依赖清单,再切换为拒绝 |
如何验证收紧真的生效
配置改完不等于风险消失,需要一次受控验证。做法是在你自己的测试仓库开一个分支,放一个只做探测、不做破坏的 job,检查它是否被拦住:
- 尝试读取宿主机设备目录或列举不属于自己的进程;
- 尝试访问云元数据地址
169.254.169.254; - 打印自身环境变量,确认没有生产凭证;
- 尝试连接一个未在白名单内的外部地址。
这属于对自有环境的授权测试,但仍应事先走内部审批、限定在测试节点、并在结束后销毁分支和节点。判断标准是“每一步都失败”,任何一步成功都说明对应控制没有落地。
另一个容易被忽略的验收点是可复现性:同一个 commit 在收紧前后产出的制品应当一致。如果收紧后产物变了,常见原因是原先的构建隐式依赖了节点上的残留状态——这本身就是节点未隔离的证据,值得当成缺陷来修,而不是把配置调回去。
落地顺序与回退策略
合理的顺序是观察、告警、阻断,而不是一次性全量切换:先只读审计,把现存的暴露面列出来;再把规则设为告警,跑一到两周确认误报可控;最后才对受保护分支生效阻断。阻断规则一旦上线,必须配一条明确的人工豁免通道(指定审批人、写明原因、设定过期时间),否则团队会用绕过流水线的方式解决它。
回退也要提前写清楚。每条改动都对应一个“旧路径仍然可用但有额外约束”的状态:旧构建 job 手动触发加审批、旧密钥保留但缩短有效期、旧节点保留但只跑不携带凭证的任务。这样出问题时能立刻降级,而不是在事故中临时决定要不要关掉安全控制。
工具和平台的版本同样会影响结论。构建器、runner、编排器的不同版本在默认 capability、seccomp 配置和缓存行为上并不一致,落地时锁定具体版本或镜像摘要,并在升级后重跑一次上面的验证清单——很多“收紧之后又松回去”的情况,是升级把默认值改回来了。
- CI/CD
- it2021
- it2021.com
- Kubernetes
- OWASP
- tag
- test
- 日志脱敏
- 访问控制





广东 深圳 1F
之前真没把流水线当生产系统审过