Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,56 @@
# Security Policy
# Security policy

## Supported Versions
python-discovery is a Python library that finds Python interpreters on a machine and reports their metadata. This policy
says how to report a security problem and how we decide whether a report is one.

Only the latest released version is supported with security updates. Older versions will not receive patches.
## Reporting a vulnerability

## Reporting a Vulnerability
**Do not open a public issue for a security problem.** Use
[GitHub's private vulnerability reporting](https://github.com/tox-dev/python-discovery/security/advisories/new). If
neither works for you, email [gaborjbernat@gmail.com](mailto:gaborjbernat@gmail.com).

Please report security vulnerabilities via [GitHub's private vulnerability reporting](../../security/advisories/new).
Include what the attacker controls and what they gain, the steps or a proof of concept, and the versions and platform
you tested. Write the report yourself; unreviewed tool or model output takes longer to triage.

## What to expect

Volunteers maintain python-discovery, with no on-call rotation. We aim to acknowledge a report within **5 working days**
and assess it within **15 working days**. Fixes are developed in a private fork attached to the draft advisory, ship in
a new release, and the advisory, with a CVE requested through GitHub, goes out with that release. The default embargo is
**90 days** from the report; we agree any change with the reporter. We credit reporters in the advisory under the name
they choose, or not at all. There is no bug bounty.

## Supported versions

Only the latest release receives fixes. Confirm the problem reproduces on it before reporting.

## Vulnerability or bug

A report is a vulnerability when an attacker who controls only an input that the [threat model](THREAT_MODEL.md) lists
as untrusted breaks a guarantee python-discovery makes. A report that first needs control of something the threat model
trusts, such as an executable on `PATH` or in a version manager's directory, is a bug: we fix it in the open and publish
no advisory. Classify by who supplies the dangerous input, not by how bad the outcome sounds.

**In scope**

- Executing a binary from a location the documented search order does not include, such as the current directory through
an empty `PATH` entry.
- A cache entry from one interpreter being reported for another, or surviving a change of the binary.
- Interrogation output from one candidate being parsed as another candidate's metadata.
- A spec's version, implementation, architecture, free-threading or debug constraint passing for a shim or launcher
while the interpreter it resolves to fails it.
- A PEP 514 record or a spec making discovery run a program other than the candidate with its registered arguments.
- Another local user writing a shared cache directory so a victim accepts an interpreter, or metadata, that discovery
would otherwise reject.
- A small candidate response exhausting memory or processes.

**Out of scope**

- A malicious interpreter in a searched location: python-discovery runs candidates to learn about them, so whoever can
place one there already runs code as the user.
- Values in the process environment (`PATH`, `PYENV_ROOT`, `UV_PYTHON_INSTALL_DIR`, `PY_DISCOVERY_TIMEOUT`) and the
Windows registry entries of PEP 514.
- A `.python-version` file choosing among interpreters that discovery would find anyway.
- Tampering with a cache directory only the caller can write.

If you cannot tell which side a finding falls on, report it privately and we will decide.
106 changes: 106 additions & 0 deletions .github/THREAT_MODEL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Threat model

This model says what python-discovery trusts, where untrusted input enters, and which guarantees hold at each boundary,
so a report can be classified as a vulnerability or a bug. [SECURITY.md](SECURITY.md) covers reporting; if the two
disagree, SECURITY.md wins and we fix this file.

## How python-discovery works

A caller passes a requirement such as `python3.12` or `>=3.11,<3.13`. python-discovery walks `try_first_with` paths, the
running interpreter, the PEP 514 registry on Windows, `PATH` (resolving pyenv, mise and asdf shims to the binary they
select), and uv's install directories. It runs each candidate with a bundled interrogation script and reads a JSON
answer framed by two random cookies, then caches that answer in a directory the caller supplies, valid while the
binary's path and modification time, the script's hash and any PEP 514 arguments stay the same.

```mermaid
flowchart LR
caller["Caller<br/>spec, env, cache dir (trusted)"] --> pd["python-discovery"]
pd -->|search| locs[("PATH, version managers,<br/>PEP 514 registry (trusted)")]
pd -->|"run candidate + script"| py["Candidate interpreter"]
py -->|"JSON between cookies"| pd
pd <-->|"checked against path, mtime,<br/>script hash, PEP 514 args"| cache[("Cache dir")]
classDef trusted fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;
classDef boundary fill:#ffe3a3,stroke:#d29200,color:#3a2c00;
class caller,pd,locs trusted;
class py,cache boundary;
```

## Compared with similar tools

We drew the boundary where comparable projects in other ecosystems draw theirs. The shared rule, stated by the
[Node.js threat model](https://github.com/nodejs/node/blob/main/SECURITY.md#the-nodejs-threat-model), is that a report
which first needs a trusted element compromised is out of scope; the first triage question, as
[DOMPurify](https://github.com/cure53/DOMPurify/blob/main/SECURITY.md#what-we-consider-a-vulnerability) puts it, is what
the attacker controls.

| Project | Language | Where it draws the line |
| ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| [Go `os/exec` path lookup](https://go.dev/blog/path-security) | Go | Refuses to run a binary found through the current directory; python-discovery skips empty `PATH` entries for the same reason. |
| [nvm](https://github.com/nvm-sh/nvm/blob/HEAD/.github/THREAT_MODEL.md) | Shell | Trusts what it installs and executes as the user; treats mirror metadata, not payloads, as the untrusted boundary. |
| [uv](https://github.com/astral-sh/uv/security/policy) | Rust | Its policy says invoking the Python interpreters it finds, to read their metadata, is not a vulnerability. |

## Trusted

An attacker who controls any of these already has what python-discovery could give them:

- The caller and the spec, environment mapping, cache directory and `try_first_with` paths it passes.
- Every directory searched: `PATH` entries, version-manager roots and install directories, and the PEP 514 registry,
including `ExecutableArguments`.
- The interpreters found there; running them is how discovery works.
- A cache directory that only the caller can write (for example virtualenv's app-data).

## Untrusted inputs

| Input | Comes from | What python-discovery guarantees |
| ----------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| The current working directory | Wherever the caller happens to run | Empty `PATH` components, which shells read as the current directory, are skipped. |
| Interrogation stdout | Startup noise or a `sitecustomize` that prints | Only the JSON between two per-run random cookies is parsed; anything else is a failed candidate. |
| Cache entries | Earlier runs, possibly of another interpreter build | Reused only when the binary's path and modification time, the script hash and the PEP 514 arguments all match. |
| A shared cache directory | Another local user who can write it | Must not make a victim accept an interpreter or metadata that discovery would reject; report any way it does. |

## Threats and controls

| STRIDE | Threat | Control |
| ---------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Spoofing | Output printed by a candidate's startup code impersonates its metadata. | Random start and end cookies frame the answer. |
| Tampering | A stale cache entry describes a binary that has since been replaced. | The entry records the binary's modification time, the script's hash and the PEP 514 arguments. |
| Denial of service | A candidate hangs during interrogation. | The interrogation has a timeout, configurable with `PY_DISCOVERY_TIMEOUT`. |
| Elevation of privilege | A binary in the current directory is picked up through `PATH`. | Empty `PATH` entries are skipped. |

## Release chain

Releases run on the shared [tox-dev/github-workflows](https://github.com/tox-dev/github-workflows) pipeline. A release
App, not a person, pushes the release commit and tag; the build installs its tools from a hash-checked `pylock.toml`
resolved with a seven-day upload cooldown by default; PyPI receives the files through
[trusted publishing](https://docs.pypi.org/trusted-publishers/) with attestations, and the GitHub Release is immutable.
Every workflow pins third-party actions to a commit SHA.

## Vulnerability or bug

| Report | Verdict | Why |
| --------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------- |
| A `python3` in the working directory runs because `PATH` ends with `:` | Vulnerability | The empty entry must not reach the current directory. |
| A `pyenv` shim that is malware runs during discovery | Not a vulnerability | Searched locations are trusted. |
| A cached answer for `/usr/bin/python3` is returned after the OS upgraded it | Bug | Cache correctness, crossing no boundary; a vulnerability only if it can point at a different binary. |
| `PY_DISCOVERY_TIMEOUT=inf` lets a hung interpreter block forever | Not a vulnerability | The environment is trusted and the value is documented. |

## Severity

- **High:** argument handling runs a separate command the attacker chose, or another local user poisons a shared cache
so a victim runs an interpreter outside the requested constraints, without control of the victim's `PATH`, registry or
interpreters.
- **Medium:** cache poisoning changes security-relevant metadata without execution, or a small candidate response causes
sustained memory or process exhaustion.
- **Low:** the wrong interpreter selected without crossing a privilege boundary, a crash on malformed output, or stale
metadata from a cache only the caller writes.

## Accepted risks

- Discovery executes every candidate it considers; it cannot learn an interpreter's version without running it.
- PEP 514 `ExecutableArguments` are raw command-line text from the registry, passed to the candidate as written, without
a shell.

## Keeping this current

Revisit this model when python-discovery gains a new input source, network access, a new way to run code, or a new place
it writes to, and whenever an advisory is published.