代码扫描与 SAST:CodeQL、Semgrep 与安全门禁

系统讲解在 GitHub Actions 中落地代码扫描与 SAST 的完整方案,涵盖 CodeQL 的查询包与 build-mode 配置、Semgrep 规则集与 SARIF 输出、code scanning 结果上传、基于严重级别的合并门禁、误报抑制与增量扫描调优。

静态应用安全测试(SAST,Static Application Security Testing)在 CI 中的价值,不在于"扫出了多少问题",而在于能否把发现的问题变成可阻断、可追踪、可收敛的工程信号。很多团队接入了 CodeQL 或 Semgrep,却把它跑成一个"每周看一眼的红叉",结果漏洞依然被合并进主干。本文聚焦一个核心问题:如何让代码扫描真正成为合并门禁(merge gate),而不是一个装饰性的检查。

我们以 CodeQL 与 Semgrep 两种主流引擎为主线,从工作流配置讲到 SARIF 上传、严重级别门禁、误报抑制与增量扫描,最终给出可以直接落地的 .github/workflows 片段。

一、SAST 在 CI 中的定位与门禁模型

1.1 三种扫描时机

SAST 的接入点决定了它的成本与收益:

时机触发方式优点代价
PR 增量扫描pull_request反馈快、只扫改动可能漏掉历史问题
主干全量扫描push 到 main覆盖完整耗时长、噪音大
定时全量扫描schedule不影响 PR 体验发现滞后

实践中最有效的组合是PR 增量 + 定时全量:PR 上只对改动做快速门禁,全量扫描放到夜间,结果汇总成 issue 或安全面板。SAST 只是应用安全的一环,运行时的纵深防御可参考 应用安全 专题。

1.2 门禁的本质:从"报告"到"阻断"

扫描结果只有落到 GitHub 的 check run 上,才能被分支保护规则(branch protection)识别为 required check。SARIF(Static Analysis Results Interchange Format)是连接扫描引擎与 code scanning 面板的桥梁:

扫描引擎(CodeQL / Semgrep)
        │  生成
        ▼
     SARIF 文件
        │  upload-sarif
        ▼
 GitHub code scanning(Security 面板 + PR 注解)
        │  作为 check run 上报
        ▼
   分支保护规则(required check)→ 阻断合并

关键点:只有 upload-sarif 成功,扫描结果才会出现在 PR 的 “Files changed” 里并生成注解。很多团队只跑了引擎却没上传 SARIF,等于白跑。

二、CodeQL 深度配置

CodeQL 是 GitHub 官方引擎,优势在于语义分析能力强、对 C/C++/Java/Go 等编译型语言支持好,代价是首次分析较慢。它与团队既有的静态分析质量体系(如 PHP 静态分析与质量 中讨论的规则治理)是互补关系,前者偏安全,后者偏代码质量。

2.1 标准三段式工作流

CodeQL 官方 Action 采用 init → build(可选)→ analyze 三段式:

# .github/workflows/codeql.yml
name: CodeQL

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "17 3 * * 1"

jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      actions: read
      contents: read
    strategy:
      fail-fast: false
      matrix:
        language: [javascript-typescript, python, go]
    steps:
      - uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
          queries: security-extended,security-and-quality

      - name: Autobuild
        uses: github/codeql-action/autobuild@v3

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: "/language:${{ matrix.language }}"

permissions 必须包含 security-events: write,否则上传会因权限不足而失败。category 用来区分不同语言的扫描结果,避免互相覆盖。

2.2 build-mode 与编译型语言

对 Java、C#、C/C++、Rust 这类需要构建的语言,CodeQL 必须先编译才能提取语义信息。新版 Action 提供 build-mode:

- uses: github/codeql-action/init@v3
  with:
    languages: java-kotlin
    build-mode: manual   # 或 autobuild / none

- name: Build with Maven
  run: mvn -B -DskipTests clean package

- uses: github/codeql-action/analyze@v3

三种模式的取舍:

  • autobuild:让 CodeQL 自己猜构建命令,简单但容易失败(尤其是多模块 Maven/Gradle)。
  • manual:由你提供精确的构建步骤,最可靠,推荐用于复杂工程。
  • none:跳过构建,仅适用于解释型语言(Python/JS/Ruby)。

2.3 查询套件(Query Suite)

queries 参数决定扫描规则的广度:

套件说明适用场景
default默认安全查询日常 PR 门禁
security-extended更多安全查询,含低置信度定时全量
security-and-quality安全 + 代码质量追求高覆盖

对 PR 用 default 保持快速,对定时全量用 security-extended 提升覆盖率,是常见的双轨策略。

2.4 自定义查询包

团队可以把内部规则打成 CodeQL 查询包(QL pack)并引用:

- uses: github/codeql-action/init@v3
  with:
    languages: javascript-typescript
    packs: |
      codeql/javascript-queries:AlertSuppression.ql
      my-org/my-custom-queries@1.2.0

自定义包适合固化"团队特有的反模式",例如"禁止直接使用未封装的 exec"。

