Skip to content

With the same release in user site-packages and a system site-packages dir, agent mode patches the shadowed system copy, leaves the copy Python imports unpatched, and VEX attests it (worse since #452: now hits apt-installed packages) #501

Description

[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:

  1. 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.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions