20 KiB
20 KiB
md-vector-db 全面安全与质量审计报告
审计日期: 2026-07-10 审计范围: 全部源代码(22 个源文件)、13 个测试文件、配置、文档 测试基线: 90 个测试全部通过 项目版本: commit
3b8b585
一、审计概览
本次审计由三个并行代理分别审查 核心模块(config/db/embedder/ingest/search/security/splitters)、服务层与 CLI(app/auth/deps/main.py/scripts)、测试与配置(tests/pyproject.toml/config.yaml/文档),最终整合为本报告。
问题统计
| 严重级别 | 数量 | 含义 |
|---|---|---|
| CRITICAL | 1 | 阻断 — 数据重复处理浪费 GPU 资源 |
| HIGH | 13 | 警告 — 合并前应修复 |
| MEDIUM | 11 | 信息 — 应考虑修复 |
| LOW | 10 | 注意 — 可选修复 |
| 总计 | 35 |
覆盖评估
| 模块 | 覆盖程度 |
|---|---|
core/security.py |
✅ 优秀 — 所有路径穿越场景已覆盖 |
core/config.py |
✅ 良好 — YAML 加载/默认值/环境变量 |
core/embedder.py |
✅ 良好 — 所有 provider 初始化和 batch_embed |
core/splitters/* (除 markdown) |
✅ 良好 |
core/db.py |
⚠️ 基本 — 缺少 list_collections、write_guard、并发测试 |
core/ingest.py |
❌ 不足 — 缺少 ingest_file、ingest_directory |
core/search.py |
❌ 不足 — 缺少 3/4 方法测试 |
server/deps.py / app.py |
⚠️ 基本 — 缺少认证/速率限制/CORS 配置测试 |
server/auth.py |
❌ 零覆盖 |
cli/main.py |
❌ 零覆盖 |
| 整体估算 | ~55-60%(低于 80% 目标) |
二、CRITICAL 级别(1 个)
[CRIT-1] ingest_obsidian.py 顶层 .md 文件被重复入库
- 文件:
scripts/ingest_obsidian.py,第 54-77 行 - 根因: 第一轮用
rglob("*.md")递归扫描所有文件,第二轮用glob("*.md")再次收集顶层文件。由于rglob已经包含顶层文件,每个顶层.md被处理两次。 - 影响: 嵌入计算浪费(GPU 资源),入库时间翻倍于顶层文件数量。第二次入库虽然会覆盖第一次(file_name 相同,先删后加),但嵌入已完成,白白消耗了 GPU。
- 修复: 删除第二轮循环(第 67-77 行),
rglob已覆盖所有文件。
三、HIGH 级别(13 个)
安全
[HIGH-1] CORS allow_credentials=True 与 allow_origins="*" 冲突
- 文件:
src/server/app.py,第 66-72 行 - 根因: CORS 规范禁止
Access-Control-Allow-Origin: *与Access-Control-Allow-Credentials: true同时使用。浏览器会拒绝此配置。当前默认CORS_ORIGINS="*"且allow_credentials=True直接违反规范。 - 此外:
*允许任意来源跨域访问,安全上过于宽松。 - 修复:
app.add_middleware( CORSMiddleware, allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","), allow_credentials=False, # 默认关闭 ... )
[HIGH-2] search_documents 端点缺少异常处理
- 文件:
src/server/app.py,第 147-155 行 - 根因:
ingest_document有完整的try/except包裹,但search_documents完全没有。如果嵌入模型加载失败或 ChromaDB 查询异常,会返回 FastAPI 默认的原始 traceback,泄露内部路径和调用栈。 - 修复: 添加异常捕获:
try: searcher = state.get_searcher(req.collection) results = searcher.search(req.query, top_k=req.top_k) return {"results": results, "collection": searcher.collection_name} except Exception: logger.exception("检索失败") raise HTTPException(status_code=500, detail="服务器内部错误")
[HIGH-3] delete_document 的 collection 查询参数缺少输入校验
- 文件:
src/server/app.py,第 163 行 - 根因:
IngestRequest.collection和SearchRequest.collection都使用了pattern=r"^[a-zA-Z0-9_-]+$"校验,但delete_document的collection查询参数没有任何正则校验。ChromaDB collection 名称允许的字符集有限,传入非法字符可能导致未预期的 ChromaDB 内部错误。 - 修复: 添加
Query(default=None, max_length=128, pattern=r"^[a-zA-Z0-9_-]+$")。
[HIGH-4] CLI 与 API 的路径安全检查逻辑不一致
- 文件:
src/cli/main.py第 85 行 vssrc/server/app.py第 125-130 行 - 根因: CLI 仅用
is_safe_path()做基础检查(拒绝..+ 绝对路径),而 API 还额外做了os.path.commonpath绑定当前工作目录的检查。两处策略不同增加了安全漏洞风险。 - 修复: 提取统一的
is_path_within_workspace()函数到security.py,两处共用:def is_path_within_workspace(path_str: str) -> bool: """检查路径是否在当前工作目录内(防路径穿越)。""" if not is_safe_path(path_str): return False path = Path(path_str).resolve() cwd = Path.cwd().resolve() try: return Path(os.path.commonpath([str(path), str(cwd)])) == cwd except ValueError: return False
功能
[HIGH-5] chunk 配置项被硬编码覆盖 — YAML 中的 chunk 设置完全无效
- 文件:
src/core/ingest.py,第 46 行 - 根因:
get_splitter(file_path, max_size=1000, overlap=100)写死了值。用户无论怎么修改config.yaml里的chunk.max_size和chunk.overlap,都完全不会生效。 - 影响: 所有通过 API/CLI 正常流程的入库都使用硬编码值。
AppConfig.chunk数据类从未在正常入库链路中被读取。 - 修复:
DocumentIngestor构造函数接受ChunkConfig参数,deps.py的AppState.get_ingestor传入self.config.chunk。
[HIGH-6] CLI serve 命令双重加载嵌入模型
- 文件:
src/cli/main.py第 174 行 +src/server/deps.py - 根因:
serve命令调用get_state().config,触发AppState.__init__→create_embedder()加载模型(第一次)。uvicorn 子进程启动后,src.server.app:app生命周期内再次调用get_state()→ 加载模型(第二次)。对于serve命令,只需要服务配置(host, port, SSL),不需要加载 ML 模型。 - 影响: 模型被加载两次,浪费几 GB 内存和数秒启动时间。
- 修复: 将配置加载与模型加载解耦——提供一个不初始化模型的轻量配置读取函数给 CLI
serve命令使用。
代码质量
[HIGH-7] PDF 文件句柄泄漏 — fitz.Document 在异常路径下未关闭
- 文件:
src/core/splitters/pdf.py,第 34-43 行 - 根因:
doc.close()在try块内,如果在for page in doc或page.get_text()期间抛出异常,close()永远不会被调用。 - 修复: 使用
with fitz.open(pdf_path) as doc:(pymupdf 支持上下文管理器协议)。
[HIGH-8] embedder.py 中环境变量操作不是线程安全的
- 文件:
src/core/embedder.py,第 80-90 行 - 根因:
os.environ["HF_ENDPOINT"]的读写没有锁保护。如果多个线程同时创建LocalEmbedder实例,可能产生竞态——线程 A 设置的值被 B 覆盖,A 的finally恢复时读到 B 的值。 - 修复: 使用线程锁保护该区域,或通过
huggingface_hub的 API 在进程启动时统一设置而非按实例设置。
[HIGH-9] ingest_obsidian.py 初始化阶段无异常处理
- 文件:
scripts/ingest_obsidian.py,第 32-35 行 - 根因:
load_config()/VectorDB()/create_embedder()/DocumentIngestor()任一失败都会直接崩溃,没有友好的错误信息或恢复路径。 - 修复: 添加顶层
try/except包裹,捕获异常后打印 traceback 并以sys.exit(1)退出。
测试缺口
[HIGH-10] CLI 模块零测试覆盖
- 文件:
src/cli/main.py(216 行,5 个命令) - 影响: 通配符展开、stdin 输入、JSON 格式化输出、路径安全检查等逻辑无自动化验证。
- 修复: 创建
tests/test_cli.py,使用typer.testing.CliRunner覆盖每个命令。
[HIGH-11] 认证与速率限制零单元测试
- 文件:
src/server/auth.py(58 行) - 影响:
verify_api_key()和RateLimiter两个安全关键组件完全没有独立测试。所有 API 测试因未设置MD_VECTOR_API_KEY而跳过认证验证。 - 修复: 创建
tests/test_auth.py,覆盖密钥匹配/不匹配/未提供、速率限制正常/超限/过期恢复、并发线程安全。
[HIGH-12] ingest_file / ingest_directory 零测试
- 文件:
src/core/ingest.py(121 行) - 影响: 包含扩展名检测、路径哈希、PDF/EPUB 二进制特殊处理、目录递归扫描和过滤逻辑,全部未测试。
- 修复: 在
tests/test_ingest.py中补充文件入库和多格式目录入库的集成测试。
[HIGH-13] Searcher 三个方法零测试
- 文件:
src/core/search.py(97 行) - 影响:
list_sources()、delete_by_source()、get_collection_info()以及search(source_file=...)过滤均无测试。 - 修复: 在
tests/test_search.py中补充。
四、MEDIUM 级别(11 个)
[MED-1] content 模式默认 file_name 为 "untitled.md" 导致数据竞态覆盖
- 文件:
src/server/app.py,第 137 行 - 根因: 多个并发请求不提供
file_name时都写入"untitled.md"。ingest_content先_remove_by_source(file_name)再_add_chunks(),且两次操作之间锁释放,存在竞态——后完成的请求覆盖先完成的请求数据。 - 修复: 自动生成 UUID 唯一名称:
file_name = req.file_name or f"untitled_{uuid.uuid4().hex[:8]}.md"
[MED-2] X-XSS-Protection 头已过时且可能引入安全问题
- 文件:
src/server/app.py,第 81 行 - 根因: 此响应头已被所有现代浏览器废弃。旧版 IE 的 XSS Auditor 自身存在安全漏洞。OWASP 等安全组织明确建议不要设置此头。
- 修复: 替换为
Content-Security-Policy: default-src 'self'
[MED-3] EXPECTED_API_KEY 在模块导入时求值,存在加载顺序隐患
- 文件:
src/server/auth.py,第 14 行 - 根因: 该常量在模块被 import 时求值。虽然目前
config.py先调用load_dotenv()再触发导入链,但这是一个隐式依赖——如果有人调整导入顺序或在测试中直接 importauth.py,EXPECTED_API_KEY可能为空。 - 修复: 改为惰性求值函数:
def _get_api_key() -> str: return os.environ.get("MD_VECTOR_API_KEY", "")
[MED-4] ingest.py 与 search.py 存在去重删除逻辑重复
- 文件:
ingest.py:108-120和search.py:83-97 - 根因: 两处
_remove_by_source/delete_by_source逻辑几乎一模一样:获取匹配文档、删除 IDs、捕获ValueError。 - 修复: 统一放到
VectorDB.delete_by_metadata(where)方法中。
[MED-5] list_collections_with_stats 静默吞掉所有异常
- 文件:
src/server/deps.py,第 54-59 行 - 根因: 当 ChromaDB 不可用时,返回空列表而不是错误。调用方向用户报告"没有任何集合",而非"数据库连接失败"。错误语义被扭曲。
- 修复: 让异常向上传播,由 API 层统一处理并返回适当的 HTTP 错误。
[MED-6] EPUB 缺失依赖测试设计有误
- 文件:
tests/test_splitters_epub.py,第 51-56 行 - 根因:
test_epub_missing_dependency_message声称测试"未安装 ebooklib 时应给出明确提示",但模块顶部有pytest.importorskip("ebooklib", ...),意味着当 ebooklib 未安装时整个文件被 skip,该测试永远不会在目标场景下执行。 - 修复: 将此测试移到独立文件中(不使用
importorskip),或使用monkeypatch模拟ImportError。
[MED-7] 环境变量测试使用 importlib.reload 过于脆弱
- 文件:
tests/test_config.py,第 89-106 行 - 根因:
importlib.reload(config)有全局副作用,可能影响后续测试。finally块中的清理逻辑也不完整。 - 修复: 使用
monkeypatch.setenv+ 重新实例化代替reload。
[MED-8] 速率限制器的配置值硬编码
- 文件:
src/server/auth.py,第 58 行 - 根因:
RateLimiter(max_requests=30, window_seconds=60)无法通过环境变量或配置文件调整。 - 修复: 从环境变量读取:
max_requests=int(os.environ.get("RATE_LIMIT_MAX", "30"))
[MED-9] ingest_obsidian.py 存在硬编码的绝对回退路径
- 文件:
scripts/ingest_obsidian.py,第 47-51 行 - 根因:
targets中的三个路径仅在本机有效,在其他开发者的机器上毫无意义。如果恰好存在同名目录,会意外入库其他数据。 - 修复: 移除硬编码回退路径,直接报错提示用户提供参数。
[MED-10] MarkdownSplitter 测试组织不清晰
- 文件: 分散在
tests/test_ingest.py中 - 根因: Markdown 分块测试与入仓器测试混在一起,缺少 Markdown 特有的边界测试:无标题文档、深层标题、代码块内含
#号被误识别为标题等。 - 修复: 提取到
tests/test_splitters_markdown.py,补充边界测试。
[MED-11] splitters/__init__.py 未导出 HTMLSplitter
- 文件:
src/core/splitters/__init__.py,第 10-20 行 - 根因:
HTMLSplitter在第 7 行被 import 但未加入__all__列表,而PDFSplitter和EPUBSplitter都在列表中。 - 修复: 在
__all__中加入"HTMLSplitter"。
五、LOW 级别(10 个)
| # | 文件 | 问题 | 建议 |
|---|---|---|---|
| L-1 | src/core/splitters/epub.py:44 |
EPUB 硬编码 UTF-8 解码,非 UTF-8 文件会丢失章节 | 添加编码检测回退(先 UTF-8,失败后 chardet) |
| L-2 | src/core/embedder.py:24 |
模块级 import requests 依赖传递性依赖项 |
改为惰性导入(在 DashscopeEmbedder.embed 内部) |
| L-3 | src/core/embedder.py:176 |
list.sort() 原地修改 API 响应 |
改为 sorted(embeddings_raw, key=...) |
| L-4 | src/core/config.py:16 |
DEFAULT_CONFIG_PATH 使用相对路径 |
统一使用 load_config 的路径解析逻辑 |
| L-5 | src/core/splitters/registry.py:20 |
_custom_registry 模块级字典无线程锁保护 |
添加 threading.Lock 或文档说明仅限启动时调用 |
| L-6 | pyproject.toml:21 |
dev 依赖缺少 pytest-cov 和 ruff/mypy |
添加 pytest-cov>=5.0、ruff>=0.8.0、mypy>=1.13 及相关 tool 配置节 |
| L-7 | config.yaml |
host: 0.0.0.0 与代码默认值 127.0.0.1 不一致 |
统一默认值或在配置文件中添加注释说明差异 |
| L-8 | config.yaml |
SSL 字段(ssl_keyfile/ssl_certfile)未在配置文件中暴露 | 添加注释说明可用配置 |
| L-9 | scripts/serve.py |
DeprecationWarning 被 Python 默认过滤,多数用户看不到 |
改为 print(..., file=sys.stderr) 或 FutureWarning |
| L-10 | CLAUDE.md / README.md |
文档中测试数量写"46 个",实际 90 个;数据流未反映多格式支持 | 更新为实际数量,数据流加入 get_splitter() 描述 |
六、架构与设计评估
优点
- 策略模式设计良好 —
Embedder(Protocol)和Splitter(Protocol)接口清晰,新增 provider/格式只需实现接口并注册 - 扩展名自动路由 —
registry.py的get_splitter()工厂 + 回退到 TextSplitter 的设计优雅 - 线程安全基础扎实 —
VectorDB._write_lock+deps.py的_state_lock/_cache_lock构成多层保护 - 安全防御分层 — API Key 认证 → 速率限制 → 路径穿越防护,防御纵深合理
- 配置热加载友好 —
load_config()支持文件变更检测,适合长期运行的服务
需要改进的架构问题
- 配置与模型加载未解耦 —
AppState.__init__做了太多事(配置+模型+数据库),导致 CLIserve双重加载。建议拆分为load_config_only()和init_full_state() - chunk 配置未贯传整个链路 —
config.yaml → AppConfig.chunk存在但被ingest.py硬编码覆盖。应该在DocumentIngestor构造函数中接受ChunkConfig - 路径安全检查逻辑分散 — CLI 和 API 各有一套检查,应该提取为统一的
is_path_within_workspace() - 去重删除逻辑重复 —
ingest.py和search.py中几乎相同的代码应合并到VectorDB
七、测试策略建议
当前测试分布
tests/
├── test_api.py # 14 个 — API 端点基本功能
├── test_config.py # 7 个 — 配置加载
├── test_db.py # 4 个 — 数据库 CRUD
├── test_deps.py # 11 个 — AppState 生命周期
├── test_embedder.py # 8 个 — 嵌入 provider
├── test_ingest.py # 11 个 — 入仓(偏 MarkdownSplitter)
├── test_search.py # 8 个 — 基本搜索
├── test_security.py # 11 个 — 路径安全
├── test_splitters.py # 10 个 — TextSplitter + 注册表
├── test_splitters_html.py # 4 个
├── test_splitters_pdf.py # 3 个
├── test_splitters_epub.py # 3 个
└── (缺失)
├── test_auth.py ❌ 认证与速率限制
├── test_cli.py ❌ CLI 命令
└── test_splitters_markdown.py ❌ Markdown 边界
建议新增测试
tests/test_cli.py— CLI 5 个命令(使用CliRunner)tests/test_auth.py—verify_api_key+RateLimitertests/test_splitters_markdown.py— 标题边界、代码块内#号、空文档- 补充
tests/test_ingest.py—ingest_file/ingest_directory - 补充
tests/test_search.py—list_sources/delete_by_source/get_collection_info
八、修复优先级路线图
第一阶段(立即修复 — 1 个 CRITICAL)
| 问题 | 文件 | 工作量 |
|---|---|---|
| CRIT-1: 顶层 .md 文件重复入库 | scripts/ingest_obsidian.py |
5 分钟 |
第二阶段(尽快修复 — 13 个 HIGH)
| 问题 | 领域 | 工作量 |
|---|---|---|
| HIGH-5: chunk 配置被子覆盖 | 功能 | 30 分钟 |
| HIGH-7: PDF 文件句柄泄漏 | 资源 | 5 分钟 |
| HIGH-1: CORS 配置冲突 | 安全 | 5 分钟 |
| HIGH-2: search 端点无异常处理 | 安全 | 5 分钟 |
| HIGH-3: delete collection 缺少校验 | 安全 | 5 分钟 |
| HIGH-4: 路径安全检查不一致 | 安全 | 20 分钟 |
| HIGH-6: serve 双重加载模型 | 性能 | 30 分钟 |
| HIGH-8: HF_ENDPOINT 非线程安全 | 稳定性 | 15 分钟 |
| HIGH-9: ingest_obsidian 无异常处理 | 稳定性 | 10 分钟 |
| HIGH-10: CLI 零测试 | 测试 | 1-2 小时 |
| HIGH-11: auth 零测试 | 测试 | 1 小时 |
| HIGH-12: ingest_file/dir 零测试 | 测试 | 1 小时 |
| HIGH-13: Searcher 方法零测试 | 测试 | 30 分钟 |
第三阶段(计划修复 — 11 个 MEDIUM)
涉及竞态覆盖、过时安全头、重复代码、错误语义、测试设计等。
第四阶段(择机修复 — 10 个 LOW)
涉及文档过时、编码回退、线程锁、工具链配置等。
九、合规性检查
对照用户全局安全规则(~/.claude/rules/ecc/zh/security.md)进行逐项检查:
| 检查项 | 状态 | 说明 |
|---|---|---|
| 无硬编码密钥 | ✅ 通过 | API Key 均从环境变量读取 |
| 所有用户输入已验证 | ⚠️ 部分 | delete_document 的 collection 参数缺少正则校验 |
| SQL 注入防护 | N/A | 无 SQL 数据库 |
| XSS 防护 | ⚠️ 部分 | X-XSS-Protection 已过时,应换为 CSP |
| CSRF 保护 | ⚠️ 部分 | CORS 配置存在规范冲突 |
| 认证/授权已验证 | ✅ 通过 | API Key 认证 + hmac.compare_digest |
| 所有端点启用速率限制 | ✅ 通过 | RateLimiter 中间件 |
| 错误消息不泄露敏感数据 | ⚠️ 部分 | search_documents 缺少异常处理会泄露 traceback |
审计人: Claude Code(多代理协作审查) 下次审计建议: 在修复 CRITICAL 和 HIGH 问题后进行复验