From 852fa6d6b6e6491250dc7707b3486ac444eadc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 28 Oct 2026 00:45:47 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=9D=20docs(security):=20add=20a=20?= =?UTF-8?q?threat=20model=20and=20align=20the=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/SECURITY.md | 51 +++++++++++++++++++--- .github/THREAT_MODEL.md | 93 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 .github/THREAT_MODEL.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md index ae419c1..6b57acf 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,9 +1,50 @@ -# 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. + +**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 the caller owns. + +If you cannot tell which side a finding falls on, report it privately and we will decide. diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md new file mode 100644 index 0000000..e40b635 --- /dev/null +++ b/.github/THREAT_MODEL.md @@ -0,0 +1,93 @@ +# 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, `PATH`, pyenv, mise, asdf and uv install directories, and the PEP 514 registry on Windows. 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, keyed by the binary's path, modification time and the script's hash. + +```mermaid +flowchart LR + caller["Caller
spec, env, cache dir (trusted)"] --> pd["python-discovery"] + pd -->|search| locs[("PATH, version managers,
PEP 514 registry (trusted)")] + pd -->|"run candidate + script"| py["Candidate interpreter"] + py -->|"JSON between cookies"| pd + pd <-->|"keyed by path, mtime, script hash"| 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 | Runs interpreters it discovers and publishes a reporting policy without a separate discovery threat model. | + +## 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. +- The cache directory, which belongs to the caller (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 and the script hash all match. | + +## 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 key includes the binary's modification time and the script's hash. | +| 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 hash-checked from `pylock.toml` with +a seven-day upload cooldown; 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. | + +## 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 and are passed through as written. + +## 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. From 8d7d87b8b8ebad52c3e72957005e376f6ab66313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 28 Oct 2026 07:08:36 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs(security):=20check=20th?= =?UTF-8?q?e=20threat=20model=20against=20the=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/SECURITY.md | 8 ++++++- .github/THREAT_MODEL.md | 53 +++++++++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 6b57acf..e58db90 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -37,6 +37,12 @@ no advisory. Classify by who supplies the dangerous input, not by how bad the ou 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** @@ -45,6 +51,6 @@ no advisory. Classify by who supplies the dangerous input, not by how bad the ou - 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 the caller owns. +- 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. diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index e40b635..9760750 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -7,9 +7,10 @@ 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, `PATH`, pyenv, mise, asdf and uv install directories, and the PEP 514 registry on Windows. 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, keyed by the binary's path, modification time and the script's hash. +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 @@ -17,7 +18,7 @@ flowchart LR pd -->|search| locs[("PATH, version managers,
PEP 514 registry (trusted)")] pd -->|"run candidate + script"| py["Candidate interpreter"] py -->|"JSON between cookies"| pd - pd <-->|"keyed by path, mtime, script hash"| cache[("Cache dir")] + pd <-->|"checked against path, mtime,
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; @@ -36,7 +37,7 @@ the attacker controls. | ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | [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 | Runs interpreters it discovers and publishes a reporting policy without a separate discovery threat model. | +| [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 @@ -46,30 +47,31 @@ An attacker who controls any of these already has what python-discovery could gi - 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. -- The cache directory, which belongs to the caller (for example virtualenv's app-data). +- 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 and the script hash all match. | +| 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 key includes the binary's modification time and the script's hash. | -| 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. | +| 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 hash-checked from `pylock.toml` with -a seven-day upload cooldown; PyPI receives the files through +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. @@ -82,10 +84,21 @@ Every workflow pins third-party actions to a commit SHA. | 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 and are passed through as written. +- PEP 514 `ExecutableArguments` are raw command-line text from the registry, passed to the candidate as written, without + a shell. ## Keeping this current