Safeguard
Supply Chain Security•

GitPython's 39 Advisories in Six Months: Why Guards Around the git CLI Keep Breaking

GitPython published 39 advisories from April to October 2026, most variations of git option injection. What they share, why denylists fail, and what to do.

Safeguard Security Team
Security Research
9 min read

GitPython published 39 security advisories between 22 April and 14 October 2026, across 18 releases from 3.1.47 to 3.2.1. In 2023 and 2024 together, its repository published three. Most of this year's bugs share one shape: a value reached git's command line or configuration as something git treats as an option or a directive, and a GitPython guard meant to stop that didn't. Eighteen of the 39 advisories describe themselves as a bypass, a residual or an incomplete fix of an earlier protection.

GitPython is the 172nd most-downloaded package on PyPI, with about 160 million downloads in the 30 days to 1 October. It sits inside CI systems, repository importers, scanners, ML tooling and bots. This post is for teams that use it, directly or through another package. It sets out what the advisories have in common, why fixing them one at a time hasn't worked, and what to do in your own code, with a script that finds the GitPython calls worth reviewing.

The short answer

Upgrade to GitPython 3.2.1, and keep upgrading, because the fixes are still coming. More importantly, stop relying on GitPython's guards to make untrusted input safe. Never pass a URL, branch name, refspec, path or config value from a user, webhook or cloned repository to GitPython without validating it against an allowlist first. Run git operations on untrusted repositories in an isolated environment with no secrets in its environment variables.

What the 39 advisories have in common

We read the summaries of every published advisory in GitPython's security advisories and grouped them:

GroupAdvisoriesExamples
A value reached git as a command-line option22--upload-pack through a Remote.pull() refspec; --template and --separate-git-dir missing from the clone denylist; option abbreviations and joined short options slipping past the guard; --output writing files from diff and rev-list
Submodule and path handling8Paths outside the work tree; a submodule cloned into .git/hooks before its path was rejected; repository content impersonating the git directory
Injection into git config5Newlines and special characters in section, option and value names, leading to directives such as core.hooksPath or core.sshCommand
Environment variable leaks2os.path.expandvars() applied to clone and remote URLs
Regular expression denial of service2Backtracking in author and committer parsing

Severity skews high: 27 rated high and one critical, 11 medium. The critical one, CVE-2026-110858 (fixed in 3.2.0 on 30 September), is a good example of the main group. Remote.pull() is documented to reject --upload-pack unless allow_unsafe_options=True, but its guard only checked keyword arguments. A refspec passed as a positional argument went straight through, and because git pull hands its trailing arguments to an internal git fetch, a refspec of --upload-pack=<command> ran that command over local-path or SSH transports.

Another from the same release shows that "untrusted input" includes the repository itself. In GHSA-23mf-xhv8-69c2, a repository whose default branch is named --pathspec-from-file=<path> makes an application that clones it and calls repo.active_branch.checkout() read a local file and return its contents in an error message. The application passes no attacker-supplied value at all. The newest advisory, fixed in 3.2.1 on 14 October, lets create_submodule() clone into .git/hooks before its path check runs, planting a hook that executes on the next git operation.

Why fixing them one at a time hasn't worked

GitPython is mostly a wrapper around the git command-line tool. Its main defence, added in 3.1.30 in December 2022 after CVE-2022-24439, is a list of "unsafe" options and protocols that it refuses unless the caller opts in. That defence is a denylist in front of a program with hundreds of options, and git accepts the same option in several spellings. It allows unambiguous abbreviations of long options, joins single-letter options together, takes values attached or as the next argument, and lets some commands forward their arguments to others. Several of this year's bypasses exploit exactly those spellings.

The other groups have the same structure. Git config files, .gitmodules and ref names are all formats where a newline or a leading dash changes the meaning, and each place GitPython builds one from caller data needs its own escaping. Every fix closes one path, and the next report finds a neighbour. The maintainers have been quick, often releasing within days, and the advisories are clear. The pattern reflects how many ways there are to say something to git, not neglect.

It's also not unique to GitPython. Any library that builds command lines for another program, whether git, ffmpeg, ImageMagick or a shell, has the same problem whenever untrusted data can start with a dash.

What to do in your own code

  1. Upgrade to 3.2.1, and watch for more. With 18 releases in six months, pinning an old version for stability means carrying known bugs. Make sure your dependency updates actually reach GitPython, including when it's a transitive dependency.
  2. Validate inputs with allowlists before they reach GitPython. Accept only the URL schemes you expect, usually https://. Check ref and branch names against a strict pattern, or with git check-ref-format, and reject anything that starts with -. Don't pass user data into config writers at all.
  3. Treat cloned repositories as untrusted input. Branch names, submodule definitions, .gitmodules paths and commit metadata all come from whoever controls the repository. Avoid checking out active_branch or updating submodules on repositories you don't control, or do it only in isolation.
  4. Isolate the work. Run clones and checkouts of untrusted repositories in a container or sandbox with no credentials in its environment variables, a throwaway filesystem and no network access beyond what the clone needs.
  5. Find the calls that matter. The script below lists GitPython calls in a codebase whose arguments aren't constants.

