我们给生产 RAG 补了 5 个 P0 安全洞,并发现自进化闭环在「注水」
摘要:本文以开源 RAG 智能体项目 enterprise-ai(LangGraph + Milvus + Ollama,多租户)为例,记录一次以全模块代码深读为基础的工程加固。我们修复了五个 P0 级安全缺陷(认证后门、路径穿越、ACL fail-open、角色隔离、向量索引误删),并提出一条贯穿始终的设计原则------fail-closed。在此基础上,我们对系统自诩的「自进化闭环」做了一次诚实审计,发现其三级成功信号中有两级在线上基本失效、度量被注水与污染。文章最后归纳出一套面向安全修复的验证方法论:走真实入口函数、做机制级并发测试、把 fail-fast 当作可断言的契约。
0. 背景与范围
enterprise-ai 是一套面向企业知识库的生产级 RAG 智能体,核心链路为 LangGraph 编排 + Milvus 向量检索 + 本地 Ollama 大模型,支持多租户隔离与一份「自进化」经验机制(playbook):相似问题命中历史经验后复用改写词、成功则强化、失败则沉淀为 bad case 供诊断。
在一次完整的代码深读后,我们按优先级梳理出 13 个问题,定位到具体 file:line。其中 P0(立即修)5 项全部属于「安全止血」性质,P1(闭环真实性 + 凭据外置)8 项。本文聚焦两部分:已落地并验证的 P0 安全修复 ,以及对自进化闭环的度量审计------后者虽多为诊断与待实施项,却是我们认为更值得写下的部分。
所有 P0 改动均经过真实入口函数的测试验证;P1 部分以审计结论与修复方案呈现,未夸大其实施程度。
1. 一条贯穿始终的设计原则:fail-closed
五个 P0 缺陷看似分散,实则共享同一个反模式:依赖项不可用时,系统默认倒向了宽松一侧(fail-open)。
- 数据库挂了 → 退回硬编码口令(越权登录)
- ACL 配置文件缺失 → 所有受限文档变公开
- 向量库瞬时抖动 → 直接 drop 整个集合重建
在安全工程里,依赖(数据库、配置文件、向量库)是不可信的。正确的默认姿态是 fail-closed:当依赖不可达或出错时,退向「拒绝 / 受限 / 报错退出」的安全状态,而非「放行 / 公开 / 静默继续」。下面五项修复,都以这条原则为统一主线。

2. 五个 P0 安全缺陷的修复
下图按「数据丢失 / 越权 / 认证 / 路径 / ACL」五类汇总了修复全景,后文逐条展开根因与验证证据。

