Back to glossary

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

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. A source distribution (sdist) is a tarball that has to be built. A wheel is a zip archive that is already built.

Building an sdist means running code from the package. Under PEP 517, 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. 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 unzips it, writes files into site-packages and bin/, and records them. That property is why pip's secure-installs guide 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.
  1. 1

    Resolve and build · sdist only

    Build backend runs setup.py or PEP 517 hooks

    Triggered by: pip install, pip download, pip install --dry-run

    Stopped by: --only-binary :all: or uv --no-build

    MemoryOS 2.0.34 sdist: swapped build backend
  2. 2

    Install · wheel

    Unzip into site-packages: no package code runs

    But: every file is now on disk, including .pth and sitecustomize.py

  3. 3

    Interpreter startup

    site executes import lines in every .pth file

    Triggered by: any python or pip process, even pip list

    Stopped by: only python -S

    Hades wave, Jun 2026litellm 1.107.0, Oct 2026
  4. 4

    Import

    __init__.py, module top level, native extension init

    Triggered by: import pkg, directly or through another dependency

    Stopped by: never importing it

    dydx-v4-client, Jantelnyx, Marlightning, AprHades .abi3.so, JunMemoryOS, Sep
  5. 5

    Entry point

    Console script runs the function the package registered

    Triggered by: running pkg-cli, uvx pkg, or pipx run pkg

Runs package codeRuns no package code

Figure 1. Where a PyPI package's code runs, and which 2026 compromises used each point.
StageWhat runsTriggered byStopped by2026 compromises that used it
1. Resolve and build (sdist only)setup.py or the PEP 517 build backendpip install, pip download, pip install --dry-run--only-binary :all: in pip, --no-build in uvMemoryOS 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.pypip install, uv pip install, uv syncNot applicableNone
3. Interpreter startupimport lines in every .pth file, plus sitecustomize.pyAny python or pip process, including pip listOnly python -SHades wave (June), litellm 1.107.0 (October)
4. Import__init__.py, module top-level code, native extension initializationimport pkg, directly or through another dependencyNever importing the packagedydx-v4-client (January), telnyx (March), lightning (April), Hades native extensions (June), MemoryOS (September)
5. Entry pointThe function registered as a console scriptRunning the command, or uvx and pipx runNot running itNone 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.

Bash
$ 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:

Bash
$ 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:

Bash
$ 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 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 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 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 versionsInitial accessWhere the code ranScale
January 27dydx-v4-client 1.1.5post1Consistent with a maintainer account compromise, unconfirmedImport: _bootstrap.py starts a remote access trojan on first importClient library for a derivatives exchange, compromised on npm at the same time
Marchtelnyx 4.87.1, 4.87.2Stolen credentials, traced by PyPI to a hijacked GitHub Action used in the project's CIImport: injected into _client.pyOfficial Telnyx SDK with tens of thousands of monthly downloads
April 30lightning 2.6.2, 2.6.3Likely a project-scoped PyPI tokenImport: _runtime/start.py downloads Bun and runs an 11 MB obfuscated JavaScript payloadHundreds of thousands of daily downloads
Early Junedynamo-release, coolbox, embiggen, langchain-core-mcp, and othersMaintainer account takeover, plus typosquats such as rsquestsStartup: *-setup.pth. Import: trojanized .abi3.so native extensions60 artifacts across 37 PyPI packages
September 23MemoryOS 2.0.34Unconfirmed. Malicious commits appeared in MemTensor's GitHub repositories firstImport: hooked get_logger(), which 149 modules call at importWheel grew from 951,210 to 19,201,772 bytes
October 21litellm 1.107.0Release workflow abuse after attackers held the GitHub org for at least 11 daysStartup: cook.pth plus sitecustomize.pyAbout 23 million weekly downloads

The dYdX client compromise 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 and the Hades wave of the Miasma Mini Shai-Hulud campaign both shipped JavaScript payloads that ran under Bun, downloaded at runtime. The Python package was only the delivery vehicle. In the Hades bioinformatics wave, the visible Python source looked clean because the trigger lived in compiled native code.

The MemoryOS 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 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 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 is the oldest form: fabrice, a typosquat of the 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 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 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. Revival hijacking 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 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. In July 2026, PyPI began rejecting new files on releases older than 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 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 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, defined in PEP 740, that bind each file's hash to that identity.

The Simple API exposes all of it per file. Three fields matter for security:

Bash
$ 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 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 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 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. 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:

Bash
$ 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, 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. 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 record exact versions and file hashes. A lockfile with hashes 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 refuses releases younger than a set age, using PyPI's per-file upload-time. uv accepts relative durations:

TOML, also INI
[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 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. 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.

ControlBuild timeStartup (.pth)Import and entry pointsFresh malicious releaseWhat it costs
Hash-pinned lockfilePartial: build deps unpinnedNoNoBlocks it until you updateUpdate churn
Cooldown (exclude-newer, --uploaded-prior-to)DelaysDelaysDelaysBlocks it for the windowFixes wait too
--only-binary :all: / --no-buildYesNoNoNoSome packages lack wheels
Trusted publishing and attestationsNoNoNoOnly if attestations are checked and the attacker published outside CIPublisher-side setup
Pre-install artifact analysisYesYesYesYes, once flaggedA 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 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.