我们给生产 RAG 补了 5 个 P0 安全洞,发现自进化闭环存在的漏洞

我们给生产 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_collectionhas_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

修复:两条铁律。

  1. 瞬时错误重试 → 仍失败则 fail-fast 抛错describe 前两次抛错、第三次成功就正常返回;重试耗尽说明 Milvus 真病了,应当启动失败让人来修,而不是带着 schema 不匹配的老集合继续服务、也不是静默丢掉整个索引。
  2. 锁必须覆盖 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.ContextVaruser / 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)才补齐了 legacy query() 角色设定序列这一「真靶心」的证据。

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_successdelete + insert 非原子,进程崩溃或并发命中同主键时会丢行、互相覆盖。
  • 经验失效(P1-10):知识库重新 ingest 后,playbook 没有任何失效机制,旧经验仍被复用,可能把答案带偏。

3.3 一条重要工程判断:评测 / 打分不要进热路径

最初的修复方案里有一条「每轮问答都加一个 node_faithfulness 打分节点」。这是错的,会带来两个致命问题:

  1. 延迟与成本崩坏:每次回答都要多一次 DeepSeek 调用。本地 7B 模型跑几分钟的答案,再叠一个 judge,用户体验直接崩。
  2. 回落陷阱 :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_hitsparent_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. 结论与经验

  1. 根因精准 > 粗暴修复:P0-2 区分默认 LangGraph 路径与 legacy 路径,避免把已安全的代码改出回归。
  2. 诚实优于好看:L2/L3 在线上是死的,就承认它,先接线或先改文案,而不是让「三级成功信号」继续作为对外立论被引用。
  3. fail-closed 应成为基础设施不可达时的默认姿态:拒绝、受限、报错退出,永远优于放行、公开、静默继续。
  4. 自进化系统的度量有效性,是与模型质量同等重要的一级议题:没有可靠的度量,闭环越快,退化越隐蔽。

附:生产 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.mdP0_test_report.md 两份工程记录整理。全部 P0 改动经真实入口函数测试验证,未夸大实施程度;自进化闭环部分以审计结论与修复方案呈现。项目地址:github.com/lingluo1hao/enterprise-ai(搜索即可获取)。

相关推荐
高洁011 小时前
工信部教考中心证书
人工智能·深度学习·算法·机器学习·知识图谱
桃西西呀1 小时前
Harbor 不是 RL 框架:评测 vs 训练,以及它能产出什么
人工智能
QYR_111 小时前
光伏级ETFE薄膜市场2032年预计达5.54亿元,10.6%增速打开轻量化光伏新空间
人工智能
小黄人软件1 小时前
【证书批量转换】PEM->CER证书批量转换工具.exe 纯 Python实现,无需安装OpenSSL
人工智能·学习
tanglinS1 小时前
工业陶瓷在半导体设备里都用哪里?氮化铝基板与氮化硅结构件的静电无尘角色
大数据·运维·人工智能·自动化·材质
ZJU_统一阿萨姆1 小时前
【推理优化】连续批处理:当推理吞吐量遇到天花板
人工智能·语言模型·系统架构
AI攻城狮小关1 小时前
Claude Code 接入 DeepSeek 完整教程:3 步配置,告别订阅限额
人工智能·程序人生·api·agent·配置教程
aiot189189352182 小时前
2026IOTE国际物联网展,深圳国际会展中心10B52蓝牙AOA行业内卷到头没有?
人工智能·人员定位·室内定位·蓝牙aoa·核芯物联
flashier2 小时前
半年没写博客了,聊聊 AI、技术和生活
人工智能·生活