三、Semgrep 集成

Semgrep 的优势是规则即配置:模式匹配语法简单,自定义规则门槛低,对新语言和框架的适配快。它和 CodeQL 是互补关系,很多团队两者并用。

3.1 基础工作流

# .github/workflows/semgrep.yml
name: Semgrep

on:
  pull_request: {}
  schedule:
    - cron: "0 4 * * *"

jobs:
  semgrep:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      contents: read
    container:
      image: semgrep/semgrep
    steps:
      - uses: actions/checkout@v4

      - name: Run Semgrep
        run: |
          semgrep scan \
            --config p/security-audit \
            --config p/owasp-top-ten \
            --sarif --output semgrep.sarif \
            --error

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: semgrep.sarif
          category: semgrep

注意 --error:让 Semgrep 在发现符合规则的问题时返回非零退出码,从而让 job 失败。如果只用 --sarif 而不加 --error,扫描即使发现问题 job 也是绿色的,门禁形同虚设。

3.2 规则来源与自定义

Semgrep 的规则可以来自多个渠道,通过多个 --config 叠加:

semgrep scan \
  --config p/default \
  --config p/secrets \
  --config .semgrep/ \
  --config https://semgrep.dev/orgs/my-org/rules

自定义规则的写法(.semgrep/no-eval.yml):

rules:
  - id: no-eval-user-input
    languages: [javascript, typescript]
    severity: ERROR
    message: 禁止对用户输入使用 eval()
    patterns:
      - pattern: eval($X)
      - pattern-not: eval("...")

patterns 支持 pattern-not、pattern-inside、metavariable-regex 等组合,可以精确表达"在什么上下文里禁止什么写法"。

3.3 严重级别与门禁联动

Semgrep 的 severity 分为 INFO / WARNING / ERROR。可以用 --severity 过滤:

# 只把 ERROR 级别作为阻断条件
semgrep scan --config p/security-audit --severity ERROR --error

这样低级别的告警仍然会出现在 SARIF 里供人工参考,但不会阻断 PR,避免"告警疲劳"导致门禁被绕过。

3.4 CodeQL 与 Semgrep 选型对比

两者并非二选一,而是覆盖不同的分析深度与规则来源:

维度CodeQLSemgrep
分析方式语义/数据流分析语法模式匹配
规则编写需要学习 QL 语言YAML 模式,门槛低
跨文件追踪强弱(需 pattern-inside 辅助)
编译型语言需要构建无需构建
运行速度较慢快
官方规则量中大(Registry 生态)

常见组合是:PR 用 Semgrep 做快速门禁,定时用 CodeQL 做深度全量,两者结果都汇入同一个 Security 面板。

四、SARIF 上传与 code scanning 结果

4.1 SARIF 的关键字段

一份能被 GitHub 正确解析的 SARIF 需要包含 tool.driver.name、results[].ruleId、results[].locations[].physicalLocation 等字段。手写 SARIF 容易出错,优先用引擎原生输出。

4.2 上传与去重

多个扫描任务上传 SARIF 时,category 是去重的关键。同一个 category 下重复上传会互相覆盖;不同 category 的结果会并列展示:

- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: results.sarif
    category: "semgrep-${{ matrix.language }}"

4.3 PR 注解与安全面板

上传成功后:

  • PR 的 “Files changed” 中会在对应代码行显示告警注解;
  • Security → Code scanning alerts 面板可以按规则、严重级别、状态筛选;
  • 每条告警支持 dismiss(可标记为 false positive / won’t fix / used in tests)。

4.4 上传失败排查

常见失败原因与对策:

现象原因对策
Resource not accessible缺少 security-events: write补权限
sarif file not found引擎未生成文件检查 --output 路径
结果不显示SARIF 结构不合法用 sarif validate 校验
结果被覆盖多个任务共用 category加区分字段

五、安全门禁:阻断合并的策略

5.1 让扫描成为 required check

在仓库 Settings → Branches → Branch protection 中,把 CodeQL、Semgrep 这两个 job 勾选为 required status checks。此后任何带有未处理高危告警的 PR 都无法合并。

5.2 严重级别门禁矩阵

不是所有告警都值得阻断。建议按级别分层:

级别PR 行为主干行为
Critical / High阻断阻断 + 通知
Medium仅注解记录 issue
Low / Info仅面板定期清理

5.3 用脚本实现自定义门禁

如果需要"仅阻断本次新增的高危告警"(增量门禁),可以在上传后读取 SARIF 并统计:

# 统计本次扫描中 severity=error 的结果数
python - <<'PY'
import json, sys
data = json.load(open("semgrep.sarif"))
rules = {r["id"]: r for r in data["runs"][0]["tool"]["driver"]["rules"]}
high = 0
for res in data["runs"][0].get("results", []):
    sev = rules.get(res.get("ruleId"), {}).get("defaultConfiguration", {}).get("level")
    if sev == "error":
        high += 1
