GitHub Actions 实战:把测试、密钥扫描与 Docker 镜像检查接入 CI

GitHub Actions 实战:把测试、密钥扫描与 Docker 镜像检查接入 CI

OK,OK,大家好,欢迎大家来到大鹏 AI 教育,我是张大鹏。

很多项目已经有 GitHub Actions,但真正能称为"安全 CI"的并不多。最常见的情况是:测试失败了还能合并,密钥扫描只是输出一段日志,Docker 镜像发现高危漏洞后流水线依然显示绿色。

CI 的核心不是"自动跑过几个命令",而是把团队认可的质量与安全规则变成必须执行的门禁。本文从一个 Python Web 项目出发,把单元测试、Secret 扫描、Docker 构建和镜像漏洞扫描串成一条可执行流水线。任何关键检查失败,Pull Request 都不能进入主分支。

一、先定义我们要守住什么

一条最小的安全流水线至少覆盖四类风险:

  1. 代码功能是否被破坏:交给单元测试。
  2. 仓库里是否出现硬编码凭据:交给 Secret 扫描。
  3. Dockerfile 是否还能成功构建:交给镜像构建。
  4. 基础镜像和依赖是否有高危漏洞:交给镜像扫描。

流程可以表示为:

text 复制代码
提交代码
  ├── test
  ├── secret-scan
  └── image-security
        ├── docker build
        └── trivy scan
             ↓
       所有任务通过才允许合并

这里最重要的设计是"并行检查、统一门禁"。测试与密钥扫描不需要互相等待,镜像构建和镜像扫描放在同一个 Job 中,确保扫描对象正是本次提交构建出来的镜像。

二、准备一个最小 Python 项目

示例目录如下:

text 复制代码
secure-ci-demo/
├── app/
│   ├── __init__.py
│   └── main.py
├── tests/
│   └── test_main.py
├── .github/
│   └── workflows/
│       └── secure-ci.yml
├── .dockerignore
├── Dockerfile
└── requirements.txt

app/main.py

python 复制代码
from fastapi import FastAPI

app = FastAPI()


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}

tests/test_main.py

python 复制代码
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)


def test_health() -> None:
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}

requirements.txt

text 复制代码
fastapi
uvicorn
pytest
httpx

Dockerfile

dockerfile 复制代码
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

RUN useradd --create-home appuser
USER appuser

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

不要把整个开发目录无脑复制进镜像。.dockerignore 至少排除:

text 复制代码
.git
.github
.env
.venv
__pycache__
.pytest_cache
tests

这既能缩小构建上下文,也能降低把本地密钥和缓存带进镜像的概率。

三、完整 GitHub Actions 工作流

创建 .github/workflows/secure-ci.yml

yaml 复制代码
name: secure-ci

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  test:
    name: Unit tests
    runs-on: ubuntu-24.04
    timeout-minutes: 10

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: python -m pip install -r requirements.txt

      - name: Run tests
        run: python -m pytest -q

  secret-scan:
    name: Secret scan
    runs-on: ubuntu-24.04
    timeout-minutes: 10

    steps:
      - name: Checkout full history
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Run Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  image-security:
    name: Build and scan image
    runs-on: ubuntu-24.04
    timeout-minutes: 20

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build image
        run: docker build -t secure-ci-demo:ci .

      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: secure-ci-demo:ci
          format: table
          exit-code: "1"
          ignore-unfixed: true
          vuln-type: os,library
          severity: CRITICAL,HIGH

这份配置有几个值得解释的细节。

1. permissions: contents: read

GitHub 官方建议默认给 GITHUB_TOKEN 只读权限,再由确实需要写权限的 Job 单独提升。测试、扫描和本地镜像构建都不需要写仓库,因此全局只读足够。

如果以后要上传 SARIF、发布 Release 或推送镜像,应该只在对应 Job 增加最小权限,而不是把全局权限直接设成 write-all

2. fetch-depth: 0

