把团队踩过的坑写成 Semgrep 规则¶
昨天把 SonarQube 的门禁接上了,今天轮到 SAST。先拿 p/owasp-top-ten 扫了一遍,报出来的都是些「理论上该防」的东西,而我们这边复盘里反复出现的那个问题——HTTP 调用不设 timeout,CI 被一个不响应的下游挂住半小时——一条都没报。
不怪规则集。这是我们自己封装 client 时用出来的坏习惯,通用规则不认识。所以今天做的事很具体:把这个坑写成一条规则,再让它进 PR 门禁。
一、先写一条最小规则¶
Semgrep 的规则是 YAML,按代码结构匹配,不是按字符串。下面这条放在 .semgrep/http-timeout.yml:
rules:
- id: qalab-http-missing-timeout
languages: [python]
severity: WARNING
message: >-
requests 调用没设 timeout,对端不响应时会一直挂住 CI 或线上线程。
统一走封装好的 client,或显式传 timeout=(3, 10)。
patterns:
- pattern-either:
- pattern: requests.get($URL, ...)
- pattern: requests.post($URL, ...)
- pattern-not: requests.get($URL, timeout=$T, ...)
- pattern-not: requests.post($URL, timeout=$T, ...)
paths:
exclude:
- tests/
- "**/conftest.py"
几个字段说明一下,因为写错一个就整条规则不生效:
pattern-either是「或」,patterns里其余项是「且」。上面两段pattern-not负责把已经传了timeout的调用排除掉。$URL、$T是元变量,...匹配任意剩余参数。所以requests.get(url, headers=h, timeout=5)不会被误报,而requests.get(url)会。severity用INFO / WARNING / ERROR三档。我这条先给 WARNING,观察两周再决定要不要提到 ERROR 阻断。paths.exclude别省。测试夹具里故意不设 timeout 很常见,不排除的话第一次跑就是几十条噪音。
其余常用的算子还有 pattern-inside(限定上下文)、metavariable-pattern(约束元变量取值)、pattern-not 的变体。写复杂规则前先在本地验语法:
semgrep scan 本地跑不需要账号。注意它默认不因为有问题就返回非 0,本地看到结果不代表 CI 会红,退出码要显式加 --error 才会变。
二、semgrep ci 里不能带 --config¶
这是今天卡最久的地方。官方 CLI reference 里,semgrep ci 的参数表把这条写得很清楚:
也就是说,semgrep ci 走的是 Semgrep AppSec Platform 上配置的组织规则(靠 SEMGREP_APP_TOKEN 拉取),不接受本地 --config。我一开始把本地调好的命令直接改成 semgrep ci --config .semgrep/,结果规则根本没加载,job 还一片绿。
两条路,按有没有平台账号选:
| 方式 | 命令形态 | 规则从哪来 |
|---|---|---|
| 不接平台 | semgrep scan --config ... --baseline-commit ... --error |
仓库里的 .semgrep/ + 注册表 p/* |
| 接平台 | semgrep ci |
AppSec Platform 上配置的规则,PR 里只报 blocking 的 |
我们这种自建 CI、规则想跟着仓库走、还要进代码评审的,用第一种。semgrep ci 更适合已经把规则治理收到平台上的团队。
一条能用的增量命令:
# CI 里先确保基线 commit 拿得到
git fetch origin main:origin/main
semgrep scan \
--config p/python \
--config .semgrep/ \
--baseline-commit "$(git merge-base origin/main HEAD)" \
--severity ERROR \
--error \
--sarif --output semgrep.sarif
--baseline-commit 的作用是只报「这个 commit 里没有」的问题,老代码的历史告警不会一次糊上来。这跟昨天 SonarQube 的 new code 是一个思路,但实现方式完全不同:这里是拿两个快照的结果做差。
三、--baseline-commit 在 CI 里中止的三个原因¶
官方 CLI reference 对这个参数写了三条中止条件,我全踩过:
第 2 条最阴。构建步骤在 workspace 里生成了 target/、.coverage 之类的文件又没加进 .gitignore,Semgrep 就认为工作区不干净,直接 abort,日志里还不太显眼。解决办法是在 scan 之前把生成物清掉,或者干脆把扫描 job 单独起一个干净 checkout。
第 3 条来自浅克隆:git merge-base 算出来的 commit 在本地对象库里根本不存在。所以前面的 git fetch origin main:origin/main 不是可选项,GitHub Actions 里对应的是 fetch-depth: 0。
如果走平台那套 diff-aware 扫描,官方给的是环境变量组合:SEMGREP_PR_ID + SEMGREP_BASELINE_REF,同样要求基线 ref 在扫描环境里能拿到,Bitbucket / Azure 的示例配置里都带了一句 git fetch。GitLab 的 MR 流水线会自动带 CI_MERGE_REQUEST_IID,semgrep ci 能自己识别并切成增量。
四、nosemgrep 别放任¶
nosem 注释默认是启用的(--enable-nosem 为默认,对应 SEMGREP_ENABLE_VERSION_CHECK 那套开关里的一个),也就是说代码里写一行 # nosemgrep: rule-id,这条告警就不报了。方便,但也最容易变成「压掉算了」。
我们的约定是三件事:
# 抑制必须带规则 id、理由、复审日期
resp = requests.get(url) # nosemgrep: qalab-http-missing-timeout
# 该下游为内网健康检查接口,调用方已在外层设 3s 整体超时(SEC-2401,2026-12-31 复审)
- 理由和复审日期写进代码,跟代码一起过评审,不另开表格。
- 每季度跑一次
--disable-nosem做全量审计,看看被压掉的到底还有多少、有没有过期没清的。 - 规则集裁剪看误报率。上一轮
p/owasp-top-ten里有一条规则报了 40 多个结果全是固定参数的 subprocess 调用,直接从 CI 规则集里摘掉,留到季度审计再跑,比给每行加抑制干净得多。
Semgrep 还支持 --gitlab-sast、--gitlab-secrets 这类平台专用输出,以及 --sarif 给 GitHub 上传。选哪个取决于结果最终落在哪,别每个都生成然后没人看。
五、验收:证明规则不是摆设¶
1. 本地 semgrep validate 通过,scan 能命中故意写的违规行
2. 提交一个只含违规调用的 PR,job 必须红,SARIF 里能看到 rule id
3. 加上 nosemgrep 再跑,job 变绿
4. 跑一次 --disable-nosem,被压的那条应重新出现
5. 从 main 拉一个新分支不改代码,job 应是绿的(基线生效,历史问题不阻断)
第 5 条专门验增量。基线没配好时,新分支会被历史告警淹掉,一眼就能看出来。
坑位表¶
| 现象 | 根因 | 处理 |
|---|---|---|
semgrep ci --config .semgrep/ 规则没生效 |
ci 模式不支持 --config |
改用 semgrep scan,或把规则传到平台 |
| 本地有结果,CI job 还是绿 | scan 默认不返回失败码 |
加 --error |
| baseline 扫描直接中止 | 有 unstaged 改动 | 清理生成物 / 用干净 checkout |
| baseline 扫描直接中止 | 浅克隆里没有该 commit | git fetch 或 fetch-depth: 0 |
| 首次全量几百条 | 没设增量基线 | --baseline-commit $(git merge-base ...) |
| 抑制越积越多 | nosem 默认开启且无复审 | 理由+期限进代码,季度跑 --disable-nosem |
还留着没做的¶
规则目前只覆盖 Python。Java 那边同样的坑是 RestTemplate 不设超时,写法得另起一条,还没来得及验;另外 severity 提到 ERROR 的时机也没定,打算先看两周误报率再拍。