DevSecOps 与安全测试左移¶
一、先看一条被禁用的流水线¶
某团队第一版安全流水线接入了 5 个工具,PR 平均耗时从 6 分钟涨到 47 分钟,安全 job 连续两周红灯。第三周有人在仓库里提交了:
之后再没恢复。半年后 Log4Shell 爆发,log4j-core-2.14.1.jar 在生产躺了 9 天。
根因不是工具选错,而是三个设计缺失:没有基线(历史问题和新问题一起报)、没有 owner(红灯没人认领)、没有分级(低危也阻断)。安全左移的本质是把检查变成“有人负责、可判定、不阻塞正常交付”的门禁。
二、各阶段卡点¶
| 阶段 | 卡点 | 输入 | 通过标准 |
|---|---|---|---|
| 需求 | 威胁建模(STRIDE) | 数据流图、信任边界 | 每个边界有对应控制项 |
| 设计 | 安全设计评审 | 鉴权、加密、日志方案 | 无“待定”项 |
| 编码 | pre-commit 密钥扫描 + IDE 插件 | 暂存区代码 | 无明文密钥、无高危规则命中 |
| PR | SAST + SCA(增量) | diff、依赖清单 | 新增高危=0 |
| 构建 | 镜像扫描 + SBOM | 镜像、制品 | 无 Critical/High(有修复版本) |
| 测试环境 | DAST baseline + IAST | 运行中的应用 | 无新增高危 |
| 上线前 | 核心接口渗透 | 授权范围、测试账号 | 高危清零或登记接受风险 |
| 运行 | 日志告警 + 情报订阅 | 线上日志、CVE 源 | 新 CVE 24h 内完成影响判定 |
注意“增量”二字:PR 阶段只扫 diff 和新增依赖,全量扫描放 nightly。这是把 47 分钟降到 8 分钟的关键。
三、需求阶段:STRIDE 怎么问出东西¶
| 威胁 | 提问 | 典型测试 |
|---|---|---|
| Spoofing 伪造 | 这个接口如何证明调用者是谁? | 换 token、重放、伪造 header |
| Tampering 篡改 | 客户端传来的金额/状态能否被改? | 改包、改隐藏字段 |
| Repudiation 抵赖 | 关键操作能否追溯到人? | 查审计日志五要素 |
| Information Disclosure 泄露 | 响应是否返回了不需要的字段? | 对比接口文档与真实响应 |
| Denial of Service 拒绝服务 | 单用户能否打满资源? | 并发导出、超大分页 |
| Elevation of Privilege 提权 | 普通用户能否触达管理动作? | 垂直越权、批量赋值 |
建模产出不是文档,而是测试用例的前置条件:每个信任边界至少一条负向用例,否则视为未覆盖。
四、一条可用的 GitHub Actions 工作流¶
name: security-gate
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
security-events: write
packages: write
concurrency:
group: sec-${{ github.ref }}
cancel-in-progress: true
jobs:
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks 需要完整历史
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sast:
runs-on: ubuntu-latest
needs: secret-scan
continue-on-error: ${{ github.event_name == 'pull_request' }} # PR 告警,push 阻断
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
- run: pip install semgrep==1.90.0
- name: Semgrep
run: |
semgrep scan \
--config p/java --config p/secrets --config p/owasp-top-ten \
--severity ERROR --error \
--sarif --output semgrep.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: semgrep.sarif
sca:
runs-on: ubuntu-latest
needs: secret-scan
continue-on-error: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Cache NVD data
uses: actions/cache@v4
with:
path: ~/.m2/repository/org/owasp/dependency-check-data
key: dc-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
restore-keys: dc-${{ runner.os }}-
- name: OWASP dependency-check
env:
NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
run: |
mvn -B org.owasp:dependency-check-maven:9.2.0:check \
-DnvdApiKey="$NVD_API_KEY" \
-DfailBuildOnCVSS=7 \
-Dformats=HTML,JSON,SARIF
- uses: actions/upload-artifact@v4
if: always()
with:
name: dependency-check
path: target/dependency-check-report.*
image:
runs-on: ubuntu-latest
needs: [sca, sast]
continue-on-error: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- run: docker build -t app:${{ github.sha }} .
- uses: aquasecurity/trivy-action@0.28.0 # 生产请锁 commit SHA
with:
image-ref: app:${{ github.sha }}
format: sarif
output: trivy.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '1'
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy.sarif
codeql:
runs-on: ubuntu-latest
needs: secret-scan
permissions:
security-events: write
actions: read
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- uses: github/codeql-action/init@v3
with:
languages: java-kotlin
queries: security-extended
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
with:
category: "/language:java-kotlin"
dast:
runs-on: ubuntu-latest
needs: [image, codeql]
if: github.event_name == 'push' # DAST 只在合入 main 后跑,避免 PR 排队
steps:
- uses: actions/checkout@v4
- name: Start test stack
run: docker compose -f docker-compose.test.yml up -d
- name: Wait for health
run: |
for i in $(seq 1 30); do
curl -fsS http://localhost:8080/actuator/health && break
sleep 2
done
- uses: zaproxy/action-baseline@v0.13.0
with:
target: 'http://localhost:8080'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a -j -m 5' # -m 5 限制每 host 并发,避免打挂测试环境
几个容易写错的点:fetch-depth: 0 少了会导致 gitleaks 只扫最后一次提交;continue-on-error 写在 job 上而不是 step 上,才能让后续 job 继续;upload-sarif 必须 if: always(),否则扫描失败时报告一起丢。
五、门禁怎么定级¶
| 条件 | PR | main | 理由 |
|---|---|---|---|
| Critical(CVSS≥9)或已有公开 EXP | 阻断 | 阻断 | 利用成本极低 |
| High 且有修复版本 | 阻断 | 阻断 | 升级即可解决 |
| High 但无修复版本 | 告警 + 补偿措施 | 告警 | 阻断也没有解法 |
| Medium | 告警 | 告警 + 7 日任务 | 排期处理 |
| Low | 记录 | 记录 | 版本清理 |
| 历史存量 | 基线外不阻断 | 同左 | 避免一次性红 |
判定要带可达性:只被测试代码引用、不在运行时的依赖,降一级;ignore-unfixed: true 就是只阻断“能修的”。
六、修复 SLA¶
| 等级 | 时限 | 例外 |
|---|---|---|
| Critical | 24 小时 | 需 CISO 审批并断网隔离 |
| High | 3 天 | 需书面补偿控制 |
| Medium | 14 天 | 排入当前迭代 |
| Low | 下一版本 | 可接受风险登记 |
接受风险必须写清四件事:影响范围、不能修的原因、补偿控制(WAF 规则/网络 ACL)、复审日期。只写“已知悉”等于没登记。
七、误报治理¶
误报不治理,门禁一定会被关掉。四件事:
- 责任人:每条告警按目录 owner 自动分配,CODEOWNERS 同步到工单。
- 抑制要带理由和期限,且进代码评审:
// nosemgrep: java.lang.security.audit.sqli.jdbc-sqli.jdbc-sqli
// 参数已由 SqlBuilder 白名单拼接,非用户输入(SEC-2317,2026-12-31 复审)
String sql = "select * from t order by " + sortColumn;
<!-- dependency-check-suppression.xml -->
<suppress until="2026-11-30Z">
<notes>仅测试作用域,不进入制品(SEC-2319)</notes>
<cve>CVE-2023-12345</cve>
</suppress>
- 每周统计误报率:误报数/告警总数。超过 30% 说明规则集该裁剪,而不是继续加工具。
- ZAP 用 rules.tsv 收敛:
10038 IGNORE (Content Security Policy Header Not Set),只保留与业务相关的规则。
八、SBOM 与制品签名¶
# 生成 CycloneDX SBOM
mvn -B org.cyclonedx:cyclonedx-maven-plugin:2.8.0:makeAggregateBom
ls target/bom.json target/bom.xml
# 推送镜像后按 digest 签名(keyless,依赖 OIDC)
cosign sign --yes ghcr.io/demo/order-service@sha256:3f2a...
# 部署前验签
cosign verify ghcr.io/demo/order-service@sha256:3f2a... \
--certificate-identity-regexp 'https://github.com/demo/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# 失败时输出:Error: no matching signatures
SBOM 的价值在“新 CVE 出现时能 5 分钟内回答哪些镜像受影响”,而不是交差。签名解决的是“部署的镜像是不是流水线产出的那个”,必须按 digest 而不是 tag 签。
九、密钥扫描配置¶
# .gitleaks.toml
[extend]
useDefault = true
[[rules]]
id = "internal-service-token"
description = "内部服务令牌"
regex = '''svc_[A-Za-z0-9]{32}'''
tags = ["key", "internal"]
[allowlist]
paths = [
'''test/fixtures/.*''',
'''docs/examples/.*''',
]
注意 allowlist 只能放测试夹具,不能放 src/;一旦密钥进过远程历史,删文件无效,必须轮换。
十、从 0 落地的顺序¶
- 第 1 周:密钥扫描 + SCA(只扫新增依赖),先做到“不泄密、不带已知漏洞”。
- 第 2~3 周:SAST 只扫 diff,规则集从
p/java这类成熟规则开始,不自己写规则。 - 第 1 月:镜像扫描 + SBOM,建立基线,存量问题全部标记为 accepted 并建单。
- 第 2 月:DAST baseline 只覆盖登录后核心 10 个接口, nightly 跑。
- 第 3 月:IAST 试点一个服务,验证低误报后再推广。
- 持续:每月复盘误报率、平均修复时长、门禁拦截数。
顺序的核心是先做不会误报的(密钥、SCA、镜像),再做误报高的(SAST/DAST)。反过来一定会在第二周被关掉。
十一、三个真实的失败原因¶
- 全量扫描卡住 PR:单次 CodeQL 全量 25 分钟,开发者转去别的分支。改成 PR 扫 diff、nightly 全量后,PR 等待降到 6 分钟。
- 没有 owner 的告警:每周 200 条 SARIF 结果进同一个群,两周后无人打开。按 CODEOWNERS 拆到人,并只推送新增高危,处理率从 12% 升到 78%。
- 一上来就阻断历史遗留:首次全量报 400 条,团队直接加
if: false。先建基线、只拦新增,第二个月才开始阻断。
坑位表¶
| 坑 | 现象 | 处理 |
|---|---|---|
fetch-depth 默认 1 |
gitleaks 只扫最后一次提交 | 设为 0 |
| dependency-check 无 API Key | 首次跑 30 分钟、报 NVD 请求失败 | 申请 Key + 缓存数据目录 |
| 缓存 key 只用 OS | 命中旧 NVD 库,漏新 CVE | key 带 pom 哈希 + 定期刷新 |
continue-on-error 写在 step |
后续 job 被跳过 | 写在 job 级 |
Trivy 未加 ignore-unfixed |
无修复版本的漏洞永久红灯 | 只阻断可修复项 |
| DAST 扫生产 | 产生脏数据、触发告警 | 只扫测试环境并限速 |
| SBOM 按 tag 签名 | tag 可被覆盖,验签失效 | 按 digest 签名 |
| 抑制项无期限 | 三年后没人知道为什么忽略 | 必填 until 与工单号 |