Secret 可能不在当前文件,而在历史提交里。完整检出 Git 历史能让扫描覆盖历史范围。代价是大型仓库下载更慢,可以结合仓库规模和扫描策略调整。

3. Trivy 的 exit-code: "1"

这是安全门禁是否生效的关键。发现满足条件的漏洞后返回非零退出码,Job 才会失败。只生成报告但仍返回 0,流水线看起来"有扫描",实际上没有阻断能力。

4. 只阻断 CRITICAL,HIGH

演示中先把高危与严重漏洞作为门禁,降低首次接入的噪声。成熟后可以根据资产等级、漏洞可利用性和修复窗口逐步收紧,不能把"忽略中低危"误解为它们永远不需要处理。

四、怎样验证门禁真的有效

流水线显示绿色只是第一步。至少要主动制造三类失败,证明每个门禁确实能阻断。

场景 1:制造单元测试失败

把测试期望临时改成错误值:

python 复制代码
assert response.json() == {"status": "down"}

提交 Pull Request 后,Unit tests 必须失败。验证完立即撤销这次测试修改。

场景 2:使用测试规则验证 Secret 扫描

不要提交真实密钥。可以在隔离测试分支使用 Gitleaks 文档提供的测试方式或团队自定义的无效测试模式,确认扫描器返回失败。测试后删除文件,并确保组织策略允许这种安全演练。

真正的凭据一旦进入 Git 历史,仅删除文件并不够:应立即吊销并轮换该凭据,再处理历史记录。永远不要把日志中的脱敏提示当成"凭据没有泄露"的证明。

场景 3:验证镜像漏洞门禁

临时使用团队明确知道存在高危漏洞的测试镜像,或者在专用演练仓库固定一个受控样本。Trivy 发现 HIGHCRITICAL 后,Build and scan image 必须失败。

验证重点是退出状态,而不是报告有没有出现红字。

五、在 GitHub 中设置分支保护

工作流失败能阻止 Job,但要阻止合并,还必须配置仓库规则。

进入仓库 Settings,创建针对 main 的 Ruleset 或 Branch protection rule,并启用:

  • Require a pull request before merging。
  • Require status checks to pass。
  • 选择 Unit testsSecret scanBuild and scan image
  • Require branches to be up to date before merging。
  • 对关键仓库限制管理员跳过规则。

此后只要任何一个检查失败,合并按钮就会被阻止。CI 配置和分支规则缺一不可。

六、进一步减少供应链风险

1. 固定第三方 Action

示例为了可读性使用了版本标签。更严格的生产环境应把第三方 Action 固定到审核过的完整提交 SHA,并通过 Dependabot 或内部流程更新。标签可能移动,提交 SHA 才是不可变引用。

配置形式为 owner/action@<审核通过的 40 位 commit SHA>。其中尖括号里的内容需要替换为团队实际审核的提交值,不能照抄示例。

更新时先阅读 Release Note,再在测试分支验证,不要自动把所有安全工具升级直接合入主分支。

2. 不在来自 Fork 的不可信代码上暴露秘密

Pull Request 会执行贡献者提交的代码。如果 Job 同时拥有高权限 Token 或生产密钥,攻击者可能通过测试脚本把它们导出。

原则是:

  • PR 验证 Job 不注入生产凭据。
  • 需要部署的 Job 只在受信任分支运行。
  • 敏感环境使用 Environment 审批。
  • 避免对不可信代码使用高权限的 pull_request_target

3. 区分构建与发布

这篇文章的流水线只构建本地镜像,不推送仓库。正式发布可以增加独立 Job,并设置:

发布 Job 应限制为主分支 Push 触发,并声明

needs: [test, secret-scan, image-security]

environment: production。这样它不会在普通 Pull Request 上取得生产环境授权。

needs 明确表示:测试、Secret 扫描和镜像安全检查全部通过后,发布才有机会开始。

4. 保存必要证据,但不要上传秘密

测试报告、SBOM 和漏洞扫描结果有利于审计,但 Artifact 同样可能泄露文件路径、依赖信息或配置。上传前要明确保留时间、访问范围和脱敏规则。

