# Python Supply Chain Security

> Python supply chain security keeps PyPI malware and vulnerable packages out of Python environments, from sdist builds to .pth startup hooks and imports.

Also known as: Python security, PyPI malware, PyPI security, Python package security, malicious PyPI packages, Python supply chain attack, pip security

Source: https://socket.dev/glossary/python

Last updated: 2026-10-27

Python supply chain security is the practice of keeping vulnerable and malicious packages, including PyPI malware, out of Python environments. A Python package can run code at four points: an sdist build, interpreter startup through `.pth` files, import, and console entry points. Every PyPI compromise we analyzed in 2026 ran at startup or import, not during `pip install`.

Startup and import execution change what a defense has to do. The usual Python advice is to install only wheels, pin hashes, and prefer projects with trusted publishing. Each of those closes one door. None of them answers the question that matters: is the code in this wheel safe to have on disk? Once a file lands in `site-packages`, the next `python` or `pip` command can run it. Our position is that Python dependencies have to be judged before install, from the contents of the distribution file, not from the project page, the JSON API, or a provenance badge. As of October 2026, no Python installer does this by default.

## Where Python runs a package's code

Python packages ship in two formats on [PyPI](https://socket.dev/glossary/python-package-index-pypi). A source distribution (sdist) is a tarball that has to be built. A [wheel](https://socket.dev/glossary/wheel-python-packaging-format) is a zip archive that is already built.

Building an sdist means running code from the package. Under [PEP 517](https://peps.python.org/pep-0517/), the installer creates an isolated environment, installs the build backend the sdist names in `pyproject.toml`, and calls hooks such as `prepare_metadata_for_build_wheel`. For setuptools projects, that executes [setup.py](https://socket.dev/glossary/setup-py-python). The installer needs that metadata to learn the package's dependencies, so the build runs during resolution, before anything is installed.

Installing a wheel runs no code from the package. [pip](https://socket.dev/glossary/pip-python-package-index) unzips it, writes files into `site-packages` and `bin/`, and records them. That property is why [pip's secure-installs guide](https://pip.pypa.io/en/stable/topics/secure-installs/) recommends `--only-binary :all:`. The advice is right for the install step and misleading for everything after it.

After install, a package's code runs at three more points:

- **Interpreter startup.** The `site` module scans `site-packages` for `.pth` files on every Python start. Any line beginning with `import` is passed to `exec()`. A top-level `sitecustomize.py` is imported too.
- **Import.** `import pkg` executes `pkg/__init__.py` and anything it imports. Loading a native extension (`.so` or `.pyd`) runs its C initialization code.
- **Entry points.** A console script such as `pkg-cli` runs whatever function the package registered.

> Figure 1. Where a PyPI package's code runs, and which 2026 compromises used each point.
>
> The five stages a PyPI package passes through on your machine, which ones run package code, and which 2026 compromises used each

| Stage | What runs | Triggered by | Stopped by | 2026 compromises that used it |
| --- | --- | --- | --- | --- |
| 1. Resolve and build (sdist only) | setup.py or the PEP 517 build backend | pip install, pip download, pip install --dry-run | --only-binary :all: in pip, --no-build in uv | MemoryOS 2.0.34 sdist, which swapped the build backend |
| 2. Install (wheel) | No package code. Files are written to site-packages, including any .pth or sitecustomize.py | pip install, uv pip install, uv sync | Not applicable | None |
| 3. Interpreter startup | import lines in every .pth file, plus sitecustomize.py | Any python or pip process, including pip list | Only python -S | Hades wave (June), litellm 1.107.0 (October) |
| 4. Import | \_\_init\_\_.py, module top-level code, native extension initialization | import pkg, directly or through another dependency | Never importing the package | dydx-v4-client (January), telnyx (March), lightning (April), Hades native extensions (June), MemoryOS (September) |
| 5. Entry point | The function registered as a console script | Running the command, or uvx and pipx run | Not running it | None in this set |

## Why wheels-only installs do not stop PyPI malware

Wheels-only installs remove exactly one of the four execution points. We built two small packages to show where the remaining risk sits. Both reproduce with pip 26.2.1 and CPython 3.13.

The first is an sdist whose `setup.py` writes a marker file when it runs:

```python
from setuptools import setup
import os
# Runs whenever pip has to build this sdist, including metadata preparation
open("/tmp/setup_py_ran", "a").write("setup.py executed by pid %d\n" % os.getpid())
setup(name="demo-pkg", version="1.0.0", packages=["demo_pkg"], package_dir={"": "src"})
```

`pip download` sounds like a passive fetch. It is not. To resolve dependencies, pip prepares the sdist's metadata, and that executes `setup.py` twice. `pip install --dry-run` does the same.

```sh
$ pip download --no-deps --find-links ./dist demo-pkg==1.0.0 -d ./dl
Looking in links: ./dist
Processing ./dist/demo_pkg-1.0.0.tar.gz
  Installing build dependencies: started
  Installing build dependencies: finished with status 'done'
  Getting requirements to build wheel: started
  Getting requirements to build wheel: finished with status 'done'
  Preparing metadata (pyproject.toml): started
  Preparing metadata (pyproject.toml): finished with status 'done'
Saved ./dl/demo_pkg-1.0.0.tar.gz
Successfully downloaded demo-pkg
$ cat /tmp/setup_py_ran
setup.py executed by pid 791
setup.py executed by pid 792
```

`--only-binary :all:` stops this. pip refuses the sdist and nothing runs. In the same test, `uv pip compile` and `uv pip install --dry-run` resolved the sdist from its `PKG-INFO` without running `setup.py`. That is a convenience, not a control: the sdist author decides whether its metadata is static.

Hash pinning does not stop it either. We pinned the sdist with `--hash` and ran pip in hash-checking mode. pip verified the sdist, then fetched the build backend, `setuptools>=61`, unpinned and unhashed, resolved it to setuptools 84.2.1, and ran `setup.py`. The verbose output below is trimmed to the relevant lines:

```sh
$ pip install --no-cache-dir --dry-run --no-deps --find-links ./dist -r req.txt -v
  Collecting setuptools>=61
  Using cached setuptools-84.2.1-py3-none-any.whl (876 kB)
  Installing collected packages: setuptools
  Successfully installed setuptools-84.2.1
Would install demo-pkg-1.0.0
$ cat /tmp/setup_py_ran
setup.py executed by pid 959
setup.py executed by pid 960
```

Build dependencies live outside `--require-hashes`. pip's `--build-constraint` option can pin them, but nothing pins them by default.

The second package is a wheel with one extra file, `pth_demo-hook.pth`, holding a single `import` line that prints to stderr once per process. Installing it runs nothing. Every later interpreter start runs it, including pip itself. The `pip list` output is truncated:

```sh
$ pip install --no-index --find-links ./dist pth-demo==1.0.1
Successfully installed pth-demo-1.0.1
$ python -c "print(1 + 1)"
[pth_demo-hook.pth] ran in pid 741: ['-c']
2
$ pip list
[pth_demo-hook.pth] ran in pid 742: ['./v2/bin/pip', 'list']
Package  Version
$ python -I -c pass
[pth_demo-hook.pth] ran in pid 744: ['-c']
$ python -S -c pass
$
```

Isolated mode (`-I`) does not help. Only `-S`, which disables the `site` module entirely, prevents the hook. Almost nothing runs Python that way. The [Python documentation](https://docs.python.org/3/library/site.html) is explicit: executable `.pth` lines "are always run at Python startup" regardless of whether the package is ever used.

The hook also fires when you investigate. Running `pip list` or `pip show` in an infected environment to see what is installed executes the payload again.

Python 3.15, released on October 9, 2026, starts to close this. [PEP 829](https://peps.python.org/pep-0829/) deprecates `import` lines in `.pth` files and adds `.start` files that name an entry point instead. Import lines keep running through Python 3.17 and are ignored from 3.18. The replacement still runs code at startup, just in a structured form. The PEP's own security section says it plainly: "The overall pre-start code execution attack surface is not eliminated by this PEP."

## Six 2026 PyPI compromises and where each one executed

Attackers have read the same playbook. The [Telnyx SDK compromise](https://socket.dev/blog/telnyx-python-sdk-compromised) in March 2026 put its payload in `src/telnyx/_client.py`, which every `import telnyx` loads. Our analysis noted why: install hooks are among the most heavily monitored signals in PyPI malware detection, so the payload moved to import time.

| Date (2026) | Package and versions | Initial access | Where the code ran | Scale |
| --- | --- | --- | --- | --- |
| January 27 | dydx-v4-client 1.1.5post1 | Consistent with a maintainer account compromise, unconfirmed | Import: \_bootstrap.py starts a remote access trojan on first import | Client library for a derivatives exchange, compromised on npm at the same time |
| March | telnyx 4.87.1, 4.87.2 | Stolen credentials, traced by PyPI to a hijacked GitHub Action used in the project's CI | Import: injected into \_client.py | Official Telnyx SDK with tens of thousands of monthly downloads |
| April 30 | lightning 2.6.2, 2.6.3 | Likely a project-scoped PyPI token | Import: \_runtime/start.py downloads Bun and runs an 11 MB obfuscated JavaScript payload | Hundreds of thousands of daily downloads |
| Early June | dynamo-release, coolbox, embiggen, langchain-core-mcp, and others | Maintainer account takeover, plus typosquats such as rsquests | Startup: \*-setup.pth. Import: trojanized .abi3.so native extensions | 60 artifacts across 37 PyPI packages |
| September 23 | MemoryOS 2.0.34 | Unconfirmed. Malicious commits appeared in MemTensor's GitHub repositories first | Import: hooked get\_logger(), which 149 modules call at import | Wheel grew from 951,210 to 19,201,772 bytes |
| October 21 | litellm 1.107.0 | Release workflow abuse after attackers held the GitHub org for at least 11 days | Startup: cook.pth plus sitecustomize.py | About 23 million weekly downloads |

The [dYdX client compromise](https://socket.dev/blog/malicious-dydx-packages-published-to-npm-and-pypi) shows the pattern in miniature: the same actor shipped a wallet stealer to npm and PyPI at once, and the Python version added a trojan that starts the moment the package is imported. The [lightning compromise](https://socket.dev/blog/lightning-pypi-package-compromised) and the [Hades wave of the Miasma Mini Shai-Hulud campaign](https://socket.dev/supply-chain-attacks/miasma-mini-shai-hulud-supply-chain-attack) both shipped JavaScript payloads that ran under Bun, downloaded at runtime. The Python package was only the delivery vehicle. In the [Hades bioinformatics wave](https://socket.dev/blog/mini-shai-hulud-miasma-and-hades-worms-target-bioinformatics-and-mcp-developers-via-malicious), the visible Python source looked clean because the trigger lived in compiled native code.

The [MemoryOS compromise](https://socket.dev/blog/memtensor-compromise) also shipped a tampered sdist that replaced the project's Poetry build backend with a bundled `sckit_poetry_build.py`, aimed at MemTensor's own release pipeline. Build-time execution is not gone. It moved from end users to maintainers' CI.

The [LiteLLM 1.107.0 compromise](https://socket.dev/blog/litellm-compromise) is the clearest case of startup execution. Its seven wheels carried `cook.pth`, `cook.py`, and `sitecustomize.py` outside the `litellm/` package directory. The collector enabled 16 modules, reaching cloud metadata endpoints, SSH and GPG keys, and AI coding tool credentials such as `~/.claude/.credentials.json`. [Our follow-up timeline](https://socket.dev/blog/litellm-attack-timeline) found secret-exfiltration workflows running in the project's GitHub organization from October 10.

## How do attackers get malware onto PyPI?

Attackers get malware onto PyPI by publishing new packages under misleading names or by publishing new versions of real projects with stolen credentials. The two routes need different defenses.

New packages rely on someone choosing the wrong name. [Typosquatting](https://socket.dev/glossary/typosquatting) is the oldest form: [fabrice, a typosquat of the fabric SSH library](https://socket.dev/blog/malicious-python-package-typosquats-fabric-ssh-library), was live on PyPI from 2021 until at least November 2024 and collected more than 37,000 downloads while stealing AWS keys. The [TrapDoor campaign](https://socket.dev/supply-chain-attacks/trapdoor-crypto-stealer) in May 2026 used each registry's own trigger: `postinstall` on npm, `build.rs` on crates.io, and import-time execution on PyPI. [Dependency confusion](https://socket.dev/glossary/dependency-confusion) exploits installers that merge indexes. pip's `--extra-index-url` picks the best version across every index, while uv stops at the first index that has the package [to prevent dependency confusion](https://docs.astral.sh/uv/concepts/indexes/). [Revival hijacking](https://socket.dev/blog/how-socket-protects-against-revival-hijacking-attacks-on-pypi) re-registers names their owners deleted.

Compromised projects are worse, because users already trust the name and their version ranges already accept the next release. Where the access path is known, each incident in the table above started with a [maintainer account takeover](https://socket.dev/glossary/maintainer-account-takeover) or a stolen CI or publishing credential. PyPI traced the March 2026 LiteLLM and Telnyx compromises to the projects' mutable references to a GitHub Action hijacked in the [Trivy GitHub Actions compromise](https://socket.dev/supply-chain-attacks/trivy-github-actions-compromise). In July 2026, PyPI [began rejecting new files on releases older than 14 days](https://blog.pypi.org/posts/2026-07-22-releases-now-reject-new-files-after-14-days/), so a stolen token can no longer add a malicious wheel to an old, pinned version. PyPI says not to rely on that behavior until it has a defined API.

PyPI's response has improved. The registry [processed more than 2,000 malware reports in 2025](https://blog.pypi.org/posts/2025-12-31-pypi-2025-in-review) and handled 66% of them within 4 hours. Four hours is still a long window for CI jobs that resolve fresh versions on every run.

## Trusted publishing proves origin, not intent

[Trusted publishing](https://socket.dev/glossary/npm-trusted-publishing) replaces long-lived PyPI API tokens with short-lived ones. A CI job presents an OpenID Connect token, PyPI checks it against the repository and workflow the project registered, and mints an upload token valid for 15 minutes. Since November 2024, PyPI also [stores digital attestations](https://socket.dev/blog/pypi-introduces-digital-attestations), defined in [PEP 740](https://peps.python.org/pep-0740/), that bind each file's hash to that identity.

The [Simple API](https://peps.python.org/pep-0691/) exposes all of it per file. Three fields matter for security:

```sh
$ curl -s -H 'Accept: application/vnd.pypi.simple.v1+json' \
    https://pypi.org/simple/sampleproject/ \
  | jq '.files[-2] | {filename, "upload-time", provenance, "core-metadata", hashes}'
{
  "filename": "sampleproject-4.0.0-py3-none-any.whl",
  "upload-time": "2024-11-06T22:37:09.220617Z",
  "provenance": "https://pypi.org/integrity/sampleproject/4.0.0/sampleproject-4.0.0-py3-none-any.whl/provenance",
  "core-metadata": {
    "sha256": "067ccfe9a9c2bab291a27fa8662536adbd63ab12e3da003ae5dffdb0d20b2061"
  },
  "hashes": {
    "sha256": "c23e447ea90d796d1e645c35c4b2de125040add12a845825546f91c93f391b6b"
  }
}
$ curl -s https://pypi.org/integrity/sampleproject/4.0.0/sampleproject-4.0.0-py3-none-any.whl/provenance \
  | jq '.attestation_bundles[].publisher'
{
  "claims": null,
  "environment": "",
  "kind": "GitHub",
  "repository": "pypa/sampleproject",
  "workflow": "release.yml"
}
```

`upload-time` is what cooldowns key on. `provenance` names the repository and workflow that built the file. `core-metadata` is a separate copy of the wheel's `METADATA` file, which pip downloads to resolve dependencies without fetching the whole wheel.

Adoption is real but partial. [PyPI reports](https://blog.pypi.org/posts/2025-12-31-pypi-2025-in-review) that more than 20% of uploads in 2025 came through trusted publishers, and 17% carried an attestation.

Provenance answers where a file came from. It does not answer whether the code is safe. The [Ultralytics compromise](https://socket.dev/blog/ultralytics-pypi-package-compromised-through-github-actions-cache-poisoning) in December 2024 shipped a crypto miner in 8.3.41 with valid attestations, because the attacker poisoned the GitHub Actions cache of the legitimate release workflow. LiteLLM 1.107.0's container provenance recorded a lookalike source repository, `Berri-AI/litellm`, one hyphen away from `BerriAI/litellm`. A valid signature over the wrong inputs is still valid.

Provenance is necessary. It can catch an attacker who steals a token and publishes from a laptop, if consumers record which publisher to expect: Ultralytics 8.3.45 and 8.3.46 went out with an old API token and had no attestations. It does not catch an attacker inside the pipeline.

The `core-metadata` field has its own gap. A [2026 Trail of Bits audit of PyPI](https://socket.dev/blog/pypi-fixes-high-severity-issues-found-in-security-audit) found that PyPI never checks the wheel's embedded `METADATA` against the metadata submitted with the upload form. The JSON API and project page show the form data. pip installs from the embedded file. A wheel can declare no dependencies on its project page and pull in `evil-helper` at install. That is the Python version of [manifest confusion](https://socket.dev/glossary/manifest-confusion). PyPI accepted the risk for now, calling the fix non-trivial, and it remained open in the May 2026 audit summary.

## How do you find malicious packages on PyPI?

You find malicious packages on PyPI by inspecting the distribution files themselves and comparing each release with the one before it. Project pages, download counts, and JSON API metadata describe what a package claims to be. Only the archive shows what it will run.

Each incident in the table tripped at least one of these mechanical signals:

- A new `.pth` file with an `import` line, or a new `sitecustomize.py`.
- New top-level entries outside the package's own directory.
- New or changed native extensions.
- A large jump in archive size. MemoryOS 2.0.34 was 20 times larger than 2.0.33.
- New network, subprocess, `exec`, or base64-decoding code on an import path.
- A release with no matching tag in the source repository. Neither malicious Telnyx version had one.
- A changed build backend in the sdist.

A wheel is a zip file, so the startup hooks can be listed without executing anything:

```python
# Usage: python -I wheelhooks.py some.whl  (reads the zip as data; executes nothing from it)
import re, sys, zipfile
whl = zipfile.ZipFile(sys.argv[1])
tops = set()
for n in whl.namelist():
    path = re.sub(r"^[^/]+\.data/(purelib|platlib)/", "", n)  # these land in site-packages too
    top, base = path.split("/")[0], path.rsplit("/", 1)[-1]
    if not top.endswith((".dist-info", ".data")):
        tops.add(top)
    if path.endswith(".pth"):
        for line in whl.read(n).decode("utf-8", "replace").splitlines():
            if line.startswith(("import ", "import\t")):
                print(f"EXEC .pth   {path}: {line[:100]}")
    elif base in ("sitecustomize.py", "usercustomize.py"):
        print(f"STARTUP     {path}")
    elif path.endswith((".so", ".pyd")):
        print(f"NATIVE      {path}")
print("top-level entries:", sorted(tops))
```

Run it on two legitimate wheels and the limits of a static rule show up immediately:

```sh
$ python -I wheelhooks.py litellm-1.106.4-cp310-abi3-manylinux_2_28_x86_64.whl
NATIVE      litellm/rust_bridge/_native.abi3.so
top-level entries: ['litellm']
$ python -I wheelhooks.py setuptools-84.2.1-py3-none-any.whl
EXEC .pth   distutils-precedence.pth: import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enab
top-level entries: ['_distutils_hack', 'distutils-precedence.pth', 'setuptools']
```

setuptools has shipped an executable `.pth` for years, for a legitimate reason. A rule that blocks every `.pth` blocks setuptools. The useful signal is change: litellm 1.106.4 has one top-level entry and no startup hook, and 1.107.0 added three top-level files, two of them startup hooks. A useful check diffs each release against its predecessor and then reads the new code to decide what it does.

That reading step is where automation earns its keep. Socket's AI scanner [flagged the seven malicious litellm wheels seven minutes after publication](https://socket.dev/blog/litellm-compromise), and lightning 2.6.2 and 2.6.3 eighteen minutes after. Across 381 TrapDoor releases, [our median detection time was 5 minutes 27 seconds](https://socket.dev/blog/trapdoor-crypto-stealer-npm-pypi-crates). Scanning for CVEs would have found nothing. Malware does not get a CVE.

## How do you manage Python dependencies securely?

You manage Python dependencies securely by controlling which versions resolve, which artifacts are allowed to run code, and what is checked before files reach `site-packages`. Most controls close one execution point. Only a check of the artifact before install covers all four, and only for payloads it can recognize.

**Lock with hashes.** `uv lock`, `pip-compile --generate-hashes`, and the standard [`pylock.toml` format from PEP 751](https://socket.dev/blog/python-adopts-standard-lock-file-format-for-reproducible-installs) record exact versions and file hashes. A [lockfile with hashes](https://socket.dev/glossary/lockfile) stops a version from changing under you. It does not tell you whether the version you locked was malicious. pip's hash-checking mode does not cover build dependencies, and no project lockfile covers tools run with `uvx` or `pipx run`, which resolve their own dependency trees at run time.

**Add a cooldown.** A [dependency cooldown](https://socket.dev/glossary/dependency-cooldown) refuses releases younger than a set age, using PyPI's per-file `upload-time`. uv accepts relative durations:

```toml
[tool.uv]
exclude-newer = "7 days"
```

uv records the policy in `uv.lock` as `exclude-newer-span = "P7D"`. pip has supported `--uploaded-prior-to` since [pip 26.0](https://pip.pypa.io/en/stable/news/) and accepts durations such as `P7D` since 26.1. On October 27, 2026, `pip install --dry-run litellm` resolved to 1.106.4, and the same command with `--uploaded-prior-to P7D` resolved to 1.106.0. Each malicious release in the table above was publicly reported within days of publication, well inside a seven-day window. The cost is that security fixes wait seven days too, unless you override per package. A cooldown buys detection time. Malicious code that nobody notices for a week is just as malicious on day eight.

**Refuse source builds.** `--only-binary :all:` in pip and `--no-build` in uv remove build-time execution for consumers. Some packages publish no wheel for your platform, so expect exceptions.

**Pin your indexes.** Use a single index, or uv's default first-index strategy with `explicit = true` for private packages. Avoid pip's `--extra-index-url` for anything internal.

**Do not count on virtual environments.** A virtual environment isolates dependency versions, not privileges. Code in a venv runs as you, with your home directory, SSH keys, cloud credentials, and environment variables.

**Check artifacts before install.** Socket Firewall Free wraps the installer, as in `sfw pip install` or `sfw uv pip install`, and [blocks package downloads that Socket has confirmed as malware](https://docs.socket.dev/docs/socket-firewall-free). Packages that our AI scanner flags but humans have not yet confirmed produce a warning, not a block. Artifacts already in the local cache are not checked, so the documentation recommends clearing the cache first.

| Control | Build time | Startup (.pth) | Import and entry points | Fresh malicious release | What it costs |
| --- | --- | --- | --- | --- | --- |
| Hash-pinned lockfile | Partial: build deps unpinned | No | No | Blocks it until you update | Update churn |
| Cooldown (exclude-newer, --uploaded-prior-to) | Delays | Delays | Delays | Blocks it for the window | Fixes wait too |
| --only-binary :all: / --no-build | Yes | No | No | No | Some packages lack wheels |
| Trusted publishing and attestations | No | No | No | Only if attestations are checked and the attacker published outside CI | Publisher-side setup |
| Pre-install artifact analysis | Yes | Yes | Yes | Yes, once flagged | A scanner in the install path |

The verdict: use a hash-pinned lockfile everywhere, a cooldown of at least a few days for anything that resolves fresh versions, and `--only-binary :all:` in CI where your dependencies allow it. Treat provenance as a check on who published, not on what was published. Put an artifact check in front of the installer, because it is the only control in the table that sees startup and import payloads before they reach disk.

## What Python packaging still gets wrong

Python still makes "installed" mean "will run." Any wheel can add a `.pth` file that executes on every interpreter start, and the standard deprecation path keeps those lines running through Python 3.17 while adding a structured replacement that also runs at startup. Python packaging should require an explicit opt-in before a dependency can register startup code, the way [npm 12](https://socket.dev/blog/npm-12) now does for install scripts.

Resolution still executes code. `pip download` and `pip install --dry-run` build sdists to read their metadata, and hash-checking mode leaves the build backend unpinned. pip should not run untrusted build code to answer a question about metadata, and `--require-hashes` should cover the build environment or fail loudly.

The registry still keeps two sources of truth for dependencies. Until PyPI validates embedded wheel metadata against what it displays, any tool that reads the JSON API instead of the wheel can see a different dependency tree than pip installs.

The sharpest point is the simplest. In Python, the last moment you can make a decision about a package is before its files land in `site-packages`. After that, the next `pip list` runs whatever the package wanted to run.
