在 CI/CD 流水线中收紧执行环境:从特权容器、密钥暴露到构建节点隔离

枫少@KillBoy
枫少@KillBoy
枫少@KillBoy
管理员
386
文章
0
粉丝
安全开发1 28字数 3079阅读10分15秒阅读模式
AI智能摘要
一条从代码提交直通生产凭证的危险短路径,往往藏在对 CI/CD 流水线的三个默认假设中:特权容器、密钥环境变量泛滥及构建节点未隔离。掩码无法阻止进程读取敏感信息,共享节点的状态残留更是让不可信代码轻易窃取发布凭证。遵循 OWASP 与 NIST 指南,通过最小化权限、短时化凭证及环境隔离,可切断这条攻击链路。在构建速度与安全性之间,你的流水线究竟能守住多少底线?
— AI 生成的文章内容摘要

在多数团队里,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,外部贡献者不需要改动受保护分支就能让代码拿到这些变量。这类路径在生产事故里出现的频率,比“日志泄露密钥”高得多。

改造顺序

  1. 盘点:列出所有持有生产或类生产凭证的 job,标出触发条件(push、tag、手动、外部 PR)和密钥的静态有效期。
  2. 换短期凭证:云上优先用 OIDC 换取短期令牌,把信任策略限定到具体仓库、分支或 environment;数据库和第三方 API 若没有 OIDC,至少改为按 job 领取、有效期以小时计。
  3. 缩小注入范围:从 job 级 env 下沉到真正需要它的那一个 step,并避免把密钥传给会打印环境的脚本。
  4. 加人工卡点:对生产发布使用受保护的 environment,要求审批,并限制可触发分支。
  5. 最后再轮换旧密钥,确认没有遗留调用方之后再吊销。

影响主要落在开发体验上:本地无法再用同一份长期密钥复现发布,需要在测试环境保留一套低权限凭证;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 配置和缓存行为上并不一致,落地时锁定具体版本或镜像摘要,并在升级后重跑一次上面的验证清单——很多“收紧之后又松回去”的情况,是升级把默认值改回来了。

 
枫少@KillBoy
    • 光翼
      光翼 2

      之前真没把流水线当生产系统审过

    匿名

    发表评论

    匿名网友

    拖动滑块以完成验证