静态应用安全测试(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 选型对比
两者并非二选一,而是覆盖不同的分析深度与规则来源:
| 维度 | CodeQL | Semgrep |
|---|---|---|
| 分析方式 | 语义/数据流分析 | 语法模式匹配 |
| 规则编写 | 需要学习 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 安全防线。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。