BREW-NBDIME-CVE-2026-110855 (GHSA-FX3J-RWGX-FR94)
Vulnerability from osv_homebrew – Published: 2026-10-28 09:23 – Updated: 2026-10-28 09:23 – Source websiteSummary
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).
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.2.1",
"upstream_fixed_in": "3.2.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "nbdime",
"purl": "pkg:brew/nbdime"
},
"ranges": [
{
"events": [
{
"introduced": "1.1.0"
},
{
"fixed": "4.0.4_9"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.2.1",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.2.1"
}
]
},
"details": "### Summary\n`Repo._clone()`\u0027s handling of `separate_git_dir` never disables `Git.polish_url()`\u0027s 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\u0027s 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.\n\n### Root cause / Details\n`git/repo/base.py`\u0027s `_clone()` has:\n\n```python\nsep_dir = kwargs.get(\"separate_git_dir\")\nif sep_dir:\n kwargs[\"separate_git_dir\"] = Git.polish_url(sep_dir)\n```\n\nThis 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\u0027s 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` \u2014 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()`\u0027s 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.\n\n`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 \u2014 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 \u2265 1.7.5, i.e. the default path for virtually every real-world installation.\n\n### Reproduction (live, against real, unmodified GitPython code, no monkeypatching)\nBuilt a hostile repository with a plain, ordinary git command: `git submodule add --name \u0027$GPY_SECRET_MARKER\u0027 ../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\u0027s own PoC also uses:\n\n```python\nrepo = Repo.clone_from(evil_parent_url, victim_clone_dir)\nrepo.submodule_update(init=True, recursive=True)\n```\n\nObserved result (`ls .git/modules/`):\n\n```\n.git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/ (a full git repo: HEAD, config, objects/, refs/, hooks/)\n```\n\nThe literal `$GPY_SECRET_MARKER`-named directory was never created \u2014 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 \u2014 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\u0027s own `Popen(...)` debug log line prints the fully expanded `--separate-git-dir=\u003cvalue\u003e` subprocess argument verbatim \u2014 `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 \u2014 but that error message itself does not contain the expanded/secret value.)\n\n**Control**: the same call with an ordinary (no `$`) submodule name succeeds normally and creates `.git/modules/\u003cname\u003e` as expected \u2014 isolating the gap to submodule names containing environment-variable syntax specifically.\n\n### Already-known check\nChecked all 35 published `gitpython-developers/GitPython` security advisories in full text. `GHSA-hmq2-w58f-27jc` covers the same `name` \u2192 `.git/modules/\u003cname\u003e` 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`).\n\nThe closest topical match is `GHSA-8mcc-hrx5-hvxc` (\"`clone_from()`/`clone()` omit `--separate-git-dir` from `unsafe_git_clone_options`\") \u2014 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 \u2014 the *value* itself is expanded via `os.path.expandvars()` regardless of the denylist outcome \u2014 so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it.\n\nNo advisory describes environment-variable expansion via the internal `separate_git_dir` path.\n\n### Suggested fix\nAdd `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.\n\n### Verification level\nReproduced in a real environment: plain Linux, unmodified GitPython from current `main`, real git \u2265 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).\n\n### AI Use Disclosure\nAI (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.\n\nCredit: please use \"baeseungwon1010\" (github.com/baeseungwon1010).",
"id": "BREW-nbdime-CVE-2026-110855",
"modified": "2026-10-28T09:23:24Z",
"published": "2026-10-28T09:23:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fx3j-rwgx-fr94"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2255"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/9349ff58ee00c9fb14d515c8e693f33b2144b238"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name",
"upstream": [
"GHSA-fx3j-rwgx-fr94",
"CVE-2026-110855"
]
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.