为什么公开问答只解释、不取证:两层防御怎么做
一个反直觉的事实
大部分人对"公开 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;示例资产名来自公开知识库文档,不是内部数据。

