{"id":"GHSA-fx3j-rwgx-fr94","kind":"vulnerability","aliases":["GHSA-fx3j-rwgx-fr94"],"related":[],"title":"GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name","description":"### Summary\n`Repo._clone()`'s handling of `separate_git_dir` never disables `Git.polish_url()`'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since `separate_git_dir` can be derived from an attacker-controlled `.gitmodules` submodule name, a completely ordinary `clone` + `submodule_update(init=True, recursive=True)` on a malicious repository expands an environment variable of the attacker's choosing and exposes its value both as a persistent on-disk directory name and, whenever DEBUG-level logging is enabled (a common CI/verbose-logging configuration), in a `Popen(...)` log line that is not redacted the way credential-bearing arguments are.\n\n### Root cause / Details\n`git/repo/base.py`'s `_clone()` has:\n\n```python\nsep_dir = kwargs.get(\"separate_git_dir\")\nif sep_dir:\n    kwargs[\"separate_git_dir\"] = Git.polish_url(sep_dir)\n```\n\nThis is the only remaining `Git.polish_url()` call site handling attacker-influenced data that does not pass `expand_vars=False` (there is one other unguarded call site, `clone_path` at line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes for `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` added `expand_vars=False` to `clone_url` (5 lines below this one, in the same function), `git/repo/base.py:1599`, `remote.py:814`, and `submodule/base.py:686` — this one did not receive it. (`submodule/root.py` also currently sets `expand_vars=False` at a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.) `Git.polish_url()`'s docstring itself says: \"Disable this for values obtained from an untrusted source, such as remote URLs.\" Its non-Cygwin branch does plain `os.path.expandvars()` on the entire string, including embedded variables anywhere in an absolute path.\n\n`separate_git_dir` reaches `_clone()` from `Submodule._clone_repo()` (`git/objects/submodule/base.py`) as `module_abspath = osp.join(parent_repo.git_dir, \"modules\", name)`, where `name` is the raw `.gitmodules` submodule section name — fully attacker-controlled content of a cloned repository. `_validated_name()` (added by the fix for `GHSA-hmq2-w58f-27jc`) only rejects names that are absolute, contain a drive letter, or contain `..` path components; it does not reject `$`. `_need_gitfile_submodules()` is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation.\n\n### Reproduction (live, against real, unmodified GitPython code, no monkeypatching)\nBuilt a hostile repository with a plain, ordinary git command: `git submodule add --name '$GPY_SECRET_MARKER' ../evil_submodule sub`. Set an environment variable in the \"victim\" process (standing in for a real CI secret such as `AWS_SECRET_ACCESS_KEY`): `GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345`. Then ran the exact ordinary workflow the `GHSA-hmq2-w58f-27jc` advisory's own PoC also uses:\n\n```python\nrepo = Repo.clone_from(evil_parent_url, victim_clone_dir)\nrepo.submodule_update(init=True, recursive=True)\n```\n\nObserved result (`ls .git/modules/`):\n\n```\n.git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/   (a full git repo: HEAD, config, objects/, refs/, hooks/)\n```\n\nThe literal `$GPY_SECRET_MARKER`-named directory was never created — only the expanded one exists, proving expansion happened before the value reached the `git clone --separate-git-dir=...` subprocess argument. The persistent, expanded-name directory under `.git/modules/` is itself the primary disclosure channel — it survives on disk for as long as the clone does. A second, independent channel: with the `git.cmd`/`git.repo.base` loggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's own `Popen(...)` debug log line prints the fully expanded `--separate-git-dir=<value>` subprocess argument verbatim — `remove_password_if_present()` only redacts URL passwords/Authorization headers, not path-style arguments like this one, so the expanded value is not scrubbed. (The call also fails downstream with an `OSError`/`FileNotFoundError` referencing the original, unexpanded name, since `_write_git_file_and_module_config()` looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.)\n\n**Control**: the same call with an ordinary (no `$`) submodule name succeeds normally and creates `.git/modules/<name>` as expected — isolating the gap to submodule names containing environment-variable syntax specifically.\n\n### Already-known check\nChecked all 35 published `gitpython-developers/GitPython` security advisories in full text. `GHSA-hmq2-w58f-27jc` covers the same `name` → `.git/modules/<name>` path but for path-traversal (`../../..`), not variable expansion. `GHSA-59cr-6r3x-644w` covers the sibling `path` field reaching unguarded `os.makedirs()`. `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` cover `expand_vars`, but only on URL arguments. `git log -p -S \"separate_git_dir\" -- git/repo/base.py` shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit `57d05379`).\n\nThe closest topical match is `GHSA-8mcc-hrx5-hvxc` (\"`clone_from()`/`clone()` omit `--separate-git-dir` from `unsafe_git_clone_options`\") — already fixed, and its own text discusses this same `_clone()` handling of `separate_git_dir`. That fix only added `--separate-git-dir` to the `unsafe_git_clone_options` denylist, which gates the direct-kwarg vector; `Submodule._clone_repo()` explicitly sets `allow_unsafe_options=True` for the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against current `main`, which already has that fix). The bug this report describes is independent of that denylist — the *value* itself is expanded via `os.path.expandvars()` regardless of the denylist outcome — so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it.\n\nNo advisory describes environment-variable expansion via the internal `separate_git_dir` path.\n\n### Suggested fix\nAdd `expand_vars=False` to the `Git.polish_url(sep_dir)` call in `_clone()`, matching the four other call sites already fixed for the same class of issue.\n\n### Verification level\nReproduced in a real environment: plain Linux, unmodified GitPython from current `main`, real git ≥ 1.7.5 behavior, an ordinary `git submodule add` + `clone` + `submodule_update(init=True, recursive=True)` workflow with no non-default configuration or monkeypatching required. Causation isolated via a control group (identical workflow, ordinary submodule name, no expansion).\n\n### AI Use Disclosure\nAI (Claude) was used as a tool under my direction for code review, code summarization, and script development. A human reviewed and verified the AI-generated results before submission.\n\nCredit: please use \"baeseungwon1010\" (github.com/baeseungwon1010).","summary":"A malicious repository can name a submodule with an environment variable reference, and GitPython expands it during clone and submodule_update, writing the variable's value into a directory name under .git/modules. With DEBUG logging enabled the expanded value also appears unredacted in a Popen log line, so an attacker who controls a repository you clone can read secrets such as CI credentials from the environment.","severity":"medium","cvss":6.5,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"GitPython","fixed":["3.2.0"],"affected":"<= 3.1.62","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["ci","cwe-200","gitpython","package","python","self-hosted"],"not_affected_if":["gitpython-3.2.0-or-later","no-submodule-init","trusted-repos-only"],"how_to_check":"- Run `pip show GitPython` and check whether the version is at or below `3.1.62`.\n- List `.git/modules/` in clones made with GitPython and look for directory names containing unexpected expanded values.\n- Check whether DEBUG logging is enabled for the `git.cmd` or `git.repo.base` loggers in CI.","fix":"- Upgrade GitPython to `3.2.0`, the first fixed version.\n- Until upgraded, avoid cloning untrusted repositories with `submodule_update(init=True, recursive=True)` or disable DEBUG logging for the git loggers.","status":"open","published_at":"2026-10-27T15:26:32+00:00","first_seen_at":"2026-10-27T15:28:57+00:00","triaged_at":"2026-10-27T15:29:24+00:00","updated_at":"2026-10-27T15:29:24+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-27T15:29:24Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade GitPython to `3.2.0`, the first fixed version.\n- Until upgraded, avoid cloning untrusted repositories with `submodule_update(init=True, recursive=True)` or disable DEBUG logging for the git loggers.","reach":"internet","title":"GitPython: crafted .gitmodules name leaks environment variables during submodule clone","impact":"medium","summary":"A malicious repository can name a submodule with an environment variable reference, and GitPython expands it during clone and submodule_update, writing the variable's value into a directory name under .git/modules. With DEBUG logging enabled the expanded value also appears unredacted in a Popen log line, so an attacker who controls a repository you clone can read secrets such as CI credentials from the environment.","category":"package","nicknames":[],"who_cares":["python","gitpython","ci","self-hosted"],"confidence":0.8,"needs_auth":"no","worst_case":"Environment variable values such as CI secrets are written to disk and into debug logs","analyst_note":"","how_to_check":"- Run `pip show GitPython` and check whether the version is at or below `3.1.62`.\n- List `.git/modules/` in clones made with GitPython and look for directory names containing unexpected expanded values.\n- Check whether DEBUG logging is enabled for the `git.cmd` or `git.repo.base` loggers in CI.","needs_analyst":false,"not_affected_if":["trusted-repos-only","no-submodule-init","gitpython-3.2.0-or-later"],"who_is_affected":"Teams whose code uses GitPython to clone or update repositories they do not fully trust, such as CI jobs, bots or tools that process user-supplied repository URLs. The exposure needs a clone plus submodule initialization on a hostile repository, and the disclosure is through the on-disk directory name and debug logs, so environments that only clone trusted repositories are less exposed.","user_interaction":"yes","needs_special_setup":"no","severity_if_unknown":"medium"},"cost_usd":0.000945,"input_tokens":5982,"output_tokens":693,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-200':26B '/evil_submodule':500C '1.7.5':462C '12345':533C '1537':265C '1599':312C '27jc':436C,545C '4cq8':288C '5':302C '686':316C '94p4':287C '9g67':289C 'absolut':389C,442C 'abspath':401C 'access':521C 'add':295C,338C,427C,495C 'advisori':546C 'akia':526C 'also':72B,324C,550C 'anywher':386C 'appear':73B 'argument':206C,612C,675C 'attack':82B,126C,139C,164C,244C,279C,418C 'attacker-control':138C,278C,417C 'attacker-influenc':125C,243C 'aw':519C 'bear':205C 'branch':375C 'built':483C 'call':240C,259C,333C 'caller':119C,271C 'caus':209C 'channel':626C,641C 'choos':166C 'ci':24B,94B,515C 'ci/verbose-logging':190C,652C 'clone':50B,88B,147C,213C,261C,300C,395C,423C,560C,606C,636C 'code':480C 'command':492C 'commit':344C 'common':189C,651C 'complet':145C 'compon':450C 'config':578C 'configur':191C 'contain':443C,448C 'content':420C 'control':84B,140C,280C,419C 'craft':9A 'creat':591C 'credenti':95B,204C 'credential-bear':203C 'current':325C 'cwe':25B 'cygwin':374C 'data':128C,246C 'debug':66B,183C,648C,663C 'debug-level':182C 'default':465C 'deriv':135C 'destin':275C 'detail':210C 'dir':4A,106C,132C,217C,221C,224C,228C,232C,393C,404C,561C,610C,673C 'directori':61B,178C,588C,618C 'disabl':108C,358C 'disclosur':625C 'disk':177C,630C 'docstr':355C 'drive':445C 'embed':384C 'enabl':68B,187C 'entir':381C 'env':6A 'environ':42B,98B,113C,160C,504C 'environment-vari':112C 'everi':117C,469C 'evil':556C 'exact':537C 'exist':596C 'expand':47B,70B,158C,251C,296C,327C,594C,616C,669C 'expanded-nam':615C 'expans':115C,598C 'expos':168C 'fake':528C 'fals':253C,298C,329C 'fix':283C,351C,430C 'flag':658C 'fr94':17A,22A 'full':574C 'fulli':416C,668C 'function':310C 'fx3j':15A,20A 'ghsa':14A,19A,286C,291C,433C,542C 'ghsa-94p4-4cq8-9g67':285C 'ghsa-fx3j-rwgx-fr94':13A,18A 'ghsa-hmq2-w58f-27jc':432C,541C 'ghsa-rwj8-pgh3-r573':290C 'git':3A,105C,131C,220C,227C,392C,461C,491C,493C,575C,605C,609C,672C 'git.cmd':644C 'git.polish':109C,229C,238C,352C 'git.repo.base':645C 'git/modules':64B,571C,620C 'git/modules/akia_totally_fake_but_secret_looking_value_12345':572C 'git/objects/submodule/base.py':398C 'git/repo/base.py':211C,311C 'gitfil':456C 'gitmodul':10A,141C,412C 'gitpython':1A,23A,27B,46B,479C,659C 'gpi':497C,523C,584C 'handl':102C,124C,242C 'happen':599C 'head':577C 'helper':122C 'hmq2':434C,543C 'hook':581C 'host':32B 'hostil':485C 'i.e':463C 'incident':339C 'includ':383C 'independ':640C 'influenc':127C,245C 'init':150C,564C 'input':268C 'instal':473C 'key':522C 'kwarg':225C 'kwargs.get':218C 'later':342C 'leak':5A 'letter':446C 'level':184C,649C 'line':79B,196C,264C,303C,665C 'liter':583C 'live':475C 'local':274C 'log':67B,78B,185C,195C,664C 'logger':646C 'long':633C 'look':531C 'ls':570C 'malici':34B,156C 'marker':499C,525C,586C 'modul':400C,405C 'monkeypatch':482C 'name':12A,37B,62B,143C,179C,406C,408C,415C,426C,439C,496C,587C,617C 'need':455C 'never':107C,590C 'non':373C 'non-cygwin':372C 'object':579C 'observ':568C 'obtain':362C 'on-disk':175C 'one':256C,306C,318C,595C 'ordinari':146C,490C,538C 'os.path.expandvars':378C 'osp.join':402C 'packag':28B 'parent':557C 'parent_repo.git':403C 'part':347C 'pass':250C 'password':678C,684C 'path':262C,276C,390C,449C,466C 'persist':174C,614C 'pgh3':293C 'plain':377C,489C 'poc':549C 'popen':77B,194C,662C 'present':680C 'primari':624C 'print':666C 'process':509C 'prove':597C 'python':29B,215C,552C 'r573':294C 'ran':535C 'raw':411C 'reach':394C,603C 'read':90B 'real':471C,477C,514C 'real-world':470C 'receiv':321C 'recurs':152C,566C 'redact':200C,682C 'ref':580C 'refer':44B 'reject':438C,454C 'relat':332C 'remain':237C 'remot':369C 'remote.py:814':313C 'remov':677C 'repo':553C,576C 'repo._clone':100C 'repo.clone':554C 'repo.submodule':562C 'repositori':35B,86B,157C,424C,486C 'reproduct':474C 'requir':655C 'result':569C 'root':208C 'rwgx':16A,21A 'rwj8':292C 'say':357C 'second':639C 'secret':91B,498C,516C,520C,524C,530C,585C 'section':414C 'secur':350C 'self':31B 'self-host':30B 'sep':216C,223C,231C 'separ':2A,104C,130C,219C,226C,391C,608C,671C 'separate-git-dir':607C,670C 'set':326C,502C,653C 'sibl':282C 'sinc':129C 'site':241C,260C,334C 'sourc':366C 'special':657C 'stand':510C 'string':382C 'sub':501C 'submodul':11A,39B,52B,142C,148C,413C,457C,494C 'submodule._clone_repo':397C 'submodule/base.py':315C 'submodule/root.py':323C 'subprocess':611C,674C 'summari':99C 'surviv':628C 'total':527C 'true':151C,153C,459C,565C,567C 'unguard':258C 'unlik':116C 'unmodifi':478C 'unredact':74B 'unrel':343C 'untrust':365C 'updat':53B,149C,563C 'url':110C,230C,239C,301C,353C,370C,558C,683C 'use':551C 'valid':425C 'valu':58B,71B,170C,361C,532C,602C 'var':7A,252C,297C,328C 'variabl':43B,56B,114C,161C,385C,505C 'verbatim':676C 'via':8A 'victim':508C,559C 'virtual':468C 'w58f':435C,544C 'way':202C 'whenev':181C 'workflow':539C 'world':472C 'write':54B","nicknames":[],"mitigations":[],"who_affected":"Teams whose code uses GitPython to clone or update repositories they do not fully trust, such as CI jobs, bots or tools that process user-supplied repository URLs. The exposure needs a clone plus submodule initialization on a hostile repository, and the disclosure is through the on-disk directory name and debug logs, so environments that only clone trusted repositories are less exposed.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-27T15:26:32+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-fx3j-rwgx-fr94","url":"https://github.com/advisories/GHSA-fx3j-rwgx-fr94","severity":"medium","published_at":"2026-10-27T15:26:32+00:00","modified_at":"2026-10-27T15:26:33+00:00","first_seen_at":"2026-10-27T15:28:57+00:00","fetched_at":"2026-10-27T15:28:57+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"}