Back to glossary

Python Virtual Environment (venv)

A Python virtual environment is a directory with its own site-packages and pyvenv.cfg that isolates dependency versions. It does not sandbox code.

Also known as virtualenv, Python venv

A Python virtual environment (venv, or virtualenv) is a directory holding a pyvenv.cfg file, a link to a base interpreter, and a private site-packages folder. Python started from that directory imports packages from the venv instead of the system install. Virtual environments isolate dependency versions between projects. They do not isolate code from your files, credentials, or network.

Version isolation is not a security boundary. Every package in a Python virtual environment runs with your user ID, your home directory, and your environment variables. A wheel can place a .pth file in site-packages that executes on every interpreter start in that environment, including the pip uninstall you run to remove it. On October 21, 2026, the LiteLLM 1.107.0 compromise used that path to steal cloud and AI credentials. We treat a venv as a namespace for imports, never as a sandbox.

What is a Python virtual environment?

#

A Python virtual environment is a site-packages directory plus a config file that points the interpreter at it. Each Python interpreter has one global site-packages directory. Two projects that need different versions of the same library cannot share it, which is the classic form of dependency hell. PEP 405 added the venv module to CPython 3.3 to solve that problem. Its scope is package isolation. The current venv documentation describes environments as isolated from other installed packages and makes no security claim.

Creating one with the standard library produces very little:

Bash
$ python3 -m venv .venv
$ ls -A .venv
.gitignore  bin  include  lib  lib64  pyvenv.cfg
$ ls .venv/bin
Activate.ps1  activate  activate.csh  activate.fish  pip  pip3  pip3.13  python  python3  python3.13
$ cat .venv/pyvenv.cfg
home = /usr/local/bin
include-system-site-packages = false
version = 3.13.18
executable = /usr/local/bin/python3.13
command = /usr/local/bin/python3 -m venv /tmp/app/.venv
$ readlink -f .venv/bin/python
/usr/local/bin/python3.13
$ ls .venv/lib/python3.13/site-packages
pip  pip-26.2.2.dist-info

On Linux and macOS, bin/python is a symlink to the base interpreter, not a copy. The private site-packages starts with only pip. Since Python 3.13, venv also writes a .gitignore containing *, so the directory never lands in a commit. Nothing in this listing restricts what code can do: there is no seccomp profile, no namespace, and no permission manifest.

Code can detect a venv by comparing two prefixes, which is the check the venv documentation recommends:

Bash
$ .venv/bin/python -c "import sys; print(sys.prefix, sys.base_prefix, sys.prefix != sys.base_prefix)"
/tmp/app/.venv /usr/local True

How do you create a virtual environment in Python?

#

Create a Python virtual environment with python3 -m venv .venv, then activate it or call its interpreter by path. With uv, uv venv does the same job and uv run uses the environment without activation.

Bash
python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
python -m pip install requests
deactivate

# Same result, no activation
.venv/bin/python -m pip install requests

# With uv
uv venv                            # creates .venv
uv pip install requests
uv run python app.py

Activation is a shell convenience, not a mode. The bash activate script sets VIRTUAL_ENV, prepends the venv's bin directory to PATH, and changes your prompt. The interpreter binary you start is what selects the environment:

Bash
$ grep -n 'PATH="\$VIRTUAL_ENV' .venv/bin/activate
54:PATH="$VIRTUAL_ENV/"bin":$PATH"
$ head -1 .venv/bin/pip
#!/tmp/app/.venv/bin/python3

Console scripts such as pip hard-code the venv interpreter's absolute path in their shebang. That is why the Python docs describe environments as "not considered as movable or copyable." Delete the directory and recreate it instead.

On Debian-family systems you often have no choice about using one. Under PEP 668, a distribution marks its system Python as externally managed with an EXTERNALLY-MANAGED file, and pip refuses to install into it:

Bash
$ /usr/bin/python3.12 -m pip install requests
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

The rest of that message tells you to create a virtual environment. On those systems, a venv is where third-party code from PyPI ends up by default.

Startup is where package code gets its first turn

#

The interpreter looks for pyvenv.cfg next to its executable or one directory above it, without resolving symlinks. Finding it sets sys.prefix to the venv. The site module then adds the venv's site-packages to sys.path and reads every .pth file there in alphabetical order. A .pth line naming a directory extends sys.path. A line starting with import followed by a space or tab is passed to exec(), as the site documentation specifies. Then site tries to import sitecustomize. All of this happens before the first line of your script, and before pip, pytest, or a Jupyter kernel does anything.

