Local Deep Research 1.9.1 版本解析:Sofya 搜索引擎接入、SQL LIKE 通配符注入加固与 API 密钥脱敏修复
【免费下载链接】local-deep-research~95% on SimpleQA (e.g. Qwen3.6-27B on a 3090). Supports all local and cloud LLMs (llama.cpp, Ollama, Google, ...). 10+ search engines - arXiv, PubMed, your private documents. Everything Local & Encrypted.项目地址: https://gitcode.com/GitHub_Trending/lo/local-deep-research
Local Deep Research(LDR)1.9.1 是一次以安全加固为主轴、并带来首个 Sofya 托管搜索引擎支持的版本。本篇基于仓库中 docs/release_notes/1.9.1.md 的发布说明,结合对应源码实现,完整拆解该版本的五类变更:SQLLIKE通配符转义、diagnose 门控的安全日志体系、GET /settings/api/bulk明文密钥泄露修复、异常信息在 HTTP 边界的净化,以及 Sofya 引擎的两阶段检索实现。读完本文,你可以理解 LDR 如何在自己数据库与 API 层封堵通配符注入和 CWE-209 信息泄露,并能实际配置 Sofya 引擎接入本地深度研究流程。
1. SQL LIKE 通配符注入:用户输入如何变成通配匹配
发布说明在 Security 一节列出的第一条修复,针对的是"用户提供的字符串流入 SQLLIKE查询"这一通用场景,涉及四处:
- 资料库(library)搜索与域名过滤器;
- 域名分类器(domain-classifier)的采样;
- 新闻订阅过滤器(news subscription filters)。
修复前的行为是:SQL 中%和_是通配符,%匹配任意长度任意字符,_匹配单个任意字符。因此当用户输入100%(例如"显示 100% 的相关性"这类过滤条件)或a_b时,这两个字符被数据库当作通配符解释,匹配到了无关的行。发布说明将其定性为"通配符注入/枚举向量(wildcard-injection/enumeration vector)"——攻击面局限在用户自己的数据库内,但依然可以被用来枚举本不该命中的记录。修复方式是统一转义%与_,使搜索与过滤按字面量精确匹配(对应上游 PR #3094)。
这一类漏洞的通用排查思路可以推广到任何使用LIKE的 Python 项目:凡是"用户输入 →LIKE子句"的链路,都需要显式转义通配符,否则100%、50%_off、a_b这类看起来无害的值都会扩大匹配范围。
2. 安全日志体系:diagnose 门控的 secure_logging 包装器
1.9.1 的第二个安全变更影响面最大:所有 LLM provider、embedding provider 和 web search engine 模块的日志,现在统一走 diagnose 门控的security.secure_logging包装器。其核心目标是:这些路径上的异常堆栈(traceback)只有在显式开启 diagnose 模式时才会写入日志——生产日志不再收到可能回显 API key 或请求内部状态的完整堆栈。
仓库中的实现是 src/local_deep_research/security/secure_logging.py。从源码可以看到关键设计:
- 该模块是一个对 loguru 的薄代理,重写
exception()方法; - 是否附加异常堆栈由
is_diagnose_mode()决定,该函数要求LDR_APP_DEBUG与LDR_LOGURU_DIAGNOSE两个环境变量同时为真("1"、"true"、"yes"); - 未开启 diagnose 时,调用
logger.exception(...)不会通过.opt(exception=True)附加堆栈,日志中只剩消息文本。
# src/local_deep_research/security/secure_logging.py(节选) def is_diagnose_mode() -> bool: """Requires BOTH ``LDR_APP_DEBUG`` and ``LDR_LOGURU_DIAGNOSE`` to be for loguru's frame-locals ``diagnose`` rendering.""" return env_truthy("LDR_APP_DEBUG") and env_truthy("LDR_LOGURU_DIAGNOSE")也就是说,排查 provider 故障时,你可以临时设置这两个环境变量来获得完整堆栈;生产环境则默认拿不到堆栈细节,从机制上降低了 API key 经日志泄露的概率。
发布说明还提到,配套引入的check-sensitive-loggingpre-commit 钩子将上述约定"以构造方式强制(enforces this by construction)"。钩子的拒绝面相当宽:
- 在这些目录内,直接的 loguru 原始 import 会被拒绝;
- 所有已知绕过路径都会被拦截:
logger.opt()、@logger.catch、私有 logger 句柄、traceback/sys.exc_info()插值、动态 import loguru/traceback、logger 重绑定、getattr规避; bind()/patch()链式调用的消息,与直接 logger 调用接受同样的异常清洗检查;- 通过属性或下标访问拿到的异常细节(如
e.args、e.response.text、e.strerror)会被按与str(e)同级的规则标记。
这种"包装器 + 静态钩子"的双层设计值得借鉴:包装器负责运行时行为正确,钩子负责在提交前阻止未来代码重新绕过它。
3.GET /settings/api/bulk明文密钥泄露修复
这是 1.9.1 中最典型的一个"接口不对称"漏洞,涉及两个 PR:
#5028:命名空间请求返回未脱敏的嵌套密钥。GET /settings/api/bulk支持按keys[]参数批量取设置。当请求一个命名空间(例如keys[]=llm)时,接口会返回该命名空间下所有设置,其中嵌套的*.api_key值原本没有被打码——因为脱敏检查只看最外层请求的 key 名,没有递归进入设置子树。更严重的是keys[]=%可以通过未转义的LIKE把整张设置表都导出来。修复包含两点:脱敏现在递归进入设置子树;key 查找对LIKE通配符做了转义。
#5038:非常规命名的 password 类型设置漏网。bulk 端点只按 key 名做脱敏,而单条 GET 端点按类型打码。于是叶子名不在"经典 secret token"清单内的 password 类型设置(例如client_secret、secret_key、bearer_token、api_secret、app_secret)在 bulk 请求中被明文带出,而单条 GET 会正确打码。修复后 bulk 端点也按类型 + 这类命名补齐脱敏。
对使用 LDR 自托管部署的用户,这个修复的意义在于:任何调用/settings/api/bulk的前端页面或第三方集成,都不可能再经由命名空间批量请求拿到 API key 明文。
4. 异常字符串的 HTTP 边界净化:关闭 CWE-209 信息暴露
发布说明的第四条安全项描述了一条完整的泄露链路:被捕获的异常被插值进策略层的错误字段后,可以原样到达 API 客户端——出现在quick_summary()/analyze_documents()实际返回的summary字段、formatted_findings,或逐条 finding 的content中,凭据和堆栈文本都可能完整保留。CodeQL 将其标记为 CWE-209(错误消息中的信息暴露)。
修复方案是在两个层面净化:
- HTTP 边界:
/api/v1/quick_summary与/api/v1/analyze_documents的 JSON 响应中,凡是由异常派生的字符串都经过sanitize_error_for_client处理; - 策略层:
SourceBasedSearchStrategy内部同样净化,独立覆盖 web-UI 的 SSE 路径(research_service.py→ErrorReportGenerator,该路径直接消费策略输出而不经过 HTTP 边界)。
净化函数的实现见 src/local_deep_research/security/log_sanitizer.py:
def sanitize_error_for_client(message: str, max_length: int = 200) -> str:其行为约束在发布说明中写得很明确:凭据被清洗、长度截断到上限(默认 200 字符),同时保留"Error: "前缀,让客户端仍能识别错误载荷的语义。另外/api/v1/generate_report也加上了同样的边界净化,发布说明注明这是"纯预防"——其当前载荷(content/metadata)并不携带错误字段。
从源码结构看,这种"边界 + 生产层"双净化是必要的:SSE 路径消费的是策略输出而非 HTTP 响应,只净化 HTTP 层会漏掉 UI 侧的推送通道。
5. Sofya 搜索引擎:一次请求覆盖两阶段检索
1.9.1 唯一的新功能是为可选的托管搜索引擎加入 Sofya 支持。其设计契合点是 LDR 的两阶段检索模型:先取预览(previews)做相关性过滤,再对命中的结果取全文(full content)。Sofya 的POST /v1/search在单次调用中同时返回排序结果、SERP 摘要(description)和抽取的页面正文(content,markdown 格式),因此不需要第二个抓取往返。
实现位于 src/local_deep_research/web_search_engines/engines/search_engine_sofya.py。关键常量与参数如下:
| 项目 | 值/取值 | 说明 |
|---|---|---|
| API 基础地址 | https://sofya.co/v1 | BASE_URL常量 |
| 结果数上限 | max_results默认 10,硬上限 20 | Sofya 每次请求最多 20 条 |
| 域名 include/exclude | 各最多 10 个 | 超出部分被截断 |
| 请求超时 | 30 秒 | REQUEST_TIMEOUT |
topic | "general"/"news" | 非法值回落到"general" |
freshness | "day"、"week"、"month"、"year"或"YYYY-MM-DD:YYYY-MM-DD" | None表示不过滤 |
search_depth | basic(含正文,3 credits)/snippets(仅摘要,1 credit) | 按是否消费正文自动选择 |
其中"按需付费"是源码里一个值得注意的细节。_build_payload只在正文确实会被消费时才使用basic深度:
# src/local_deep_research/web_search_engines/engines/search_engine_sofya.py(节选) wants_content = (self.include_full_content and not self.search_snippets_only) payload = { "query": query, "search_depth": "basic" if wants_content else "snippets", "max_results": min(self.MAX_RESULTS_CAP, self.max_results), "topic": self.topic, }search_snippets_only为None时从include_full_content推导,显式值(例如由工厂从search.snippets_only设置转发)优先。_get_previews把每条结果的完整响应暂存在_full_result内部字段中;_get_full_content再把其中抽取的content提升为full_content(报告引用处理器在回退到snippet之前读取该字段),无正文时回退到摘要,保证full_content始终可用。
引擎在工厂中通过search_tool == "sofya"分支接入,见 src/local_deep_research/web_search_engines/search_engine_factory.py。
配置方式
按发布说明,API key 有三种提供途径(_resolve_api_key的解析顺序:参数 → 设置快照 → 环境变量):
- 设置界面:Settings → Search → Sofya,对应设置键
search.engine.web.sofya.api_key; - 环境变量
LDR_SEARCH_ENGINE_WEB_SOFYA_API_KEY; - 引擎构造参数直接传入。
此外,GitHub 账号注册 Sofya 每月赠送 1000 免费 credits,意味着按snippets深度(1 credit/次)或basic深度(3 credits/次)计费时,免费额度足以支撑相当规模的检索。
6. 其他 Bug 修复:从会话回滚到引用标签
发布说明的 Bug Fixes 一节还有六个修复,按影响模块归类:
新闻订阅历史端点。/news/api/subscriptions/<id>/history对任何发生过研究运行的订阅都会抛内部错误:get_subscription_history对created_at/completed_at调用了.isoformat(),而这两个字段存储的本身就是 isoformat 字符串。修复为直接返回原值(#3094)。
SearXNG 标题空白折叠。HTML 解析器用BeautifulSoup.Tag.get_text(strip=True)读取结果标题,该调用会剥离首尾空白并把内部所有空白串折叠为空,导致 "Word One Word Two Word Three" 在## Sources块中渲染为 "WordOneWordTwoWordThree"。修复改为get_text(" ", strip=True)(BeautifulSoup 官方文档中"去首尾、内部空白归一为单空格"的惯用写法),摘要做了同样处理(#4970)。修复的收益不止于显示层:标题保真会传导到 LLM 相关性过滤与BaseCandidateExplorer/ProgressiveExplorer的候选短语提取,这两处此前从 SearXNG 来源的集合中只能看到单 token 标题。
注册/登录的部分会话回滚。认证后的初始化流程中途失败时,用户会处于"静默已登录"状态。现在部分会话会被回滚,报告的失败与实际登录状态一致——你并没有登录(#5006)。
资料库/RAG 来源的引用标签渲染为空[]。DOMAIN_HYPERLINKS、DOMAIN_ID_HYPERLINKS、DOMAIN_ID_ALWAYS_HYPERLINKS三种模式此前把urlparse(url).netloc直接用作引用标签。相对 URL(最典型的是形如/library/document/<uuid>的 RAG/资料库命中)的netloc为空字符串,导致单条相对 URL 引用坍缩成[[]](url)、多条时泄漏出[[-1]](url)、[[-2]](url)后缀。现在格式器回退到文档标题的 slug 化版本(小写、仅字母数字),最终回退到引用编号本身,标签永远非空且链接携带文档名;真实 web URL(arxiv.org、github.com 等)行为不变——netloc非空时域名仍优先。
formatBytes在 1 TB 以上显示 "N undefined"。共享的字节格式化器只定义到 GB,库中一个 1 TB 的 blob 总量会显示为1 undefined。现在单位表覆盖到 TB~EB,并在两端钳制单位索引,超大值不再越界、亚 1 字节值也不再产生负索引。
Research Logs 面板的 500 条 DOM 上限修剪策略。面板达到上限时原先直接丢弃最旧条目,导致大量常规 info 条目冲掉上限时,旧的 warning/error 也被丢掉。现在修剪按优先级遍历超出上限的槽位:info 最先,依次是 milestones、warnings、errors,保证长时间研究运行时面板保留的是最有诊断价值的条目。
7. 其他变更:content_fetcher 惰性导入与 pre-commit 钩子补强
Other Changes 一节的变更集中在导入结构与工程基建,但它们共同支撑了安全日志改造的落地:
移除citation_formatter.py的磁盘加载 workaround。此前content_fetcher在包__init__中急切导入,导致citation_formatter引入url_classifier时必须绕开importlib.util磁盘加载(#4880 引入)。现在content_fetcher.ContentFetcher改为首次属性访问时通过 PEP 562 模块级__getattr__惰性解析,url_classifier(以及任何经content_fetcher包的导入)不再拖入requests、playwright、bs4、lxml等传递依赖,citation_formatter得以使用普通的from ..content_fetcher.url_classifier import URLClassifier, URLType。对外 API 不变(#4992,同时修正了 #5023 测试文件中子进程测试的 project-root 路径 off-by-one 和隐藏 stderr 的check=True调用)。
带来源标签的引用格式化不再重复查询 URL 分类器。预先计算好的标签缓存改为惰性查询,每个来源的标签只解析一次。
安全导入回退测试的顺序依赖修复。相关测试曾用新副本替换local_deep_research.security*模块,会重建Sensitivity等枚举,破坏同一 worker 上后运行测试的身份(identity)检查。
Sofya egress-labels 测试改用.value比较。在 Docker 测试环境中,包同时可从/app/src(PYTHONPATH)与已安装 wheel 解析,SofyaSearchEngine的类属性与测试侧的Sensitivity可能解析为两个成员相同但不同的类对象。Python 的Enum.__eq__跨类返回NotImplemented(==与is一样失败),只有.value(或.name)能在这种双类场景下存活。
check-pathlib-usage钩子补上别名盲区。此前 AST 匹配器只标记字面名os,import os as <alias>后的<alias>.path.*会静默绕过检查——#4880 中的_os.path.join(...)就是这样漏到人工审查才被抓住的。
8. 版本小结:安全修复的三条主线
把 1.9.1 的安全项放在一起看,可以看到三条互相印证的防线:
- 输入侧:用户字符串进入
LIKE查询前必须转义通配符(资料库、域名分类、新闻订阅、设置 key 查找均覆盖); - 日志侧:敏感模块的异常堆栈默认不进日志,diagnose 模式(
LDR_APP_DEBUG+LDR_LOGURU_DIAGNOSE)显式开启,pre-commit 钩子阻止绕过; - 输出侧:异常派生字符串在 HTTP 边界与策略层两处经
sanitize_error_for_client净化,CWE-209 向量关闭,SSE 通道独立覆盖。
功能侧的 Sofya 引擎则是"零额外抓取往返"的两阶段检索范例:预览、正文同响应返回,深度与 credits 按实际消费自动切换。对自托管 LDR 的用户,该版本的实际收益是可验证的:设置批量接口不再可能带出明文密钥,日志默认不再回显 provider 内部状态,而检索侧多了一个按 credits 计费的托管选项。
如需核对本文引用的实现细节,可依次查看 release notes、secure_logging.py、log_sanitizer.py 与 search_engine_sofya.py。
【免费下载链接】local-deep-research~95% on SimpleQA (e.g. Qwen3.6-27B on a 3090). Supports all local and cloud LLMs (llama.cpp, Ollama, Google, ...). 10+ search engines - arXiv, PubMed, your private documents. Everything Local & Encrypted.项目地址: https://gitcode.com/GitHub_Trending/lo/local-deep-research
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考