元数据卡
- 前置知识:第7章(SAST/DAST/SCA 概念)、CI/CD 基础知识、容器构建流程
- 预计时间:50 分钟
- 完成标志:能描述 DevSecOps 的核心理念,能配置 CI 流水线中的 SAST/DAST 步骤,能解释 Policy as Code 和容器签名的基本原理
你的进度
你在边境要塞已经构建了一套完整的防御体系:加密通信保护信使,证书系统识别友军,SDL 流程把安全需求嵌入到软件开发的每个阶段。
但你很快发现了一个新问题——即使 SDL 明确定义了安全活动,实际执行中,开发团队和安全的"协作"仍然是接力模式:开发写完代码,交给安全团队扫描,发现问题再打回重做。安全始终是流水线的"最后一道关卡",而不是流水线的一部分。
需要解决的矛盾很简单:安全团队只有几个人,开发团队有几十个人。等到发布前一周才来扫漏洞,要么推迟发布日期,要么带着漏洞上线。两者不可接受。
你的任务
把安全嵌入到 CI/CD 流水线的每一步,而不是留在最后。你将理解 DevSecOps 的理念——为什么安全要"左移",以及在哪些场景下也要"右移"。你将配置 SAST 扫描、秘密检测、容器签名和策略即代码(Policy as Code),让每次代码提交都自动通过安全门。
本章分层
- 必读:DevSecOps 核心理念、CI 中集成 SAST/SCA、秘密管理、容器签名
- 选读:Policy as Code(OPA / Kyverno)
- 进阶:Supply-chain Levels for Software Artifacts (SLSA) 与构建完整性
破局 · 溯源
问题:下个月要塞要启用新的补给系统。开发团队已经写了一千多行 Python 代码,现在安全团队在发布前审计,发现了一个硬编码的 API 密钥、三个未修复的 CVE 依赖、一个允许未认证访问的端点。
修复需要两周,但发布计划定在下周一。
这就是"安全做在最后"的典型后果。安全发现得太晚——修正成本从"改一行代码"变成了"推迟整个发布计划"。更糟的是,如果每次都这样,团队会形成习惯:绕开安全审计直接上线。
你需要的不是"安全门"而在最后一道门,而是"安全闸"嵌入到每一段路程中——每次代码提交都自动通过安全检查,而不是发布前集中扫一次。
第一块:DevSecOps 的理念
DevSecOps 不是一种工具,而是把安全整合进 DevOps 文化、实践和自动化流水线的方法论。核心思想其实只有一句话:
安全是开发团队的日常职责,不是安全团队的唯一职责。
传统模式下,安全团队承担了所有安全责任,开发团队只负责"写功能代码"。DevSecOps 反过来:开发团队在写代码时就考虑安全性,安全团队负责提供工具、策略和培训。
传统模式(最左到最右):
开发 ──→ 测试 ──→ 安全扫描 ──→ 发布
│
└── "发现漏洞,打回给开发重做"
DevSecOps 模式:
左移:安全嵌入每一步
│
├─ Commit → SAST + 依赖检查 + 秘密检测
├─ PR/MR → 代码审查 + 容器扫描
├─ Test → DAST + Fuzz 测试
├─ Build → 镜像签名 + SBOM 生成
└─ Deploy → 策略验证 + 运行时监控第二块:NIST SSDF(Secure Software Development Framework)
美国国家标准与技术研究院(NIST)在 2021 年发布了 SP 800-218,即 SSDF 框架。它定义了一套安全软件开发的最佳实践,分为四个核心实践组:
| 编号 | 实践组 | 核心活动 |
|---|---|---|
| PO | 准备组织(Prepare Organization) | 定义安全角色、培训、配置管理 |
| PS | 保护软件(Protect Software) | 组件安全、代码完整性、安全编码 |
| PW | 生产发布(Produce Well-Secured Software) | 威胁建模、安全测试、漏洞扫描 |
| RV | 响应(Respond to Vulnerabilities) | 漏洞发现和修复、安全公告 |
SSDF 并不规定具体工具,而是定义"你应该做什么"。DevSecOps 是实现 SSDF 建议的操作手段——在 CI/CD 中,你通过自动化把 PS 和 PW 落地。
SSDF 中的关键活动(PS.2、PW.4、PW.6)正好映射到 DevSecOps 的自动化门禁:
PS.2(保护组件安全) → SCA 依赖检查
PW.4(审查代码安全) → SAST 静态分析
PW.6(测试运行安全) → DAST 动态测试第三块:SAST 在 CI 中的集成
GitHub Actions 工作流中添加安全扫描步骤。每次开发者的代码提交经过 CI 时,自动启动安全扫描——就像补给车经过哨站时自动被检查,不需要专门派人盯着:
# .github/workflows/sast.yml
name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Bandit (Python SAST)
run: |
pip install bandit
bandit -r src/ -ll -f json -o bandit-report.json
continue-on-error: true # 不阻止构建,但生成报告
- name: Upload SAST Report
uses: actions/upload-artifact@v4
with:
name: sast-report
path: bandit-report.json
- name: Fail on Critical Findings
run: |
CRITICAL=$(jq '.metrics._totals.CRITICAL' bandit-report.json)
if [ "$CRITICAL" -gt 0 ]; then
echo "发现 $CRITICAL 个严重漏洞,阻断构建"
exit 1
fi关键设计决策:
| 策略 | 说明 | 适用场景 |
|---|---|---|
continue-on-error: true | 扫描结果不阻止构建 | 遗留代码库,先建立基线 |
| 阻断 CRITICAL | 只有严重漏洞才阻止 | 已经有一定安全基础的团队 |
| 阻断 ALL 发现 | 任何发现都阻止 | 绿地基代码库 |
| 仅报告,不阻断 | 完全不影响流水线 | 刚开始集成时 |
第三块:DAST 在 CI 中的集成
DAST 更像一个巡逻队,在你已有战报系统的前提下,从外部模拟攻击者来试探防御。
GitLab CI 中集成 OWASP ZAP:
# .gitlab-ci.yml
dast:
stage: test
image: ghcr.io/zaproxy/zaproxy:stable
script:
- zap.sh -cmd -quick-url https://staging.fortress.local
-quick-out report.html
-quick-progress
artifacts:
paths:
- report.html
when: always
only:
- merge_requestsDAST 一般不在每次提交都运行(因为时间太长),而是在:
| 触发时机 | 频率 | 说明 |
|---|---|---|
| 每次 PR/MR | 可选 | 如果项目小且测试环境随时准备好 |
| 每日定时 | 推荐 | 确保主分支始终通过安全检查 |
| 发布前 | 必须 | 版本发布前最后一道安全测试 |
| 基础设施变更后 | 按需 | 代理、防火墙、网络配置修改后 |
第四块:SCA(依赖扫描)的安全嵌入
你的要塞不可能自己锻造每一把武器。你从外部军械库采购装备,但你得确保这些装备没有被动手脚。
现代项目中 90% 以上的代码来自第三方依赖。一个安全漏洞可能藏在某个深层依赖中——你直接依赖的库是安全的,但它依赖的一个工具包有 CVE。
Trivy 是目前最受欢迎的容器和文件系统扫描器:
# docker-compose 集成 trivy 扫描
security-scan:
image: aquasec/trivy:latest
volumes:
- ./src:/src
- ./trivy-cache:/root/.cache
command: fs --exit-code 1 --severity CRITICAL,HIGH /src--exit-code 1 是关键的阻断策略——如果发现了严重或高危漏洞,Trivy 会以非零退出码退出,让 CI 流水线失败。
# GitHub Actions 中集成 Trivy 扫描 Docker 镜像
- name: Build Docker Image
run: docker build -t my-app:${{ github.sha }} .
- name: Scan Image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1 # 发现漏洞就失败第五块:秘密管理(Secrets Management in CI/CD)
最尴尬的漏洞不是 SQL 注入,而是某个开发者在代码里硬编码了数据库密码,然后不小心提交到了公开仓库。
GitHub 的 secret scanning 是内置的,但你自己也可以配置流水线来检测秘密泄露:
# 用 truffleHog 在 CI 中检测硬编码秘密
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 检查整个 git 历史
- name: Scan for secrets
uses: trufflesecurity/trufflehog@main
with:
extra_args: --results=verified,unknown更关键的是——不要在 CI 日志里打印秘密。一个常见的错误是调试时输出环境变量,结果密码就暴露在了 CI 的日志面板里:
# 危险:日志中会暴露
- name: Configure AWS
run: |
echo "Setting up AWS..."
aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }}
# 安全:避免日志输出
- name: Configure AWS
run: |
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}在 CI 中使用秘密的原则:
| 原则 | 说明 | 实践 |
|---|---|---|
| 最小化 | 只提供必要权限 | 只用 READ 权限的 token,不是 admin |
| 临时性 | 每次构建使用临时凭据 | OIDC 临时 token,不是长期 API key |
| 可轮换 | 秘密必须能随时替换 | Vault / AWS Secrets Manager |
| 可审计 | 每一次秘密访问都有记录 | CI 日志 + 审计追踪 |
| 不传递 | 不在日志、构建产物、镜像层中留下痕迹 | --no-cache 构建,日志脱敏 |
使用 OIDC(OpenID Connect)避免长期凭据:
# 用 GitHub OIDC 代替长期 AWS 密钥
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy-role
aws-region: us-east-1
role-session-name: GitHubActions-${{ github.sha }}这样每次构建时,GitHub Actions 从 AWS 获取一个临时令牌,有效期只有几小时。即使泄露了,令牌过期就自动失效。
第六块:容器镜像签名和验证
你的要塞收到了一箱军火,你怎么确认这箱军火确实是军需官发的,而不是敌人伪造的?
答案是签名和验证——就像你在要塞大门检查官文书信一样。
容器镜像签名用 Sigstore / cosign(将在第10章详细展开)。这里先看一小段集成——在构建后签名,在部署前验证:
# 构建阶段:签名
build-and-sign:
runs-on: ubuntu-latest
permissions:
id-token: write # 需要 OIDC token 来签名
contents: read
steps:
- uses: actions/checkout@v4
- name: Build Image
run: docker build -t registry.fortress.local/app:latest .
- name: Sign Image with Cosign
run: |
cosign sign --key env://COSIGN_PRIVATE_KEY \
registry.fortress.local/app:latest
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_KEY }}部署时验证:
# 部署阶段:验证签名
deploy:
runs-on: ubuntu-latest
steps:
- name: Verify Image Signature
run: |
cosign verify --key k8s://fortress-ns/cosign-public-key \
registry.fortress.local/app:latest
- name: Deploy
run: kubectl set image deployment/app app=registry.fortress.local/app:latest如果签名验证失败(镜像被篡改或来自未知来源),cosign verify 会以非零退出码退出,部署流水线就此阻断。
第七块:Policy as Code(策略即代码)
你有一支巡逻队,但巡逻规则写在一本厚厚的纸质手册里。每次要更改巡逻路线或检查标准,你得重印手册、重新培训。这就是传统策略管理的问题。
Policy as Code 把"规则"变成代码,存放在版本控制系统中,自动生效。在云原生环境中,两个最流行的策略引擎是 OPA(Open Policy Agent)和 Kyverno。
写一段 OPA 策略,限制容器镜像只能从你信任的仓库拉取:
# policy/registry.rego — OPA Rego 语言
package kubernetes.admission
import rego.v1
deny contains msg if {
input.request.kind.kind == "Pod"
image := input.request.object.spec.containers[_].image
not startswith(image, "registry.fortress.local/")
msg := sprintf("镜像 %v 来自未授权仓库,只能使用 fortress 注册表中的镜像", [image])
}这段策略部署在 Kubernetes 的准入控制器中。每次创建 Pod 时,OPA 都会检查——如果镜像不是来自 registry.fortress.local/,直接拒绝创建。
Kyverno 是一个更面向 Kubernetes 的策略引擎(策略用 YAML 写,不用学新语言):
# kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-immutable-image-tag
spec:
validationFailureAction: Enforce # 强制阻断
rules:
- name: disallow-latest-tag
match:
any:
- resources:
kinds:
- Pod
validate:
message: 不能使用 latest 标签,必须指定具体版本
pattern:
spec:
containers:
- image: "!*:latest"这段 Kyverno 策略强制要求所有 Pod 使用的镜像必须有具体版本标签(v1.2.3),不能用 :latest。部署到集群后,任何违反规则的操作都会在创建前被拒绝。
Sarif 格式——前面 SAST 扫描时你可能注意到了 format: sarif。SARIF(Static Analysis Results Interchange Format)是一种标准化的扫描报告格式,让安全工具之间可以互操作:
{
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "Bandit",
"version": "1.7.5"
}
},
"results": [
{
"ruleId": "B105",
"level": "error",
"message": {
"text": "检测到硬编码密码"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/config.py"
},
"region": {
"startLine": 42
}
}
}
]
}
]
}
]
}第八块:Shifting Left vs Shifting Right
"左移"(Shift Left)的意思是:在软件开发生命周期的早期(左侧)发现并修复安全问题。SAST 是左移的典型——在写代码时就检查。
"右移"(Shift Right)的意思是:在运行时(右侧)持续检测安全威胁。DAST 是右移的一种,运行时监控和模糊测试也是。
两者不是二选一,而是互补:
左移 右移
●─────●─────●─────●─────●─────●─────●
Commit PR Build Test Stage Prod
│ │ │ │ │ │
│ │ │ │ │ └─ 运行时监控
│ │ │ │ └─────── 金丝雀发布 + 验证
│ │ │ └───────────── DAST / 渗透测试
│ │ └─────────────────── 镜像签名验证
│ └───────────────────────── SAST + 代码审查
└──────────────────────────────── 本地 Lint + Pre-commit| 策略 | 左移 | 右移 |
|---|---|---|
| 发现时机 | 写代码时 | 运行时 |
| 覆盖范围 | 所有代码路径 | 实际执行路径 |
| 误报率 | 较高 | 较低 |
| 修复成本 | 低(改代码立刻验证) | 高(需要热修复、回滚) |
| 典型工具 | Bandit, SonarQube | OWASP ZAP, Falco |
| 适用漏洞 | SQL 注入、硬编码密钥 | 运行时权限提升、异常行为 |
你的要塞不缺"发现漏洞"的能力,缺的是"在正确的时间发现漏洞"——既能尽快发现(左移),也能持续监控(右移)。
常见陷阱
- 只左移,不右移。 以为只要 CI 里跑了 SAST 就万事大吉。SAST 看不到运行时配置错误、权限绕过、依赖在运行时的动态行为。左移和右移必须组合使用。
- 把 DevSecOps 理解成"买一个工具"。 工具只是自动化手段。DevSecOps 的核心是文化和流程——开发团队主动为安全负责。买了 SonarQube 但没人查看报告,等于没买。
- 所有发现都阻断构建。 如果团队每天都在跟大量告警条对抗,他们会学会想办法绕过扫描。更有效的策略:严重漏洞阻断、低风险告警只报告、建立告警瘦身计划。
- 在 CI 日志里暴露秘密。
echo $MY_SECRET、--verbose模式下打印环境变量、Curl 命令中明文传递 token——这些都可能在 CI 输出中泄露秘密。GitHub Actions、GitLab CI 都对日志进行安全扫描。 - 容器镜像的
latest标签。latest让你永远不知道跑的是什么版本。在生产和 CI 中始终使用具体版本标签(v1.2.3或 commit SHA)。
通关挑战
- 热身:基于你现有的(或者想象的一个)项目,创建一个 GitHub Actions 工作流,集成 Bandit(Python)或 SpotBugs(Java)的 SAST 扫描,配置为:严重漏洞阻断构建,高危告警上传为构建产物。
- 挑战:在 CI 流水线中增加 Trivy 容器镜像扫描和 cosign 容器签名。镜像构建后自动签名,部署前验证签名,签名不通过不部署。
- 观察:用
trufflehog扫描一个现有的 git 仓库(比如你自己的个人项目),看看是否能在历史提交中找到敏感信息。 - 排障:你的团队在 CI 中跑 SAST 遇到一个问题——扫描每次要 15 分钟,开发提交流程变慢。你发现扫描的是全部的代码,而不是 diff 中变更的部分。如何优化?(提示:只扫描变更文件 + 增量扫描)
旅人笔记
- DevSecOps 把安全嵌入到 CI/CD 流水线的每个阶段,而不是放在最后作为门禁
- NIST SSDF 定义了安全软件开发的四组实践(PO/PS/PW/RV),DevSecOps 是它的自动化实现
- SAST 在代码阶段发现漏洞,SCA 检查第三方依赖的已知 CVE,DAST 在运行时模拟攻击
- 秘密管理是 DevSecOps 的核心:使用临时凭据(OIDC)、不在日志输出秘密、最小权限原则
- 容器镜像签名(cosign)保证你部署的镜像是你构建的,未被篡改
- Policy as Code(OPA/Kyverno)把安全规则变成代码,自动在部署前执行
- 左移降低成本,右移捕获运行时威胁;两者互补而非替代
下一站预告
你在 CI/CD 中集成了安全扫描和策略检查,流出了中部署的镜像也被签了名。但有一个问题没解决:你如何知道你的软件里包含了哪些第三方组件?这些组件的安全状态如何?下一章,我们看软件供应链安全——SBOM、SLSA 和完整的可信构建链。