在 SAST 扫描结果中,真正困难的往往不是发现告警,而是稳定地区分真实漏洞与误报。若把每条结果直接交给 LLM,不仅调用成本迅速增加,判断还可能受到输出波动和上下文遗漏影响。要让大模型进入安全分析流水线,需要在模型前后加入确定性规则、缓存、容错机制和可持续积累的反馈闭环。
配套仓库:github.com/mabupt/open…
感谢各位查看并提出意见,劳烦各位star。
做 SAST + LLM 的工具,最常见的写法大概是:把静态告警的代码片段拼一个 prompt,丢给大模型让它判"真漏洞还是误报",拿到 JSON 就完事。
听起来很简单,但跑起来会遇到一堆问题:
temperature=1 时同一条告警两次判据可能相反;temperature=0 虽然稳了,但模型偶尔会"过度自信",给你一个完全合理但错误的理由。os.system(ping + url))换个项目就又要重新判一遍,没有积累。在 OpenSoft Detect 里,模块3(LLM 误报研判)专门解决这些问题。设计核心是一个原则:
凡是能用确定性规则判定的,绝不调 LLM。
整条研判链路分四层,我从底层往上讲每一层的设计和取舍。
这层完全不走 LLM,纯确定性。Prefilter 对每条 finding 过四道门,任何一道命中就直接丢弃并写原因:
看起来简单,做起来有个容易踩的坑。我最初的实现是:只要路径里出现 test 就判测试文件。然后我扫了一个放在 bench/test_projects/ 下的真实项目——结果这个项目本身也被当成了测试文件全部排除。
修复方式:测试文件判定只对被测工程自身的根目录生效。具体逻辑是:
rel = finding_path.relative_to(project_root)
if any(seg in _TEST_KEYWORDS for seg in rel.parts):
return "测试文件"
被测工程在扫描范围之外时(比如用户扫了自己仓库里某个外部路径的项目),退化为仅按文件名启发式(test_*.py / *_test.py / conftest.py)。这样既不误杀真正的靶场项目,又能覆盖被测工程自己的测试目录。
静态工具会偶发地把注释和 docstring 里的示例代码当成真代码。三层检测:
# 开头 → 直接丢弃ast.walk 遍历所有 Constant(str) 节点,检查 lineno ≤ 命中行 ≤ end_lineno第三层是关键——它能识别 docstring 里的伪代码、多行字符串里的安全示例。之前 CodeQL 有一条告警就是命中了某个模块级 docstring 里的 "os.system(cmd)",这层一判就挡掉了。
命中行里直接出现下面 7 个函数之一 → 直接判"已防护",省一次 LLM 调用:
secure_filename shlex.quote html.escape literal_eval
is_relative_to os.path.commonpath is_path_inside
都是 Python 安全编码里公认的硬消毒剂。出现即意味着"这个位置大概率没问题"。
只有当规则名/消息里出现 sql / sqli / execute / queryset 等关键字时才启用,两条正则:
# execute(sql, <独立参数>) 且参数不是拼接串
r".s*executes*(s*[^,)]+,s*(?:[[(]|(?:params|args|values)b)"
# execute 带 %s/? 占位符且第二参为变量
r".s*(?:execute|executemany)s*(s*['"][^'"]*%(?:s|(.+?))s[^'"]*['"]s*,s*[A-Za-z_]"
简单有效,专门治那些".execute(query, params) 这种明显安全的写法被 CodeQL 误标成 SQL 注入"的场景。
预过滤丢弃的记录全部落盘为 prefilter_dropped.json,每条带 finding_id / rule_id / file_path / reason。整个预过滤是可审计、可回放、可微调的。
通过预过滤的 finding 进入 Runner,这里做第二层分流,目的和预过滤一样:尽量减少 LLM 调用次数。
依赖漏洞(pip-audit / OSV) → 事实性放行,0 次调用
弱哈希类(md5 / sha1 / CWE-327) → 策略直判,0 次调用
没配 LLM Key → 跳过研判
配置了 LLM → 进入 LLM 调用层
前两条是两类"模型根本没必要看"的场景:
依赖漏洞是事实性结论——pip-audit 结合 OSV(Open Source Vulnerabilities 数据库)告诉你 Django 4.2 有 CVE-2024-xxx,这个结论不涉及"误报"或"真阳性",就是事实。所以直接给 TRUE_POSITIVE + HIGH 置信度,根本不走 LLM。
弱哈希类(hashlib.md5 / hashlib.sha1 / CryptographicHash 规则)是策略可判的——明文密码用了 MD5 就是弱哈希,不存在"可能是误报"的情况。走 LLM 纯属浪费 token。
路由决策也落盘了,叫 llm_routing,可以看到每轮扫描里各种路由各走了多少条。
真正进入 LLM 的只有"含糊项":不是依赖漏洞、不是弱哈希、预过滤也没挡住。这一层的工程决策全部围绕稳定性来设计。
消除随机性。同一个 prompt 调两次,temperature=0 的输出是一致的(对大多数主流模型)。我不希望今天的扫描结果和明天不一样,原因只是模型的"创意波动"。
OpenAI 兼容协议用 response_format={"type": "json_object"};Anthropic 没有原生 JSON mode,靠 system prompt 里的"只输出 JSON"指令 + 严格解析兜底。
解析函数 parse_json_object 三重容错:
json / 围栏再解析{ 到最后一个 } 的子串再解析全部失败才抛 RuntimeError。
单次请求 30 秒超时,最多重试 3 次,退避间隔 min(2^attempt, 8) 秒。三次都失败,这条 finding 降级为 UNVERIFIED,继续下一条——单条失败不阻断整个流程。
连续失败累计到 3 次 → 触发熔断,本轮剩余所有 finding 直接标记 skip_reason="endpoint_down",不再调 LLM。
为什么要熔断?假设你要扫 200 条 finding,LLM 端点挂了。没有熔断的话:200 × 3 次重试 × 30 秒超时 = 最多 50 分钟的无效等待。熔断后:3 次失败 + 197 次秒级跳过 = 秒级降级。
熔断只在本轮生效,下一轮 Runner 会重新尝试——因为端点可能只是短暂波动。
缓存 key = provider + model + system_prompt + user_prompt 的 SHA-256 前 20 位,TTL 30 天。缓存文件存在 output/.cache/llm_<key>.json 里,内容带时间戳和 JSON payload。
同一份 prompt(同一个模型 + 同一个 finding + 同一个代码切片)第二次调用时直接命中缓存,不发真实 API 请求。实测同一个项目扫两次,第二次 LLM 调用次数为 0。
缓存对迭代很友好——你改了 prompt 版本号,system_prompt 变了,key 就变了,自动走新 prompt。这就是为什么我把 prompt_v=1.4 写在 system prompt 的第一行。
System prompt:
prompt_v=1.4
角色(资深安全审计专家)
JSON schema(verdict / confidence / fp_factors / reason / cwe_ids / fix)
判定要点(外部输入直达危险函数 TP;有消毒/参数化/不可达 FP;证据不足 UNCERTAIN)
特别注意(切片里可能有跨文件守卫函数,请先找出来)
Few-shot 双例(可选):
示例A(真漏洞):os.system('ping ' + request.GET.get('ip')) → TP
示例B(误报):有 is_path_inside 守卫的路径访问 → FP
User prompt:
告警基本信息(id / 规则 / 严重程度 / 位置 / 描述)
CWE 官方描述
路由可达性(GET /cmd_lab -> introduction.apis.cmd_lab)
知识库语义命中(CWE-78 / ATT&CK T1059 等)
代码切片(定向切好的 100 行以内代码)
历史相似案例(如有,标注"历史确认漏洞"或"历史判定误报")
Few-shot 双例是两个真实案例,不是我随便编的。示例 A 来自 pygoat 的命令注入 lab,示例 B 来自 chainlit 项目里 is_path_inside 守卫被 CodeQL 误报的场景。这两个例子的作用是让模型对"跨文件守卫"这种静态分析最难判的情况有一个一致的参考基准,而不是每次自己"发挥"。
Runner 里还有一个我没在 A02 里展开的机制:如果某个 finding 用基础模型判了 UNCERTAIN,并且配置了 OPENSOFT_LLM_ESCALATION_MODEL(比如 gpt-4o 或 claude-sonnet 作为基础模型,claude-opus 作为升级模型),就用强模型再跑一次。但这个特性是可选的——默认不启用,因为强模型贵。
这是整层设计里最"反直觉"的一步:即使 LLM 判了 TRUE_POSITIVE,我仍然会用确定性规则再扫一遍代码切片。
原因很直接:LLM 会漏看一些东西,尤其是跨文件的守卫函数。CodeQL 有个系统性误报模式——静态规则看到 FileResponse(p),但 is_path_inside(p, base) 这个守卫函数在另一个文件里。CodeQL 的跨文件数据流分析没覆盖到,于是标了 TP。LLM 如果也没注意到切片里的守卫调用,就会跟着 CodeQL 一起判 TP。
后过滤的逻辑非常简单:
GUARD_HINTS = (
"is_path_inside", "secure_filename", "html.escape", "escape(",
"sanitize", "validate", "whitelist", "allowlist", "normalize",
"is_relative_to", "commonpath", "literal_eval", "quote(",
"parameterized", "shlex.quote", "os.path.realpath",
)
def detect_guard_in_context(code_text):
low = code_text.lower()
return [g for g in GUARD_HINTS if g.lower() in low]
命中任何一个守卫特征,且 LLM 已经判了 TP → 降级为 UNVERIFIED + MEDIUM confidence。降级原因写进 metadata.guard_postfilter,标注命中了哪个守卫、为什么降级。
这一层完全不依赖 LLM 的判断。它就是"不信任"——LLM 再聪明,也不如一条确定性规则可靠。
最后,研判结果会写回知识库,形成闭环。关键点:
FALSE_POSITIVE 就写入 fp_features 集合metadata.test_status == "confirmed")才写入 vuln_features 集合为什么 FP 可以直接写、TP 要等动态验证?因为误报的模式重复率很高——"参数化查询被误标 SQL 注入"、"有守卫函数被误标路径穿越",这些模式换个项目就又出现了。FP 案例能快速积累,下一次研判时就能在 prompt 里直接引用"历史判定误报"来抑制重复误报。
而 TP 的写入条件严,是为了防止 LLM 偶发误判(比如把一个有守卫的 case 误判成 TP)污染真漏洞集合。TP 集合里只存动态验证也通过了的案例,保证质量。
研判任何一条 finding 之前,先同时查 vuln_features 和 fp_features 两个集合。相似度阈值 0.85,Top-3 × 2 = 最多 6 条参考案例。
历史案例在 prompt 里的标注是区分开的:
案例1 [历史确认漏洞] (score=0.92) rule=command-injection cwe=CWE-78 ...
案例2 [历史判定误报] (score=0.89) rule=path-traversal cwe=CWE-22 ...
"历史判定误报"这条对抑制重复误报特别有效——模型看到一条几乎一样的 case 之前已经被判过误报,就会更倾向于判 FP。
写入知识库的特征不是完整的代码切片,而是一个规范化的 code_pattern:
rule_id | cwe_ids | Sink 行签名 | 数据流变量链(最多 8 个)
Sink 签名做了去空白归一化,数据流签名从 taint_flow 里提取变量名。Point ID 用 code_pattern 的 SHA-256 hash——相同模式重复写入即覆盖,天然去重。
FP 集合的 point ID 加了 "fp|" 前缀,确保和同 pattern 的 TP 不冲突。
Qdrant 连不上?Vector Store 不可用?知识库初始化失败?runner.py 里的处理是:
try:
kb = VulnerabilityKB(config.qdrant)
kb.ensure_collection()
except Exception as exc:
logger.warning("漏洞知识库不可用,本轮不做特征闭环:%s", exc)
kb = None
kb = None 之后,fp_judge.py 里所有 if self.kb is not None 的分支全部跳过。研判照常进行——只是没有历史先验、不入库。LLM 研判链路本身不依赖知识库。
Finding 进入
│
├─[Prefilter]─────── 测试文件 / 注释 / docstring / 强消毒 / 参数化 ── 丢弃,写 reason
│
├─[Runner 路由]────── 依赖漏洞 → 事实放行;弱哈希 → 策略直判 ── 0 次 LLM 调用
│ 没配 Key → 跳过
│ 端点熔断 → 跳过剩余
│
├─[LLMClient]─────── temperature=0 + JSON mode + 30s timeout + 3 次重试
│ 判定缓存(SHA-256 key,TTL 30 天)
│ 升级模型复核(可选)
│
├─[Guard Postfilter] LLM 判了 TP 但切片里有守卫函数 ── 降级为待复核
│
└─[知识库闭环]────── FP 直接入库;TP 等动态确认后入库
研判前检索历史案例(TP/FP 双集合,>0.85 采纳)
四层设计的核心思想:LLM 只处理"人也拿不准"的事,确定性能解决的绝不用 LLM,LLM 的输出还要过一层确定性校验。
这层设计解决了开头说的四个问题:成本(预过滤 + 路由 + 缓存)、稳定性(temperature=0 + 确定性后过滤)、重复劳动(缓存 + 知识库闭环)、无法收敛(FP/TP 双集合闭环 + 历史先验)。
项目地址:github.com/mabupt/open…
模块3 核心文件:modules/llm_analysis/{prefilter,client,fp_judge,knowledge_base,runner}.py
ubuntu怎么选择最快的更新源? ubuntu更改最快的更新源的图文教程
Claude 订阅停止覆盖第三方 Agent 后还能通过 OAuth 使用吗?
Ubuntu系统的笔记本触摸板怎么调节鼠标光标速度?
Claude Code 订阅套餐和 API 按量调用的成本如何管理?
新一代 AI 工具进阶指南:从基本使用到高效工作流
Pi Agent 应该如何通过 Agent SDK 正确连接 Claude 或 Codex?