Find GitPython calls that take non-constant arguments

The script parses your Python files, keeps those that import git, and reports calls to GitPython methods that reach git's command line or act on repository content when any argument isn't a literal. It also flags active_branch.checkout(), where the input comes from the repository itself. It's static and approximate, so treat its output as a review list. It uses only the standard library.

#!/usr/bin/env python3
"""List GitPython calls in a codebase whose arguments aren't constants, i.e. the places where
user, repository or network data can reach git's command line. Also reports the installed
GitPython version. Static and approximate: review every line it prints. Standard library only.
Usage: python3 gitpython_untrusted_calls.py src/ [more/paths ...]"""
import ast, os, sys
from importlib import metadata

# GitPython entry points that pass arguments to git, or act on repository content
SINKS = {"clone_from", "clone", "pull", "fetch", "push", "checkout", "ls_remote", "archive", "init",
         "create_remote", "create_head", "create_tag", "config_writer", "set_value", "diff",
         "iter_commits", "submodule_update", "update", "move", "add", "remove", "reset", "execute"}

def is_constant(node):
    return isinstance(node, ast.Constant) or (
        isinstance(node, (ast.List, ast.Tuple)) and all(is_constant(e) for e in node.elts))

def scan(path):
    tree = ast.parse(open(path, encoding="utf-8", errors="replace").read(), path)
    imports_git = any(isinstance(n, (ast.Import, ast.ImportFrom)) and
                      any((a.name or "").split(".")[0] == "git" for a in n.names) or
                      (isinstance(n, ast.ImportFrom) and (n.module or "").split(".")[0] == "git")
                      for n in ast.walk(tree))
    if not imports_git:
        return
    for node in ast.walk(tree):
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
            name = node.func.attr
            via_git_cmd = isinstance(node.func.value, ast.Attribute) and node.func.value.attr == "git"
            if name in SINKS or via_git_cmd:
                dynamic = [ast.unparse(a) for a in node.args if not is_constant(a)]
                dynamic += [f"{k.arg}={ast.unparse(k.value)}" for k in node.keywords
                            if k.arg and not is_constant(k.value)]
                if "active_branch" in ast.unparse(node.func.value):
                    dynamic.append("branch name read from the repository itself")
                if dynamic:
                    yield node.lineno, ast.unparse(node.func), dynamic

try:
    print(f"GitPython installed: {metadata.version('GitPython')} (latest fixes: 3.2.1)\n")
except metadata.PackageNotFoundError:
    print("GitPython not installed in this environment\n")
hits = 0
for top in sys.argv[1:]:
    for dirpath, dirs, files in os.walk(top):
        dirs[:] = [d for d in dirs if d not in (".git", ".venv", "venv", "node_modules", "site-packages")]
        for f in files:
            if f.endswith(".py"):
                path = os.path.join(dirpath, f)
                for line, call, args in scan(path) or []:
                    hits += 1
                    print(f"{path}:{line}  {call}(...)  non-constant: {', '.join(args)}")
print(f"\n{hits} call(s) to review. Treat any argument that can come from a user, a webhook "
      "or a cloned repository as hostile.")

Run against a small fixture application that imports a repository from a web form and refreshes it from a webhook:

import tempfile
from git import Repo

def import_repo(url, branch):
    workdir = tempfile.mkdtemp()
    repo = Repo.clone_from(url, workdir)               # url comes from a web form
    repo.git.checkout(branch)                            # branch comes from the same form
    repo.active_branch.checkout()
    return repo

def refresh(repo, refspec):
    repo.remote("origin").pull(refspec)                  # refspec from a webhook payload

def safe_status(repo):
    return repo.git.status("--short")
$ python3 gitpython_untrusted_calls.py fx/app
GitPython not installed in this environment

fx/app/importer.py:6  Repo.clone_from(...)  non-constant: url, workdir
fx/app/importer.py:7  repo.git.checkout(...)  non-constant: branch
fx/app/importer.py:8  repo.active_branch.checkout(...)  non-constant: branch name read from the repository itself
fx/app/importer.py:12  repo.remote('origin').pull(...)  non-constant: refspec

4 call(s) to review. Treat any argument that can come from a user, a webhook or a cloned repository as hostile.

All four flagged lines match a group above: the clone URL, a branch name and a refspec from outside, and a checkout of a branch named by the repository. The status("--short") call isn't flagged, because every argument is a constant.

The fair view

Counting advisories can mislead. A project that takes reports seriously and publishes every one will look worse than a quieter project with the same bugs, and GitPython's maintainers have handled a heavy year in the open. Many of the advisories also need an application to pass attacker-controlled data into a specific API, so the risk depends on how you use the library.

But 39 advisories in six months, most of them variations on one theme, is a clear signal about where the safety boundary is. The library's guards are a backstop. The real boundary is your code: what you allow to reach git, and where you let git run. If you handle untrusted repositories or user-supplied git parameters, build that boundary yourself, and upgrade GitPython as well.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.