LLM 做 SAST 误报研判:四层防护让结果不飘

用大模型判误报的工程化细节:确定性优先、缓存兜底、知识闭环

配套仓库:github.com/mabupt/open...

感谢各位查看并提出意见,劳烦各位star。

问题:为什么"直接把告警丢给 LLM"行不通

做 SAST + LLM 的工具,最常见的写法大概是:把静态告警的代码片段拼一个 prompt,丢给大模型让它判"真漏洞还是误报",拿到 JSON 就完事。

听起来很简单,但跑起来会遇到一堆问题:

  1. 成本爆炸:CodeQL 随便扫一个中等规模项目就能出几百条告警,每条调一次 LLM,API 账单顶不住。
  2. 结果不稳定temperature=1 时同一条告警两次判据可能相反;temperature=0 虽然稳了,但模型偶尔会"过度自信",给你一个完全合理但错误的理由。
  3. 重复劳动 :同一个代码片段(比如 os.system(ping + url))换个项目就又要重新判一遍,没有积累。
  4. 误报无法收敛:模型判对了/判错了,下一次不会更好。没有反馈回路。

在 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 里的示例代码当成真代码。三层检测:

  1. 命中行本身以 # 开头 → 直接丢弃
  2. snippet 里所有非空行都是注释 → 丢弃
  3. 命中点落在某个字符串常量区间内 → 用 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 三重容错:

  1. 直接解析(纯 JSON)
  2. 去掉前后的 json / 围栏再解析
  3. 从文本里提取第一个 { 到最后一个 } 的子串再解析

全部失败才抛 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_featuresfp_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

相关推荐
苏打水com2 小时前
内容合规红线:大模型生成内容,哪些能做哪些会犯法?
人工智能·安全·大模型
2601_962364973 小时前
重装系统后,Windows Hello 为什么要重新录入人脸?
windows·安全·电脑
网教盟人才服务平台3 小时前
Spring4Shell 漏洞原理、利用手段与全场景防御方案
安全·web安全
仙魁XAN4 小时前
【WorkBuddy·基础入门】第五篇 :模型、权限和工作空间:让 WorkBuddy 做得好,也做得安全
人工智能·安全·workbuddy·workbuddy 基础入门
jimmyleeee4 小时前
大模型安全之十三:威胁建模
人工智能·安全
Purple Coder4 小时前
黄浦区-------------------
安全
TunerT_TQ4 小时前
智能体评测的哲学——当“跑分”不再等于“能力”|第0期 · 序章
安全·架构·agent
天涯明月19935 小时前
Agent Sandbox 深度解析——给会动手的 AI 一个安全的房间
人工智能·安全·大模型·agent·sandbox