print(f"high severity findings: {high}")
sys.exit(1 if high > 0 else 0)
PY

把它作为 workflow 的最后一个 step,即可实现"高危即失败"的硬门禁。

5.4 与依赖扫描的分工

SAST 扫描的是你自己的代码;依赖漏洞(SCA)由另一条链路负责,两者不要混淆。前者交给 CodeQL/Semgrep,后者交给 Dependabot 或 Dependency Review,可参考 /github-actions-dependabot-dependency-security/。

六、误报治理与增量扫描

6.1 抑制而非关闭

误报的正确处理是抑制(suppression)而不是关闭整条规则。Semgrep 支持行内注释:

// nosemgrep: javascript.lang.security.audit.eval-detected
const result = eval(trustedTemplate);

CodeQL 支持在告警上 dismiss 并在面板记录原因。无论哪种方式,都要保留审计痕迹。

6.2 路径过滤

对生成代码、测试夹具、vendor 目录做路径排除,能大幅降低噪音:

- uses: github/codeql-action/init@v3
  with:
    config-file: ./.github/codeql/codeql-config.yml
# .github/codeql/codeql-config.yml
paths-ignore:
  - "**/node_modules"
  - "**/*.generated.ts"
  - "test/fixtures/**"

6.3 增量扫描

CodeQL 支持 diff-informed 分析,只报告本次改动引入的问题;Semgrep 可以用 --baseline-commit 对比基线:

semgrep scan --config p/default --baseline-commit "${{ github.event.pull_request.base.sha }}"

增量扫描让 PR 门禁只关心"你改坏了什么",历史包袱交给定时全量处理。

七、多语言与性能调优

7.1 语言矩阵

多语言仓库用 matrix 并行扫描,各语言独立上传:

strategy:
  matrix:
    include:
      - language: javascript-typescript
        build-mode: none
      - language: go
        build-mode: autobuild
      - language: java-kotlin
        build-mode: manual

配合 /github-actions-matrix-strategy/ 中的 fail-fast: false,避免一个语言失败拖垮整批。

7.2 缓存与超时

CodeQL 会自动缓存数据库,但首次运行仍可能超过默认超时。为耗时语言单独设置:

jobs:
  analyze:
    timeout-minutes: 60

7.3 成本控制

CodeQL 对私有仓库消耗 Actions 分钟数,全量扫描在大型仓库上可能很贵。控制手段:

  • PR 只跑 default 套件,全量跑 security-extended;
  • 用 paths 过滤,仅在相关代码变更时触发;
  • 定时扫描频率从每天降到每周。

八、安全门禁落地清单

把上面的机制串成一份可勾选的清单:

  • permissions 精确到 security-events: write,其余最小化;
  • 引擎用 --error 或等价方式让发现即失败;
  • SARIF 上传成功且 category 唯一;
  • 扫描 job 在分支保护中设为 required check;
  • 高危阻断、中低危仅注解的分级策略已明确;
  • 误报有抑制路径且有审计记录;
  • 生成代码与依赖目录已排除;
  • 定时全量扫描与 PR 增量扫描双轨运行。

九、常见问题(FAQ)

Q:扫描很慢,能不能只在 main 上跑?
可以,但会失去 PR 门禁的意义。更优的做法是 PR 上用轻量套件(default 或 --severity ERROR)快速反馈,把重量级全量放到 schedule。

Q:fork 仓库的 PR 上传 SARIF 失败怎么办?
fork PR 的 GITHUB_TOKEN 默认没有 security-events: write 权限,上传会失败。这是 GitHub 的刻意设计——防止外部代码污染安全面板。对策是改用 pull_request_target(需谨慎,参见安全加固)或在合并到 main 后再扫描。

Q:如何避免"改了配置结果全变"?
固定 Action 版本到 commit SHA,并把查询套件、自定义规则都纳入版本控制。规则集变更应当走 PR 评审。

Q:告警太多,团队直接忽略怎么办?
先做两件事:一是把阻断范围收窄到 Critical/High;二是把历史存量告警通过 paths-ignore 或 baseline 隔离,让 PR 上只看到新增问题。门禁一旦"经常红",就会被绕过。

Q:能否在本地复现 CI 的扫描结果?
CodeQL 有 CLI(codeql database create/analyze),Semgrep 可直接 semgrep scan。本地复现能显著缩短调试循环。

总结

代码扫描的价值不在引擎本身,而在于把扫描结果接进 GitHub 的检查体系。CodeQL 提供语义级深度分析,Semgrep 提供灵活的自定义规则,两者通过 SARIF 汇入 code scanning 面板,再通过 required check 变成合并门禁。真正决定效果的三个细节是:让发现即失败(--error)、按级别分层阻断、以及用增量扫描控制噪音。把它们与 /github-actions-security-hardening/ 中的工作流加固一起落地,才能构成完整的 CI 安全防线。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「github-actions」更多文章

  1. 多云部署编排与基础设施漂移检测
  2. 文档站与静态站点发布流水线
  3. AI 代码审查与 PR 助手集成