我给自己的 AI 家教系统(灵犀学堂)做了一次全面审计,结果发现:API 密钥明文裸奔、付费内容可免费白嫖、3 个用户共用同一个激活码、23 个临时脚本堆在根目录。这篇记录我怎么一步步发现问题、定位根因、逐个修复——以及为什么 AI 产品的安全审计比传统软件更难做。


1. 缘起:你的 AI 产品真的安全吗?

做 AI 产品的人很容易陷入一个误区:功能跑通了 = 产品没问题

我的灵犀学堂(Education Agent)上线两个月,学生端能聊天、能做题、能看教案,遗忘曲线引擎每天自动提醒复习——看起来一切正常。直到 8 月 16 号,我决定给自己做一次全面审计。

为什么突然想到要审计?因为我在清理项目根目录时,发现了一堆一次性的 Python 脚本——fix_01a.pycleanup_all_misplaced_objectives.pybatch_review_all.py……这些脚本是之前调试时临时写的,一直堆在那里没人管。我开始好奇:如果连脚本都没清理,其他地方是不是也有隐患?

于是我写了一个审计计划,从 6 个维度对整个系统做了一次体检。结果——每一个维度都发现了问题

2. 审计发现:6 个维度,没有一个满分

2.1 安全与合规:密钥明文裸奔

第一个让我冒冷汗的发现:

1
2
embedding_config.json → 完整明文 OpenRouter API key,文件权限 644(全局可读)
admin_llm_config.json → 明文 API key,权限 600

644 权限意味着这台机器上的任何用户、任何进程都能读到这个密钥。 而这个密钥关联的是 OpenRouter 的付费账号——如果被人拿去调用,账单会直接飙升。

修复很简单:把密钥迁到环境变量,配置文件只保留引用。

1
2
3
4
5
# 修复前:直接读配置文件里的明文密钥
api_key = config["embedding_config"]["api_key"]

# 修复后:环境变量优先,配置文件兜底
api_key = os.environ.get("EDU_EMBEDDING_API_KEY") or config["embedding_config"]["api_key"]

一个 commit(ade8631),10 行代码,密钥从此不再落盘。

2.2 付费墙:形同虚设

这是最严重的业务漏洞。

灵犀学堂的前端 /lesson 页面有付费墙——没开通的用户看不到付费内容。但 API 层没有任何校验。

这意味着什么?只要知道 API 端点,任何人都可以绕过前端付费墙,直接拉取付费内容:

端点 漏洞 影响
GET /api/graph/{id} 返回全部节点(含付费) 知识点结构泄露
GET /api/question-bank/{id} 返回付费题目+答案+解析 题库白嫖
POST /api/question-bank/check qid 可枚举 正确答案遍历
GET /api/learning/stage/{id} 返回学习进度 数据泄露
POST /api/learning/advance 可推进付费节点 无付费触发教学内容生成

5 个端点,全部裸奔。

修复方案:在每个端点加 has_access 校验——没开通对应商品的用户直接返回 403。核心逻辑复用已有的 RedeemStore.has_access(student_id, kp_id),免费节点放行,付费节点需开通任一包含该节点的商品。

1
2
3
# 修复:每个内容端点加校验
if not redeem_store.has_access(student_id, knowledge_id):
return jsonify({"error": "未开通此内容", "locked": True}), 403

一个 commit(7a68530),27 行代码,付费墙从前端延伸到 API 层。

教训:前端付费墙只是 UI 层的遮羞布。真正的安全边界在 API 层。

2.3 商业化:3 个用户共用一个激活码

审计激活码系统时发现了一个诡异的数据:

1
2
激活码 LX-JTXJ-4JLW-85VP → 3 个不同用户已兑换
但该码状态仍为 "unused" → 可被再次兑换

3 个人买了同一个码?不对。这是一个部分写入回滚失败的 bug。

根因:RedeemStore.redeploy()and 短路分别写两个文件——先写 redeem_codes.json(标记为 used),再写 entitlements.json(记录开通)。如果 codes 写成功但 ents 写失败,函数返回失败,但 codes 已经落盘为 used。而 ents 没写入,所以权益不存在。

但更诡异的是:后续重试时,因为 codes 已经是 used,又被拒绝——导致这个码既被标记为已使用,又没有对应的权益。而其他用户拿到同一个码(可能从二手渠道),发现它还是 unused(因为之前的失败回滚逻辑把 codes 恢复了),于是又能兑换。

修复:改为分别保存 + 失败回滚。任一文件写失败,恢复内存态和已落盘文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 修复前(and 短路,部分失败不回滚)
success = codes_write_ok and ents_write_ok

# 修复后(分别保存,任一失败即回滚)
codes_snapshot = deepcopy(codes)
ents_snapshot = deepcopy(ents)
try:
save_codes(codes)
save_ents(ents)
except Exception:
codes = codes_snapshot # 回滚内存态
ents = ents_snapshot
restore_codes_from_disk() # 回滚已落盘文件
raise

2.4 垃圾教案:n0/n1/n2 幽灵

审计 review_progress.json 时发现 reviewed 列表末尾有 3 个条目:n0n1n2——它们不在 58 个正式知识点中,也不在 generatedextra_on_disk 列表中。

追踪发现,这是 8 月 7 号和 13 号两次调试时产生的测试残留教案。它们的 id 是 n0n1n2——调试用的占位节点名,不是真实知识点。