2.1 Milvus 瞬时报错触发整库重建(P0-1)
现象 :服务重启或 gunicorn 多 worker 并发初始化时,整个 rag_docs 集合被无条件删除重建。
根因 :schema 检查逻辑中,describe_collection 抛异常被 except Exception: pass 吞掉,随即落入无条件的 drop_collection;has_collection 为真时,任何瞬时网络错误都会触发全量重建。多 worker 并发 init 时更会「抢删」。
python
if self.client.has_collection(self.collection):
try:
desc = self.client.describe_collection(self.collection)
...
if "sparse" in existing and existing_dim == dim and "tenant_id" in existing:
return
except Exception:
pass # ← describe 异常被吞掉
self.client.drop_collection(self.collection) # ← 无条件 drop
修复:两条铁律。
- 瞬时错误重试 → 仍失败则 fail-fast 抛错 :
describe前两次抛错、第三次成功就正常返回;重试耗尽说明 Milvus 真病了,应当启动失败让人来修,而不是带着 schema 不匹配的老集合继续服务、也不是静默丢掉整个索引。 - 锁必须覆盖
check + drop + create整段,而非只保护某一步。否则「单 worker 负责重建」方案下,未就绪的 worker 在集合建好前仍会各自进入 drop 分支。
验证证据 :离线单测注入 describe_collection 持续抛错 → 断言 drop 次数 = 0 且抛出 RuntimeError(fail-fast);改编探针驱动真实入口 _ensure_collection,观察到「重试 3 次 → fail-fast 抛错 → 集合状态未知,绝不 drop」。
(诚实注记:方案还提到用 Redis 分布式锁彻底杜绝多 worker 抢删。当前以「任一 worker 失败即整体 fail-fast 退出」的保守行为兜底------这是安全的,但非最优;后续可补锁让其余 worker 等待已建好的集合。)
2.2 角色隔离:只修真正有坑的 legacy 路径(P0-2)
这一项的根因定位本身就是一个教训。
关键认知 :默认 LangGraph 入口(LangGraphRAGApp)早已用 contextvars.ContextVar 把 user / user_role / tenant_id 做成请求级隔离属性,注释直写「P0 级安全问题,SSE 多线程串号」。默认路径不存在串味,无需改动。
真正有坑的是 --no-langgraph 的 legacy RAGOrchestrator:每个 gthread worker 只有 1 个共享实例,8 线程共用,query() 把请求级的 user_role / cache.current_role / doc_skill.user_role 写成可变实例字段 。admin 与 user 并发时,后到的请求会覆盖先到请求的 user_role,普通用户短暂拿到 admin 角色、读到受限文档。
修复 :仅把 legacy 路径的 user_role / cache.current_role 改为 ContextVar 属性,与 LangGraph 入口同构,调用语法不变(底层路由到 ContextVar,回归风险最低)。
教训:根因要精准。若不加区分地把 ContextVar 改造铺到已安全的默认路径,只会引入回归、扩大改动面。
验证证据 :构造「单例共享实例 + threading.Barrier」的 32 线程并发测试------所有线程先在屏障处写完各自角色,再同时读角色。若字段会串味,屏障后必读到别人写的值;实际角色串味次数 = 0 ,并断言三者已是 property 而非普通实例字段。
注:首测仅覆盖了
GET /api/docs列表接口的并发隔离;后续补做的机制级测试(见 §4.2)才补齐了 legacyquery()角色设定序列这一「真靶心」的证据。
2.3 认证后门:MySQL 挂了还能 admin/admin123 登录(P0-3)
根因 :AuthManager.login() 在 self.available == False(DB 不可达)时,fallback 到源码写死的 admin/admin123 口令------等于把认证边界在故障期完全敞开。
python
def login(self, username, password):
if not self.available:
if username == "admin" and password == "admin123":
return admin_session # ← 故障期后门
return None
修复:fail-closed,直接拒绝登录,并补一行显式日志佐证(避免「密码恰好不对」的巧合式假阴性):
python
if not self.available:
print("[AuthManager] ⚠ MySQL 不可达,拒绝登录(fail-closed,P0-3)")
return None
验证证据 :以 MYSQL_PORT=3307(VM 上 3307 实测不可达,模拟 MySQL 宕机)启动服务,登录 admin/admin123 返回 HTTP 401 ,无 token、无 admin 会话;离线直驱 AuthManager.login() 确认返回 None 且打印上述日志。控制组:MySQL 正常时 superadmin 登录成功拿 token。
2.4 租户名未校验导致的路径穿越(P0-4)
根因 :上传接口的 tenant 仅做非空判断,未做白名单校验。super_admin 可传 tenant = "../../etc" 实现目录穿越写文件。
修复:白名单正则 + 路径回锚双重校验------
python
TENANT_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
if not TENANT_RE.match(tenant):
return jsonify({"error": "非法租户名"}), 400
dest_dir = os.path.abspath(os.path.join(DOC_FOLDER, tenant))
doc_root = os.path.abspath(DOC_FOLDER)
if os.path.commonpath([doc_root, dest_dir]) != doc_root: # 二次回锚,防穿越
return jsonify({"error": "非法路径"}), 400
验证证据 :../../tmp / ./../evil / .. 均返回 400 ;合法 default 返回 200;磁盘上 C:\tmp 未被创建(服务端在白名单校验处即拒绝,根本未进入 save)。
2.5 ACL 加载失败静默全公开(P0-5)
根因 :access_rules.yaml 缺失或格式错误时,加载逻辑 except 后把规则字典置为空 {},get_access_level() 一律返回 public。受限文档(如客户指令表 JM-S509)在故障期全部暴露。
修复 :fail-closed + 启动告警。加载失败或文件缺失时,默认级别改为 restricted (宁可误伤公开文档,不可泄露受限文档);并提供显式开关 ACCESS_RULES_FAIL_OPEN=1 供明确选择宽松模式(默认关闭);启动时打印 ERROR 级日志告知 ACL 未生效。
验证证据 :删掉 access_rules.yaml 后,命中受限规则的文档 access_level 入库为 restricted;仅当显式设置 FAIL_OPEN=1 且规则为空时才放宽到 public;正常加载时 JM-S509 仍为 restricted。
3. 自进化闭环的度量审计
系统对外宣称拥有一套「自进化」机制,由三级成功信号驱动经验沉淀:
- L1 检索级:检索命中相关文档 → 成功;
- L2 答案级:答案与检索内容的一致性(faithfulness);
- L3 反馈级:用户对答案的点赞 / 点踩。
这套设计的立意是好的。但一次诚实审计后我们发现:闭环的一部分是真实的,另一部分只是看起来在运转。

