# 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

Source: https://socket.dev/glossary/virtual-environment-python

Last updated: 2026-10-30

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](https://socket.dev/blog/litellm-compromise) used that path to steal cloud and AI credentials. We treat a venv as a namespace for imports, never as a [sandbox](https://socket.dev/glossary/sandbox-environment).

## 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](https://socket.dev/glossary/dependency-hell). [PEP 405](https://peps.python.org/pep-0405/) 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](https://socket.dev/glossary/pip-python-package-index). 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](https://docs.python.org/3/library/venv.html) 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](https://socket.dev/glossary/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](https://peps.python.org/pep-0668/), 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](https://socket.dev/glossary/python-package-index-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](https://docs.python.org/3/library/site.html) 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.

> Python interpreter startup in a virtual environment, in seven steps. Steps 1 to 3 are the interpreter locating pyvenv.cfg and adding the venv site-packages to sys.path. Step 4 executes import lines in every .pth file, step 5 imports sitecustomize, step 6 runs your command, and step 7 runs package \_\_init\_\_ code on import. On Python 3.15 and later, step 4 also calls entry points listed in .start files. Steps 4, 5 and 7 run package-supplied code. Every step runs with the same user ID, home directory, environment variables and network access.

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](https://github.com/python/cpython/blob/3.13/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](https://socket.dev/glossary/setup-py-python). 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](https://socket.dev/glossary/wheel-python-packaging-format) 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 point | When it fires | Stopped by a venv? | Stopped by --only-binary :all:? |
| --- | --- | --- | --- |
| Build backend or setup.py in an sdist | pip install of a source distribution | No | Yes, sdists are refused |
| Executable .pth line, sitecustomize.py, or .start entry point | Every interpreter start in that environment | No | No |
| Package \_\_init\_\_.py and its imports | import pkg | No | No |

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:21 | A commit adds data = "cook" to the \[tool.maturin\] build config |
| 10:46:29 | A commit copies the cook directory into the backend, gateway, and migrations Dockerfiles |
| 10:51:23 to 11:06:31 | Container build runs; provenance records the source as Berri-AI/litellm, one hyphen off BerriAI/litellm |
| 11:39:51 | Seven 1.107.0 wheels publish to PyPI |
| 11:46:30 | Socket'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"]
```

```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](https://socket.dev/blog/shai-hulud-descends-to-hades-miasma-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](https://docs.socket.dev/docs/socket-firewall-free) 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](https://socket.dev/glossary/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.

| Property | Python venv | Container (Docker, devcontainer) | VM or microVM |
| --- | --- | --- | --- |
| Isolates dependency versions | Yes | Yes | Yes |
| Hides ~/.aws, ~/.ssh, and other host credentials | No | Yes, unless you mount them | Yes |
| Restricts network egress | No | Only with a network policy | Only with a network policy |
| Shares the host kernel | Yes, it is an ordinary process | Yes | No |
| Setup cost | Seconds | Image build and volume mounts | Highest |

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](https://peps.python.org/pep-0829/). 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](https://socket.dev/blog/npm-12) 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.
