AI误报陷阱:我告诉模型"扫描器已标记",它竟全盘接受

原文:https://dev.to/alimafana/i-told-the-ai-a-scanner-flagged-this-and-it-agreed-with-everything-4jn6(作者 @alimafana)

我给两个AI模型提供了相同的200段代码、相同的提示词、相同的问题。其中一个模型消除了51%的误报,另一个只消除了20%的误报——并确认了它所看到的90%的内容。

相同的输入,相同的指令,在我唯一测量的东西上,产生了2.5倍的差距。

表现不佳的模型本身并不差。它是来自一家前沿实验室、备受推崇的商用模型。它在这个特定任务上失败,原因具体且可预测:我告诉它,一个扫描器已经标记了这段代码,它便相信了我。

这篇文章将探讨这种失败模式,我为应对它而构建的四种对策,以及一个令人不安的发现:这些对策是否有效,主要取决于模型本身,而非提示词。


为什么我的扫描器要询问AI

如果你是本系列的新读者,这里提供一些背景。

我的扫描器分两阶段工作。固定规则追踪代码中的数据流,找出所有用户输入到达危险位置——数据库查询、文件操作、系统命令——的地方。这一阶段是确定性的:相同的代码输入,每次都会输出相同的嫌疑点。

问题在于,这一阶段的误报率非常高。它会标记这样的代码:

String id = request.getParameter("id");
if (!id.matches("[0-9]+")) {
    throw new IllegalArgumentException("id must be numeric");
}
String sql = "DELETE FROM products WHERE id = " + id;
stmt.executeUpdate(sql);


不受信任的输入确实到达了SQL字符串,这个路径是真实的。但是,matches("[0-9]+") 意味着 id 只能是数字,所以不存在攻击。规则看到的是关联;它们无法理解含义

因此,第二阶段将每个被标记的代码片段交给一个语言模型,并询问一个具体的问题:这是否真的可被利用?

这就是全部的赌注,而它本身就存在一个核心缺陷。


缺陷:我必须告诉模型它在审视什么

提示词需要解释情况。这是我实际 llm.py 中的一行:

A static-analysis engine flagged the code below as a possible {vuln_class}
({cwe}). Decide whether it is a REAL vulnerability or a FALSE ALARM.


请像模型那样解读这句话。在它看到一行代码之前,它已经被告知一个专家系统已认定某处有问题。

这是一个巨大的提示。而语言模型经过刻意训练,倾向于附和——基于人类反馈的强化学习奖励了人们认可的回答,而人们通常认可附和。这种倾向已众所周知,并有一个名称:阿谀倾向

对于大多数应用而言,附和无害甚至可取。但对我的场景则是致命的。一个对所有指控都点头称是的法官不是法官,它只是盖了章的橡皮图章。

更糟的是,这种失败是悄无声息的。流程运行,裁决返回,结果报告——输出结果与没有法官时完全一样。只有通过测量才能发现,而这正是大多数构建此类系统的人从未做的事。


对策一:明确允许不同意

第一道防线最直接。来自真实提示词的 RULES 块:

RULES
- Judge ONLY the code shown. Never assume code you cannot see.
- confirmed=true only if attacker-controlled input reaches the dangerous sink
  with nothing neutralising it on the way.
- confirmed=false if the input is not attacker-controlled, never reaches the
  sink, or is neutralised (parameterised query, escaping, encoding, allow-list).
- Static engines raise false alarms routinely. Rejecting a finding is a correct
  and expected answer. Do NOT agree just because the engine flagged it.


最后一条规则起到了关键作用。它同时做到三点:

它陈述了一个事实 —— 静态分析引擎会常规性地产生误报。这不是一条指令,而是一个背景信息,使得拒绝判定显得合理,而非唱反调。

它将拒绝标记为正确。 没有这一点,模型可能会将“不”视为未能提供帮助。模型会极度优化以显得有帮助。

它指出了具体的错误 —— “不要仅仅因为引擎标记了它就同意”。指出具体的错误比笼统的“要小心”更有效。

还需注意规则2和3的作用:它们为两种答案都提供了具体、可核查的准则。不是“运用你的判断力”,而是“如果输入未被攻击者控制、从未到达汇点,或已被中和(参数化查询、转义、编码、允许列表),则 confirmed=false”。模型拿到了一个清单,而“允许列表”正好能抓住 matches("[0-9]+") 这种情况。

对策2:同时展示确认与拒绝的案例

指令用于描述,示例则用于演示。该提示中恰好包含两个完整的示例——而两者之间的平衡正是关键所在。

第一个示例展示了一个真实漏洞被确认的情况:

String id = req.getParameter("id");
stmt.executeQuery("SELECT * FROM t WHERE id = " + id);


{
  "reasoning": "getParameter 受攻击者控制。它被直接拼接到 SQL 字符串中。没有对其进行任何转义或参数化处理。",
  "confirmed": true,
  "severity": "Critical",
  "explanation": "攻击者可以修改 SQL 语句,从而读取或篡改任意数据行。",
  "fix": "PreparedStatement ps = conn.prepareStatement(\"SELECT * FROM t WHERE id = ?\"); ps.setString(1, id);"
}