更麻烦的是:这些幽灵教案的 .html.blocks.json.sha256 三件套齐全,而且 review_progress.json 里还有它们的评审记录(score 67.6 / 60.0 / 78.2)。如果不清理,每次质量循环跑起来都会”看到”这些假数据。

根因a2a_bridge 在图谱查不到节点时,会用空的 KnowledgeNode 兜底生成教案。n0/n1/n2 作为无效节点名,竟然也能通过生成管线。

修复:两道防线。第一道在 a2a_bridge,图谱查不到节点直接拒绝;第二道在 lesson_gen.generate_for_node,node 无效/无名称直接返回 error。

1
2
3
4
# 修复:断根——无效节点不再兜底生成
node = graph.get_node(knowledge_id)
if node is None or not node.name:
return {"error": f"节点 {knowledge_id} 不存在或无效,拒绝生成"}

2.5 代码卫生:23 个临时脚本

项目根目录堆积了 23 个一次性脚本:fix_01a.pybatch_review_all.pycleanup_all_misplaced_objectives.py……这些是之前调试时写的,修完问题后没人清理。

它们不参与运行时(不会被 import 或调用),但会:

  • 污染 ls 输出,让新 contributor 困惑
  • 掩盖真正的项目结构
  • 可能包含硬编码路径或临时逻辑

修复:git mvscripts/archive/——保留历史可回滚,但不再污染根目录。

2.6 注册:公开注册无防护

/register 端点完全公开,任何人可以注册账号。没有验证码、没有频率限制、没有邀请码机制。

修复:加环境变量开关 EDU_ALLOW_REGISTER,默认关闭。

1
2
3
4
5
6
7
# 修复前:任何人都能注册
@app.route("/register", methods=["POST"])
def register(): ...

# 修复后:默认关闭,需要显式开启
if not os.environ.get("EDU_ALLOW_REGISTER"):
return jsonify({"error": "注册已关闭"}), 403

3. 修复节奏:同一天,7 个 commit

所有修复在 8 月 16 号一天内完成。commit 顺序经过设计——先修最严重的安全问题,再修业务逻辑,最后清理代码卫生:

时间 Commit 修复内容 优先级
12:35 23cb699 垃圾教案断根(n0/n1/n2 来源) P1
14:56 7a68530 付费墙 API 层防绕过 P0
14:56 2ae0ed4 RedeemStore 部分写入回滚 P0
15:07 ade8631 Embedding 密钥环境变量化 P1
15:08 95c1efe 归档 23 个临时脚本 P2
15:09 3a46de4 公开注册加开关 P1

6 个 commit,从发现到修复不到 3 小时。 审计报告本身就是最好的 bug tracker。

4. 踩坑记录:AI 产品审计的 3 个特殊陷阱

陷阱 1:前端 ≠ 安全边界

传统 Web 开发有个常识:后端校验才是真正的安全边界。但 AI 产品更容易犯”前端付费墙”的错误,因为 AI 产品的交互往往是对话式的——用户在聊天窗口里提问,系统返回答案。开发者很自然地在对话逻辑里加”如果没付费就不回答”,但忘了 API 端点本身是裸的。

规则:AI 产品的每一个内容生成端点,都必须有独立的权限校验。不能依赖前端 UI 或对话流程来”挡住”未授权访问。

陷阱 2:LLM 兜底 = 垃圾数据来源

a2a_bridge 的”空节点兜底”逻辑本意是好的——查不到节点就用空的 KnowledgeNode 继续生成,避免报错。但这导致了无效节点名也能生成教案,产生了 n0/n1/n2 这些幽灵数据。

AI 系统特别容易犯这个错误:为了让 LLM 流程”跑通”,加了大量 fallback 和兜底逻辑。但每一个兜底都可能产生垃圾数据,而垃圾数据会污染后续的训练/评测/统计。

规则:AI 系统的兜底逻辑必须有明确的”拒绝路径”。查不到就是查不到,不要用空数据假装查到了。

陷阱 3:部分写入 = 数据不一致地狱

RedeemStore.redeem() 的 bug 本质是一个经典的分布式系统问题:两个独立写入没有原子性保证。在传统 Web 开发中,你会用数据库事务;但在 AI 产品的轻量级架构中(JSON 文件 + 内存态),事务往往被忽略。

更隐蔽的是:这种 bug 不会报错——函数返回失败,但部分数据已经落盘。用户看到的是”兑换失败,请重试”,但实际上 codes 文件已经被修改了。

规则:任何涉及多个文件/状态的写入操作,都必须有回滚机制。要么用临时文件 + rename(原子替换),要么在 catch 块里恢复已修改的状态。

5. 总结:审计是最好的老师

这次审计教会我三件事:

  1. AI 产品不是”功能跑通就安全”。付费墙、权限校验、数据一致性——这些传统 Web 开发的基本功,在 AI 产品中同样重要,甚至更重要(因为 AI 产品的 API 端点更多、数据流更复杂)。

  2. 审计报告本身就是最有价值的文档。它比设计文档更真实(因为是基于实际代码和数据写的),比 bug tracker 更全面(因为是系统性扫描而非逐个修复)。

  3. 修复的顺序比修复的速度更重要。先修 P0(付费墙绕过、数据不一致),再修 P1(密钥暴露、垃圾教案),最后修 P2(脚本清理、注册开关)。一天 7 个 commit,每个都有明确的优先级。

一句话 takeaway:给你的 AI 产品做一次审计吧——不是为了证明它安全,而是为了发现它哪里不安全。


相关资源