Semgrep 代码审计实战:SAST 自动化扫描配置

Semgrep 代码审计把过去靠人工翻代码找漏洞的过程,变成了一条可以重复执行、结果可对比的自动化检查链路。本文从零搭一套 SAST 流水线:先用官方规则集覆盖常见漏洞模式,再用自定义 taint 规则追踪「用户输入 → 危险函数」的完整传播路径,最后把它接进提交钩子与持续集成,让注入类问题在代码合并前就被拦住。全文命令与规则文件均可直接复制执行,适用于有代码仓库权限、能在开发机或构建容器里安装 Python 环境的中小团队。

适用场景

以下四类情况适合直接套用本文方案:

  • 网站或后台业务系统上线前需要一轮代码层安全检查,但预算不足以请外部审计团队;
  • 已经接入 WAF 却仍被 SQL 注入、命令注入绕过,需要从源码根因上补一层;
  • 团队多人协作、提交频繁,希望把安全检查前置到代码合并环节;
  • 需要长期留存扫描结果,用于合规检查或漏洞修复进度跟踪。

前置条件

  • 一台能访问代码仓库的 Linux 主机(或直接用官方容器镜像),内存建议不低于 2GB;
  • Python 3.8+,可联网拉取 Semgrep Registry 规则;
  • 代码仓库已用 Git 管理,能取到主分支引用(用于增量扫描对比);
  • 若要在 CI 中上传结果,需要一个支持 SARIF 展示的平台(GitHub Code Scanning、GitLab SAST 报表或自建 DefectDojo 均可)。

原理说明

语法模式匹配与轻量数据流

Semgrep 不做完整编译,而是把源码解析成抽象语法树后,用「模式」去匹配结构。模式的写法接近目标语言本身,例如 $CURSOR.execute(...) 中的 $X 表示任意表达式,因此不需要为每种框架写正则。对于跨函数的数据流,Semgrep 使用 taint 模式:声明「污染源」(pattern-sources)、「汇聚点」(pattern-sinks)与「净化函数」(pattern-sanitizers),由引擎自动判断从源到汇聚点的路径上是否存在未被净化的变量,命中即告警。

规则分级与去噪思路

SAST 落地最大的阻力不是查不到,而是告警太多。Semgrep 的规则可以按 severity 分为 ERROR、WARNING、INFO 三档,配合增量扫描与 # nosemgrep 注释,可以把「存量历史问题」和「本次新增问题」分开处理:CI 只对新增代码阻断,历史问题进台账慢慢消化。

操作步骤

步骤一:安装并验证环境

# 方式一:pip 安装(推荐,便于与 Python 项目共用环境)
python3 -m pip install --upgrade semgrep
semgrep --version

# 方式二:容器运行,无需污染主机环境
docker run --rm -v "$(pwd):/src" semgrep/semgrep semgrep --version

首次运行需要联网同步 Registry 规则,离线环境可把规则文件提前 git clone 到本地,用 --config ./rules/ 指定本地路径。

步骤二:跑一轮全量基线扫描

# 官方规则集,覆盖 OWASP Top 10 常见模式
semgrep --config=p/owasp-top-ten --exclude=tests --exclude=vendor .

# 输出 JSON 便于归档与统计,退出码非 0 表示有命中
semgrep --config=p/ci --json --output=semgrep-baseline.json --error .

# 统计命中数量
python3 -c "import json;d=json.load(open('semgrep-baseline.json'));print(len(d['results']))"

把这一轮的 JSON 作为基线归档。后续每次只关注新增命中,避免历史问题反复刷屏。

步骤三:编写自定义 taint 规则

官方规则不一定覆盖自研框架,需要自己写。新建 rules/sql-injection.yaml:

rules:
  - id: python-flask-sql-taint
    mode: taint
    languages: [python]
    severity: ERROR
    message: >-
      用户输入未参数化直接进入 SQL 执行,存在 SQL 注入风险,
      请改用参数化查询或 ORM 绑定变量。
    metadata:
      category: security
      cwe: "CWE-89: SQL Injection"
      owasp: "A03:2021 - Injection"
    pattern-sources:
      - pattern: flask.request.args.get(...)
      - pattern: flask.request.form.get(...)
      - pattern: flask.request.values.get(...)
    pattern-sinks:
      - pattern: $CURSOR.execute(...)
      - pattern: $CURSOR.executemany(...)
    pattern-sanitizers:
      - pattern: db.escape(...)
      - pattern: $INT(...)

关键点:pattern-sources 要覆盖框架真实的入参入口(args / form / values / json / cookies),pattern-sanitizers 要把类型转换和转义函数全部列进去,否则会出现「转换后仍告警」的误报。若同一规则要覆盖多个框架,可把 sources 写成 pattern-either 分支,或直接拆成多条规则、用 id 前缀统一归类。

步骤四:接入提交钩子与持续集成

# 本地提交前只扫暂存区变更,速度最快
git diff --cached --name-only --diff-filter=ACM \
  | grep -E '\.(py|js|ts|java|go|php)$' \
  | xargs -r semgrep --config=rules/ --config=p/ci --error

把它保存为 .git/hooks/pre-commit 并 chmod +x 即可生效。CI 侧建议用官方镜像,只对变更做增量阻断:

name: sast
on: [pull_request]
jobs:
  semgrep:
    runs-on: ubuntu-latest
    container: semgrep/semgrep
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: |
          semgrep ci --config=p/ci --baseline-commit=origin/main \
            --sarif --output=semgrep.sarif

--baseline-commit 让引擎只报告相对基线分支新增的问题,老代码里的历史命中不会阻断流水线。

配置验证

# 1. 校验规则文件语法是否合法
semgrep --validate --config=rules/

# 2. 用单元测试文件验证规则能否准确命中与放行
semgrep --test --config=rules/

# 3. 造一个含漏洞的样张,确认规则真的会触发
cat > /tmp/vuln_demo.py <<'EOF'
from flask import Flask, request
app = Flask(__name__)
@app.route("/user")
def user():
    uid = request.args.get("id")
    return app.db.execute("select * from t where id = " + uid)
EOF
semgrep --config=rules/sql-injection.yaml /tmp/vuln_demo.py

正常情况下第 3 步会输出一条 python-flask-sql-taint 命中,并给出文件行号与数据流路径;若没有任何输出,优先检查 sources 的写法是否与代码里的真实调用一致(例如 request.args.get 与 request.args["id"] 是两种不同写法,需要各写一条 pattern)。

常见问题

FAQ 1:误报太多,逐条排查不现实怎么办?

先区分三类告警:确认无害的用 # nosemgrep: <rule-id> 就地标注并附原因;规则本身写得太宽的(例如 sinks 写成 $F(...)),回到规则文件收窄;确实是框架特性导致的,用 paths.exclude 把测试目录和 SDK 目录整体排除。Semgrep 还支持 --triage-mode 交互式逐条标记,判定结果落盘到本地基线文件,团队可复用同一份结论,不必每人重复判断。

FAQ 2:全量扫描太慢、CI 经常超时怎么办?

三个方向的优化:一是弃用 --config=auto 这类规则庞大的配置,按语言精准指定;二是用 --max-target-bytes 跳过超大文件(如前端打包产物),并用 .semgrepignore 排除 node_modules、dist、vendor;三是把全量扫描放到每日定时任务,PR 流水线只跑 --baseline-commit 增量模式。

FAQ 3:Semgrep 能替代人工代码审计吗?

不能。SAST 擅长发现模式化的注入、硬编码密钥、不安全随机数等问题,但对业务逻辑缺陷、越权设计、加密方案误用基本无能为力。合理定位是把容易出错的机械检查自动化,把人力留给逻辑层评审。

总结

Semgrep 的落地路径可以概括为四步:官方规则集跑基线、自定义 taint 规则补盲区、提交钩子做本地拦截、CI 做增量阻断。真正决定效果的不是工具本身,而是两件事——污染源与净化函数是否覆盖完整,以及误报是否被及时收敛到可接受水平。建议先用两周时间把误报压到个位数,再开启阻断模式,否则流水线很快会被绕过。