第二个示例展示了一个被标记的发现被拒绝的情况:

String id = req.getParameter("id");
PreparedStatement ps = conn.prepareStatement("SELECT * FROM t WHERE id = ?");
ps.setString(1, id);
ps.executeQuery();


{
  "reasoning": "数据源受攻击者控制,但已通过参数化查询的 setString 绑定。驱动程序将其作为数据发送,而非 SQL 代码。",
  "confirmed": false,
  "severity": "Low",
  "explanation": "查询是参数化的,因此输入无法改变 SQL 结构。",
  "fix": "无需修改。"
}


为什么这种特定的配对方式很重要:

两个示例的起始代码完全相同。 两者都以 req.getParameter("id") 开头——毫无疑问,这是受攻击者控制的输入。区别完全在于后续的处理方式。模型无法通过“这是否涉及用户输入?”来取巧,因为两个案例都涉及。

被拒绝的案例并非显而易见的简单情况。 我刻意没有使用像硬编码字符串这样明显安全的例子。我使用了一个真实的、会被静态分析工具标记的发现——它被正确地驳回了。这正是我需要的行为,因此我将其作为演示。

推理过程示范了正确的思考方式。 “驱动程序将其作为数据发送,而非 SQL 代码”是实际的安全推理,表述简洁。这些示例不仅是在教授输出格式,更是在教授如何思考问题。

如果只使用一个确认漏洞的示例进行一次性提示,那将是有害的——它会教会模型预期的答案总是“是”(存在漏洞)。

对策3:抵消检索层的影响

我的扫描工具还会检索与当前审查代码相似的、已公开的真实漏洞,并将它们作为背景信息粘贴到提示中。这是一个非常有用的功能,但也非常危险。

想想看:我即将询问“这段代码有漏洞吗?”,而在提问之前,我立刻向模型展示了三个真实的、已确认的、看起来与它类似的漏洞。这是教科书式的“启动效应”。

因此,检索到的代码块附带了明确的免责声明:

已知相关安全公告 — 真实的 {漏洞类别} 报告,仅用于事实参照。
它们不能证明下方代码存在漏洞;请基于代码本身进行判断,如果代码安全则应驳回。


还有一个比措辞更重要的实现细节:当没有检索到任何内容时,提示内容与无检索版本的提示在字节级别上完全一致。 不是“相似”——是完全一致。这使得后续文章中的 A/B 测试结果是可信的。如果两个提示在空格上都存在差异,我就无法将测量到的任何差异归因于检索功能本身。

对策4(结构性对策):先推理,后裁决

这个对策不在提示文本中——它体现在响应模式中:

_RESPONSE_SCHEMA = {
    "type": "OBJECT",
    "properties": {
        "reasoning":   {"type": "STRING"},
        "confirmed":   {"type": "BOOLEAN"},
        "severity":    {"type": "STRING", "enum": ["Critical", "High", "Medium", "Low"]},
        "explanation": {"type": "STRING"},
        "fix":         {"type": "STRING"},
    },
    "required": ["reasoning", "confirmed", "severity", "explanation", "fix"],
}


reasoning 字段被声明在第一位。由于模型按顺序生成 JSON,它必须先产出分析,然后才能给出裁决。

如果交换这两个字段的顺序,情况就会逆转:模型会先确定 confirmed: true,然后生成文本来为其已经做出的决定辩护。相同的模型,相同的提示,更差的答案——仅仅因为字段顺序不同。

顺便一提,推理内容被限制在三句话以内,这有一个无聊的原因,我将在后续文章中介绍:之前使用的模型用掉了全部 8192 token 的预算来“思考”,结果什么输出都没有。


这些对策有效吗?

我接下来要讲的内容需要谨慎,因为这恰恰是人们在没有证据时常做的那种宣称。

在我的小规模测试项目中,扫描器标记了6个候选问题,其中2个属于已清理但仍被标记的案例。审查模型恰好拒绝了这2个。它针对SQL案例给出的推理如下:

“输入'id'在拼接进SQL查询前,已通过仅允许数字的正则表达式'[0-9]+'进行校验。这种白名单防护阻止了任何SQL注入字符到达危险函数。”

针对XSS案例:

“数据源是request.getParameter('comment')。输入通过了一个strip()方法,该方法使用正则表达式仅允许字母数字字符和空格。这有效地中和了任何XSS攻击载荷。”

这是正确的安全推理,并对一个被引擎标记的问题得出了“无问题”的结论。这些对策似乎起作用了。

在基准测试规模下,使用来自OWASP基准的200个分层案例:

| | 结果 |

|---|---|

| 移除的误报 | 98个中的50个 (51%) |

| 丢失的真实漏洞 | 98个中的2个 (2%) |

| 精确率 | 0.50 → 0.67 |

| 95% 置信区间 | [0.43–0.57] → [0.59–0.74],不重叠 |

一半的误报被移除,代价是2%的真实漏洞,且置信区间不重叠——因此,这种改进不是抽样误差所致。

这就是我想要的结果。

然后我只改变了一件事

我使用不同的模型运行了完全相同的实验。同样的200个候选问题。同样的代码片段。一字不差的提示词。

| 审查模型 | 确认比例 | 移除的误报 | 精确率 |

|---|---|---|---|

| gemma-4-31b-it (31B, 开放权重) | 73% | 51% | 0.50 → 0.67 |

| gpt-4o-mini (商业模型) | 90% | 20% | 0.50 → 0.55 |

gpt-4o-mini确认了它看到的所有内容的90%。其精确率的提升——从0.50到0.55——的置信区间与什么都不做的区间重叠。从统计上看,我无法将其与没有审查模型的情况区分开来。

两次运行中,所有四项对策都存在。一个模型遵循了它们。另一个则基本没有遵循。

显而易见的反驳

“你对比的是便宜的小模型。它当然会输。”

有道理。于是我用同样的200个候选问题——相同的代码片段、一字不差的提示词——通过了gpt-4o,这个前沿的同系列模型。

| 审查模型 | 确认比例 | 移除的误报 | 丢失的真实漏洞 | 精确率 |

|---|---|---|---|---|

| gemma-4-31b-it (31B, 开放权重) | 73% | 51% | 2% | 0.50 → 0.67 |

| gpt-4o (前沿模型) | 80% | 40% | 0% | 0.50 → 0.62 |

| gpt-4o-mini (小型商业模型) | 90% | 20% | 1% | 0.50 → 0.55 |

在同一家族内,能力确实重要:gpt-4o移除的误报是其小模型的两倍,并且是唯一一个没有遗漏任何真实漏洞的审查模型。但它仍然落后于一个免费的、中等规模的开放模型,并且其置信区间([0.55–0.70])仍然与无审查模型的区间([0.43–0.57])有接触。在这个样本量下,Gemma仍然是唯一一个改进具有统计显著分离的模型。排行榜预测了mini到4o的提升。没有预测到开放模型会位居榜首。

深入探讨 (如果你只想知道结论可以跳过)

>

首先:这个结论在扫描器后续的自我修复后还成立吗?本次实验运行后,发现阶段加入了最终的召回率改进,因此我在完整的流水线上重新运行了相同的协议。结果可以复现——97个误报中移除了50个(52%),同样的2%真实漏洞代价,精确率0.51 → 0.67,区间仍然分离。我引用的是最初的运行结果,因为那是三个审查模型共同参与的那一次。

>

我还用gpt-4o-mini整个候选集进行了判断——4,357个中判断了4,356个,速度为每分钟148次判断,花费约1.6美元——以检查样本是否对我产生了误导。并没有:在完整普查中,它移除了611个误报中的111个(18%),丢失了743个真阳性中的3个(这是早期运行、最终召回修复完成前的候选级别计数)。区间仍然重合。

>

有必要精确说明这证明了什么以及没证明什么。它没有显示gpt-4o-mini是一个普遍更差的模型——它更快,每个token更便宜,在很多事情上表现更好。它表明在这个任务上,配合这个提示词,它更可能同意提供给它的前提。

>

我也要提醒,不要仅从n=3个模型就过度推广。我能站得住脚的观点是狭窄的:三个合理选项之间的差异如此之大,以至于它主导了我做的每一个其他工程决策,而公开的基准分数只预测了排序的一部分——OpenAI家族内部的提升,而非开放模型最终胜出。

我学到的

1. 如果你的提示词断言了某件事,要验证模型是否只是表示同意。

这远不止适用于安全领域。任何时候你写下“系统检测到了X,这正确吗?”或“用户报告了Y,这合理吗?”,你就已经给了模型一个结论。你得到的礼貌回答可能只是纯粹的附和。要知道真实情况,唯一的办法是输入一些正确答案是“不”的案例,然后进行计数。

2. 提示词工程的效果有模型天花板。 我在那四个对策上投入了真功夫,如果重来一次我还会这么写——遵循它们的模型产出了统计上可靠的结果。但相同的指令在不同模型上产生了2.5倍的结果差异。提示词是必要的;但它不是充分的。

3. 对于判断任务,怀疑精神比能力更重要。 提升模型能力层级确实有帮助——gpt-4o在零召回损失的情况下,误报去除率是其同类模型的两倍。但让Gemma成为最佳判断者的特性,不是推理能力或知识量。而是它愿意反驳提示词中提供的前提假设。这一特质在我知道的任何排行榜上都没有体现,这意味着你不能仅凭声誉选择判断者——你必须在自己的任务上亲自衡量它。

4. 设计的是结构,而不仅仅是表述。reasoning字段放在confirmed之前对我没有任何额外成本,却改变了模型的处理流程。字段顺序就是提示词工程。

本系列下一篇:OWASP Benchmark——1478个测试用例,其中701个是专门用来欺骗像我这样的工具的。

原文:https://dev.to/alimafana/i-told-the-ai-a-scanner-flagged-this-and-it-agreed-with-everything-4jn6(作者 @alimafana)

发布评论
全部评论(0)