{"id":"GHSA-pcc4-rvhr-2pr8","kind":"vulnerability","aliases":["GHSA-pcc4-rvhr-2pr8"],"related":[],"title":"Graphify Fortran extractor: incomplete cpp #include hardening allows arbitrary host-file read/exfiltration when indexing an untrusted repo","description":"## Summary\n\nThe Fortran extractor's C-preprocessor step (`graphify/extractors/fortran.py`, `_cpp_preprocess`) applies an incomplete `#include` hardening. The `-nostdinc -I /dev/null` flags it relies on (comment \"Security (F-007)\") do **not** stop a capital-`F` Fortran source file from including **absolute paths** (`#include \"/etc/passwd\"`) or from **path-traversing out of the corpus** (`#include \"../../../../home/victim/.ssh/id_rsa\"`). `cpp` always searches the directory of the file being preprocessed for quoted includes and always honours absolute include paths, regardless of `-nostdinc`/`-I`. As a result, indexing an untrusted repository that contains a `.F`/`.F90`/`.F95`/`.F03`/`.F08` file reads arbitrary local files off the host and inlines their contents into the knowledge graph, which is then shipped to the LLM/agent and written to the on-disk, shareable output (`graph.json`, `GRAPH_REPORT.md`, the Obsidian wiki, HTML/SVG exports).\n\nThis is the exact \"corpus-side file-exfiltration vector\" the F-007 comment says it is defending against; the mitigation is bypassable.\n\n## Severity\n\nHigh. Arbitrary local-file read on the host, triggered by graphify's core action (indexing an untrusted folder — \"turn any folder of code ... into a queryable knowledge graph\"), defeating a documented security control. It runs on the default, offline, no-API-key AST path (`graphify update .` / `graphify extract`) — no credentials, network, or LLM call required. CWE-73 (external control of file name/path) / CWE-22 (path traversal) / CWE-200 (information exposure).\n\n## Where\n\n`graphify/extractors/fortran.py`, `_cpp_preprocess()`:\n\n    result = subprocess.run(\n        [\"cpp\", \"-w\", \"-P\", \"-nostdinc\", \"-I\", \"/dev/null\", str(path.resolve())],\n        capture_output=True, timeout=30,\n    )\n\nReached from `extract_fortran()` (same file) for any suffix in `_FORTRAN_CPP_EXTS = {\".F\", \".F90\", \".F95\", \".F03\", \".F08\"}`, which are collected by default (`detect.CODE_EXTENSIONS`) and dispatched by `extract._DISPATCH`.\n\n## Why the mitigation does not work\n\n`-nostdinc` only removes the standard *system* include directories, and `-I /dev/null` only sets the *additional* search path. Neither affects:\n\n1. Absolute includes — `#include \"/etc/passwd\"` is opened directly by cpp.\n2. Quoted includes relative to the current file — cpp always searches the directory containing the file being preprocessed for `#include \"...\"`, so `#include \"../../../../etc/shadow\"` traverses out of the corpus.\n\n## Proof of concept (verified)\n\nReproduced against the shipped code with the exact command `_cpp_preprocess` builds (`cpp -w -P -nostdinc -I /dev/null <file>`), and against `extract_fortran()` directly.\n\nSecret on disk (`/tmp/secret.txt`): `TOP_SECRET_...`\n\n(a) Absolute include — `evil_abs.F90`:\n\n    program evil\n    #include \"/absolute/path/to/secret.txt\"\n    end program evil\n\n(b) Relative traversal — `evil_rel.F90`:\n\n    program evil\n    #include \"../secret.txt\"\n    end program evil\n\nBoth produce preprocessed output containing the full secret file contents (`\"SECRET IN OUTPUT: True\"` for both).\n\nSurfacing into the graph — `leak.F90` places the include in an identifier position via a free-form continuation so the leaked value becomes a node/edge:\n\n    module m\n    contains\n    subroutine s\n    use &\n    #include \"/path/to/token.secret\"\n    end subroutine s\n    end module m\n\n`extract_fortran()` on this file emits a node whose label is the entire single-line contents of `token.secret` and an `imports` edge to it. That node is written to `graph.json` / `GRAPH_REPORT.md` / the wiki and returned by the MCP `query_graph` / `get_node` / `get_neighbors` tools — i.e. exfiltrated into the agent's context and into persisted, shareable artifacts.\n\nFidelity note: Fortran is case-insensitive, so captured identifiers are lower-cased. Case-insensitive secrets (file paths, hostnames, usernames, hex tokens, connection-string fragments, and the mere existence/partial content of a readable file) leak cleanly; high-entropy case-sensitive blobs (base64/JWT) are partially corrupted by the lower-casing but still confirm readability and structure. Single-token-per-line secret files (API-key files, single-line credential/token files) leak that token in full (lower-cased). Requires `cpp`/gcc on PATH (near-ubiquitous on the dev/CI hosts graphify targets); it cleanly falls back to raw bytes when absent.\n\n## Suggested fix\n\nMirror the containment approach already used elsewhere in the codebase (`_project_xml_is_safe`, `security.validate_graph_path`): before invoking cpp, scan the source for `#include` directives and **refuse or neutralise any include whose target is absolute or resolves outside the corpus/repo root**. Concretely, in `_cpp_preprocess`:\n\n- Parse `#include \"...\"` / `#include <...>` directives.\n- Resolve each target against the file's directory; if it is absolute, or its `.resolve()` escapes the scan root, strip that line (or fall back to `path.read_bytes()` with no preprocessing).\n- Only allow includes that resolve to a path inside the corpus root.\n\nA blunter but safe alternative is to strip all `#include` lines before preprocessing (macros still expand; cross-file includes lose fidelity but no host files can be read). Running cpp inside a filesystem sandbox (bwrap/unshare with only the corpus mounted) would also close it but is heavier.\n\n## Detected by\n\nAeon vuln-scanner. Semgrep flagged the Fortran subprocess path; manual review of the F-007 mitigation plus an empirical `cpp` repro confirmed the bypass.","summary":"Graphify's Fortran extractor runs the C preprocessor on .F/.F90 files, and the existing -nostdinc and -I /dev/null flags do not block absolute or ../ traversal #include paths. An attacker who gets a victim to index a malicious repository can read arbitrary local files on the host, and their contents end up in graph.json, GRAPH_REPORT.md, and the MCP tools.","severity":"medium","cvss":null,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"graphifyy","fixed":["0.9.70"],"affected":">= 0.7.2, < 0.9.70","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["coding-agents","cwe-22","cwe-73","graphifyy","mcp","package","pypi","python"],"not_affected_if":["no-fortran-sources","untrusted-repos-never-indexed","version-0.9.70-or-later"],"how_to_check":"Run pip show graphifyy and check whether the version is >= 0.7.2 and < 0.9.70.\nSearch the indexed repos for Fortran files: find . -iname '*.F' -o -iname '*.F90' -o -iname '*.F95' -o -iname '*.F03' -o -iname '*.F08'\nSources do not say whether graph.json or other artifacts already contain leaked content.","fix":"Upgrade graphifyy to 0.9.70 or later.\nUntil then, do not run graphify on untrusted repositories that contain capital-F Fortran files, and treat any graph output from such runs as potentially containing host file contents.","status":"open","published_at":"2026-10-19T16:51:36+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T20:15:37+00:00","updated_at":"2026-10-23T20:15:37+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-10-23T20:15:37Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade graphifyy to 0.9.70 or later.\nUntil then, do not run graphify on untrusted repositories that contain capital-F Fortran files, and treat any graph output from such runs as potentially containing host file contents.","summary":"Graphify's Fortran extractor runs the C preprocessor on .F/.F90 files, and the existing -nostdinc and -I /dev/null flags do not block absolute or ../ traversal #include paths. An attacker who gets a victim to index a malicious repository can read arbitrary local files on the host, and their contents end up in graph.json, GRAPH_REPORT.md, and the MCP tools.","category":"package","who_cares":["python","pypi","graphifyy","coding-agents","mcp"],"confidence":0.85,"analyst_note":"","how_to_check":"Run pip show graphifyy and check whether the version is >= 0.7.2 and < 0.9.70.\nSearch the indexed repos for Fortran files: find . -iname '*.F' -o -iname '*.F90' -o -iname '*.F95' -o -iname '*.F03' -o -iname '*.F08'\nSources do not say whether graph.json or other artifacts already contain leaked content.","needs_analyst":false,"not_affected_if":["no-fortran-sources","untrusted-repos-never-indexed","version-0.9.70-or-later"],"severity_if_unknown":"unknown"},"cost_usd":0.000656,"input_tokens":4121,"output_tokens":487,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-007':128C,245C '-200':326C '-22':34B,322C '-73':36B,315C '/../../../etc/shadow':434C '/../../../home/victim/.ssh/id_rsa':154C '/absolute/path/to/secret.txt':481C '/dev/null':59B,120C,340C,393C,461C '/etc/passwd':143C,406C '/path/to/token.secret':545C '/secret.txt':493C '/tmp/secret.txt':470C '1':402C '2':412C '2pr8':23A,28A '30':347C 'abs.f90':477C 'absolut':64B,140C,171C,403C,474C 'action':271C 'addit':397C 'affect':401C 'agent':32B,602C 'allow':8A 'alway':156C,169C,421C 'api':299C 'appli':112C 'arbitrari':9A,82B,195C,258C 'artifact':609C 'ast':301C 'attack':70B 'b':485C 'becom':535C 'block':63B 'build':455C 'bypass':255C 'c':48B,106C 'c-preprocessor':105C 'call':312C 'capit':133C 'captur':343C,618C 'case':615C,623C,625C 'case-insensit':614C,624C 'cle':648C 'code':31B,280C,448C 'coding-ag':30B 'collect':368C 'command':452C 'comment':125C,246C 'concept':442C 'connect':635C 'connection-str':634C 'contain':186C,425C,501C,540C 'content':90B,204C,506C,568C,642C 'context':604C 'continu':530C 'control':290C,317C 'core':270C 'corpus':152C,237C,439C 'corpus-sid':236C 'cpp':5A,110C,155C,331C,335C,359C,411C,420C,453C,456C 'credenti':308C 'current':418C 'cwe':33B,35B,314C,321C,325C 'default':295C,370C 'defeat':286C 'defend':250C 'detect.code':371C 'direct':409C,466C 'directori':159C,390C,424C 'disk':222C,469C 'dispatch':374C 'document':288C 'edg':574C 'emit':557C 'end':91B,482C,494C,546C,549C 'entir':564C 'evil':476C,479C,484C,488C,491C,496C 'exact':235C,451C 'exfiltr':241C,599C 'exist':55B 'existence/partial':641C 'export':231C 'exposur':328C 'ext':360C 'extens':372C 'extern':316C 'extract':306C,350C,464C,552C 'extract._dispatch':376C 'extractor':3A,45B,103C 'f':127C,134C,188C,244C,361C 'f/.f90':51B 'f03':191C,364C 'f08':192C,365C 'f90':189C,362C 'f95':190C,363C 'fidel':610C 'file':12A,52B,84B,137C,162C,193C,197C,240C,261C,319C,353C,419C,427C,505C,556C,628C,646C 'file-exfiltr':239C 'flag':60B,121C 'folder':275C,278C 'form':529C 'fortran':2A,44B,102C,135C,351C,358C,465C,553C,612C 'fragment':637C 'free':528C 'free-form':527C 'full':503C 'get':72B,593C,595C 'ghsa':20A,25A 'ghsa-pcc4-rvhr-2pr8':19A,24A 'graph':208C,285C,516C,592C 'graph.json':94B,225C,582C 'graph_report.md':95B,226C,583C 'graphifi':1A,42B,268C,303C,305C 'graphify/extractors/fortran.py':109C,330C 'graphifyy':29A,37B 'harden':7A,116C 'hex':632C 'high':257C 'honour':170C 'host':11A,87B,200C,265C 'host-fil':10A 'hostnam':630C 'html/svg':230C 'i.e':598C 'identifi':523C,619C 'import':573C 'includ':6A,67B,115C,139C,142C,153C,167C,172C,389C,404C,405C,414C,431C,433C,475C,480C,492C,520C,544C 'incomplet':4A,114C 'index':15A,76B,181C,272C 'inform':327C 'inlin':202C 'insensit':616C,626C 'key':300C 'knowledg':207C,284C 'label':561C 'leak':533C,647C 'leak.f90':517C 'line':567C 'llm':311C 'llm/agent':215C 'local':83B,196C,260C 'local-fil':259C 'lower':622C 'lower-cas':621C 'm':539C,551C 'malici':78B 'mcp':38B,98B,590C 'mere':640C 'mitig':253C,379C 'modul':538C,550C 'name/path':320C 'neighbor':596C 'neither':400C 'network':309C 'no-api-key':297C 'node':559C,578C,594C 'node/edge':537C 'nostdinc':56B,118C,176C,338C,383C,459C 'note':611C 'obsidian':228C 'offlin':296C 'on-disk':220C 'open':408C 'output':224C,344C,500C,509C 'p':337C,458C 'packag':39B 'path':68B,141C,147C,173C,302C,323C,399C,629C 'path-travers':146C 'path.resolve':342C 'pcc4':21A,26A 'persist':607C 'place':518C 'posit':524C 'preprocess':111C,164C,332C,429C,454C,499C 'preprocessor':49B,107C 'produc':498C 'program':478C,483C,490C,495C 'proof':440C 'pypi':40B 'python':41B 'queri':591C 'queryabl':283C 'quot':166C,413C 'reach':348C 'read':81B,194C,262C 'read/exfiltration':13A 'readabl':645C 'regardless':174C 'rel.f90':489C 'relat':415C,486C 'reli':123C 'remov':385C 'repo':18A 'repositori':79B,184C 'reproduc':444C 'requir':313C 'result':180C,333C 'return':587C 'run':46B,292C 'rvhr':22A,27A 'say':247C 'search':157C,398C,422C 'secret':467C,472C,504C,507C,627C 'secur':126C,289C 'set':395C 'sever':256C 'shareabl':223C,608C 'ship':212C,447C 'side':238C 'singl':566C 'single-lin':565C 'sourc':136C 'standard':387C 'step':108C 'stop':131C 'str':341C 'string':636C 'subprocess.run':334C 'subroutin':541C,547C 'suffix':356C 'summari':100C 'surfac':513C 'system':388C 'timeout':346C 'token':633C 'token.secret':570C 'tool':99B,597C 'top':471C 'travers':66B,148C,324C,435C,487C 'trigger':266C 'true':345C,510C 'turn':276C 'untrust':17A,183C,274C 'updat':304C 'use':543C 'usernam':631C 'valu':534C 'vector':242C 'verifi':443C 'via':525C 'victim':74B 'w':336C,457C 'whose':560C 'wiki':229C,585C 'work':382C 'written':217C,580C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-19T16:51:36+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-pcc4-rvhr-2pr8","url":"https://github.com/advisories/GHSA-pcc4-rvhr-2pr8","severity":"medium","published_at":"2026-10-19T16:51:36+00:00","modified_at":"2026-10-19T16:52:26+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}