七、常见错误与改进方法

错误 1:扫描 Job 使用 continue-on-error: true

这会把安全失败变成黄色警告。试运行阶段可以短期观察,但进入门禁前必须恢复失败阻断,并记录例外审批。

错误 2:镜像使用固定 latest

并发构建时,latest 很容易指向错误对象。应使用本次工作流的唯一运行标识或提交 SHA 生成标签,扫描结果才能和代码一一对应。

错误 3:把所有秘密塞进一个 JSON

GitHub 会对已注册 Secret 做日志遮蔽,但复杂编码、拼接和转换仍可能泄露内容。秘密应尽量小、独立、最小权限,并避免输出到日志。

错误 4:只扫描依赖文件,不扫描最终镜像

依赖扫描看不到基础镜像操作系统包的风险。最终交付 Docker 镜像,就应该扫描最终镜像。

错误 5:门禁名称频繁变化

分支保护按检查名称匹配。如果随意修改 Job 名称,原规则可能失效。把门禁名称视为接口,变更时同步检查仓库规则。

八、团队落地清单

  • PR 与主分支 Push 都会触发工作流。
  • GITHUB_TOKEN 默认只有只读权限。
  • 单元测试失败会返回非零状态。
  • Secret 扫描覆盖必要的 Git 历史。
  • 镜像标签能够唯一对应本次工作流运行。
  • 高危镜像漏洞会让 Job 失败。
  • 主分支要求三项状态检查全部通过。
  • PR Job 不接触生产凭据。
  • 第三方 Action 有固定版本与更新流程。
  • 安全例外有负责人、原因和到期时间。

总结

安全 CI 不是多装两个扫描器,而是把"发现问题"升级为"阻止问题进入主分支"。

本文的最小流水线完成了四件事:

  1. 用单元测试验证功能;
  2. 用 Gitleaks 检查硬编码秘密;
  3. 用 Docker 构建本次提交的真实交付物;
  4. 用 Trivy 对镜像执行高危漏洞门禁。

最后再通过分支保护把三个 Job 设为必需检查。只有这样,测试失败、Secret 泄露和高危镜像漏洞才不会停留在日志里,而会真正阻止合并。

参考资料

  • GitHub Actions Secure use reference 与 Secrets reference
  • Gitleaks Action 官方仓库
  • Aqua Security Trivy Action 官方仓库
  • 项目知识库《Security Automation Using SIEM, SOAR, and DevSecOps Platforms》中的安全 CI/CD 章节
相关推荐
gwf2162 小时前
SSD读写速度深度解析:顺序读写vs随机读写、IOPS、延迟,你的硬盘性能到底怎么看?
git·嵌入式硬件·缓存·github·智能硬件
Kevin Wang7274 小时前
Nvidia-AGX-spark部署手册——课堂质量诊断(jetpack:r36)
python·docker·容器
回眸不遇6 小时前
将 Docker虚拟磁盘文件ext.vhdx迁移出C盘 ,更换到D盘
c语言·docker·容器
潘正翔8 小时前
k8s基础_kubeadm搭建k8s集群
linux·运维·docker·云原生·容器·kubernetes
Echo flower8 小时前
Docker 容器中 Puppeteer 僵尸进程排查与修复
运维·docker·容器·puppeteer
笨鸟先飞,勤能补拙8 小时前
下一个十年:网络安全正在被重写
网络·人工智能·安全·web安全·网络安全·github
流星白龙9 小时前
【Docker】3.Docker 组件与生产应用
docker
潘正翔9 小时前
k8s进阶_Harbor镜像仓库
git·云原生·容器·kubernetes·gitee·github
vibecoding779 小时前
GitHub 2026 年 7 月热榜:累计 Star 总榜与月度飙星榜
人工智能·github·ai编程
江湖有缘9 小时前
Docker实战 | 使用Docker部署Donetick任务与家务管理应用
运维·docker·容器