Skip to content

Why Installing a Python Package Can Run Code

pip install has never meant “copy some files into site-packages.” A Python package gets three separate chances to run code on your machine: once when it installs, and twice more every time you start Python after that.

In March 2026, a malicious release of litellm exploited one of them. The payload was a .pth file that ran on every Python invocation and quietly sent cloud credentials and Kubernetes secrets to an attacker-controlled server. The other two surfaces are just as available, and the handbook’s supply-chain defenses only make sense once you can see all three.

Code runs when the package installs

When pip installs a package from a source distribution (an sdist), it doesn’t just copy files. It asks the package to build itself. That “ask” runs package-controlled Python code on your machine, with your permissions, before you have imported anything from the package.

In older projects the build code lives in setup.py at the project root. setup.py is an ordinary Python program: it can read your SSH keys or drop a payload into site-packages for later.

Modern projects declare their build backend in pyproject.toml. This is the PEP 517 build-system interface: pip reads the declaration and calls the named backend through a standardized API. Common backends are hatchling and the setuptools backend.

The backend runs in an isolated build environment, which is a real improvement over the old implicit setup.py workflow. Isolation is not sandboxing, though: the backend is still an ordinary Python program with the same ability to read your files or drop payloads as any legacy setup.py. Christopher Ariza reported at PyTexas 2026 that 454 of the top 1000 PyPI packages still ship a setup.py (per the bernat.tech recap), so this install-time surface is live today.

Installing a pre-built wheel sidesteps this surface. Wheels are archives; installing one unpacks files into site-packages without invoking a backend. But the files a wheel unpacks can include the next two surfaces.

Code runs at every Python startup through .pth files

A .pth file is a plain text file that lives in site-packages. When the interpreter starts, Python reads every .pth file it finds. Most lines are directory names that get added to sys.path, which is the benign use.

Lines that begin with the word import are something else. Python executes them as code at every interpreter startup, and keeps doing so until the file is removed. The user does not have to import the package, or even know the file exists.

This is what the litellm attack used. The malicious litellm==1.82.8 release shipped a file called litellm_init.pth. Anyone who installed that version (directly or as a transitive dependency) fired the payload the next time they typed any python command at all, including python -c "print(1)" in an unrelated terminal window.

Wheels can ship .pth files just as easily as sdists. A build backend feature that maps arbitrary filesystem paths into a wheel (hatch exposes this via tool.hatch.build.targets.wheel.force-include) will bundle one, and attackers have also been seen modifying legitimate wheels to slip a .pth in before redistributing them. The wheel format protects you at install time but not from what runs next.

Code runs at every Python startup through sitecustomize and usercustomize

After Python processes .pth files, it looks for a module called sitecustomize anywhere on sys.path and imports it. Whatever it finds first wins. The legitimate use is a system administrator adjusting defaults on a shared machine. The abuse case: a package drops a sitecustomize.py into site-packages, and from then on every python invocation in that environment runs it.

usercustomize is the per-user analogue. When the user’s site-packages directory is active (the default outside virtual environments), Python looks for usercustomize there and imports it the same way.

Match each defense to the surface it blocks

The handbook’s supply-chain guidance makes more sense once the surfaces are explicit.

Trusted publishing and digital attestations defend the upload side. If an attacker cannot upload a malicious release to PyPI in the first place, no downstream surface matters.

Pinning dependencies with hashes defends the retrieval side. An installer like uv that verifies each artifact against a recorded hash refuses substituted or tampered wheels, which removes the opportunity to inject a .pth file between PyPI and the destination machine.

Dependency cooldown via exclude-newer defends the timing side. Most compromised releases are yanked within hours or days; a rolling cooldown that refuses recently-published packages turns the community’s detection delay into insulation.

Running uv audit or pip-audit defends the disclosure side. Once an advisory database flags a compromised version, a CI-time scan catches a pinned-but-vulnerable dependency before it reaches production.

For direct inspection of an environment, fetter enumerates every file runnable at interpreter startup and validates the installed set against a lockfile. That makes it the right tool for auditing an environment that may already be compromised.

Because each measure blocks a different failure mode, the handbook recommends layering them rather than picking one.

Details worth knowing

When you are auditing an environment rather than reading for orientation, a few precise mechanics matter.

Failures from .pth execution are suppressed. If a line raises any exception that inherits from Exception, Python catches it, writes a short message to stderr, and skips the rest of that .pth file. On a CI runner or an IDE terminal the message is often invisible, and whatever the payload did before raising has already happened.

Failures from sitecustomize and usercustomize are suppressed too. An ImportError whose name attribute equals 'sitecustomize' (the “module doesn’t exist” case) is silenced. Any other exception is caught and reported to stderr, without a traceback unless PYTHONVERBOSE is set, and then suppressed; a payload that runs without raising leaves no trace at all.

Three CLI flags disable parts of this for audit work.

  • python -S skips site processing entirely. No .pth files, no sitecustomize, no usercustomize. This is the clean-room mode for auditing an environment you do not trust yet.
  • python -I (isolated mode) keeps site enabled but disables the user site-packages directory and ignores all PYTHON* environment variables.
  • python -s disables just the user site-packages directory.

Learn More

Last updated on

Please submit corrections and feedback...