3.1 信号真实性:L1 在线,L2/L3 基本是死的
- L2(答案可信度)失效 :
evaluate_success的判定逻辑已写好,但整条管线从未把faithfulness_score写入state→ 该值恒为None→ L2 形同虚设。 - L3(用户反馈级)失效 :
reinforce_feedback函数定义好了却零调用 ;/api/feedback接口只保存反馈、不保存本次问答命中的used_playbook_pk→ 无法把反馈回灌到经验。
结论:对外「三级成功信号」的表述目前并不属实。要么先把 L2/L3 真正接好,要么先改文案------而不是让它继续作为立论基础被引用。
3.2 度量被注水与污染的几种模式
即便在已生效的 L1 上,度量本身也并不可靠:
- 假 bad case(P1-6) :
complex任务的检索发生在子 agent 内、chitchat本就无检索,导致顶层doc_grades恒为空 → 被统一判为「检索失败」→ 闲聊和复杂任务被灌进 bad case,污染闭环与评测数字。 - 双计与「失败也算成功」(P1-8) :同一 playbook 命中时,
classify阶段与query顶层各调用一次patch_success→ 计数翻倍;更隐蔽的是,顶层那次patch_success发生在graph.invoke之前 ------只要命中 playbook 就 +1,哪怕后续 LLM 全挂、走熔断返回失败答案,这次也记了成功。 - 经验丢失(P1-9) :
patch_success的delete + insert非原子,进程崩溃或并发命中同主键时会丢行、互相覆盖。 - 经验失效(P1-10):知识库重新 ingest 后,playbook 没有任何失效机制,旧经验仍被复用,可能把答案带偏。
3.3 一条重要工程判断:评测 / 打分不要进热路径
最初的修复方案里有一条「每轮问答都加一个 node_faithfulness 打分节点」。这是错的,会带来两个致命问题:
- 延迟与成本崩坏:每次回答都要多一次 DeepSeek 调用。本地 7B 模型跑几分钟的答案,再叠一个 judge,用户体验直接崩。
- 回落陷阱 :DeepSeek 未配置 key 时,
evalgrade自动回落到local-small(1.5B 弱评委)------正是当初被判「幻觉相关 = 0.9」的那个弱模型。生产环境的 L2 反而因弱评委失准,比不接还糟。
正解 :L2 必须离开热路径 ------离线化 / 可采样 / 默认关闭,且严禁回落弱评委;只有显式开启且有强评委时才参与。L3 本就是反馈回调、不在热路径,可复用系统已有的 last_task_id ContextVar(原本用于「请求级任务 ID 供前端点赞点踩关联全链路 trace」)做 task → feedback 关联,无需新设计。
3.4 自进化系统的度量有效性
这一节是本文想强调的 research 视角:一个自我改进的系统,其「进步」是否真实,取决于度量本身是否有效。
度量一旦被注水(双计)、被污染(假 bad case)、或被架空(L2/L3 死),系统就会在错误的反馈上「进化」------这比不进化更危险,因为没有人知道它在悄悄退化。对任何 self-improving loop,我们都建议把「度量真实性审计」作为与模型 / 算法改进同等重要的一级关卡,独立前置。
4. 验证方法论:如何证明修复是真的修好
P0 修复之所以可信,不只是因为代码改了,更因为验证方式本身吸取了过去的教训。下图归纳四条可复用的验证纪律。

4.1 走真实入口函数,不要自建「等价」脚本
我们曾为验证 RAG,自己写脚本从 Milvus 取 content 字段拼 context 调 LLM,测出「答对了、已闭环」;但产品真实路径走的是 _parse_hits 取 parent_content,线上照样答错。「等价」假设本身往往就是 bug 所在------绕开它等于验证了个寂寞,还会给出虚假的「已修复」结论。正确的做法是调用产品代码的真实入口函数,并捕获其内部 warning,确认没有静默 fallback。
4.2 机制级测试优于墙钟压测
真实 legacy query() 走本地 7B 模型,单次回答数分钟,并发压测不现实。但 P0-2 的真问题是「共享实例字段被并发改写」------用「单例 + threading.Barrier」构造 32 线程:先让所有线程在屏障处写完各自角色,再同时读角色,串味即刻暴露。无需 LLM、确定性可复现,直接证明隔离性质。
4.3 fail-fast 是可断言的安全契约
P0-1 的核心安全性质可以写进单测:drop 次数 == 0 且抛出 RuntimeError。这是硬契约,比「看起来没删集合」可靠得多。
4.4 A/B 对照:只改一个变量
定位复杂链路 bug 时,用同数据、同服务、同 prompt,只改一个变量,两组都跑到最终输出(含 LLM 答案)做对比。它能同时证明「真因是它」和「改了就好」,比任何静态走读都硬。
4.5 区分「行为正确」与「日志文案」
T1 的安全行为(401)正确,但期望的「拒绝登录」日志文案在首版实现里没写进代码------这属文案级差异,不影响安全结论。验证时别把「有日志」误当成「修好了」,也别因「没日志」误判「没修好」。
5. 结论与经验
- 根因精准 > 粗暴修复:P0-2 区分默认 LangGraph 路径与 legacy 路径,避免把已安全的代码改出回归。
- 诚实优于好看:L2/L3 在线上是死的,就承认它,先接线或先改文案,而不是让「三级成功信号」继续作为对外立论被引用。
- fail-closed 应成为基础设施不可达时的默认姿态:拒绝、受限、报错退出,永远优于放行、公开、静默继续。
- 自进化系统的度量有效性,是与模型质量同等重要的一级议题:没有可靠的度量,闭环越快,退化越隐蔽。
附:生产 RAG 安全自检清单(可直接照着查)
把上面五项修复与一条原则,收敛成一份上线前可逐项打勾的清单。建议每次发版前过一遍:
A. 失败默认姿态(fail-closed)
- 认证后端不可达时,是否拒绝登录而非退回硬编码口令?(对应 P0-3)
- 权限/ACL 配置文件缺失或解析失败时,默认是 restricted 而非 public?(对应 P0-5)
- 依赖(DB / 向量库 / 缓存)抖动时,是否重试→失败则启动失败,而非静默继续或误删数据?(对应 P0-1)
B. 多租户与隔离
- 租户名 / 用户传入的路径片段是否做了白名单正则 +
os.path.commonpath回锚双校验?(对应 P0-4) - 请求级身份(user / role / tenant)是否走 ContextVar 而非可变实例字段?并发下是否实测过「0 串味」?(对应 P0-2)
- 缓存键是否带 role/tenant,避免 admin 答案泄漏给普通用户?
C. 自进化闭环的度量真实性
- 对外宣称的「成功信号」是否每一项都在线上真正接线?未被写入 state / 零调用的信号要要么接好、要么从文案删掉。
- 是否存在假失败 / 假 bad case(无检索的动作被误判为检索失败)?
- 计数是否存在双计或「失败也算成功」?(经验回写应发生在主流程成功之后)
- 经验(playbook / memory)是否有随知识库更新而失效的机制?
- 任何打分 / 评测节点是否不在答题热路径上?(避免延迟崩坏与弱评委回落)
D. 验证纪律
- 修复是否用真实入口函数验证,而非自建「等价」脚本自欺?
- 并发安全性质是否用机制级测试(单例 + 屏障)确定性复现,而非靠墙钟压测?
- 是否把 fail-fast / 拒绝等安全性质写成可断言的契约 (如
drop 次数 == 0)?
本文基于 enterprise-ai 项目的 code_optimization_plan.md 与 P0_test_report.md 两份工程记录整理。全部 P0 改动经真实入口函数测试验证,未夸大实施程度;自进化闭环部分以审计结论与修复方案呈现。项目地址:github.com/lingluo1hao/enterprise-ai(搜索即可获取)。