{"id":"CVE-2026-100689","aliases":["CLSA-2026-1790833527","DEBIAN-CVE-2026-100689","EUVD-2026-87662","GHSA-59cr-6r3x-644w","GHSA-cw5h-x3x9-4mwp","UBUNTU-CVE-2026-100689","openSUSE-SU-2026:11971-1"],"status":"published","title":"GitPython before 3.1.62 Path Traversal via gitmodules path","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","severity":{"version":"4.0","score":8.7,"vector":"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","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-10","score":0.00401,"percentile":0.34718,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-22","CWE-73"],"published":"2026-09-26T13:23:48Z","modified":"2026-10-31T14:00:07Z","fixes":[{"ecosystem":"PyPI","package":"gitpython","fixed_in":["3.1.62"],"sources":["ghsa","osv"]},{"ecosystem":"TuxCare:PyPI","package":"gitpython","fixed_in":["3.1.31.post14+tuxcare"],"sources":["osv"]},{"ecosystem":"Debian:12","package":"python-git","fixed_in":["3.1.30-1+deb12u3"],"sources":["osv"]},{"ecosystem":"Debian:13","package":"python-git","fixed_in":["3.1.44-1+deb13u1"],"sources":["osv"]},{"ecosystem":"Debian:14","package":"python-git","fixed_in":["3.1.62-1"],"sources":["osv"]},{"ecosystem":"openSUSE:Tumbleweed","package":"python-GitPython","fixed_in":["3.2.0-1.1"],"sources":["osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"gitpython","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"3.1.61"},{"fixed":"3.1.62"}]}]},{"source":"osv","ecosystem":"TuxCare:PyPI","package":"gitpython","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.1.31"},{"fixed":"3.1.31.post14+tuxcare"}]}],"versions":["3.1.31","3.1.31.post1+tuxcare","3.1.31.post10+tuxcare","3.1.31.post11+tuxcare","3.1.31.post12+tuxcare","3.1.31.post13+tuxcare","3.1.31.post2+tuxcare","3.1.31.post3+tuxcare","3.1.31.post4+tuxcare","3.1.31.post5+tuxcare","3.1.31.post6+tuxcare","3.1.31.post7+tuxcare","3.1.31.post8+tuxcare","3.1.31.post9+tuxcare"]},{"source":"osv","ecosystem":"Debian:12","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.1.30-1+deb12u3"}]}],"versions":["3.1.30-1","3.1.30-1+deb12u2"]},{"source":"osv","ecosystem":"Debian:13","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.1.44-1+deb13u1"}]}],"versions":["3.1.44-1"]},{"source":"osv","ecosystem":"Debian:14","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.1.62-1"}]}],"versions":["3.1.44-1","3.1.45-1","3.1.46-1","3.1.50-1","3.1.61-1"]},{"source":"osv","ecosystem":"PyPI","package":"gitpython","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.1.62"}]}],"versions":["0.1.7","0.2.0-beta1","0.3.0-beta1","0.3.0-beta2","0.3.1-beta2","0.3.2","0.3.2.1","0.3.2.RC1","0.3.3","0.3.4","0.3.5","0.3.6","0.3.7","1.0.0","1.0.1","1.0.2","2.0.0","2.0.1","2.0.2","2.0.3","2.0.4","2.0.5","2.0.6","2.0.7","2.0.8","2.0.9","2.0.9.dev0","2.0.9.dev1","2.1.0","2.1.1","2.1.10","2.1.11","2.1.12","2.1.13","2.1.14","2.1.15","2.1.2","2.1.3","2.1.4","2.1.5","2.1.6","2.1.7","2.1.8","2.1.9","3.0.0","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.1.0","3.1.1","3.1.10","3.1.11","3.1.12","3.1.13","3.1.14","3.1.15","3.1.16","3.1.17","3.1.18","3.1.19","3.1.2","3.1.20","3.1.22","3.1.23","3.1.24","3.1.25","3.1.26","3.1.27","3.1.28","3.1.29","3.1.3","3.1.30","3.1.31","3.1.32","3.1.33","3.1.34","3.1.35","3.1.36","3.1.37","3.1.38","3.1.4","3.1.40","3.1.41","3.1.42","3.1.43","3.1.44","3.1.45","3.1.46","3.1.47","3.1.48","3.1.49","3.1.5","3.1.50","3.1.51","3.1.52","3.1.53","3.1.54","3.1.55","3.1.56","3.1.57","3.1.58","3.1.59","3.1.6","3.1.60","3.1.61","3.1.7","3.1.8","3.1.9"]},{"source":"osv","ecosystem":"Ubuntu:Pro:14.04:LTS","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["0.3.2~RC1-2","0.3.2~RC1-3","0.3.2~RC1-3ubuntu0.1~esm1","0.3.2~RC1-3ubuntu0.1~esm2","0.3.2~RC1-3ubuntu0.1~esm3"]},{"source":"osv","ecosystem":"Ubuntu:Pro:16.04:LTS","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["1.0.1+git137-gc8b8379-1","1.0.1+git137-gc8b8379-2","1.0.1+git137-gc8b8379-2.1","1.0.1+git137-gc8b8379-2.1ubuntu0.1~esm1","1.0.1+git137-gc8b8379-2.1ubuntu0.1~esm2","1.0.1+git137-gc8b8379-2.1ubuntu0.1~esm4"]},{"source":"osv","ecosystem":"Ubuntu:Pro:18.04:LTS","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.1.5-1","2.1.6-1","2.1.7-1","2.1.8-1","2.1.8-1ubuntu0.1~esm1","2.1.8-1ubuntu0.1~esm2","2.1.8-1ubuntu0.1~esm4"]},{"source":"osv","ecosystem":"Ubuntu:Pro:20.04:LTS","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.1.11-1","3.0.4-1","3.0.5-1","3.0.7-1","3.0.7-1ubuntu0.1~esm1","3.0.7-1ubuntu0.1~esm2","3.0.7-1ubuntu0.1~esm4"]},{"source":"osv","ecosystem":"Ubuntu:Pro:22.04:LTS","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.1.14-1","3.1.23-1","3.1.24-1","3.1.24-1ubuntu0.1~esm1","3.1.24-1ubuntu0.1~esm2","3.1.24-1ubuntu0.1~esm3"]},{"source":"osv","ecosystem":"Ubuntu:Pro:24.04:LTS","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.1.30-1","3.1.37-1","3.1.37-3","3.1.37-3ubuntu0.1~esm2"]},{"source":"osv","ecosystem":"Ubuntu:26.10","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.1.46-1","3.1.50-1","3.1.61-1"]},{"source":"osv","ecosystem":"Ubuntu:Pro:26.04:LTS","package":"python-git","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["3.1.44-1","3.1.45-1","3.1.46-1","3.1.46-1ubuntu0.1~esm1"]},{"source":"osv","ecosystem":"openSUSE:Tumbleweed","package":"python-GitPython","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.2.0-1.1"}]}]}],"products":[{"vendor":"gitpython-developers","product":"gitpython"}],"references":[{"url":"https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w","tags":["vendor-advisory","advisory","web","report"]},{"url":"https://www.vulncheck.com/advisories/gitpython-before-3.1.62-path-traversal-via-gitmodules-path","tags":["third-party-advisory","advisory","report"]},{"url":"https://github.com/gitpython-developers/GitPython/pull/2225","tags":["web"]},{"url":"https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093","tags":["web"]},{"url":"https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62","tags":["web"]},{"url":"https://github.com/advisories/GHSA-59cr-6r3x-644w"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-100689","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-cw5h-x3x9-4mwp"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/100xxx/CVE-2026-100689.json","tags":["advisory"]},{"url":"https://security-tracker.debian.org/tracker/CVE-2026-100689","tags":["advisory"]},{"url":"https://github.com/gitpython-developers/GitPython","tags":["package"]},{"url":"https://ubuntu.com/security/CVE-2026-100689","tags":["report"]},{"url":"https://www.cve.org/CVERecord?id=CVE-2026-100689","tags":["report"]},{"url":"","tags":["advisory"]},{"url":"https://www.suse.com/security/cve/CVE-2026-100689","tags":["web"]}],"sources":{"cve":[{"id":"CVE-2026-100689","title":"GitPython before 3.1.62 Path Traversal via gitmodules path","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","status":"published","published":"2026-09-26T13:23:48.759Z","modified":"2026-10-08T15:21:59.548Z","cvss":[{"version":"4.0","score":8.7,"vector":"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","level":"high","assessor":"cna"},{"version":"3.1","score":5.9,"vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:L","level":"medium","assessor":"cna"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T14:20:06.562031Z"}],"euvd":[{"id":"EUVD-2026-87662","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","status":"published","published":"2026-09-26T13:23:48Z","modified":"2026-10-08T15:21:59Z","cvss":[{"version":"4.0","score":8.7,"vector":"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","level":"high"}],"updated_at":"2026-11-02T17:27:32.18003Z"}],"ghsa":[{"id":"GHSA-59cr-6r3x-644w","title":"GitPython submodule update path traversal can write outside the repository","description":"**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.\n\n---\n\n## The gap\n\nThe fix for `GHSA-hmq2-w58f-27jc` added `Submodule._validated_name()` and wired it into `update()` and five siblings, closing the `.gitmodules` **name** → `.git/modules/\u003cname\u003e` traversal. The other attacker-controlled `.gitmodules` field, **`path`**, is read raw:\n\n```python\n# git/objects/submodule/base.py:172-177\ndef _set_cache_(self, attr):\n    if attr in (\"path\", \"_url\", \"_branch_path\"):\n        reader = self.config_reader()\n        self.path = reader.get(\"path\")          # raw .gitmodules value\n```\n\nand GitPython's own containment guard is applied in only two of the places that consume it:\n\n```\n400: def _to_relative_path(cls, parent_repo, path)      # the guard (abspath + commonpath containment)\n542:     path = cls._to_relative_path(repo, path)        # add()   — guarded\n1041:    module_checkout_path = self._to_relative_path(self.repo, module_path)   # move() — guarded\n```\n\n`update()` validates only the name and then uses the path-derived absolute location directly:\n\n```\n788:  self._validated_name(self.name)                    # NAME only\n801:  checkout_module_abspath = self.abspath             # derived from self.path — unguarded\n821:  os.makedirs(checkout_module_abspath, exist_ok=True)\n```\n\nSo `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`.\n\nThe 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.\n\n## Honest limits (please read before rating)\n\n- **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=...)`).\n- **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`.\n\n## Suggested fix\n\nApply the guard the project already has, wherever the path is consumed:\n\n```python\n# in update(), before deriving abspath (and in any other consumer of self.path):\ncheckout_rel = self._to_relative_path(self.repo, self.path)   # raises if it escapes the working tree\n```\n\nBetter 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.\n\n## Prior art checked\n\n`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.\n\n## Credit\n\n**kta1kri**.\n\n\n---\n\n## Appendix — `EVIDENCE_gitpython_path_unguarded_20260901.txt` (inlined; advisories accept no attachments)\n\n```text\n=== EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded ===\nMon Aug 31 18:45:22 UTC 2026\n\n--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---\n\n--- the containment guard GitPython owns, and its only two call sites ---\n33:    _to_relative_path,\n400:    def _to_relative_path(cls, parent_repo: \"Repo\", path: PathLike) -\u003e PathLike:\n407:            path = _to_relative_path(parent_repo.working_tree_dir, path)\n542:        path = cls._to_relative_path(repo, path)\n1041:        module_checkout_path = self._to_relative_path(self.repo, module_path)\n\n--- the parent fix (_validated_name) call sites: it validates the NAME ---\n309:    def _validated_name(cls, name: str) -\u003e str:\n321:        name = cls._validated_name(name)\n541:        cls._validated_name(name)\n788:            self._validated_name(self.name)\n1040:        self._validated_name(self.name)\n1181:        self._validated_name(self.name)\n1439:        self._validated_name(self.name)\n1440:        self._validated_name(new_name)\n1489:        self._validated_name(self.name)\n\n--- update(): name validated, path not; abspath -\u003e os.makedirs ---\n\n        try:\n            self._validated_name(self.name)\n\n            # ENSURE REPO IS PRESENT AND UP-TO-DATE\n                # END early abort if init is not allowed\n\n                checkout_module_abspath = self.abspath\n                module_abspath = self._module_abspath(self.repo, self.path, self.name)\n\n                # ``git submodule deinit`` leaves the repository in\n                # ``.git/modules`` and empties the checkout. Reconnect that retained\n                # repository instead of trying to clone over it.\n                if not dry_run and osp.isdir(module_abspath):\n                    try:\n                        git.Repo(module_abspath)\n                    except InvalidGitRepositoryError:\n                        pass\n                    else:\n                        if osp.lexists(checkout_module_abspath) and (\n                            osp.islink(checkout_module_abspath)\n                            or not osp.isdir(checkout_module_abspath)\n                            or os.listdir(checkout_module_abspath)\n                        ):\n                            raise OSError(\n                                \"Module directory at %r does already exist and is non-empty\" % checkout_module_abspath\n                            )\n                        os.makedirs(checkout_module_abspath, exist_ok=True)\n                        self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)\n                        mrepo = git.Repo(checkout_module_abspath)\n\n--- where self.path comes from (raw .gitmodules value) ---\n    def _set_cache_(self, attr: str) -\u003e None:\n        if attr in (\"path\", \"_url\", \"_branch_path\"):\n            reader: SectionConstraint = self.config_reader()\n            # Default submodule values.\n            try:\n                self.path = reader.get(\"path\")\n            except cp.NoSectionError as e:\n```","status":"published","published":"2026-09-30T23:47:12Z","modified":"2026-09-30T23:47:14Z","cvss":[{"version":"4.0","score":6.1,"vector":"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","level":"medium"}],"cwes":["CWE-22","CWE-73"],"updated_at":"2026-11-02T14:41:23.783773Z"},{"id":"GHSA-cw5h-x3x9-4mwp","title":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules...","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","status":"published","published":"2026-09-26T15:31:21Z","modified":"2026-09-26T15:31:22Z","cvss":[{"version":"4.0","score":8.7,"vector":"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","level":"high"},{"version":"3.1","score":5.9,"vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:L","level":"medium"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T15:12:30.047516Z"}],"nvd":[{"id":"CVE-2026-100689","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","status":"published","published":"2026-09-26T14:16:53.42Z","modified":"2026-09-30T18:18:01.61Z","cvss":[{"version":"4.0","score":8.7,"vector":"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","level":"high","assessor":"secondary"},{"version":"3.1","score":5.9,"vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:L","level":"medium","assessor":"secondary"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T14:12:52.081323Z"}],"osv":[{"id":"CVE-2026-100689","title":"GitPython before 3.1.62 Path Traversal via gitmodules path","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","status":"published","published":"2026-09-26T13:23:48.759Z","modified":"2026-10-28T02:31:14.248370525Z","cvss":[{"version":"4.0","score":8.7,"vector":"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","level":"high"}],"cwes":["CWE-22"],"updated_at":"2026-11-02T15:44:53.83521Z"},{"id":"DEBIAN-CVE-2026-100689","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","status":"published","published":"2026-09-26T14:16:53.42Z","modified":"2026-10-31T14:00:07.139145103Z","cvss":[{"version":"4.0","score":8.7,"vector":"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","level":"high"}],"updated_at":"2026-11-02T15:45:48.440897Z"},{"id":"GHSA-59cr-6r3x-644w","title":"GitPython submodule update path traversal can write outside the repository","description":"**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.\n\n---\n\n## The gap\n\nThe fix for `GHSA-hmq2-w58f-27jc` added `Submodule._validated_name()` and wired it into `update()` and five siblings, closing the `.gitmodules` **name** → `.git/modules/\u003cname\u003e` traversal. The other attacker-controlled `.gitmodules` field, **`path`**, is read raw:\n\n```python\n# git/objects/submodule/base.py:172-177\ndef _set_cache_(self, attr):\n    if attr in (\"path\", \"_url\", \"_branch_path\"):\n        reader = self.config_reader()\n        self.path = reader.get(\"path\")          # raw .gitmodules value\n```\n\nand GitPython's own containment guard is applied in only two of the places that consume it:\n\n```\n400: def _to_relative_path(cls, parent_repo, path)      # the guard (abspath + commonpath containment)\n542:     path = cls._to_relative_path(repo, path)        # add()   — guarded\n1041:    module_checkout_path = self._to_relative_path(self.repo, module_path)   # move() — guarded\n```\n\n`update()` validates only the name and then uses the path-derived absolute location directly:\n\n```\n788:  self._validated_name(self.name)                    # NAME only\n801:  checkout_module_abspath = self.abspath             # derived from self.path — unguarded\n821:  os.makedirs(checkout_module_abspath, exist_ok=True)\n```\n\nSo `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`.\n\nThe 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.\n\n## Honest limits (please read before rating)\n\n- **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=...)`).\n- **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`.\n\n## Suggested fix\n\nApply the guard the project already has, wherever the path is consumed:\n\n```python\n# in update(), before deriving abspath (and in any other consumer of self.path):\ncheckout_rel = self._to_relative_path(self.repo, self.path)   # raises if it escapes the working tree\n```\n\nBetter 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.\n\n## Prior art checked\n\n`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.\n\n## Credit\n\n**kta1kri**.\n\n\n---\n\n## Appendix — `EVIDENCE_gitpython_path_unguarded_20260901.txt` (inlined; advisories accept no attachments)\n\n```text\n=== EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded ===\nMon Aug 31 18:45:22 UTC 2026\n\n--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---\n\n--- the containment guard GitPython owns, and its only two call sites ---\n33:    _to_relative_path,\n400:    def _to_relative_path(cls, parent_repo: \"Repo\", path: PathLike) -\u003e PathLike:\n407:            path = _to_relative_path(parent_repo.working_tree_dir, path)\n542:        path = cls._to_relative_path(repo, path)\n1041:        module_checkout_path = self._to_relative_path(self.repo, module_path)\n\n--- the parent fix (_validated_name) call sites: it validates the NAME ---\n309:    def _validated_name(cls, name: str) -\u003e str:\n321:        name = cls._validated_name(name)\n541:        cls._validated_name(name)\n788:            self._validated_name(self.name)\n1040:        self._validated_name(self.name)\n1181:        self._validated_name(self.name)\n1439:        self._validated_name(self.name)\n1440:        self._validated_name(new_name)\n1489:        self._validated_name(self.name)\n\n--- update(): name validated, path not; abspath -\u003e os.makedirs ---\n\n        try:\n            self._validated_name(self.name)\n\n            # ENSURE REPO IS PRESENT AND UP-TO-DATE\n                # END early abort if init is not allowed\n\n                checkout_module_abspath = self.abspath\n                module_abspath = self._module_abspath(self.repo, self.path, self.name)\n\n                # ``git submodule deinit`` leaves the repository in\n                # ``.git/modules`` and empties the checkout. Reconnect that retained\n                # repository instead of trying to clone over it.\n                if not dry_run and osp.isdir(module_abspath):\n                    try:\n                        git.Repo(module_abspath)\n                    except InvalidGitRepositoryError:\n                        pass\n                    else:\n                        if osp.lexists(checkout_module_abspath) and (\n                            osp.islink(checkout_module_abspath)\n                            or not osp.isdir(checkout_module_abspath)\n                            or os.listdir(checkout_module_abspath)\n                        ):\n                            raise OSError(\n                                \"Module directory at %r does already exist and is non-empty\" % checkout_module_abspath\n                            )\n                        os.makedirs(checkout_module_abspath, exist_ok=True)\n                        self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)\n                        mrepo = git.Repo(checkout_module_abspath)\n\n--- where self.path comes from (raw .gitmodules value) ---\n    def _set_cache_(self, attr: str) -\u003e None:\n        if attr in (\"path\", \"_url\", \"_branch_path\"):\n            reader: SectionConstraint = self.config_reader()\n            # Default submodule values.\n            try:\n                self.path = reader.get(\"path\")\n            except cp.NoSectionError as e:\n```","status":"published","published":"2026-09-30T23:47:12Z","modified":"2026-10-01T00:00:05.754996881Z","cvss":[{"version":"4.0","score":6.1,"vector":"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","level":"medium"}],"cwes":["CWE-22","CWE-73"],"updated_at":"2026-11-02T15:46:38.213481Z"},{"id":"UBUNTU-CVE-2026-100689","description":"GitPython before 3.1.62 does not validate the `path` field read from an untrusted .gitmodules file when updating submodules. While a prior fix (GHSA-hmq2-w58f-27jc) added Submodule._validated_name() to constrain the `name` field, and GitPython's own containment guard Submodule._to_relative_path() is applied in add() and move(), Submodule.update() derives the absolute checkout location from the raw `path` value without that guard. A .gitmodules entry containing directory traversal components (e.g., path = ../../../tmp/escaped) can therefore cause directories to be created via os.makedirs() outside the repository working tree, populated from the submodule URL on the clone path, and removed via shutil.rmtree() when force_remove is used. Exploitation requires an application flow that updates submodules at a non-HEAD commit (such as a historical-commit API); the common clone-then-update flow re-derives the path from a canonical tree lookup and is not affected. The issue is fixed in GitPython 3.1.62.","status":"published","published":"2026-09-26T14:16:00Z","modified":"2026-10-17T12:59:41.684229816Z","cvss":[{"version":"3.1","score":5.9,"vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:L","level":"medium"},{"version":"4.0","score":8.7,"vector":"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","level":"high"}],"updated_at":"2026-11-02T16:55:58.519486Z"},{"id":"CLSA-2026-1790833527","title":"TuxCare security update for gitpython (3 CVEs)","description":"TuxCare rebuilt gitpython to address 3 CVEs. Fixed in 3.1.31.post14+tuxcare.","status":"published","published":"2026-10-01T05:45:27Z","modified":"2026-10-15T09:48:02.358431482Z","updated_at":"2026-11-02T15:43:48.65893Z"},{"id":"openSUSE-SU-2026:11971-1","title":"python313-GitPython-3.2.0-1.1 on GA media","description":"These are all security issues fixed in the python313-GitPython-3.2.0-1.1 package on the GA media of openSUSE Tumbleweed.","status":"published","published":"2026-10-02T00:00:00Z","modified":"2026-10-03T17:23:09.029263667Z","updated_at":"2026-11-02T16:58:40.245664Z"}]}}