The figure shows seven startup steps. Steps 1 to 3 belong to the interpreter: it executes .venv/bin/python, finds pyvenv.cfg and sets sys.prefix to .venv, then adds .venv/lib/python3.13/site-packages to sys.path. Step 4 runs package-supplied code: the import lines in every .pth file, and on Python 3.15 and later the entry points in .start files. Step 5 runs package-supplied code if any installed package shipped a sitecustomize.py. Step 6 is your command, whether that is app.py, pip, pytest, or a notebook kernel. Step 7 runs a package's __init__.py only when you import it. Every step runs with the same user ID, home directory, environment variables, and network access.

Executable .pth lines exist for legitimate reasons, which is why the feature has survived so long. setuptools ships distutils-precedence.pth, a one-line import os; ... that installs a distutils shim. uv and virtualenv put _virtualenv.pth, containing import _virtualenv, into every environment they create.

We found one more detail while testing. Inside a venv, the CPython 3.11, 3.12, 3.13, and 3.14 builds we tested each ran our executable .pth line twice per startup. site.venv() processes the venv's site-packages, then site.main() processes it again. A comment in Lib/site.py notes that known_paths prevents duplicate sys.path entries, but nothing prevents duplicate execution. The Python 3.15.0b4 build we tested ran the same line once. Payloads that care add their own guard. The LiteLLM collector sets PRIVCOLLECT_ACTIVE=1 so it does not collect twice in the same inherited environment.

Does a Python virtual environment protect against malicious packages?

#

A Python virtual environment does not protect against malicious packages. A venv controls which version of a package gets imported, not what that package's code can do once it runs. Malicious code gets three chances to run, and the venv blocks none of them.

At install time, from a source distribution

#

When pip installs a source distribution, it runs the package's build backend. For setuptools projects, that means executing setup.py. pip's build isolation creates a temporary environment for build dependencies. It isolates versions, not privileges. This setup.py writes to the home directory during a routine install:

Python
import pathlib, sys
from setuptools import setup

proof = pathlib.Path.home() / "written-by-setup-py.txt"
proof.write_text(f"sys.prefix={sys.prefix}\n")

setup(name="sdistdemo", version="0.1", py_modules=[])
Bash
$ .venv/bin/pip install ./sdistdemo
Processing ./sdistdemo
  Installing build dependencies: started
  Installing build dependencies: finished with status 'done'
  ...
Successfully installed sdistdemo-0.1
$ cat ~/written-by-setup-py.txt
sys.prefix=/tmp/app/.venv

The proof file landed in $HOME, outside the venv. A real payload would read ~/.aws/credentials instead of writing a text file.

At interpreter startup, from a wheel

#

A wheel install runs no package code. pip unpacks a ZIP archive and writes files. The wheel format also lets a package place files in the environment's purelib directory through a {name}.data/purelib/ folder, and in a venv, purelib is site-packages. We built a five-file wheel by hand to show what that allows:

Bash
$ unzip -Z1 hookdemo-0.1-py3-none-any.whl
hookdemo-0.1.data/purelib/hookdemo.pth
hookdemo-0.1.dist-info/RECORD
hookdemo-0.1.dist-info/METADATA
hookdemo-0.1.dist-info/WHEEL
hookdemo/__init__.py
$ cat hookdemo-0.1.data/purelib/hookdemo.pth
import os, sys; print(f"[hookdemo.pth] uid={os.getuid()} argv={sys.argv}", file=sys.stderr)
$ .venv/bin/pip install --only-binary :all: ./hookdemo-0.1-py3-none-any.whl
(installed, nothing printed)
$ .venv/bin/python -c "print(1 + 1)"
[hookdemo.pth] uid=0 argv=['-c']
[hookdemo.pth] uid=0 argv=['-c']
2
$ .venv/bin/pip uninstall -y hookdemo
[hookdemo.pth] uid=0 argv=['.venv/bin/pip', 'uninstall', '-y', 'hookdemo']
[hookdemo.pth] uid=0 argv=['.venv/bin/pip', 'uninstall', '-y', 'hookdemo']
Found existing installation: hookdemo 0.1
Uninstalling hookdemo-0.1:
  Successfully uninstalled hookdemo-0.1

The install printed nothing. The hook fired on the next unrelated python -c, even though nothing imported hookdemo. It fired again when pip started up to remove it. Each line appears twice because of the double processing described above, and uid=0 is only because our test machine runs as root. Passing --only-binary :all: changed nothing, because that flag refuses source distributions, not wheels.

