GHSA-fx3j-rwgx-fr94: Infoleak
Summary Repo.clone()'s handling of separategitdir never disables Git.polishurl()'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since separategitdir can be derived from an attacker-controlled .gitmodules submodule name, a completely ordinary clone + submoduleupdate(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.
Root cause / Details git/repo/base.py's clone() has:
python sepdir = kwargs.get("separategitdir") if sepdir: kwargs["separategitdir"] = Git.polishurl(sepdir)
This is the only remaining Git.polishurl() call site handling attacker-influenced data that does not pass expandvars=False (there is one other unguarded call site, clonepath 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 expandvars=False to cloneurl (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 expandvars=False at a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.) Git.polishurl()'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.
separategitdir reaches clone() from Submodule.clonerepo() (git/objects/submodule/base.py) as moduleabspath = osp.join(parentrepo.gitdir, "modules", name), where name is the raw .gitmodules submodule section name — fully attacker-controlled content of a cloned repository. validatedname() (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 $. needgitfilesubmodules() is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation.
Reproduction (live, against real, unmodified GitPython code, no monkeypatching) Built a hostile repository with a plain, ordinary git command: git submodule add --name '$GPYSECRETMARKER' ../evilsubmodule sub. Set an environment variable in the "victim" process (standing in for a real CI secret such as AWSSECRETACCESSKEY): GPYSECRETMARKER=AKIATOTALLYFAKEBUTSECRETLOOKINGVALUE12345. Then ran the exact ordinary workflow the GHSA-hmq2-w58f-27jc advisory's own PoC also uses:
python repo = Repo.clonefrom(evilparenturl, victimclonedir) repo.submoduleupdate(init=True, recursive=True)
Observed result (ls .git/modules/):
.git/modules/AKIATOTALLYFAKEBUTSECRETLOOKINGVALUE12345/ (a full git repo: HEAD, config, objects/, refs/, hooks/)
The literal $GPYSECRETMARKER-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 — removepasswordifpresent() 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 writegitfileandmoduleconfig() looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.)
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.
Already-known check Checked 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 expandvars, but only on URL arguments. git log -p -S "separategitdir" -- git/repo/base.py shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit 57d05379).
The closest topical match is GHSA-8mcc-hrx5-hvxc ("clonefrom()/clone() omit --separate-git-dir from unsafegitcloneoptions") — already fixed, and its own text discusses this same clone() handling of separategitdir. That fix only added --separate-git-dir to the unsafegitcloneoptions denylist, which gates the direct-kwarg vector; Submodule.clonerepo() explicitly sets allowunsafeoptions=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.
No advisory describes environment-variable expansion via the internal separategitdir path.
Suggested fix Add expandvars=False to the Git.polishurl(sepdir) call in clone(), matching the four other call sites already fixed for the same class of issue.
Verification level Reproduced in a real environment: plain Linux, unmodified GitPython from current main, real git ≥ 1.7.5 behavior, an ordinary git submodule add + clone + submoduleupdate(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).
AI Use Disclosure AI (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.
Credit: please use "baeseungwon1010" (github.com/baeseungwon1010).
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/GitPythonto a version that resolves this vulnerability.Fixed in 3.2.0 - Configuration
Pass expand_vars=False to the Git.polish_url(sep_dir) call when handling separate_git_dir paths, preventing environment-variable expansion in attacker-controlled submodule names.
GitPython Repo._clone() expand_vars = False
Event History
Frequently Asked Questions
Which workflows are exposed?
Workflows that clone an attacker-controlled repository and then run submodule_update(init=True, recursive=True) are exposed. This includes ordinary clone-and-recursive-submodule-update processing of a malicious repository.
What does an attacker need to control to trigger the issue?
The attacker needs to provide a malicious repository whose .gitmodules submodule name causes separate_git_dir to contain an environment-variable reference. No attacker authentication or prior privileges are indicated, but a user or automation job must process the repository.
When can the leaked value be observed?
The expanded value is persisted in an on-disk directory name. If DEBUG-level logging is enabled, it is also written to an unredacted Popen(...) log line; this is particularly relevant to CI or verbose-logging configurations.