pip-cve-gate 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sharky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: pip-cve-gate
3
+ Version: 0.1.0
4
+ Summary: Pre-install CVE gate for pip — blocks vulnerable and freshly published packages before install
5
+ Author-email: Sharky <sharky@augatho.com>
6
+ License-Expression: MIT
7
+ Keywords: pip,security,supply-chain,cve,vulnerability
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Security
17
+ Classifier: Topic :: Software Development :: Build Tools
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: requests>=2.31
22
+ Requires-Dist: packaging>=23
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: pytest-httpserver>=1.0; extra == "dev"
26
+ Requires-Dist: responses>=0.25; extra == "dev"
27
+ Requires-Dist: ruff>=0.4; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # pip-cve-gate
31
+
32
+ **Pre-install CVE gate for pip.** Blocks vulnerable and freshly published packages *before* any code runs on your machine.
33
+
34
+ ```
35
+ safe-pip install flask requests django
36
+ # [pip-cve-gate] Scanning 3 package(s)…
37
+ # [pip-cve-gate] Resolved 27 package(s) (incl. transitive deps)
38
+ # [pip-cve-gate] All clear — delegating to pip
39
+ ```
40
+
41
+ If a package is blocked:
42
+
43
+ ```
44
+ safe-pip install somelib
45
+ # [pip-cve-gate] BLOCKED — install aborted
46
+ # [CVE] 'somelib==1.2.3' has known vulnerabilities: GHSA-xxxx-yyyy-zzzz
47
+ # [FRESH_HOLD] 'dep==0.0.1' was published 1d ago (hold: 3d). Use --skip-fresh-hold to override.
48
+ ```
49
+
50
+ Exit code `0` = clean, `1` = blocked, `2` = error.
51
+
52
+ ---
53
+
54
+ ## Why
55
+
56
+ Post-install tools (pip-audit, safety) run *after* pip has already downloaded and potentially executed install scripts. By then it's too late for zero-hour supply chain attacks.
57
+
58
+ pip has no native plugin hook for pre-install scanning. pip-cve-gate fills that gap with a wrapper that resolves the full dependency tree, scans every package against three independent feeds, and only delegates to real pip when everything is clean.
59
+
60
+ The closest prior art — [pipask](https://github.com/feynmanix/pipask) — checks PyPI advisories but lacks freshness hold and OSSF malicious package coverage. pip-cve-gate covers all three.
61
+
62
+ ---
63
+
64
+ ## What it checks
65
+
66
+ | Signal | Source | Fail behaviour |
67
+ |--------|--------|----------------|
68
+ | Known CVEs / advisories | [OSV.dev](https://osv.dev) + PyPI Advisory DB | Block |
69
+ | OSSF malicious packages | [ossf/malicious-packages](https://github.com/ossf/malicious-packages) | Block |
70
+ | Freshness hold (default 3d) | PyPI upload timestamp | Block (overridable) |
71
+
72
+ Network failures **fail open** — a broken feed never blocks your CI.
73
+
74
+ ---
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pip install pip-cve-gate
80
+ ```
81
+
82
+ Or run directly from the repo without installing:
83
+
84
+ ```bash
85
+ git clone https://github.com/sharkyger/pip-cve-gate
86
+ cd pip-cve-gate
87
+ python bin/safe-pip install flask
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Usage
93
+
94
+ `safe-pip` accepts the same arguments as `pip install`:
95
+
96
+ ```bash
97
+ safe-pip install flask
98
+ safe-pip install "django>=4.2" "celery==5.3.6"
99
+ safe-pip install flask --skip-fresh-hold # bypass freshness hold
100
+ ```
101
+
102
+ Non-install subcommands are passed through unchanged:
103
+
104
+ ```bash
105
+ safe-pip list
106
+ safe-pip show flask
107
+ safe-pip uninstall flask
108
+ ```
109
+
110
+ ### Configuration
111
+
112
+ | Variable | Default | Description |
113
+ |----------|---------|-------------|
114
+ | `PIP_CVE_GATE_FRESH_HOLD_DAYS` | `3` | Days a new release must age before install |
115
+ | `PIP_CVE_GATE_TIMEOUT` | `10` | HTTP timeout in seconds |
116
+ | `PIP_CVE_GATE_MAX_DEPTH` | `5` | Max transitive dependency depth |
117
+ | `PIP_CVE_GATE_PIP_BIN` | `pip` | Path to real pip binary |
118
+
119
+ ---
120
+
121
+ ## Part of the safe-install fleet
122
+
123
+ pip-cve-gate is part of a pre-install CVE gate fleet for different package ecosystems:
124
+
125
+ | Ecosystem | Tool |
126
+ |-----------|------|
127
+ | Homebrew | [homebrew-safe-upgrade](https://github.com/sharkyger/homebrew-safe-upgrade) |
128
+ | Composer (PHP) | [composer-cve-gate](https://github.com/sharkyger/composer-cve-gate) |
129
+ | pip (Python) | **pip-cve-gate** ← you are here |
130
+
131
+ ---
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ git clone https://github.com/sharkyger/pip-cve-gate
137
+ cd pip-cve-gate
138
+ python -m venv .venv && source .venv/bin/activate
139
+ pip install -e ".[dev]"
140
+ pytest -v
141
+ ruff check src/ tests/
142
+ ```
143
+
144
+ ---
145
+
146
+ ## License
147
+
148
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,119 @@
1
+ # pip-cve-gate
2
+
3
+ **Pre-install CVE gate for pip.** Blocks vulnerable and freshly published packages *before* any code runs on your machine.
4
+
5
+ ```
6
+ safe-pip install flask requests django
7
+ # [pip-cve-gate] Scanning 3 package(s)…
8
+ # [pip-cve-gate] Resolved 27 package(s) (incl. transitive deps)
9
+ # [pip-cve-gate] All clear — delegating to pip
10
+ ```
11
+
12
+ If a package is blocked:
13
+
14
+ ```
15
+ safe-pip install somelib
16
+ # [pip-cve-gate] BLOCKED — install aborted
17
+ # [CVE] 'somelib==1.2.3' has known vulnerabilities: GHSA-xxxx-yyyy-zzzz
18
+ # [FRESH_HOLD] 'dep==0.0.1' was published 1d ago (hold: 3d). Use --skip-fresh-hold to override.
19
+ ```
20
+
21
+ Exit code `0` = clean, `1` = blocked, `2` = error.
22
+
23
+ ---
24
+
25
+ ## Why
26
+
27
+ Post-install tools (pip-audit, safety) run *after* pip has already downloaded and potentially executed install scripts. By then it's too late for zero-hour supply chain attacks.
28
+
29
+ pip has no native plugin hook for pre-install scanning. pip-cve-gate fills that gap with a wrapper that resolves the full dependency tree, scans every package against three independent feeds, and only delegates to real pip when everything is clean.
30
+
31
+ The closest prior art — [pipask](https://github.com/feynmanix/pipask) — checks PyPI advisories but lacks freshness hold and OSSF malicious package coverage. pip-cve-gate covers all three.
32
+
33
+ ---
34
+
35
+ ## What it checks
36
+
37
+ | Signal | Source | Fail behaviour |
38
+ |--------|--------|----------------|
39
+ | Known CVEs / advisories | [OSV.dev](https://osv.dev) + PyPI Advisory DB | Block |
40
+ | OSSF malicious packages | [ossf/malicious-packages](https://github.com/ossf/malicious-packages) | Block |
41
+ | Freshness hold (default 3d) | PyPI upload timestamp | Block (overridable) |
42
+
43
+ Network failures **fail open** — a broken feed never blocks your CI.
44
+
45
+ ---
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install pip-cve-gate
51
+ ```
52
+
53
+ Or run directly from the repo without installing:
54
+
55
+ ```bash
56
+ git clone https://github.com/sharkyger/pip-cve-gate
57
+ cd pip-cve-gate
58
+ python bin/safe-pip install flask
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Usage
64
+
65
+ `safe-pip` accepts the same arguments as `pip install`:
66
+
67
+ ```bash
68
+ safe-pip install flask
69
+ safe-pip install "django>=4.2" "celery==5.3.6"
70
+ safe-pip install flask --skip-fresh-hold # bypass freshness hold
71
+ ```
72
+
73
+ Non-install subcommands are passed through unchanged:
74
+
75
+ ```bash
76
+ safe-pip list
77
+ safe-pip show flask
78
+ safe-pip uninstall flask
79
+ ```
80
+
81
+ ### Configuration
82
+
83
+ | Variable | Default | Description |
84
+ |----------|---------|-------------|
85
+ | `PIP_CVE_GATE_FRESH_HOLD_DAYS` | `3` | Days a new release must age before install |
86
+ | `PIP_CVE_GATE_TIMEOUT` | `10` | HTTP timeout in seconds |
87
+ | `PIP_CVE_GATE_MAX_DEPTH` | `5` | Max transitive dependency depth |
88
+ | `PIP_CVE_GATE_PIP_BIN` | `pip` | Path to real pip binary |
89
+
90
+ ---
91
+
92
+ ## Part of the safe-install fleet
93
+
94
+ pip-cve-gate is part of a pre-install CVE gate fleet for different package ecosystems:
95
+
96
+ | Ecosystem | Tool |
97
+ |-----------|------|
98
+ | Homebrew | [homebrew-safe-upgrade](https://github.com/sharkyger/homebrew-safe-upgrade) |
99
+ | Composer (PHP) | [composer-cve-gate](https://github.com/sharkyger/composer-cve-gate) |
100
+ | pip (Python) | **pip-cve-gate** ← you are here |
101
+
102
+ ---
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ git clone https://github.com/sharkyger/pip-cve-gate
108
+ cd pip-cve-gate
109
+ python -m venv .venv && source .venv/bin/activate
110
+ pip install -e ".[dev]"
111
+ pytest -v
112
+ ruff check src/ tests/
113
+ ```
114
+
115
+ ---
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pip-cve-gate"
7
+ version = "0.1.0"
8
+ description = "Pre-install CVE gate for pip — blocks vulnerable and freshly published packages before install"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{name = "Sharky", email = "sharky@augatho.com"}]
13
+ keywords = ["pip", "security", "supply-chain", "cve", "vulnerability"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security",
24
+ "Topic :: Software Development :: Build Tools",
25
+ ]
26
+ dependencies = [
27
+ "requests>=2.31",
28
+ "packaging>=23",
29
+ ]
30
+
31
+ [project.scripts]
32
+ safe-pip = "pip_cve_gate.cli:main"
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8",
37
+ "pytest-httpserver>=1.0",
38
+ "responses>=0.25",
39
+ "ruff>=0.4",
40
+ ]
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ pythonpath = ["src"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+ target-version = "py39"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "W", "I"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,127 @@
1
+ """safe-pip CLI entry point.
2
+
3
+ Usage mirrors pip install:
4
+ safe-pip install flask requests
5
+ safe-pip install -r requirements.txt
6
+ safe-pip install flask --skip-fresh-hold
7
+
8
+ Non-install subcommands are passed through to real pip unchanged.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+
17
+ from pip_cve_gate import __version__
18
+ from pip_cve_gate.resolver import resolve
19
+ from pip_cve_gate.scanner import scan
20
+
21
+ EXIT_CLEAN = 0
22
+ EXIT_BLOCKED = 1
23
+ EXIT_ERROR = 2
24
+
25
+
26
+ def _real_pip() -> str:
27
+ return os.getenv("PIP_CVE_GATE_PIP_BIN", "pip")
28
+
29
+
30
+ def _passthrough(args: list[str]) -> int:
31
+ return subprocess.call([_real_pip()] + args)
32
+
33
+
34
+ def _extract_packages(args: list[str]) -> tuple[list[str], list[str]]:
35
+ """Split install args into package specs and pip flags.
36
+
37
+ Returns (specs, pip_flags). Requirements files (-r) are not resolved
38
+ in Phase 1 — they pass through after a warning.
39
+ """
40
+ specs: list[str] = []
41
+ flags: list[str] = []
42
+ skip_next = False
43
+ has_req_file = False
44
+
45
+ for i, arg in enumerate(args):
46
+ if skip_next:
47
+ skip_next = False
48
+ flags.append(arg)
49
+ continue
50
+ if arg in ("-r", "--requirement"):
51
+ has_req_file = True
52
+ flags.append(arg)
53
+ skip_next = True
54
+ elif arg.startswith("-"):
55
+ flags.append(arg)
56
+ else:
57
+ specs.append(arg)
58
+
59
+ if has_req_file:
60
+ print(
61
+ "[pip-cve-gate] WARNING: -r/--requirement not yet scanned. Passing through to pip.",
62
+ file=sys.stderr,
63
+ )
64
+
65
+ return specs, flags
66
+
67
+
68
+ def _print_block(blocks: list) -> None:
69
+ print("\n\x1b[31m[pip-cve-gate] BLOCKED — install aborted\x1b[0m", file=sys.stderr)
70
+ for b in blocks:
71
+ print(f" [{b.reason}] {b.detail}", file=sys.stderr)
72
+ print(file=sys.stderr)
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> int:
76
+ args = argv if argv is not None else sys.argv[1:]
77
+
78
+ if not args or args[0] in ("-h", "--help"):
79
+ print(f"safe-pip {__version__} — CVE gate wrapper for pip")
80
+ print("Usage: safe-pip install [pip-options] <packages>")
81
+ print(" safe-pip <any-other-pip-command> (passed through)")
82
+ return EXIT_CLEAN
83
+
84
+ if args[0] in ("-V", "--version"):
85
+ print(f"safe-pip {__version__}")
86
+ return EXIT_CLEAN
87
+
88
+ if args[0] != "install":
89
+ return _passthrough(args)
90
+
91
+ install_args = args[1:]
92
+ skip_fresh_hold = "--skip-fresh-hold" in install_args
93
+ if skip_fresh_hold:
94
+ install_args = [a for a in install_args if a != "--skip-fresh-hold"]
95
+ os.environ["PIP_CVE_GATE_FRESH_HOLD_DAYS"] = "0"
96
+
97
+ specs, pip_flags = _extract_packages(install_args)
98
+
99
+ if not specs:
100
+ # No packages to scan (e.g. safe-pip install --upgrade pip).
101
+ return _passthrough(["install"] + install_args)
102
+
103
+ print(f"[pip-cve-gate] Scanning {len(specs)} package(s)…", file=sys.stderr)
104
+
105
+ try:
106
+ packages = resolve(specs)
107
+ except (ValueError, RuntimeError) as exc:
108
+ print(f"[pip-cve-gate] ERROR: {exc}", file=sys.stderr)
109
+ return EXIT_ERROR
110
+
111
+ print(
112
+ f"[pip-cve-gate] Resolved {len(packages)} package(s) (incl. transitive deps)",
113
+ file=sys.stderr,
114
+ )
115
+
116
+ blocks = scan(packages)
117
+
118
+ if blocks:
119
+ _print_block(blocks)
120
+ return EXIT_BLOCKED
121
+
122
+ print("[pip-cve-gate] All clear — delegating to pip", file=sys.stderr)
123
+ return _passthrough(["install"] + pip_flags + specs)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())
@@ -0,0 +1,14 @@
1
+ import os
2
+
3
+ FRESH_HOLD_DAYS: int = int(os.getenv("PIP_CVE_GATE_FRESH_HOLD_DAYS", "3"))
4
+ REQUEST_TIMEOUT: int = int(os.getenv("PIP_CVE_GATE_TIMEOUT", "10"))
5
+ MAX_TRANSITIVE_DEPTH: int = int(os.getenv("PIP_CVE_GATE_MAX_DEPTH", "5"))
6
+
7
+ # OSV batch endpoint
8
+ OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
9
+
10
+ # PyPI JSON API
11
+ PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
12
+
13
+ # OSSF malicious packages dataset (GitHub raw — daily snapshot)
14
+ OSSF_MALICIOUS_URL = "https://raw.githubusercontent.com/ossf/malicious-packages/main/osv/malicious/pypi/MAL-2024-1.json"
File without changes
@@ -0,0 +1,46 @@
1
+ """OSSF Malicious Packages feed for PyPI.
2
+
3
+ Fetches the OSSF malicious-packages OSV index and returns a set of
4
+ package names known to be malicious. The index is a directory of
5
+ per-package OSV JSON files; we use the GitHub API to list them and
6
+ cache the result in-process for the lifetime of the scan.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+
13
+ import requests
14
+
15
+ from pip_cve_gate.config import REQUEST_TIMEOUT
16
+
17
+ _OSSF_INDEX_API = "https://api.github.com/repos/ossf/malicious-packages/git/trees/main?recursive=1"
18
+
19
+
20
+ @functools.lru_cache(maxsize=1)
21
+ def _fetch_malicious_names() -> frozenset[str]:
22
+ """Return normalised package names listed in the OSSF malicious-packages repo."""
23
+ try:
24
+ resp = requests.get(_OSSF_INDEX_API, timeout=REQUEST_TIMEOUT)
25
+ resp.raise_for_status()
26
+ except requests.RequestException:
27
+ # Fail open — don't block installs if OSSF is unreachable.
28
+ return frozenset()
29
+
30
+ names: set[str] = set()
31
+ for item in resp.json().get("tree", []):
32
+ path: str = item.get("path", "")
33
+ # Paths look like: osv/malicious/pypi/<name>/MAL-YYYY-NNN.json
34
+ parts = path.split("/")
35
+ if len(parts) >= 4 and parts[0] == "osv" and parts[1] == "malicious" and parts[2] == "pypi":
36
+ names.add(_normalise(parts[3]))
37
+
38
+ return frozenset(names)
39
+
40
+
41
+ def _normalise(name: str) -> str:
42
+ return name.lower().replace("-", "_").replace(".", "_")
43
+
44
+
45
+ def is_malicious(name: str) -> bool:
46
+ return _normalise(name) in _fetch_malicious_names()
@@ -0,0 +1,5 @@
1
+ """OSV.dev feed — re-exports pypi_advisory for direct OSV usage."""
2
+
3
+ from pip_cve_gate.feeds.pypi_advisory import query_batch
4
+
5
+ __all__ = ["query_batch"]
@@ -0,0 +1,43 @@
1
+ """PyPI Advisory Database feed via OSV.dev (ecosystem=PyPI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import requests
6
+
7
+ from pip_cve_gate.config import OSV_BATCH_URL, REQUEST_TIMEOUT
8
+
9
+
10
+ def query_batch(packages: list[tuple[str, str]]) -> dict[str, list[dict]]:
11
+ """Query OSV for PyPI advisories.
12
+
13
+ Args:
14
+ packages: list of (name, version) tuples
15
+
16
+ Returns:
17
+ dict mapping "name==version" to list of OSV vuln dicts
18
+ """
19
+ if not packages:
20
+ return {}
21
+
22
+ queries = [
23
+ {"package": {"name": name, "ecosystem": "PyPI"}, "version": version}
24
+ for name, version in packages
25
+ ]
26
+
27
+ try:
28
+ resp = requests.post(
29
+ OSV_BATCH_URL,
30
+ json={"queries": queries},
31
+ timeout=REQUEST_TIMEOUT,
32
+ )
33
+ resp.raise_for_status()
34
+ except requests.RequestException as exc:
35
+ raise RuntimeError(f"OSV batch query failed: {exc}") from exc
36
+
37
+ results: dict[str, list[dict]] = {}
38
+ for (name, version), result in zip(packages, resp.json().get("results", [])):
39
+ vulns = result.get("vulns", [])
40
+ if vulns:
41
+ results[f"{name}=={version}"] = vulns
42
+
43
+ return results
@@ -0,0 +1,105 @@
1
+ """Dependency resolver using the PyPI JSON API.
2
+
3
+ Resolves a list of package specs to (name, version) pairs including
4
+ transitive dependencies up to MAX_TRANSITIVE_DEPTH levels deep.
5
+ Uses PyPI's latest-version metadata — not a full SAT solver, but
6
+ sufficient for pre-install CVE scanning of the most likely candidates.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+
13
+ import requests
14
+ from packaging.requirements import Requirement
15
+ from packaging.version import Version
16
+
17
+ from pip_cve_gate.config import MAX_TRANSITIVE_DEPTH, PYPI_JSON_URL, REQUEST_TIMEOUT
18
+
19
+
20
+ class PyPIPackage:
21
+ def __init__(self, name: str, version: str, upload_time: datetime) -> None:
22
+ self.name = name
23
+ self.version = version
24
+ self.upload_time = upload_time
25
+
26
+
27
+ def _fetch_pypi(name: str) -> dict:
28
+ url = PYPI_JSON_URL.format(package=name)
29
+ try:
30
+ resp = requests.get(url, timeout=REQUEST_TIMEOUT)
31
+ resp.raise_for_status()
32
+ return resp.json()
33
+ except requests.HTTPError as exc:
34
+ if exc.response is not None and exc.response.status_code == 404:
35
+ raise ValueError(f"Package '{name}' not found on PyPI") from exc
36
+ raise RuntimeError(f"PyPI API error for '{name}': {exc}") from exc
37
+ except requests.RequestException as exc:
38
+ raise RuntimeError(f"PyPI API unreachable for '{name}': {exc}") from exc
39
+
40
+
41
+ def _parse_upload_time(ts: str) -> datetime:
42
+ return datetime.fromisoformat(ts.rstrip("Z")).replace(tzinfo=timezone.utc)
43
+
44
+
45
+ def resolve(specs: list[str]) -> list[PyPIPackage]:
46
+ """Resolve specs to concrete packages including transitive deps.
47
+
48
+ Args:
49
+ specs: e.g. ["flask", "requests>=2.28", "django==4.2.1"]
50
+
51
+ Returns:
52
+ Deduplicated list of PyPIPackage for all packages that would be installed.
53
+ """
54
+ seen: dict[str, PyPIPackage] = {}
55
+ queue: list[str] = list(specs)
56
+ depth = 0
57
+
58
+ while queue and depth < MAX_TRANSITIVE_DEPTH:
59
+ next_queue: list[str] = []
60
+ for spec in queue:
61
+ req = Requirement(spec)
62
+ name_key = req.name.lower().replace("-", "_")
63
+ if name_key in seen:
64
+ continue
65
+
66
+ data = _fetch_pypi(req.name)
67
+ info = data["info"]
68
+ latest_version = info["version"]
69
+
70
+ # If a specific version is pinned, honour it; otherwise use latest.
71
+ if req.specifier:
72
+ candidates = [
73
+ Version(v) for v in data["releases"] if list(req.specifier.filter([v]))
74
+ ]
75
+ if not candidates:
76
+ raise ValueError(f"No matching version for '{spec}' on PyPI")
77
+ resolved_version = str(max(candidates))
78
+ else:
79
+ resolved_version = latest_version
80
+
81
+ # Upload time for freshness check.
82
+ release_files = data["releases"].get(resolved_version, [])
83
+ upload_time_str = (
84
+ release_files[0]["upload_time"] if release_files else "1970-01-01T00:00:00"
85
+ )
86
+ upload_time = _parse_upload_time(upload_time_str)
87
+
88
+ pkg = PyPIPackage(req.name, resolved_version, upload_time)
89
+ seen[name_key] = pkg
90
+
91
+ # Queue direct deps for next depth level.
92
+ requires_dist = info.get("requires_dist") or []
93
+ for dep_str in requires_dist:
94
+ try:
95
+ dep = Requirement(dep_str)
96
+ # Skip extras and environment markers for Phase 1.
97
+ if dep.marker is None:
98
+ next_queue.append(str(dep))
99
+ except Exception:
100
+ pass
101
+
102
+ queue = next_queue
103
+ depth += 1
104
+
105
+ return list(seen.values())
@@ -0,0 +1,82 @@
1
+ """CVE + freshness scanner.
2
+
3
+ Coordinates all feeds and returns a list of BlockReason objects.
4
+ Exit is clean (empty list) or blocked (non-empty list).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+
12
+ from pip_cve_gate.config import FRESH_HOLD_DAYS
13
+ from pip_cve_gate.feeds import ossf, pypi_advisory
14
+ from pip_cve_gate.resolver import PyPIPackage
15
+
16
+
17
+ @dataclass
18
+ class BlockReason:
19
+ package: str
20
+ version: str
21
+ reason: str
22
+ detail: str
23
+
24
+
25
+ def scan(packages: list[PyPIPackage]) -> list[BlockReason]:
26
+ """Scan resolved packages for CVEs, malicious listings, and freshness holds.
27
+
28
+ Returns a list of BlockReason — empty means all clear.
29
+ """
30
+ blocks: list[BlockReason] = []
31
+
32
+ # --- OSSF malicious packages (per-package, no batch API) ---
33
+ for pkg in packages:
34
+ if ossf.is_malicious(pkg.name):
35
+ blocks.append(
36
+ BlockReason(
37
+ package=pkg.name,
38
+ version=pkg.version,
39
+ reason="OSSF_MALICIOUS",
40
+ detail=f"'{pkg.name}' is listed in the OSSF Malicious Packages dataset.",
41
+ )
42
+ )
43
+
44
+ # --- Freshness hold ---
45
+ now = datetime.now(tz=timezone.utc)
46
+ for pkg in packages:
47
+ age_days = (now - pkg.upload_time).days
48
+ if age_days < FRESH_HOLD_DAYS:
49
+ blocks.append(
50
+ BlockReason(
51
+ package=pkg.name,
52
+ version=pkg.version,
53
+ reason="FRESH_HOLD",
54
+ detail=(
55
+ f"'{pkg.name}=={pkg.version}' was published {age_days}d ago "
56
+ f"(hold: {FRESH_HOLD_DAYS}d). Use --skip-fresh-hold to override."
57
+ ),
58
+ )
59
+ )
60
+
61
+ # --- OSV / PyPI Advisory DB (batch) ---
62
+ tuples = [(pkg.name, pkg.version) for pkg in packages]
63
+ try:
64
+ vuln_map = pypi_advisory.query_batch(tuples)
65
+ except RuntimeError as exc:
66
+ # Fail open on network error — log and continue.
67
+ print(f"[pip-cve-gate] WARNING: OSV query failed: {exc}")
68
+ vuln_map = {}
69
+
70
+ for key, vulns in vuln_map.items():
71
+ name, _, version = key.partition("==")
72
+ ids = ", ".join(v.get("id", "?") for v in vulns)
73
+ blocks.append(
74
+ BlockReason(
75
+ package=name,
76
+ version=version,
77
+ reason="CVE",
78
+ detail=f"'{key}' has known vulnerabilities: {ids}",
79
+ )
80
+ )
81
+
82
+ return blocks
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: pip-cve-gate
3
+ Version: 0.1.0
4
+ Summary: Pre-install CVE gate for pip — blocks vulnerable and freshly published packages before install
5
+ Author-email: Sharky <sharky@augatho.com>
6
+ License-Expression: MIT
7
+ Keywords: pip,security,supply-chain,cve,vulnerability
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Security
17
+ Classifier: Topic :: Software Development :: Build Tools
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: requests>=2.31
22
+ Requires-Dist: packaging>=23
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: pytest-httpserver>=1.0; extra == "dev"
26
+ Requires-Dist: responses>=0.25; extra == "dev"
27
+ Requires-Dist: ruff>=0.4; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # pip-cve-gate
31
+
32
+ **Pre-install CVE gate for pip.** Blocks vulnerable and freshly published packages *before* any code runs on your machine.
33
+
34
+ ```
35
+ safe-pip install flask requests django
36
+ # [pip-cve-gate] Scanning 3 package(s)…
37
+ # [pip-cve-gate] Resolved 27 package(s) (incl. transitive deps)
38
+ # [pip-cve-gate] All clear — delegating to pip
39
+ ```
40
+
41
+ If a package is blocked:
42
+
43
+ ```
44
+ safe-pip install somelib
45
+ # [pip-cve-gate] BLOCKED — install aborted
46
+ # [CVE] 'somelib==1.2.3' has known vulnerabilities: GHSA-xxxx-yyyy-zzzz
47
+ # [FRESH_HOLD] 'dep==0.0.1' was published 1d ago (hold: 3d). Use --skip-fresh-hold to override.
48
+ ```
49
+
50
+ Exit code `0` = clean, `1` = blocked, `2` = error.
51
+
52
+ ---
53
+
54
+ ## Why
55
+
56
+ Post-install tools (pip-audit, safety) run *after* pip has already downloaded and potentially executed install scripts. By then it's too late for zero-hour supply chain attacks.
57
+
58
+ pip has no native plugin hook for pre-install scanning. pip-cve-gate fills that gap with a wrapper that resolves the full dependency tree, scans every package against three independent feeds, and only delegates to real pip when everything is clean.
59
+
60
+ The closest prior art — [pipask](https://github.com/feynmanix/pipask) — checks PyPI advisories but lacks freshness hold and OSSF malicious package coverage. pip-cve-gate covers all three.
61
+
62
+ ---
63
+
64
+ ## What it checks
65
+
66
+ | Signal | Source | Fail behaviour |
67
+ |--------|--------|----------------|
68
+ | Known CVEs / advisories | [OSV.dev](https://osv.dev) + PyPI Advisory DB | Block |
69
+ | OSSF malicious packages | [ossf/malicious-packages](https://github.com/ossf/malicious-packages) | Block |
70
+ | Freshness hold (default 3d) | PyPI upload timestamp | Block (overridable) |
71
+
72
+ Network failures **fail open** — a broken feed never blocks your CI.
73
+
74
+ ---
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pip install pip-cve-gate
80
+ ```
81
+
82
+ Or run directly from the repo without installing:
83
+
84
+ ```bash
85
+ git clone https://github.com/sharkyger/pip-cve-gate
86
+ cd pip-cve-gate
87
+ python bin/safe-pip install flask
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Usage
93
+
94
+ `safe-pip` accepts the same arguments as `pip install`:
95
+
96
+ ```bash
97
+ safe-pip install flask
98
+ safe-pip install "django>=4.2" "celery==5.3.6"
99
+ safe-pip install flask --skip-fresh-hold # bypass freshness hold
100
+ ```
101
+
102
+ Non-install subcommands are passed through unchanged:
103
+
104
+ ```bash
105
+ safe-pip list
106
+ safe-pip show flask
107
+ safe-pip uninstall flask
108
+ ```
109
+
110
+ ### Configuration
111
+
112
+ | Variable | Default | Description |
113
+ |----------|---------|-------------|
114
+ | `PIP_CVE_GATE_FRESH_HOLD_DAYS` | `3` | Days a new release must age before install |
115
+ | `PIP_CVE_GATE_TIMEOUT` | `10` | HTTP timeout in seconds |
116
+ | `PIP_CVE_GATE_MAX_DEPTH` | `5` | Max transitive dependency depth |
117
+ | `PIP_CVE_GATE_PIP_BIN` | `pip` | Path to real pip binary |
118
+
119
+ ---
120
+
121
+ ## Part of the safe-install fleet
122
+
123
+ pip-cve-gate is part of a pre-install CVE gate fleet for different package ecosystems:
124
+
125
+ | Ecosystem | Tool |
126
+ |-----------|------|
127
+ | Homebrew | [homebrew-safe-upgrade](https://github.com/sharkyger/homebrew-safe-upgrade) |
128
+ | Composer (PHP) | [composer-cve-gate](https://github.com/sharkyger/composer-cve-gate) |
129
+ | pip (Python) | **pip-cve-gate** ← you are here |
130
+
131
+ ---
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ git clone https://github.com/sharkyger/pip-cve-gate
137
+ cd pip-cve-gate
138
+ python -m venv .venv && source .venv/bin/activate
139
+ pip install -e ".[dev]"
140
+ pytest -v
141
+ ruff check src/ tests/
142
+ ```
143
+
144
+ ---
145
+
146
+ ## License
147
+
148
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pip_cve_gate/__init__.py
5
+ src/pip_cve_gate/cli.py
6
+ src/pip_cve_gate/config.py
7
+ src/pip_cve_gate/resolver.py
8
+ src/pip_cve_gate/scanner.py
9
+ src/pip_cve_gate.egg-info/PKG-INFO
10
+ src/pip_cve_gate.egg-info/SOURCES.txt
11
+ src/pip_cve_gate.egg-info/dependency_links.txt
12
+ src/pip_cve_gate.egg-info/entry_points.txt
13
+ src/pip_cve_gate.egg-info/requires.txt
14
+ src/pip_cve_gate.egg-info/top_level.txt
15
+ src/pip_cve_gate/feeds/__init__.py
16
+ src/pip_cve_gate/feeds/ossf.py
17
+ src/pip_cve_gate/feeds/osv.py
18
+ src/pip_cve_gate/feeds/pypi_advisory.py
19
+ tests/test_cli.py
20
+ tests/test_resolver.py
21
+ tests/test_scanner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ safe-pip = pip_cve_gate.cli:main
@@ -0,0 +1,8 @@
1
+ requests>=2.31
2
+ packaging>=23
3
+
4
+ [dev]
5
+ pytest>=8
6
+ pytest-httpserver>=1.0
7
+ responses>=0.25
8
+ ruff>=0.4
@@ -0,0 +1 @@
1
+ pip_cve_gate
@@ -0,0 +1,72 @@
1
+ """Tests for the CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from unittest.mock import patch
7
+
8
+ from pip_cve_gate.cli import EXIT_BLOCKED, EXIT_CLEAN, EXIT_ERROR, main
9
+ from pip_cve_gate.resolver import PyPIPackage
10
+ from pip_cve_gate.scanner import BlockReason
11
+
12
+
13
+ def _pkg(name="requests", version="2.31.0", days_old=30):
14
+ return PyPIPackage(name, version, datetime.now(tz=timezone.utc) - timedelta(days=days_old))
15
+
16
+
17
+ def test_no_args_prints_usage(capsys):
18
+ rc = main([])
19
+ assert rc == EXIT_CLEAN
20
+ out = capsys.readouterr().out
21
+ assert "safe-pip" in out
22
+
23
+
24
+ def test_version_flag(capsys):
25
+ rc = main(["--version"])
26
+ assert rc == EXIT_CLEAN
27
+ assert "safe-pip" in capsys.readouterr().out
28
+
29
+
30
+ def test_non_install_passthrough():
31
+ with patch("pip_cve_gate.cli._passthrough", return_value=0) as mock:
32
+ rc = main(["list"])
33
+ mock.assert_called_once_with(["list"])
34
+ assert rc == EXIT_CLEAN
35
+
36
+
37
+ def test_clean_install_delegates_to_pip():
38
+ pkg = _pkg()
39
+ with (
40
+ patch("pip_cve_gate.cli.resolve", return_value=[pkg]),
41
+ patch("pip_cve_gate.cli.scan", return_value=[]),
42
+ patch("pip_cve_gate.cli._passthrough", return_value=0) as mock_pip,
43
+ ):
44
+ rc = main(["install", "requests"])
45
+ assert rc == EXIT_CLEAN
46
+ mock_pip.assert_called_once()
47
+
48
+
49
+ def test_blocked_install_does_not_call_pip():
50
+ pkg = _pkg()
51
+ block = BlockReason("requests", "2.31.0", "CVE", "has known vulns")
52
+ with (
53
+ patch("pip_cve_gate.cli.resolve", return_value=[pkg]),
54
+ patch("pip_cve_gate.cli.scan", return_value=[block]),
55
+ patch("pip_cve_gate.cli._passthrough") as mock_pip,
56
+ ):
57
+ rc = main(["install", "requests"])
58
+ assert rc == EXIT_BLOCKED
59
+ mock_pip.assert_not_called()
60
+
61
+
62
+ def test_resolver_error_returns_exit_error():
63
+ with patch("pip_cve_gate.cli.resolve", side_effect=ValueError("not found")):
64
+ rc = main(["install", "nonexistent-xyz-123"])
65
+ assert rc == EXIT_ERROR
66
+
67
+
68
+ def test_no_packages_passthrough():
69
+ with patch("pip_cve_gate.cli._passthrough", return_value=0) as mock:
70
+ rc = main(["install", "--upgrade", "pip"])
71
+ mock.assert_called_once()
72
+ assert rc == EXIT_CLEAN
@@ -0,0 +1,71 @@
1
+ """Tests for the PyPI resolver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timezone
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+
10
+ from pip_cve_gate.resolver import _parse_upload_time, resolve
11
+
12
+
13
+ def _fake_pypi_response(name: str, version: str, upload_time: str, requires_dist=None):
14
+ return {
15
+ "info": {
16
+ "name": name,
17
+ "version": version,
18
+ "requires_dist": requires_dist or [],
19
+ },
20
+ "releases": {
21
+ version: [{"upload_time": upload_time}],
22
+ },
23
+ }
24
+
25
+
26
+ def test_parse_upload_time_utc():
27
+ dt = _parse_upload_time("2024-01-15T10:30:00")
28
+ assert dt.tzinfo == timezone.utc
29
+ assert dt.year == 2024
30
+
31
+
32
+ def test_resolve_single_package():
33
+ fake = _fake_pypi_response("requests", "2.31.0", "2023-05-22T14:00:00")
34
+ with patch("pip_cve_gate.resolver._fetch_pypi", return_value=fake):
35
+ pkgs = resolve(["requests"])
36
+ assert len(pkgs) == 1
37
+ assert pkgs[0].name == "requests"
38
+ assert pkgs[0].version == "2.31.0"
39
+
40
+
41
+ def test_resolve_deduplicates():
42
+ fake = _fake_pypi_response("requests", "2.31.0", "2023-05-22T14:00:00")
43
+ with patch("pip_cve_gate.resolver._fetch_pypi", return_value=fake):
44
+ pkgs = resolve(["requests", "requests"])
45
+ assert len(pkgs) == 1
46
+
47
+
48
+ def test_resolve_not_found_raises():
49
+
50
+ with patch("pip_cve_gate.resolver._fetch_pypi") as mock:
51
+ resp_mock = type("R", (), {"status_code": 404})()
52
+ mock.side_effect = ValueError("Package 'nonexistent' not found on PyPI")
53
+ with pytest.raises(ValueError, match="not found on PyPI"):
54
+ resolve(["nonexistent"])
55
+
56
+
57
+ def test_resolve_transitive_deps():
58
+ flask_resp = _fake_pypi_response(
59
+ "flask", "3.0.0", "2023-09-30T00:00:00", requires_dist=["werkzeug>=3.0"]
60
+ )
61
+ werkzeug_resp = _fake_pypi_response("werkzeug", "3.0.1", "2023-10-01T00:00:00")
62
+
63
+ def fake_fetch(name):
64
+ return flask_resp if name.lower() == "flask" else werkzeug_resp
65
+
66
+ with patch("pip_cve_gate.resolver._fetch_pypi", side_effect=fake_fetch):
67
+ pkgs = resolve(["flask"])
68
+
69
+ names = {p.name.lower() for p in pkgs}
70
+ assert "flask" in names
71
+ assert "werkzeug" in names
@@ -0,0 +1,86 @@
1
+ """Tests for the scanner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from unittest.mock import patch
7
+
8
+ from pip_cve_gate.resolver import PyPIPackage
9
+ from pip_cve_gate.scanner import scan
10
+
11
+
12
+ def _pkg(name: str, version: str, days_old: int = 30) -> PyPIPackage:
13
+ upload_time = datetime.now(tz=timezone.utc) - timedelta(days=days_old)
14
+ return PyPIPackage(name, version, upload_time)
15
+
16
+
17
+ def test_clean_package_returns_empty():
18
+ pkg = _pkg("requests", "2.31.0", days_old=30)
19
+ with (
20
+ patch("pip_cve_gate.scanner.ossf.is_malicious", return_value=False),
21
+ patch("pip_cve_gate.scanner.pypi_advisory.query_batch", return_value={}),
22
+ ):
23
+ assert scan([pkg]) == []
24
+
25
+
26
+ def test_fresh_hold_blocks():
27
+ pkg = _pkg("newpkg", "1.0.0", days_old=0)
28
+ with (
29
+ patch("pip_cve_gate.scanner.ossf.is_malicious", return_value=False),
30
+ patch("pip_cve_gate.scanner.pypi_advisory.query_batch", return_value={}),
31
+ ):
32
+ blocks = scan([pkg])
33
+ assert len(blocks) == 1
34
+ assert blocks[0].reason == "FRESH_HOLD"
35
+ assert blocks[0].package == "newpkg"
36
+
37
+
38
+ def test_ossf_malicious_blocks():
39
+ pkg = _pkg("evilpkg", "0.1.0", days_old=10)
40
+ with (
41
+ patch("pip_cve_gate.scanner.ossf.is_malicious", return_value=True),
42
+ patch("pip_cve_gate.scanner.pypi_advisory.query_batch", return_value={}),
43
+ ):
44
+ blocks = scan([pkg])
45
+ reasons = {b.reason for b in blocks}
46
+ assert "OSSF_MALICIOUS" in reasons
47
+
48
+
49
+ def test_cve_blocks():
50
+ pkg = _pkg("vulnerable", "1.0.0", days_old=30)
51
+ vuln_map = {"vulnerable==1.0.0": [{"id": "GHSA-xxxx-yyyy-zzzz"}]}
52
+ with (
53
+ patch("pip_cve_gate.scanner.ossf.is_malicious", return_value=False),
54
+ patch("pip_cve_gate.scanner.pypi_advisory.query_batch", return_value=vuln_map),
55
+ ):
56
+ blocks = scan([pkg])
57
+ reasons = {b.reason for b in blocks}
58
+ assert "CVE" in reasons
59
+ assert "GHSA-xxxx-yyyy-zzzz" in blocks[-1].detail
60
+
61
+
62
+ def test_multiple_packages_multiple_blocks():
63
+ pkgs = [
64
+ _pkg("fresh", "1.0.0", days_old=0),
65
+ _pkg("clean", "2.0.0", days_old=30),
66
+ ]
67
+ with (
68
+ patch("pip_cve_gate.scanner.ossf.is_malicious", return_value=False),
69
+ patch("pip_cve_gate.scanner.pypi_advisory.query_batch", return_value={}),
70
+ ):
71
+ blocks = scan(pkgs)
72
+ assert any(b.package == "fresh" for b in blocks)
73
+ assert not any(b.package == "clean" for b in blocks)
74
+
75
+
76
+ def test_osv_network_failure_does_not_block():
77
+ pkg = _pkg("requests", "2.31.0", days_old=30)
78
+ with (
79
+ patch("pip_cve_gate.scanner.ossf.is_malicious", return_value=False),
80
+ patch(
81
+ "pip_cve_gate.scanner.pypi_advisory.query_batch",
82
+ side_effect=RuntimeError("network timeout"),
83
+ ),
84
+ ):
85
+ blocks = scan([pkg])
86
+ assert blocks == []