很多团队都遇到过同一个尴尬场景:流水线里 Semgrep 扫描显示通过,OPA 策略检查也是绿色,但几周后安全团队在代码里翻出同一个命令注入,或者一个把 S3 存储桶设成公开读的 Terraform 变更。问题不在于工具没跑,而在于“扫描结果”和“合并决策”之间缺了一层明确的语义约定:Semgrep 只负责回答“代码里有没有匹配到这条模式”,它不负责判断“这个命中是否值得阻断这次合并”。当这层判断被默认交给工具退出码时,安全团队和研发对“什么算阻断”的假设差异就会集中爆发。

先对齐“什么算阻断”的默认假设
安全团队通常假设:任何高危规则命中都应该阻断合并,因为漏放一个就是生产事故。研发团队通常假设:只有确定是真问题、且能立刻修的问题才值得阻断,否则每次提交都被卡住,交付节奏就崩了。这两种假设都不是错的,但如果不显式对齐,结果就是两种典型失败:要么门禁形同虚设,要么误报把研发逼到绕过流程。
一个可操作的对齐方式是给每条 Semgrep 规则和每条 OPA 策略都标注三件事:严重级别、置信度、处置动作。严重级别描述“如果这是真的,影响有多大”;置信度描述“这条规则命中的误报概率有多高”;处置动作只有三种——block(阻断合并)、warn(评论告警但不阻断)、ignore(记录但不展示)。阻断决策由处置动作决定,而不是由严重级别单独决定。这样,“高危但高误报”的规则可以停留在 warn,而“中危但几乎零误报”的规则可以进入 block。
OWASP DevSecOps Guideline 对门禁位置给出的建议与此一致:预提交/IDE 阶段只做提示性检查(secrets、lint、快速 SAST),不要在这里阻断;拉取请求阶段才阻断新增的高危/严重问题,并把结果呈现在 PR 评论里。把阻断点放在 PR 而不是本地钩子,是减少开发者摩擦的关键设计。
从规则命中到策略决策的数据流
要让“扫描通过但漏洞仍上线”不再发生,需要把两个工具的输出统一成一份结构化事实,再交给策略判断。推荐的数据流是:
- Semgrep 产出 JSON:用
semgrep scan --json输出结果,每条 finding 至少包含check_id、path、start.line、extra.severity、extra.metadata。 - 归一化:把 Semgrep 的 severity 和 metadata 映射到内部字段,例如
rule_id、severity、confidence、category(sast/iac/secrets)。 - OPA 消费事实:把归一化后的 findings 数组作为
input传给 OPA,由 Rego 策略决定每条 finding 的处置动作以及整次检查的最终结论。 - 门禁执行:如果 OPA 返回
deny非空,CI 任务以非零退出码结束,合并被阻断;否则放行并在 PR 中留下 warn 级评论。
这里有一个容易踩的坑:Semgrep CLI 默认即使命中问题也可能返回 0,只有加上 --error 才会在命中时返回 1。如果你的流水线直接依赖 Semgrep 退出码做阻断,很可能一直在放行。更稳妥的做法是让 Semgrep 只负责产出 JSON,退出码交给 OPA 决策后的包装脚本控制。
最小可跑的 Semgrep 污点规则
下面是一条针对 Python 命令注入的污点规则,追踪从 HTTP 请求参数到 os.system 的传播路径。它短小但覆盖了 source、sink 和 propagator 三个要素。
rules:
- id: python-command-injection-taint
mode: taint
languages: [python]
severity: ERROR
message: 用户可控输入未经净化进入命令执行函数
metadata:
category: sast
confidence: high
cwe: "CWE-78"
pattern-sources:
- pattern: flask.request.args.get(...)
- pattern: flask.request.form.get(...)
pattern-sinks:
- pattern: os.system(...)
- pattern: subprocess.run(..., shell=True, ...)
pattern-propagators:
- pattern: $X = $Y
from: $Y
to: $X
这条规则有三个需要说明的边界。第一,它只覆盖 Flask 的 request.args 和 request.form 两类 source,Django、FastAPI 或自定义封装需要单独补 source 定义,否则会产生漏报。第二,subprocess.run(..., shell=True, ...) 的匹配依赖参数顺序和写法,如果代码里用变量拼接 shell 参数,规则可能匹配不到。第三,confidence: high 是人工标注的假设,不是工具自动计算的,上线前应该用历史代码或测试用例验证误报率,再决定它进 block 还是 warn。
用 OPA 做策略分级与放行判断
OPA 的价值在于把“哪些规则阻断、哪些只告警”从 CI 配置里抽出来,变成可版本化、可测试、可回退的策略文件。下面是一段最小 Rego,按规则 ID 和严重级别决定处置动作。
package ci.security
default decision := {"deny": [], "warn": []}
# 明确列入阻断清单的规则
block_rules := {
"python-command-injection-taint",
"iac-s3-public-read-acl",
}
# 只告警不阻断的规则
warn_rules := {
"python-hardcoded-secret",
"iac-missing-encryption",
}
decision := d if {
deny := [f | f := input.findings[_]; block_rules[f.rule_id]]
warn := [f | f := input.findings[_]; warn_rules[f.rule_id]]
d := {"deny": deny, "warn": warn}
}
这段策略的关键设计是:阻断清单是白名单,而不是按 severity 一刀切。这样安全团队新增一条规则时,必须显式把它加进 block_rules,也就强迫团队回答“这条规则的误报率验证过了吗”。同时,warn_rules 里的命中会进入 PR 评论,研发可以逐步修复,但不影响合并。
对于 IaC 合规,策略可以按资源类型和属性判断。例如检查 Terraform 计划里是否有公开读的 S3 ACL:
deny_public_bucket[msg] {
resource := input.iac.resources[_]
resource.type == "aws_s3_bucket_acl"
resource.acl == "public-read"
msg := sprintf("存储桶 %s 配置为 public-read", [resource.address])
}
注意,这段 Rego 依赖输入里已经有解析好的 IaC 资源结构。实际落地时,你需要先用 terraform plan -out 加 terraform show -json 或类似方式把计划转成 JSON,再喂给 OPA。直接用 HCL 文本做正则匹配是脆弱的,不推荐。
预提交与合并请求两阶段执行
把门禁拆成两个阶段,是平衡安全与研发体验的实用做法。
预提交阶段(本地或 CI 的快速检查):只跑 secrets 检测和少量高置信度、低耗时的规则,动作全部是 warn。目标是让开发者在提交前就看到问题,但绝不阻断。Semgrep 支持作为 pre-commit 检查运行,配置成本低。
合并请求阶段(PR/MR):跑完整 Semgrep 规则集和 OPA 策略,动作按分级执行。只有 block_rules 命中时才阻断合并,其余命中以评论形式呈现。这个阶段可以设置超时和并行,避免拖慢 CI。
一个常见的执行顺序问题是:先跑 Semgrep 还是先跑 OPA?正确顺序是 Semgrep 先产出 JSON,再由一个包装脚本调用 OPA。如果 OPA 返回 deny 非空,脚本打印命中详情并以非零码退出。这样 CI 的失败原因清晰指向具体规则和代码行,而不是一句笼统的“安全检查失败”。
策略变更如何回退
策略文件是代码,就必须有回退路径。三种情况需要分别处理:
规则误报导致大面积阻断:最快的回退是把该规则从 block_rules 移到 warn_rules,提交一次策略变更即可,不需要改 CI 配置。建议策略仓库保留变更记录,并在 PR 里附上误报样本,便于后续重新评估。
OPA 策略本身有 bug:回退到上一个已知可用的策略提交。由于策略是版本化的,git revert 后重新触发流水线即可。不要在 CI 里硬编码策略内容,否则回退会变成改流水线。
需要临时放行某次合并:不要提供“跳过所有检查”的开关,而是提供按规则 ID 的临时豁免机制,例如在 PR 描述里声明豁免并附带审批人,由 OPA 读取豁免列表后对该规则降级为 warn。这样豁免是可追溯、可过期的。
这套方案不解决什么
需要明确边界:Semgrep 的污点分析是静态的,它无法覆盖运行时才确定的输入路径、反射调用、动态拼接的 sink,也无法替代人工代码审计对业务逻辑漏洞的判断。OPA 策略只能对已经结构化的事实做判断,如果上游解析器漏掉了某个 IaC 资源类型,策略再完善也拦不住。因此,这套引擎的定位是把已知模式的高置信度问题挡在合并之前,而不是“完整安全方案”。它需要配合代码审计、运行时防护、依赖扫描和人工评审,才能构成完整的控制体系。
上线前建议先用历史提交做一次回放:把过去一个月的合并请求重新跑一遍新规则和策略,统计 block 和 warn 的命中量以及人工复核后的真实率。如果 block 命中里误报超过可接受比例,先把对应规则降到 warn,再逐步收紧。门禁的可信度是靠一次次准确判断积累起来的,一次大规模误阻断就足以让研发绕过流程。

重庆市 1F
–error 这个坑确实容易中招,检查下流水线配置