
PoC — symlink following per lettura/scrittura arbitraria di file al di fuori della root del progetto in code-graph-rag (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1).
Stato CVE: richiesto, in attesa di assegnazione. Questa segnalazione è pubblicata come GHSA-85gg-2gfq-q95m. All'assegnazione del CVE questo repository viene rinominato
CVE-YYYY-NNNNN-code-graph-rag-PoCe questo banner viene sostituito con il link al CVE.
| Ricercatore | Dostxodjayev Abdullox (@squeeze440) |
| Avviso | GHSA-85gg-2gfq-q95m |
| CVSS 3.1 | 7.1 (Alto) |
| Debolezza | CWE-59, CWE-22 |
Sintesi
Un attaccante remoto/locale in grado di far includere un collegamento simbolico in un repository di codice sorgente che code-graph-rag analizza può causare l'accesso in lettura e — tramite structural_replace con dry_run=False — la sovrascrittura di file arbitrari al di fuori della root di progetto configurata da parte degli strumenti structural_search e structural_replace (supportati da AstGrepService, esposti sia come strumenti MCP sia come strumenti AI agentici), poiché il controllo di contenimento del percorso dello strumento (should_skip_path/_classify_file) viene eseguito lessicalmente sul percorso non risolto e non verifica mai Path.is_symlink() né chiama .resolve(), a differenza del decoratore validate_project_path (corretto) usato altrove nel progetto stesso.
Prodotto
vitali87/code-graph-rag (PyPI: code-graph-rag, CLI: cgr)
Versione testata
Commit 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (versione pyproject 0.0.550) — main corrente al momento del test. Confermato che questo commit contiene già la correzione per l'avviso precedente (GHSA-vvr2-h2jp-838m: path traversal in read_file paginato) e il più recente gate di autenticazione bearer per HTTP-MCP (_validate_http_exposure in codebase_rag/mcp/server.py), quindi questo è un problema distinto, ancora aperto, al di sopra di tali correzioni.
CVSS v3.1 stimato
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N — 7.0 (Alto)
Metriche non ovvie:
structural_search/structural_replace su quel progetto perché la lettura/scrittura si attivi.Dettagli
AstGrepService (codebase_rag/tools/ast_grep_service.py) supporta sia gli strumenti MCP/agentici structural_search sia structural_replace. Enumera i file candidati con os.walk() e filtra ogni percorso solo attraverso should_skip_path():
codebase_rag/tools/ast_grep_service.py:84-109 (_iter_source_files) — percorre self.project_root con os.walk(); ogni dirent non-directory restituito da os.walk (incluso un collegamento simbolico che punta ovunque sul disco) viene trattato come un file in ambito.codebase_rag/tools/ast_grep_service.py:61-82 (_classify_file) — l'unico controllo di contenimento è should_skip_path(...) seguito da abs_path.relative_to(self.project_root) (riga 82); abs_path non viene mai risolto, quindi questo controllo è puramente lessicale/basato su stringhe.codebase_rag/utils/path_utils.py:80-109 (should_skip_path) e codebase_rag/utils/path_utils.py:35-37 (cached_relative_path) — calcola rel_path = file_path.relative_to(repo_path) senza mai chiamare .resolve() o Path.is_symlink(). Nulla in questa funzione tratta un collegamento simbolico diversamente da un file reale.codebase_rag/tools/ast_grep_service.py:143-144 (search) — source = self._read(abs_path) chiama path.read_text(), che Python segue attraverso il collegamento simbolico fino al suo target reale, indipendentemente da dove risieda quel target.codebase_rag/tools/ast_grep_service.py:187-208 (replace), in particolare la riga 208 — abs_path.write_text(new_source, ...) quando dry_run=False, seguendo nuovamente il collegamento simbolico e sovrascrivendo il contenuto del file target reale.Ciò è incoerente con il modo in cui il resto del codebase gestisce esattamente la stessa classe di rischio. Gli strumenti di lettura/scrittura/modifica file (file_reader.py, file_writer.py, file_editor.py) sono tutti protetti da 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() segue i collegamenti simbolici prima che venga eseguito il controllo di contenimento, quindi quegli strumenti rifiutano correttamente un target fuori dalla root. Lo stesso absolute_path_within_project_root() di path_utils.py (codebase_rag/utils/path_utils.py:156-176) documenta esplicitamente lo stesso principio nella sua docstring: "Le chiamate a resolve() sono portanti: il contenimento è verificato lessicalmente, quindi un segmento .. non risolto o un collegamento simbolico sfuggirebbe alla root." — ma should_skip_path()/AstGrepService, usati da structural_search/structural_replace, non applicano mai quel pattern.
Raggiungibilità / esposizione dei due strumenti:
structural_search (codebase_rag/tools/structural_search.py:24-46) non porta alcun flag requires_approval — il modello di un client MCP può chiamarlo autonomamente senza conferma umana.structural_replace (codebase_rag/tools/structural_editor.py:52-57) è contrassegnato con requires_approval=True, ma quel flag è applicato solo dal loop dell'agente di pydantic-ai stesso. Il percorso del server MCP lo aggira completamente: MCPToolsRegistry.structural_replace (codebase_rag/mcp/tools.py:586-596) chiama direttamente self._structural_editor_tool.function(...), e lo strumento MCP è registrato senza alcun concetto di approvazione in codebase_rag/mcp/tools.py:369-388 (ToolMetadata per MCPToolName.STRUCTURAL_REPLACE) — qualsiasi client MCP in grado di chiamare strumenti può invocare structural_replace con dry_run=False in un colpo solo.Proof of Concept
Confermato dinamicamente contro il pacchetto installato (mgclient/pymgclient mockati esattamente come nel PoC dell'avviso precedente, poiché il client nativo Memgraph non è necessario per questo percorso di codice).
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)