Skip to content

Vendored pip: a pin in a -r include outside the project root is ignored when a pylock.toml exists, so vendor --check passes and vex attests not_affected while pip install -r requirements.txt installs the unpatched release #1851

Closed

Description

[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).

Summary

Consider a project whose root requirements.txt pins a package only through a -r include outside the project root (for example -r ../shared/base.txt holding six==1.16.0), and which also has a pip lock pylock.toml. In that project, scan --mode vendored (and the hosted → vendored eject) wires the package in pylock.toml only. It leaves the out-of-root pin on PyPI and exits 0. Every follow-up check then calls the project patched:

  • The scan reports applied with a pypi_multiple_lockfiles skip ("wiring pylock.toml; installs driven by requirements.txt retain their existing sources"), and exits 0.
  • vendor --check prints pkg:pypi/six@1.16.0: committed artifact and wiring verified and exits 0.
  • vex, run against a venv installed with pip install -r requirements.txt (unpatched six), attests pkg:pypi/six@1.16.0 as not_affected and exits 0. Its only hint is "the installed tree does not match its vendored artifact … re-run your package manager's install to resync it". That advice is wrong: reinstalling from requirements.txt installs the upstream wheel again.

Two related layouts behave correctly:

So the out-of-root include gets past both the planner's refusal and discovery's contest check.

Impact

Any pip install -r requirements.txt consumer (CI, Dockerfiles) of a monorepo-style project that shares a base requirements file from a parent directory stays on the vulnerable release. Meanwhile the CLI, vendor --check and the VEX document all report it as patched. The false VEX attestation is the serious part.

Repro

This uses the repo's usual patch-API mock: batch / by-package / view / package grant pkg:pypi/six@1.16.0 and pkg:pypi/idna@3.7, and each artifact is the real PyPI wheel with SAFE = True appended. idna and certifi come from real PyPI. Real pip 26.2.2 / CPython 3.12.

mkdir -p shared app && cd app
printf 'six==1.16.0\n' > ../shared/base.txt
printf -- '-r ../shared/base.txt\nidna==3.7\ncertifi==2024.8.30\n' > requirements.txt
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/pip lock -r requirements.txt -o pylock.toml
VIRTUAL_ENV=$PWD/.venv socket-patch scan --mode vendored --yes   # exit 0
#   skipped pkg:pypi/six@1.16.0 pypi_multiple_lockfiles: "wiring pylock.toml; installs driven by requirements.txt retain their existing sources"
#   requirements.txt: idna line vendored; ../shared/base.txt untouched (six==1.16.0)
#   pylock.toml: six and idna -> .socket/vendor/pypi/<uuid>/...whl
socket-patch vendor --check                                    # exit 0, "six: committed artifact and wiring verified"
python -m venv fresh && fresh/bin/pip install --no-cache-dir -r requirements.txt
fresh/bin/python -c 'import six; print(hasattr(six, "SAFE"))'    # False (upstream PyPI wheel)
VIRTUAL_ENV=$PWD/fresh socket-patch vex --product pkg:pypi/app@1 -O vex.json   # exit 0
#   statements[0].status = not_affected, subcomponents = [idna@3.7, six@1.16.0]

Control: delete pylock.toml first, and the same scan --mode vendored fails six with pypi_requirements_outside_root (exit 1).

Expected vs actual

  • Expected: this layout gets the same refusal as the no-pylock case. The planner's own comment on pypi_requirements_outside_root says: "an include outside the project root cannot be edited by a committable vendor flow; rewriting only the in-root copy would leave pip a duplicate requirement. Fail closed." A pylock beside it doesn't change what pip install -r requirements.txt installs. Failing that, discovery should contest the pylock wiring, as it does for an in-root pin (CLI_CONTRACT.md "Contested locks": a lock that resolves the same version from elsewhere contests the wiring, vendor --check fails and vex omits it). vex must not attest not_affected for an install source that delivers the upstream release.
  • Actual: exit 0 and vendor --check exit 0. vex attests not_affected while pip installs unpatched six.

Matrix (Linux; the code paths are OS-independent)

pip / Python mode requirements.txt shape scan vendor --check pip install -r six vex six
26.2.2 / 3.12 vendored -r ../shared/base.txt (out of root) + pylock 0 0 (verified) unpatched not_affected (reproduced 3/3)
26.2.2 / 3.12 hosted → vendor (eject) same 0 0 unpatched not_affected
25.1 / 3.12 vendored same 0 0 unpatched not_affected
26.2.2 / 3.12 vendored same, no pylock 1 pypi_requirements_outside_root n/a unpatched (refused, fine) not attested
26.2.2 / 3.12 vendored in-root six[foo]==1.16.0 + pylock (#1794 shape) 0 1 (contested) unpatched omitted (correct)
26.2.2 / 3.12, 25.1 / 3.13 hosted out-of-root include + pylock 0, redirect_requirements_entry_not_found n/a unpatched not attested (correct)

Tested on main c07af61 (v5.0.1 is the latest release). No first-bad bisect: the out-of-root filter in discovery dates from the include walk itself.

Suspect code

No probe runs: this routine's probe branches are still blocked, so this was reproduced on Linux only.

Activity

  1. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged: priority:p1 (pip). This is a false VEX attestation. It's related to #1794 / PR #1796 (the same pypi_multiple_lockfiles loser path in detect_pypi_flavor), but it isn't covered there: #1796 only refuses extras pins. Here an out-of-root -r include is pushed as a loser, and discovery drops out-of-root includes, so nothing contests the pylock wiring.

    [agent] Claiming this issue (shared root cause: the vendored PyPI lock wiring and discovery ignore a pin that pip installs from an out-of-root -r include). Branch: agent/fix-pypi-out-of-root-include-pylock. Claim-ID: 2026-10-29T20:20:56Z-b35064


    Generated by Claude Code

  2. mikolalysenko commented on Oct 29, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1853


    Generated by Claude Code

  3. mikolalysenko commented on Oct 30, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] pip bug-hunt (ledger #309), new information: an absolute-path include (-r /abs/path/shared/base.txt holding six==1.16.0) beside a pip lock pylock.toml reproduces the same outcome on main 5ca5623 (pip 26.2.2 / CPython 3.12). scan --mode vendored exits 0 with the pypi_multiple_lockfiles skip, vendor --check exits 0, a fresh pip install -r gets unpatched six, and vex attests six not_affected.

    PR #1853's head (70b2147) handles it, verified with real pip:

    Binary include scan --mode vendored vendor --check on the project main vendored vex six
    main 5ca5623 absolute 0, pylock-only wiring 0 not_affected
    PR #1853 absolute 1, pypi_requirements_outside_root, pylock untouched 1, "wiring contested: … base.txt resolves the same version from elsewhere" omitted
    PR #1853 ../shared/base.txt 1, pypi_requirements_outside_root – –

    A nit for the PR, not a blocker: the contested message ends "delete whichever of the two locks the project does not install from", but base.txt isn't a lock.


    Generated by Claude Code

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

    agent:triagedbugSomething isn't workingbughuntFound by a scheduled package-manager bug-hunt agentpm:pippip / requirements.txtpriority:p1

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions