跳转至

DevSecOps 与安全测试左移

一、先看一条被禁用的流水线

某团队第一版安全流水线接入了 5 个工具,PR 平均耗时从 6 分钟涨到 47 分钟,安全 job 连续两周红灯。第三周有人在仓库里提交了:

# 临时绕过,下周恢复
security-scan:
  if: false

之后再没恢复。半年后 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)、复审日期。只写“已知悉”等于没登记。

七、误报治理

误报不治理,门禁一定会被关掉。四件事:

  1. 责任人:每条告警按目录 owner 自动分配,CODEOWNERS 同步到工单。
  2. 抑制要带理由和期限,且进代码评审:
// nosemgrep: java.lang.security.audit.sqli.jdbc-sqli.jdbc-sqli
// 参数已由 SqlBuilder 白名单拼接,非用户输入(SEC-2317,2026-12-31 复审)
String sql = "select * from t order by " + sortColumn;
# .trivyignore
# CVE-2023-12345 该函数未被调用,SEC-2318,2026-11-30 复审
CVE-2023-12345
<!-- dependency-check-suppression.xml -->
<suppress until="2026-11-30Z">
  <notes>仅测试作用域,不进入制品(SEC-2319)</notes>
  <cve>CVE-2023-12345</cve>
</suppress>
  1. 每周统计误报率:误报数/告警总数。超过 30% 说明规则集该裁剪,而不是继续加工具。
  2. 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/.*''',
]
# 本地 pre-commit 拦截,比推送后清理便宜得多
gitleaks protect --staged --redact -v

注意 allowlist 只能放测试夹具,不能放 src/;一旦密钥进过远程历史,删文件无效,必须轮换。

十、从 0 落地的顺序

  1. 第 1 周:密钥扫描 + SCA(只扫新增依赖),先做到“不泄密、不带已知漏洞”。
  2. 第 2~3 周:SAST 只扫 diff,规则集从 p/java 这类成熟规则开始,不自己写规则。
  3. 第 1 月:镜像扫描 + SBOM,建立基线,存量问题全部标记为 accepted 并建单。
  4. 第 2 月:DAST baseline 只覆盖登录后核心 10 个接口, nightly 跑。
  5. 第 3 月:IAST 试点一个服务,验证低误报后再推广。
  6. 持续:每月复盘误报率、平均修复时长、门禁拦截数。

顺序的核心是先做不会误报的(密钥、SCA、镜像),再做误报高的(SAST/DAST)。反过来一定会在第二周被关掉。

十一、三个真实的失败原因

  1. 全量扫描卡住 PR:单次 CodeQL 全量 25 分钟,开发者转去别的分支。改成 PR 扫 diff、nightly 全量后,PR 等待降到 6 分钟。
  2. 没有 owner 的告警:每周 200 条 SARIF 结果进同一个群,两周后无人打开。按 CODEOWNERS 拆到人,并只推送新增高危,处理率从 12% 升到 78%。
  3. 一上来就阻断历史遗留:首次全量报 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 与工单号

相关阅读