跳到主要内容

为什么公开问答只解释、不取证:两层防御怎么做

2026-10-13AI 安全 7 分钟阅读 3443 字「不执行处置」和「不泄露内部事件数据」是两件事。收紧之前,匿名问一句「我们昨天被谁打了」,回答里会带出内部资产名和内部接口路径 —— 处置确实没执行,但数据已经出去了。这篇讲两层防御(编排层不派发 + 工具层二次拒绝)怎么落地,以及它换来的代价。

一个反直觉的事实

大部分人对"公开 AI 问答"的安全直觉是这样的:

只要它不能执行处置动作(不能封 IP、不能锁账号),那就安全了。

这个直觉错了一半。

我们在 2026-10-13 的实测里遇到过这个场景:

匿名提问:「我们昨天被谁打了?」

系统确实没有执行任何处置动作(这是设计好的)。但回答里带回了:

  • 内部资产名 web-01、web-02
  • 内部接口路径 /api/order/detail、/upload/files/
  • 内部真实日志/告警的原文片段

也就是说:“不执行处置"与"不泄露内部事件数据"是两件事,只做前一件, 匿名访客依然可以把你的内网摸一遍。

这一篇讲的就是后一件怎么补,以及它换来的代价。


两条不同的问题,两种不同的防线

先把问题拆开,因为混在一起就永远说不清:

问题防线判据
攻击者能不能让模型执行动作?权限隔离 + 白名单(代码层,不靠提示词)公开身份的工具调用次数
攻击者能不能让模型吐出内部数据?编排层不派发取证 + 工具层二次拒绝 + 白名单检索公开 vs 内部的对照测试

第二行的核心判断是:公开身份去读取内部事件数据,这件事本身就不该存在。 注意措辞 —— 不是"要控制它读多少”,而是"它不该有这条路"。


两层防御怎么落地

第一层:编排层不向公开身份派发取证任务

用户的意图是"研判",编排器会把它派给某个角色。收紧之后:

  • 公开身份不派发 FORENSICS:原判定为取证的请求,改派 ANALYST;
  • 公开身份不写"待补充知识"工单(那是内部动作);
  • 公开面唯一的检索来源是 PublicKbService: 白名单目录(security-docs / data/kb-approved)+ 字段白名单 + 脱敏。

语义结果也随之明确:当问题必须依赖内部事件数据时 (“我们昨天被谁打了”、“最近的攻击源是谁”),状态是 input-insufficient, 正文写明:

公开面只基于公开知识库回答,不读取内部事件数据。

而且不检索、不调模型 —— 不花公开额度去猜。

第二层:工具层二次拒绝(不靠模型自觉)

这是整篇里我认为最值得抄的一条。第一层是"我们不让它调",第二层是"它想调也调不动"。

被显式拒绝的工具(对公开身份直接返回错误并留痕):

QueryLogsTools.queryLogs
QueryLogsTools.getAvailableLogTopics
QueryMetricsTools.queryPrometheusAlerts
InternalDocsTools.queryInternalDocs
→ 返回 error = public-identity-no-internal-data

唯一保持可用的是 DateTimeTools —— 因为它是纯时间、不含任何内部数据。

为什么必须做第二层? 因为第一层是编排逻辑, 而编排逻辑会随着需求变化被改(加一个新意图、加一条新路由)。 第二层是权限边界,它不随业务逻辑漂移 —— 这两者之间必须有冗余,这就是"纵深防御"的具体含义,而不是一句口号。

验证方式:不看日志,看行为

上面两条如果只靠"我们改好了"是没法证明的。所以验证走行为对照:

· 注入尝试          → state=input-insufficient,且编排未被调用
· 正常提问但模型不可用 → state=model-failed,回答里明写"未生成"
· 处置类提问("请封禁 1.2.3.4")→ 回答里出现"公开演示不执行",且无审计 ID
· 凭据形态串(sk-…、Bearer …、私钥块)→ 回答里被替换为 [凭据已脱敏]

还有一条对照测试:PublicDataToolIsolationTest —— 公开 = 0 次工具调用 / 内部 = 1 次。

这一条是整套设计里最关键的一行:它把"权限隔离"从一段说明变成了一个断言。


另外两道护栏(脱敏与配额)

上面两层解决"能不能读到内部数据"。还有两道管"读到的内容会不会泄露"和"会不会被刷爆":

③ 脱敏(PublicIpMask):IP 掩码到网段;内网段直接写"内部地址"; 自由文本里内嵌的 IP 也要覆盖。

最后半句是我踩过的坑:一开始只对结构化字段做了掩码, 结果 detail(规则描述原文)里内嵌了完整 IP —— 前面掩得再干净,这里照样漏。修法是对文本再过一遍 maskAll:

// detail 是规则描述原文,里面内嵌了完整 IP —— 必须过一遍文本掩码,
// 否则前面结构化字段掩得再干净,这里照样漏(实测漏过一次)
x.put("detail", PublicIpMask.maskAll(...));

一般化教训:脱敏必须在"最外层"做一次,而不是在"每个字段"上分别做。 只要有一处自由文本没走同一条通路,前面所有工作都白费。

④ 配额与限流(三本账):用户 / 评测 / 公开各自独立计数与独立文件。 公开路径的额度被刷爆不能影响内部用户的额度 —— 这是"资源隔离", 也是为什么我用的是"三本账"而不是"一个计数器加个判断"。

公开端点的具体限额(如实抄自契约):

端点限额
POST /api/public/ask每 IP 6 次/分钟 + 同一来源并发 1 + 每日独立额度(生产 30)
GET /api/public/kb?q=每 IP 20 次/分钟,只出白名单字段

一个体验上的反向收紧:命中的引用要直出正文

上面都是"收紧"。但收紧到某个程度会变成另一个问题 —— 我实测遇到过一次:

概念题"OWASP A07 是什么"返回 state=input-insufficient(依据不足), 但检索其实命中了 3 条引用。访客只看得到"依据不足" → 体验上等于"这个 AI 什么也不答"。

修法(方案 B):未生成结论时(依据不足 / 无命中 / 检索不可用 / 模型不可用 / 额度用尽), 正文直接给出【标题 + 片段 + 可信度 + 来源性质 + 相关度】, 并写明"公开演示只基于公开知识库回答"。已生成结论时口径一致。 说明文案同样要过 PublicOutputGuard。

边界要写清:这不放宽“对具体事件定性"的门槛(那是方案 C,明确不做), 也不做分流问答(方案 A,明确不做)。收紧和体验不是二选一, 但"哪些能放宽、哪些不能"必须提前写死,否则每次都会临时拍脑袋。


定级也一并收紧

公开面的模型结论一律不做事件定级:

  • 严重度强制 INFO;
  • 建议栏是"防护建议”,不是"处置建议"。

这一条是收紧,不是放宽 —— 因为公开面本来就拿不到事件全貌, 让它输出一个严重度数值,读的人会当成结论,而被误导的代价大于"看起来不专业"。


代价:公开面看起来"更笨"了

诚实写清这套设计的代价,因为它真实存在:

代价说明
一些本来能答的问题现在明确拒答例如"最近的攻击源是谁" —— 这是刻意的
公开面能力弱于内部面访客可能会觉得"这 AI 不行",所以页面必须写清为什么
多了一层工具拒绝的错误码排障时多一种状态要读(public-identity-no-internal-data)

为什么这个代价可以接受:因为匿名身份能读取内部事件数据这件事, 它不该存在。用"公开面看起来更聪明"去换"任何人都能摸到内网", 不是一笔划算的交易。


关于引用里的 web-01 / auth.log

有一件事必须写在前面,否则会误导人:

公开知识库的文档(security-docs/)里有大量教学示例, 里面会出现 web-01、auth.log、/api/order/detail 这类示例资产名。

遇到它们时正确的理解是:那是文档里的教学示例,不是本站的内部真实数据。 本站的规则是"内部文档一律不参与公开检索",所以这些名字只可能来自 已经公开的素材。UI 上也是这么写的 —— 不把它们包装成"内部真实数据"来撑场面。


小结

层做什么靠什么保证
编排层不向公开身份派发取证;检索只走白名单状态语义 input-insufficient
工具层四个内部数据工具对公开身份直接拒绝PublicDataToolIsolationTest(公开 0 次 / 内部 1 次)
输出层凭据、内部路径、内部标记、上游报错残留PublicOutputGuard
数据层IP 掩码到网段;自由文本也过 maskAll实测漏过一次后的修复
资源层用户 / 评测 / 公开三本账独立计数公开池被刷不影响内部

一句话:安全不是"加了拦截",而是每一条能力边界都有一个可执行的断言。


本文的状态取值、限额、测试名与错误码均来自本项目的后端契约与 RUNBOOK.md;示例资产名来自公开知识库文档,不是内部数据。