At import time

#

import pkg runs pkg/__init__.py and everything it imports. This is the path most people picture. It is the least useful one for attackers, because it requires the victim to actually use the package.

Execution pointWhen it firesStopped by a venv?Stopped by --only-binary :all:?
Build backend or setup.py in an sdistpip install of a source distributionNoYes, sdists are refused
Executable .pth line, sitecustomize.py, or .start entry pointEvery interpreter start in that environmentNoNo
Package __init__.py and its importsimport pkgNoNo

Hash pinning, through pip install --require-hashes or a uv.lock file, guarantees you install exactly the bytes you pinned. Hash pinning stops none of these execution points if the pinned version was already malicious.

How LiteLLM 1.107.0 turned a venv into a launch point

#

The LiteLLM compromise shows the startup path at production scale. LiteLLM is a common interface and proxy for large language model providers, with approximately 23 million weekly downloads according to our write-up. The timeline below is reconstructed from the build provenance and registry timestamps in that write-up. All times are UTC on October 21, 2026.

Time (UTC)Event
09:48:21A commit adds data = "cook" to the [tool.maturin] build config
10:46:29A commit copies the cook directory into the backend, gateway, and migrations Dockerfiles
10:51:23 to 11:06:31Container build runs; provenance records the source as Berri-AI/litellm, one hyphen off BerriAI/litellm
11:39:51Seven 1.107.0 wheels publish to PyPI
11:46:30Socket's AI scanner flags the wheels as malware, seven minutes after publication

The one-line build change is the entire delivery mechanism. Maturin copies the cook directory into each wheel's data folder:

Diff
 [tool.maturin]
 manifest-path = "litellm-rust/crates/python-bridge/Cargo.toml"
 module-name = "litellm.rust_bridge._native"
 python-source = "."
+data = "cook"
 python-packages = ["litellm_cli"]
Plain text
litellm-1.107.0.data/purelib/cook.pth
litellm-1.107.0.data/purelib/cook.py
litellm-1.107.0.data/purelib/sitecustomize.py

The complete contents of cook.pth are import cook. After install, every Python process started from that environment launched a credential collector, whether or not it imported LiteLLM. The sitecustomize.py file forked a detached loader that polls a GitHub-hosted pointer for a server, downloads Python from it, and runs it with exec(). The collector ran 16 modules covering AWS, Azure, and Google Cloud credentials, SSH and GPG keys, .env files, and AI tool tokens such as ~/.claude/.credentials.json and ~/.codex/auth.json.

None of those files live inside .venv. Every one was readable because a virtual environment never restricts reads to its own directory.

Two details matter beyond LiteLLM. First, the tagged release's pyproject.toml lacked the data setting, so reviewing the tag would have missed the change. Analyze the artifact that ships, not the source it claims to come from. Second, the container's build provenance faithfully recorded the lookalike source repository. Provenance tells you how an artifact was built. It does not tell you the inputs were trustworthy.

The cleanup advice inverts normal habits. The write-up warns against starting the affected environment's python or pip as the first remediation step, because doing so runs the payload again. Removing the package directory does not stop a loader that is already running, and it does not un-expose the credentials.

LiteLLM was not the first PyPI campaign to use this primitive. In June 2026, the Miasma worm's PyPI wave shipped a *-setup.pth file in 37 malicious wheels across 19 packages. Its single executable line attempted to download the Bun runtime and run an obfuscated JavaScript stealer.

What actually contains a malicious package

#

No single control covers all three execution points. Each one below closes part of the gap and leaves the rest open.

Block it before it lands. Socket Firewall wraps the installer, as in sfw pip install or sfw uv pip install, and blocks network requests for package artifacts confirmed as malware. Nothing lands in site-packages, so no startup hook exists to fire. The free tier blocks only human-confirmed malware and warns on AI-flagged packages. Artifacts already in pip's or uv's local cache never touch the network, so clear the cache first.

Wait before adopting new versions. A dependency cooldown refuses versions younger than a set age. As of October 2026, pip 26.2 accepts --uploaded-prior-to P7D and uv 0.11 accepts --exclude-newer "7 days". A cooldown helps only against compromises caught inside the window. It also delays security fixes by the same amount, so you need an override path.

Prefer wheels. --only-binary :all: removes setup.py execution from install. It does nothing about startup hooks, as the demo above shows, and some packages publish no wheel for your platform.

