[agent] Found by the scheduled Poetry bug-hunt routine (ledger #311).
Summary
When one PyPI release (here six==1.16.0) is installed both in the user site (~/.local/lib/python3.X/site-packages, from pip install --user) and in a system site dir (/usr/lib/python3/dist-packages from apt, or /usr/local/lib/python3.X/dist-packages from pip), every global-scope agent run patches the system copy only:
Python imports the user-site copy, because sys.path puts user site before the system dirs. That copy stays unpatched. The run reports success, apply re-runs say already_patched, and vex attests not_affected / inline_mitigations_already_exist. On Debian/Ubuntu the write also lands in a dpkg-owned file (/usr/lib/python3/dist-packages/six.py).
Root cause, two parts:
get_global_python_site_packages orders paths as site.getsitepackages() first and site.getusersitepackages() last (crates/socket-patch-core/src/crawlers/python_crawler.rs:1316, plus the well-known-dir scan after it, which also adds ~/.local last). That's the reverse of Python's import precedence.
- Apply keeps PyPI on a "one representative" contract and patches only
pkg_paths.first() (crates/socket-patch-cli/src/commands/apply.rs:1861 and :1888-1898). The comment there assumes "their crawlers resolve one install dir per version", which doesn't hold in global scope. rollback does fan out to every copy (it reports rolledBack: 1, alreadyOriginal: 1), so the two commands disagree on how many copies exist.
#452 (2035cbb) made this much easier to hit. Before it, apt's .egg-info installs were invisible, so with apt six plus a user-site six the user copy was the only candidate and got patched correctly. Since #452 the apt copy is listed first and wins. With two dist-info copies (/usr/local/.../dist-packages plus user site), the bug already exists in v4.0.0.
Impact
- A false VEX attestation. The imported copy is still vulnerable, and nothing warns.
- On Debian/Ubuntu (apt ships
six, requests, urllib3, idna, certifi, … as egg-info), -g writes into dpkg-owned files while leaving the copy actually in use unpatched.
- Poetry users with
virtualenvs.create = false (common in Docker images and CI) get the same result from a plain project scan.
Repro (Linux, Debian bookworm image, Python 3.11, Poetry 2.5.1)
The mock patch API serves one agent patch for pkg:pypi/six@1.16.0 that appends SOCKET_PATCHED = 1 to six.py. It uses the same routes as tests/vex_pypi_real_common/mod.rs, plus /patches/blob/<hash>. $A = --api-url http://127.0.0.1:18080 --api-token fake --org test-org --patch-server-url http://127.0.0.1:18080.
# apt's python3-six 1.16.0 egg-info is in /usr/lib/python3/dist-packages
python3 -m pip install --user --ignore-installed --break-system-packages six==1.16.0
python3 -c 'import six; print(six.__file__)' # -> /root/.local/lib/python3.11/site-packages/six.py
mkdir nc && cd nc
cat > pyproject.toml <<'EOF'
[tool.poetry]
name = "demo"
version = "0.1.0"
description = ""
authors = ["x <x@x>"]
package-mode = false
[tool.poetry.dependencies]
python = "^3.11"
six = "1.16.0"
EOF
printf '[virtualenvs]\ncreate = false\n' > poetry.toml
poetry lock && poetry install # "No dependencies to install or update"
poetry run python -c 'import six; print(six.__file__)' # user-site copy
socket-patch scan --mode agent --yes $A # success (same with: mkdir g && cd g && socket-patch scan -g --mode agent --yes $A)
grep -c SOCKET_PATCHED /usr/lib/python3/dist-packages/six.py # 1 <- shadowed copy patched
grep -c SOCKET_PATCHED ~/.local/lib/python3.11/site-packages/six.py # 0 <- imported copy unpatched
python3 -c 'import six; print(hasattr(six, "SOCKET_PATCHED"))' # False
socket-patch apply --json $A # skipped / already_patched
socket-patch vex --product pkg:pypi/demo@0.1.0 --json --output v.json $A # not_affected / inline_mitigations_already_exist
socket-patch rollback --json $A # rolledBack: 1, alreadyOriginal: 1 (it sees both copies)
It reproduces 2/2 for each of: scan -g --mode agent, vex -g, and the Poetry create = false project scan.
Expected vs actual
- Expected: agent mode patches the copy the interpreter loads, or every installed copy as gem/npm do (
apply.rs comment: "leaving the other store pristine is a silent false 'applied'"). VEX only attests when the loaded copy is patched. CLI_CONTRACT.md's VEX rule is that a statement is emitted only for a patch that is actually applied.
- Actual: the shadowed copy is patched, the loaded copy isn't, and VEX attests anyway.
OS × version
| OS |
Copies present |
Binary |
Result |
| Linux |
apt egg-info + user site |
main 61cfb9b, 2035cbb (#452) |
fail: apt copy patched |
| Linux |
apt egg-info + user site |
v4.0.0, 2035cbb^ |
pass: user copy patched |
| Linux |
/usr/local/.../dist-packages dist-info + user site |
main, 2035cbb^, v4.0.0 |
fail: /usr/local copy patched |
| Linux |
/usr/local/.../dist-packages only |
main |
pass |
| macOS / Windows |
— |
— |
untested (probe branches blocked). macOS user site (~/Library/Python/3.X/lib/python/site-packages) has the same ordering, so it's likely affected. |
Poetry versions: 2.5.1 (create = false, user-site copy kept). With create = false, Poetry 1.8.5 / 2.0.1 / 2.2.1 in this image reinstalled six into /usr/local/lib/python3.11/dist-packages, which precedes apt on sys.path, so those didn't reproduce until a user-site copy was re-added. The -g path doesn't depend on the Poetry version.
First bad commit
For the apt + user-site case: 2035cbb ("Fix Python crawler missing .egg-info installs (#447) (#452)"), bisected with release builds of 2035cbb^ and 2035cbb, 2/2 each. For two dist-info copies, it never worked (v4.0.0 fails).
Suspect code
[agent] Found by the scheduled Poetry bug-hunt routine (ledger #311).
Summary
When one PyPI release (here
six==1.16.0) is installed both in the user site (~/.local/lib/python3.X/site-packages, frompip install --user) and in a system site dir (/usr/lib/python3/dist-packagesfrom apt, or/usr/local/lib/python3.X/dist-packagesfrom pip), every global-scope agent run patches the system copy only:scan -g --mode agent, andscan --mode agentin a Poetry project withvirtualenvs.create = false(or any layout where the project venv isn't found and the crawler falls back to the global interpreter, e.g. Poetry with virtualenvs.in-project = true but no ./.venv keeps using its out-of-tree env, which socket-patch never probes: agent patches the global interpreter and hosted VEX falsely attests #476).Python imports the user-site copy, because
sys.pathputs user site before the system dirs. That copy stays unpatched. The run reportssuccess,applyre-runs sayalready_patched, andvexattestsnot_affected/inline_mitigations_already_exist. On Debian/Ubuntu the write also lands in a dpkg-owned file (/usr/lib/python3/dist-packages/six.py).Root cause, two parts:
get_global_python_site_packagesorders paths assite.getsitepackages()first andsite.getusersitepackages()last (crates/socket-patch-core/src/crawlers/python_crawler.rs:1316, plus the well-known-dir scan after it, which also adds~/.locallast). That's the reverse of Python's import precedence.pkg_paths.first()(crates/socket-patch-cli/src/commands/apply.rs:1861and:1888-1898). The comment there assumes "their crawlers resolve one install dir per version", which doesn't hold in global scope.rollbackdoes fan out to every copy (it reportsrolledBack: 1, alreadyOriginal: 1), so the two commands disagree on how many copies exist.#452 (2035cbb) made this much easier to hit. Before it, apt's
.egg-infoinstalls were invisible, so with aptsixplus a user-sitesixthe user copy was the only candidate and got patched correctly. Since #452 the apt copy is listed first and wins. With two dist-info copies (/usr/local/.../dist-packagesplus user site), the bug already exists in v4.0.0.Impact
six,requests,urllib3,idna,certifi, … as egg-info),-gwrites into dpkg-owned files while leaving the copy actually in use unpatched.virtualenvs.create = false(common in Docker images and CI) get the same result from a plain projectscan.Repro (Linux, Debian bookworm image, Python 3.11, Poetry 2.5.1)
The mock patch API serves one agent patch for
pkg:pypi/six@1.16.0that appendsSOCKET_PATCHED = 1tosix.py. It uses the same routes astests/vex_pypi_real_common/mod.rs, plus/patches/blob/<hash>.$A=--api-url http://127.0.0.1:18080 --api-token fake --org test-org --patch-server-url http://127.0.0.1:18080.It reproduces 2/2 for each of:
scan -g --mode agent,vex -g, and the Poetrycreate = falseproject scan.Expected vs actual
apply.rscomment: "leaving the other store pristine is a silent false 'applied'"). VEX only attests when the loaded copy is patched. CLI_CONTRACT.md's VEX rule is that a statement is emitted only for a patch that is actually applied.OS × version
61cfb9b, 2035cbb (#452)/usr/local/.../dist-packagesdist-info + user site/usr/localcopy patched/usr/local/.../dist-packagesonly~/Library/Python/3.X/lib/python/site-packages) has the same ordering, so it's likely affected.Poetry versions: 2.5.1 (
create = false, user-site copy kept). Withcreate = false, Poetry 1.8.5 / 2.0.1 / 2.2.1 in this image reinstalledsixinto/usr/local/lib/python3.11/dist-packages, which precedes apt onsys.path, so those didn't reproduce until a user-site copy was re-added. The-gpath doesn't depend on the Poetry version.First bad commit
For the apt + user-site case: 2035cbb ("Fix Python crawler missing .egg-info installs (#447) (#452)"), bisected with release builds of 2035cbb^ and 2035cbb, 2/2 each. For two dist-info copies, it never worked (v4.0.0 fails).
Suspect code
crates/socket-patch-core/src/crawlers/python_crawler.rs:1316(user site printed last) and the well-known-dir order right after it.crates/socket-patch-cli/src/commands/apply.rs:1861,:1888-1898(PyPI patches onlypkg_paths.first()).--system-site-packagesvenv, pip keeps the base interpreter's unpatched copy after a hosted rewrite, no stale-install warning fires, andvexattests it as patched #409 (--system-site-packagesvenv, hosted), Poetry with virtualenvs.in-project = true but no ./.venv keeps using its out-of-tree env, which socket-patch never probes: agent patches the global interpreter and hosted VEX falsely attests #476 (Poetry venv not found, so this fallback is reached).