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

OK,OK,大家好,欢迎大家来到大鹏 AI 教育,我是张大鹏。
很多项目已经有 GitHub Actions,但真正能称为"安全 CI"的并不多。最常见的情况是:测试失败了还能合并,密钥扫描只是输出一段日志,Docker 镜像发现高危漏洞后流水线依然显示绿色。
CI 的核心不是"自动跑过几个命令",而是把团队认可的质量与安全规则变成必须执行的门禁。本文从一个 Python Web 项目出发,把单元测试、Secret 扫描、Docker 构建和镜像漏洞扫描串成一条可执行流水线。任何关键检查失败,Pull Request 都不能进入主分支。
一、先定义我们要守住什么

一条最小的安全流水线至少覆盖四类风险:
- 代码功能是否被破坏:交给单元测试。
- 仓库里是否出现硬编码凭据:交给 Secret 扫描。
- Dockerfile 是否还能成功构建:交给镜像构建。
- 基础镜像和依赖是否有高危漏洞:交给镜像扫描。
流程可以表示为:
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 发现 HIGH 或 CRITICAL 后,Build and scan image 必须失败。
验证重点是退出状态,而不是报告有没有出现红字。
五、在 GitHub 中设置分支保护
工作流失败能阻止 Job,但要阻止合并,还必须配置仓库规则。
进入仓库 Settings,创建针对 main 的 Ruleset 或 Branch protection rule,并启用:
- Require a pull request before merging。
- Require status checks to pass。
- 选择
Unit tests、Secret scan、Build 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 不是多装两个扫描器,而是把"发现问题"升级为"阻止问题进入主分支"。
本文的最小流水线完成了四件事:
- 用单元测试验证功能;
- 用 Gitleaks 检查硬编码秘密;
- 用 Docker 构建本次提交的真实交付物;
- 用 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 章节