用大模型判误报的工程化细节:确定性优先、缓存兜底、知识闭环
配套仓库:github.com/mabupt/open...
感谢各位查看并提出意见,劳烦各位star。
问题:为什么"直接把告警丢给 LLM"行不通
做 SAST + LLM 的工具,最常见的写法大概是:把静态告警的代码片段拼一个 prompt,丢给大模型让它判"真漏洞还是误报",拿到 JSON 就完事。
听起来很简单,但跑起来会遇到一堆问题:
- 成本爆炸:CodeQL 随便扫一个中等规模项目就能出几百条告警,每条调一次 LLM,API 账单顶不住。
- 结果不稳定 :
temperature=1时同一条告警两次判据可能相反;temperature=0虽然稳了,但模型偶尔会"过度自信",给你一个完全合理但错误的理由。 - 重复劳动 :同一个代码片段(比如
os.system(ping + url))换个项目就又要重新判一遍,没有积累。 - 误报无法收敛:模型判对了/判错了,下一次不会更好。没有反馈回路。
在 OpenSoft Detect 里,模块3(LLM 误报研判)专门解决这些问题。设计核心是一个原则:
凡是能用确定性规则判定的,绝不调 LLM。
整条研判链路分四层,我从底层往上讲每一层的设计和取舍。
第一层:预过滤(Prefilter)------用零成本规则挡掉明显噪声
这层完全不走 LLM,纯确定性。Prefilter 对每条 finding 过四道门,任何一道命中就直接丢弃并写原因:
门 1:测试文件
看起来简单,做起来有个容易踩的坑。我最初的实现是:只要路径里出现 test 就判测试文件。然后我扫了一个放在 bench/test_projects/ 下的真实项目------结果这个项目本身也被当成了测试文件全部排除。
修复方式:测试文件判定只对被测工程自身的根目录生效。具体逻辑是:
python
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)。这样既不误杀真正的靶场项目,又能覆盖被测工程自己的测试目录。
门 2:注释 / 文档字符串
静态工具会偶发地把注释和 docstring 里的示例代码当成真代码。三层检测:
- 命中行本身以
#开头 → 直接丢弃 - snippet 里所有非空行都是注释 → 丢弃
- 命中点落在某个字符串常量区间内 → 用
ast.walk遍历所有Constant(str)节点,检查lineno ≤ 命中行 ≤ end_lineno
第三层是关键------它能识别 docstring 里的伪代码、多行字符串里的安全示例。之前 CodeQL 有一条告警就是命中了某个模块级 docstring 里的 "os.system(cmd)",这层一判就挡掉了。
门 3:强消毒函数
命中行里直接出现下面 7 个函数之一 → 直接判"已防护",省一次 LLM 调用:
lua
secure_filename shlex.quote html.escape literal_eval
is_relative_to os.path.commonpath is_path_inside
都是 Python 安全编码里公认的硬消毒剂。出现即意味着"这个位置大概率没问题"。
门 4:参数化查询(SQL 类专用)
只有当规则名/消息里出现 sql / sqli / execute / queryset 等关键字时才启用,两条正则:
python
# execute(sql, <独立参数>) 且参数不是拼接串
r"\.\s*execute\s*\(\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。整个预过滤是可审计、可回放、可微调的。
第二层:路由(Runner)------能直判的绝不送模型
通过预过滤的 finding 进入 Runner,这里做第二层分流,目的和预过滤一样:尽量减少 LLM 调用次数。
vbnet
依赖漏洞(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 调用------稳定性优先,容错兜底
真正进入 LLM 的只有"含糊项":不是依赖漏洞、不是弱哈希、预过滤也没挡住。这一层的工程决策全部围绕稳定性来设计。
temperature = 0
消除随机性。同一个 prompt 调两次,temperature=0 的输出是一致的(对大多数主流模型)。我不希望今天的扫描结果和明天不一样,原因只是模型的"创意波动"。
强制 JSON 输出
OpenAI 兼容协议用 response_format={"type": "json_object"};Anthropic 没有原生 JSON mode,靠 system prompt 里的"只输出 JSON"指令 + 严格解析兜底。
解析函数 parse_json_object 三重容错:
- 直接解析(纯 JSON)
- 去掉前后的
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 的第一行。
Prompt 结构
sql
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 的判断
这是整层设计里最"反直觉"的一步:即使 LLM 判了 TRUE_POSITIVE,我仍然会用确定性规则再扫一遍代码切片。
原因很直接:LLM 会漏看一些东西,尤其是跨文件的守卫函数。CodeQL 有个系统性误报模式------静态规则看到 FileResponse(p),但 is_path_inside(p, base) 这个守卫函数在另一个文件里。CodeQL 的跨文件数据流分析没覆盖到,于是标了 TP。LLM 如果也没注意到切片里的守卫调用,就会跟着 CodeQL 一起判 TP。
后过滤的逻辑非常简单:
python
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 再聪明,也不如一条确定性规则可靠。
知识库闭环------让系统越用越准
最后,研判结果会写回知识库,形成闭环。关键点:
写入条件很严格
- FP(误报) :只要 LLM 判了
FALSE_POSITIVE就写入fp_features集合 - TP(真漏洞) :只有动态验证也确认了(
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 里的标注是区分开的:
ini
案例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 里的处理是:
python
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 研判链路本身不依赖知识库。
整层总结
ini
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