跳转至

把团队踩过的坑写成 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 validate --config .semgrep/
semgrep scan --config .semgrep/ --config p/python app/

semgrep scan 本地跑不需要账号。注意它默认不因为有问题就返回非 0,本地看到结果不代表 CI 会红,退出码要显式加 --error 才会变。

二、semgrep ci 里不能带 --config

这是今天卡最久的地方。官方 CLI reference 里,semgrep ci 的参数表把这条写得很清楚:

-f VAL, -c VAL, --config=VAL    Not supported in 'ci' mode

也就是说,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 对这个参数写了三条中止条件,我全踩过:

1. 当前不在 git 目录里
2. 有未暂存(unstaged)的改动
3. 给定的 baseline hash 不存在

第 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 复审)
  1. 理由和复审日期写进代码,跟代码一起过评审,不另开表格。
  2. 每季度跑一次 --disable-nosem 做全量审计,看看被压掉的到底还有多少、有没有过期没清的。
  3. 规则集裁剪看误报率。上一轮 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 的时机也没定,打算先看两周误报率再拍。

相关阅读