Why Installing a Python Package Can Run Code
pip install has never meant “copy some files into site-packages.” A Python package gets four separate chances to run code on your machine: once when it installs, once whenever it gets imported, and twice more every time you start Python afterward.
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. Five weeks later, the lightning compromise used a different surface: a daemon thread spawned from the package’s __init__.py on import. The other surfaces are just as available, and the handbook’s supply-chain defenses only make sense once you can see all four.
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 the [build-system] table of pyproject.toml, the table PEP 518 introduced. The installer reads the declaration and calls the named backend through the standardized hook API from PEP 517. 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 three surfaces.
You can make the installer refuse sdists outright. uv takes --no-build (or the equivalent --only-binary :all:), and pip takes --only-binary :all::
uv pip install --no-build requests
pip install --only-binary :all: requestsWhen the only candidate for a package is an sdist, uv fails with Building source distributions is disabled and pip with No matching distribution found, and no build backend runs. uv applies the rule to local paths and local .tar.gz files too, though it will reuse a wheel it already built from the same sdist on an earlier run. pip’s flag covers only index and URL lookups: pip install --only-binary :all: ./mypkg still builds a local directory or archive.
Code runs when you import the package
The most ordinary execution surface is the one users intend. A package’s __init__.py is just Python: every import foo runs it, with the user’s permissions, in the user’s working directory. Anything the package wants to do at import time happens before the import statement returns.
The April 2026 release of lightning used this surface. Versions 2.6.2 and 2.6.3 shipped a hidden _runtime/ directory; __init__.py spawned a daemon thread that ran the payload chain in the background while the import returned normally. Anyone who installed a bad version and then ran import lightning (the standard opener for a PyTorch Lightning workflow) triggered the chain.
Compared to .pth, the import surface is narrower and broader at the same time. Narrower because the user has to import the package; a malicious release that nobody imports does nothing. Broader because most installed packages do get imported eventually, and the import is part of a workflow the developer is already running, so the payload blends into a process the user expects to be active. Like the install-time surface, the import-time surface uses ordinary Python: anything __init__.py would legitimately run for logging setup, deferred imports, or plugin discovery is also available to a malicious release.
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. The wheel format protects you at install time but not from what runs next.
Python 3.15 retires the .pth import line
PEP 829 (Package Startup Configuration Files) closes this surface from the runtime side. Python 3.15 ships a <name>.start file that holds startup entry points as pkg.mod:callable names, and .pth keeps only the sys.path job. .pth import lines still run on 3.15 through 3.17; Python 3.18 stops running them. The PEP 829 page walks through the file format and the full timeline.
The security implication is that on Python 3.18 and later, a future litellm-style payload shipped as a .pth import line stops running on its own. Until 3.18 is the floor on the systems a project runs on, the surface is open and the defenses on this page still apply.
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 or a malicious __init__.py between PyPI and the destination machine.
A 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 covers .pth and sitecustomize, but the import surface is harder to audit retrospectively: an __init__.py only runs when the user imports the package, so a static scan cannot observe its behavior without executing the code under inspection. For a package that no automated layer has vetted, how to vet a Python package before installing it covers the manual checks.
Because each measure blocks a different failure mode, the handbook recommends layering them rather than picking one.
Know how startup failures hide
When you are auditing an environment rather than reading for orientation, a few precise mechanics matter.
Failures from .pth execution never stop the interpreter, and how much they hide depends on the Python version. On Python 3.14 and earlier, an exception in an import line prints Error processing line N of <file>: plus a traceback to stderr, then Remainder of file ignored, and the rest of that .pth file is skipped. On Python 3.15, the interpreter prints Error in import line from <file>: <line> with the traceback and continues with the next line, so a later line in the same file still runs:
$ python -c "print('hello')"
line 1 ran
Error in import line from .../site-packages/zz_fail.pth: import sys; raise RuntimeError("boom")
Traceback (most recent call last):
File "<frozen site>", line 533, in _exec_imports
File "<string>", line 1, in <module>
RuntimeError: boom
line 3 ran
hello
Either way the exit code is 0. 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 -Sskipssiteprocessing entirely. No.pthfiles, nositecustomize, nousercustomize. This is the clean-room mode for auditing an environment you do not trust yet.python -I(isolated mode) keepssiteenabled but disables the usersite-packagesdirectory and ignores allPYTHON*environment variables.python -sdisables just the usersite-packagesdirectory.