Semgrep 与 CodeQL:让 SAST 门禁真的扫到代码
最危险的 SAST 事故通常不是满屏红灯,而是流水线长期绿色。某个 monorepo 改了构建入口,CodeQL 数据库只抽取到示例模块;Semgrep 的本地命令没有加失败参数,找到了问题仍返回 0。平台看起来一直工作,真正的业务代码却从门禁里消失了。
Semgrep 和 CodeQL 需要分开理解。Semgrep 直接对源码语法结构、模式和数据流条件执行规则,反馈快,适合开发机和 PR 前段;CodeQL 先把一种语言抽取成关系化数据库,再运行查询,能表达更深的控制流和数据流关系,但构建与抽取是否完整决定了结论上限。两者可以互补,不能用“双工具”掩盖同一个覆盖盲区。
先造一个不会伤害系统的缺陷夹具
不要用真实漏洞、生产仓库或攻击载荷验证扫描器。建立一个临时目录,放入一条故意命中规则的代码和一条安全对照:
LAB_DIR="$(mktemp -d)"
mkdir -p "$LAB_DIR/src" "$LAB_DIR/rules" "$LAB_DIR/artifacts"
cd "$LAB_DIR"
cat > src/demo.py <<'PY'
def unsafe(user_value):
return run_query(user_value)
def safe(user_value):
return run_query(validate(user_value))
PY
cat > rules/demo.yml <<'YAML'
rules:
- id: training.direct-input-to-query
languages: [python]
message: Unvalidated value reaches the demo query helper
severity: ERROR
patterns:
- pattern: run_query($X)
- pattern-not: run_query(validate($X))
YAML这条规则只识别夹具函数名,不提供真实攻击能力。预期 unsafe 命中,safe 不命中。正例和反例必须同时存在,否则规则可能只是“见到函数就报警”。
安装并锁定 Semgrep CLI
Semgrep 当前文档推荐在受支持的 Python 环境中使用 pipx 或 uv 安装,Windows CLI 入口仍需按当前平台说明检查。版本变化频繁,团队应把已验证版本放入工具清单或 lock 文件,而不是让每个 runner 自动取最新版:
read -rp 'Approved Semgrep CLI version: ' SEMGREP_VERSION
: "${SEMGREP_VERSION:?version is required}"
export SEMGREP_VERSION
pipx install "semgrep==$SEMGREP_VERSION"
semgrep --version升级时用 pipx install --force 或重建固定版本的工具镜像。若团队使用容器,同样固定官方镜像 digest,并记录 CLI 版本:
read -rp 'Official Semgrep image tag or digest: ' SEMGREP_IMAGE
: "${SEMGREP_IMAGE:?image is required}"
export SEMGREP_IMAGE
docker run --rm "$SEMGREP_IMAGE" semgrep --version受限网络要把 Python 包或镜像同步到受控仓库,保存来源、哈希和同步时间。代理和企业 CA 应配置到包管理器与容器运行环境,禁止通过关闭 TLS 校验换取“能下载”。
跑通 Semgrep 的正反门禁
先验证规则语法,再执行扫描:
semgrep scan --validate --config rules/demo.yml
semgrep scan \
--config rules/demo.yml \
--metrics=off \
--json-output artifacts/semgrep.json \
--sarif-output artifacts/semgrep.sarif \
src
echo "scan_exit=$?"默认 semgrep scan 即使发现问题也可能返回成功,因此这一步只观察结果。JSON 中应只有 training.direct-input-to-query 对 unsafe 的命中,safe 不应出现。
真正的本地门禁必须显式使用 --error:
set +e
semgrep scan \
--config rules/demo.yml \
--metrics=off \
--error \
src
rc=$?
set -e
test "$rc" -eq 1
printf 'expected finding blocked the gate: rc=%s\n' "$rc"预期退出码为 1,证明 finding 会阻断。随后把 unsafe 改成 run_query(validate(user_value)),再次执行,预期退出码为 0。还要做一次扫描故障实验:把规则 YAML 缩进破坏,确认错误不是被包装成普通 finding,也没有被 --suppress-errors 静默吞掉。
semgrep ci 面向 CI 和组织策略,默认退出语义、diff-aware 行为及可用分析能力与本地 scan 不完全相同。使用 Semgrep AppSec Platform 时按 CLI 参考 核对当前行为,并让 SEMGREP_APP_TOKEN 只存在于受信 job。
让规则成为可以维护的代码
生产规则至少有稳定 id、语言、消息、严重度、正例、反例和 owner。规则改名会打断告警生命周期;模式扩大可能让运行时间和误报同时上升。将规则与测试放在同一目录:
tools/security/semgrep/
├── rules/
│ └── direct-input.yml
├── tests/
│ ├── direct-input.py
│ └── direct-input.fixed.py
└── semgrep.lock在 CLI 当前支持的测试入口上执行规则测试,并以当前 semgrep test --help 为准:
semgrep test tools/security/semgrep规则运行时先解析 AST,再按模式运算符组合匹配;taint/dataflow 规则还要定义 source、propagator、sanitizer 和 sink。真实框架常通过包装器、中间件或 ORM 改变数据流,只有函数名列表而没有框架夹具,升级后很容易静默漏报。
.semgrepignore、.gitignore、--exclude 和 --include 会共同影响目标集合。--include 在已有 ignore 之后过滤,不能假设它会把被忽略目录重新加回来。每次接入都要保存扫描文件数、跳过原因和关键目录断言。
安装 CodeQL CLI 并验证来源
CodeQL CLI bundle 从 GitHub 官方 codeql-cli-binaries 发布获取。团队工具镜像应固定精确版本和下载哈希;下面的变量来自经审批的工具清单:
read -rp 'Approved CodeQL CLI version: ' CODEQL_VERSION
read -rp 'Verified release asset SHA256: ' CODEQL_SHA256
: "${CODEQL_VERSION:?version is required}"
: "${CODEQL_SHA256:?sha256 is required}"
export CODEQL_VERSION CODEQL_SHA256
export CODEQL_HOME="$HOME/.local/opt/codeql-$CODEQL_VERSION"
curl -fL --retry 3 \
-o /tmp/codeql.zip \
"https://github.com/github/codeql-cli-binaries/releases/download/v${CODEQL_VERSION}/codeql-linux64.zip"
printf '%s %s\n' "$CODEQL_SHA256" /tmp/codeql.zip | sha256sum -c -
mkdir -p "$CODEQL_HOME"
unzip -q /tmp/codeql.zip -d "$CODEQL_HOME"
export PATH="$CODEQL_HOME/codeql:$PATH"
codeql version不要凭文章抄一个会过期的版本号或校验值。实施者应从官方 Release 资产核对版本,用组织制品库固化 zip 和哈希,再由 runner 只读安装。CodeQL 的使用条款、GitHub 上公有/私有仓库可用性和组织授权会变化,接入私有仓库前要核对当前 GitHub Code Security 要求。
CodeQL 的门禁先从数据库覆盖开始
每个 CodeQL database 对应一种语言。对编译型语言,none、autobuild 和 manual 构建模式的支持边界会随语言和 CLI/Action 演进;不能把某种语言的无构建模式推广给所有语言。最稳妥的判断来自项目真实构建:代码生成、条件编译、私有依赖和多模块都必须进入抽取过程。
Java 项目的手动构建示例:
rm -rf artifacts/security/codeql-java
codeql database create artifacts/security/codeql-java \
--language=java-kotlin \
--source-root=. \
--command='./mvnw --batch-mode -DskipTests package' \
--overwrite
codeql database info artifacts/security/codeql-java--source-root 决定源码根;--language 决定 extractor;--command 必须真正编译目标模块。数据库目录不能放进会被 clean 删除的 target/。database create 会建立并完成数据库;database init、database trace-command、database finalize 是需要分步控制构建时使用的另一套流程,自动化脚本不能把两套流程混在一起。
分析时固定 query suite 或 query pack,并输出 SARIF 2.1.0:
codeql database analyze artifacts/security/codeql-java \
codeql/java-queries:codeql-suites/java-security-extended.qls \
--format=sarifv2.1.0 \
--sarif-category=java-security-extended \
--output=artifacts/security/codeql-java.sarif \
--threads=0 \
--ram=4096--sarif-category 是同一仓库内这条语言与查询配置的稳定身份。修改 query suite、构建模式或语言时要评估是否建立新 category;重跑同一分析链则保持不变。--threads=0 使用可用逻辑核心,会和同 runner 的构建任务争抢 CPU;--ram 是分析内存预算,过小可能显著变慢或失败。生产值来自代表仓库的高分位测量,不能把示例值当统一标准。
反向实验:让错误构建暴露漏扫
先用完整构建创建基准数据库,记录目标模块与抽取诊断。然后在实验分支把 --command 改为只构建一个示例模块:
codeql database create artifacts/security/codeql-bad \
--language=java-kotlin \
--source-root=. \
--command='./mvnw --batch-mode -pl examples -am package' \
--overwrite命令可能仍返回 0,但业务模块不会进入数据库。验收脚本必须比较关键模块、抽取文件或诊断指标,发现覆盖下降就让 job 失败。这个实验说明:database create 成功只证明抽取流程完成,不证明该扫的代码都在。
monorepo 应按语言、构建图和责任边界拆数据库。条件编译和生成代码要给出明确策略;无法稳定复现的 autobuild 应切换为 manual,而不是缩小项目让命令变绿。
SARIF 上传不是分析完成
SARIF 汇集规则、位置、代码片段、数据流和修复说明。多个语言或不同配置上传到同一仓库时,给每条分析链稳定的 category,避免结果相互覆盖。上传前验证 JSON 和关键字段,再使用 GitHub Action 或 CLI 的受支持入口。
CLI 上传示例把认证放在标准输入:
printf '%s' "$GITHUB_TOKEN" | codeql github upload-results \
--github-auth-stdin \
--repository="$GITHUB_REPOSITORY" \
--ref="$GITHUB_REF" \
--commit="$GITHUB_SHA" \
--sarif=artifacts/security/codeql-java.sarifupload-results 默认等待 GitHub 处理结果,最长 120 秒;平台拒绝或处理失败时命令返回非零。因此默认模式下退出 0 可以证明这次上传已被平台接受并处理,但仍不代表 finding 已被人工审查或分支保护已经生效。只有显式使用 --no-wait-for-processing 才把处理改成异步,此时必须保存返回的分析信息,并由后续步骤查询处理状态,不能把上传请求成功当成处理完成。
GitHub Actions 的上传 job 显式声明最小权限:
permissions:
contents: read
security-events: write
jobs:
upload-codeql-sarif:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<reviewed-commit-sha>
- name: Upload trusted SARIF
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
printf '%s' "$GITHUB_TOKEN" | codeql github upload-results \
--github-auth-stdin \
--repository="$GITHUB_REPOSITORY" \
--ref="$GITHUB_REF" \
--commit="$GITHUB_SHA" \
--sarif=artifacts/security/codeql-java.sarif来自 fork 的 pull_request 不应获得可写 GITHUB_TOKEN 或其他上传凭证。可以在无凭证、隔离的 runner 上分析不可信代码,再由受信工作流对来源、commit 和 SARIF 制品做验证后上传;不要为了取得 Secret 改用 pull_request_target 并检出、构建或执行 fork 代码。组织策略把 GITHUB_TOKEN 限为只读时,上传 job 应明确失败并由平台 owner 修复权限,不能降级成个人高权限 Token。
Actions 之外优先使用目标仓库安装的 GitHub App installation token,并只授予该仓库的 Code scanning alerts: write;fine-grained PAT 同样只选目标仓库并授予 Code scanning alerts: write。必须使用 classic PAT 时,至少需要 security_events,访问私有仓库还要具备相应的仓库访问范围。无论哪种凭证,都不能与依赖下载、制品发布或云环境权限共用,日志中也不得输出 Token。
SARIF 可能包含内部路径、代码片段、数据流和提交信息。不要默认加入完整文件内容;原始 SARIF、CodeQL database、构建日志和查询 trace 都按源码敏感制品设置最小权限、加密、保留期和删除验证。
项目接入:快反馈和深分析分层
一条可维护的链路通常分三层:
开发机或 pre-commit 只跑少量稳定、低误报的 Semgrep 规则,反馈以秒计。PR 流水线运行完整 Semgrep 策略和 CodeQL 默认安全查询,阻断新增高置信问题。定时任务执行更重的扩展查询、全量基线扫描和历史债务盘点。
所有层都执行同一份版本化规则清单,但资源预算和阻断条件不同。不要把所有深度查询塞进每次提交,也不要为了速度把跨文件问题压成几个字符串模式。
CI 需要把四类状态分开:finding 命中、扫描器执行错误、覆盖不足、平台上传/处理失败。高风险仓库对后三类通常 fail-closed;低风险实验分支若暂时 fail-open,也要告警、限时和留审计证据。continue-on-error 不能成为永久配置。
误报、基线和规则升级
增量门禁依赖正确 merge base 和完整 Git 历史。浅克隆、错误目标分支或历史重写会让“新增问题”判断失真。出现异常时切换全量报告并修复基线,不要默认放行。
源码 nosemgrep、路径 ignore、平台 Dismiss 和查询排除都进入同一台账:
suppressions:
- rule_id: training.direct-input-to-query
repository: example/service
location: src/demo.py:2
reason: framework-validator-modeled-by-owner
owner: team-example
approved_by: appsec-reviewer
expires_on: <EVENT_DATE>
ticket: SEC-1234抑制范围越大,未来规则越容易一起失明。优先修复规则或框架模型,其次分诊单条 finding,最后才做目录排除。CI 校验 expires_on,到期后恢复阻断或进入人工复审。
规则、Semgrep CLI、CodeQL CLI 和 query pack 升级采用三步:固定旧版本跑基准;新旧版本并行生成差异;在代表仓库观察新增/消失结果、误报、时长、峰值内存和数据库大小。确认后再改变阻断策略,并保留旧工具镜像、规则 commit 和 query pack lock 作为回退入口。
权限、数据和成本
SEMGREP_APP_TOKEN 只允许向指定组织提交结果;GitHub Token 或 App Token 只允许目标仓库上传 SARIF;依赖仓库凭证只服务构建。三者不能合并成个人高权限 PAT。manual build 会执行被分析仓库及依赖中的代码,不可信 PR 必须在隔离 runner 运行,禁用发布凭证和内网访问。
使用 Registry、AppSec Platform、Pro 引擎、Secrets 验证、托管扫描或 GitHub Code Security 前,分别核对当前数据流、授权和商业能力。Semgrep CE 本地规则扫描、Semgrep 平台能力与 CodeQL 在 GitHub 上的产品授权不是一回事,采购结论不能从 CLI 能运行直接推导。
成本至少包括 runner CPU/内存、CodeQL database 与 SARIF 存储、规则和模型维护、误报分诊、全量扫描窗口及商业授权。监控扫描时长、超时规则、文件覆盖、finding 生命周期、抑制数量、数据库大小和上传成功率。最需要告警的状态是“长期绿色但扫描文件数突然下降”。
常见故障证据
| 现象 | 第一证据 | 常见原因 | 修复后验证 |
|---|---|---|---|
| Semgrep 找到问题仍绿 | CLI 退出码与参数 | scan 未使用 --error | 正例返回 1,修复后返回 0 |
| 本地与 CI 结果不同 | 版本、规则 commit、ignore 统计 | 规则源或目标集合不同 | 固定版本后结果一致 |
| Semgrep 扫描异常却放行 | stderr 与 --suppress-errors | 扫描器错误被吞掉 | 破坏规则文件时 job 明确失败 |
| CodeQL 数据库很小 | 构建日志与 extractor diagnostics | autobuild/manual 只覆盖部分模块 | 关键模块覆盖断言通过 |
| 查询包无法解析 | codeql resolve packs | 代理、证书或 pack 版本不兼容 | 固定 lock 后离线可复现 |
| SARIF 告警重复 | rule ID、category、ref、commit | 标识或主位置漂移 | 同配置重跑保持生命周期 |
| SARIF 上传后无告警 | 平台处理状态与授权 | Token、仓库能力或处理失败 | 目标 commit 出现预期测试告警 |
| 扫描突然变快 | 扫描文件数和数据库覆盖 | ignore、浅克隆或构建入口漂移 | 覆盖恢复且时长回到基线区间 |
分析深度取决于模型,不取决于产品名称
Semgrep 数据流规则和 CodeQL 查询都依赖 source、sink、sanitizer 与框架模型。包装器、反射、动态分派、模板生成和 ORM 可能切断模型。关键框架升级时,先用正反夹具验证模型,再相信平台总分;“两个工具都没报”仍可能只是两个工具共享同一盲区。
CodeQL database 是敏感且昂贵的中间资产
数据库保存源码的结构化表示,生成过程还可能访问私有依赖。它不能放进公共缓存,也不能跨不可信仓库复用。缓存 key 至少绑定仓库、commit、语言、CLI、extractor 和构建配置;删除后验证对象存储生命周期,避免“CI 清理了工作区,远端缓存仍永久保留”。
阈值不能只看 finding 数量
规则升级可能让数量骤增,也可能因漏扫骤降。更可靠的门禁同时观察新问题严重度、关键目录覆盖、扫描器健康和基线漂移。生产阈值来自威胁模型、风险承受和历史测量,示例中的退出码只用于证明链路。
自定义规则需要产品级维护
每条规则或查询都有稳定 ID、owner、帮助文档、positive/negative 测试、适用语言/框架、性能预算、误报处置和弃用计划。CodeQL 查询还要维护 pack 依赖、查询元数据和框架模型。owner 离职或规则测试长期失败时,应降级或冻结规则,不能继续假装它在提供保护。
回滚必须同时恢复工具、规则和结果口径
只降级 CLI 但继续使用新 query pack,或只回退规则而保留新基线,都无法复现旧结果。变更包要记录 CLI/镜像 digest、规则 commit、pack lock、构建脚本、基线和平台配置。回滚后在同一夹具与代表仓库重跑,比较覆盖、finding 和 SARIF 生命周期。
上线检查
Semgrep CLI、CodeQL CLI、规则和 query pack 都固定到经过验证的版本。无害夹具包含命中与不命中样例,修复前后退出码符合预期。Semgrep 门禁显式区分 finding 与扫描器错误。
ignore、include 和关键目录覆盖有可执行断言。CodeQL 构建覆盖业务模块,不能只以 database create 成功验收。每种语言与分析配置使用稳定 SARIF category。
SARIF、CodeQL database、日志和 trace 按源码敏感制品治理。Fork PR 无法读取平台 Token、上传 Token和私有依赖凭证。增量门禁之外保留周期性全量扫描和历史风险燃尽计划。
抑制有规则 ID、原因、owner、审批、工单和到期时间。升级先做新旧结果、覆盖、资源与误报差异,再改变阻断策略。扫描未完成、覆盖下降和上传失败都有明确的失败策略。
商业授权、托管数据流和私有仓库能力在接入时单独核验。团队明确规则 owner、查询 owner、风险接受人和 CI 平台 owner。
清理实验目录前先确认路径来自本次 mktemp,再删除:
case "$LAB_DIR" in
/tmp/*) rm -rf -- "$LAB_DIR" ;;
*) printf 'refuse to remove unexpected path: %s\n' "$LAB_DIR" >&2; exit 1 ;;
esac
unset SEMGREP_VERSION SEMGREP_IMAGE CODEQL_VERSION CODEQL_SHA256 CODEQL_HOME