Audit startup hooks from outside the environment. This script lists every executable .pth line, .start file, and customize hook in a venv. Run it with a different interpreter and -S, so that the check itself cannot trigger the hooks:

Python
# Usage: python3 -S find_startup_hooks.py path/to/.venv
# -S stops this interpreter from running any startup hooks itself.
import pathlib, sys

root = pathlib.Path(sys.argv[1])
for sp in [*root.glob("lib/python3*/site-packages"), *root.glob("Lib/site-packages")]:
    for pth in sorted(sp.glob("*.pth")):
        lines = pth.read_text("utf-8", "replace").splitlines()
        for n, line in enumerate(lines, 1):
            if line.startswith(("import ", "import\t")):
                print(f"EXEC  {pth.relative_to(root)}:{n}  {line[:60]}")
    for start in sorted(sp.glob("*.start")):
        print(f"START {start.relative_to(root)}")
    for hook in ("sitecustomize.py", "usercustomize.py"):
        if (sp / hook).exists():
            print(f"HOOK  {(sp / hook).relative_to(root)}")
Bash
$ python3 -S find_startup_hooks.py .venv     # fresh python -m venv
$ python3 -S find_startup_hooks.py .uvenv    # fresh uv venv
EXEC  lib/python3.13/site-packages/_virtualenv.pth:1  import _virtualenv
$ python3 -S find_startup_hooks.py .venv     # after installing hookdemo
EXEC  lib/python3.13/site-packages/hookdemo.pth:1  import os, sys; print(f"[hookdemo.pth] uid={os.getuid()} arg

Expect _virtualenv.pth in uv and virtualenv environments, and setuptools' distutils shim where setuptools is installed. Anything else deserves a read. The audit runs after install, so any hook it finds may already have fired if anything started Python in that environment since.

Isolate the process, not the packages. Only an operating-system boundary keeps package code away from your credentials.

PropertyPython venvContainer (Docker, devcontainer)VM or microVM
Isolates dependency versionsYesYesYes
Hides ~/.aws, ~/.ssh, and other host credentialsNoYes, unless you mount themYes
Restricts network egressNoOnly with a network policyOnly with a network policy
Shares the host kernelYes, it is an ordinary processYesNo
Setup costSecondsImage build and volume mountsHighest

Use a venv for every Python project, because it solves version conflicts at almost no cost. Do not count it as a security control. Install fresh or unvetted dependencies, and run code from repositories you do not trust, inside a container or VM with no credentials mounted. In CI, keep publishing tokens out of any job that runs pip install, which is the same separation the LiteLLM write-up recommends for release pipelines.

PEP 829 moves startup code into .start files

#

Python 3.15, released October 9, 2026, deprecates import lines in .pth files under PEP 829. Its author, Barry Warsaw, names the problem directly: "import lines are executed using exec() during interpreter startup, which opens a broad attack surface." The replacement is a name.start file listing pkg.mod:callable entry points, which site resolves and calls at every startup. Python 3.15 through 3.17 still execute import lines unless a matching .start file exists. Python 3.18 and 3.19 silently ignore them, and 3.20 warns.

On the 3.15.0b4 build we tested, a .start file pointing at a function that prints one line behaves as specified, and uv's own _virtualenv.pth already draws the deprecation notice under -v:

Bash
$ cat t15/lib/python3.15/site-packages/hookmod.start
hookmod:run
$ t15/bin/python -c "print(1 + 1)"
start entry point ran
2
$ t15/bin/python -v -c pass 2>&1 | grep -E "deprecated|Executing entry point"
import lines in /tmp/t15/lib/python3.15/site-packages/_virtualenv.pth are deprecated, use entry points in a /tmp/t15/lib/python3.15/site-packages/_virtualenv.start file instead.
Executing entry point: hookmod:run from /tmp/t15/lib/python3.15/site-packages/hookmod.start

PEP 829 is the right fix for auditability. It is not a security fix. A .start entry point still runs arbitrary package code on every interpreter start in the environment. A scanner can now resolve hookmod:run to a named function instead of parsing an exec() string, but a malicious wheel built for 3.15 can ship a .start file and an ordinary module just as easily. The import-line path also stays live on every Python older than 3.18, and those versions will run in production for years.

The fix we want lives in the installer. pip and uv know, at install time, every file a wheel writes outside its own package directory, and today they write an executable .pth line, a .start file, or a sitecustomize.py without a word. npm v12 turned install scripts off by default and made running them an explicit approval. Python installers should treat startup hooks the same way: show them, and require an opt-in before writing them.