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.
- pip_cve_gate-0.1.0/LICENSE +21 -0
- pip_cve_gate-0.1.0/PKG-INFO +148 -0
- pip_cve_gate-0.1.0/README.md +119 -0
- pip_cve_gate-0.1.0/pyproject.toml +54 -0
- pip_cve_gate-0.1.0/setup.cfg +4 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/__init__.py +1 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/cli.py +127 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/config.py +14 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/feeds/__init__.py +0 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/feeds/ossf.py +46 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/feeds/osv.py +5 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/feeds/pypi_advisory.py +43 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/resolver.py +105 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate/scanner.py +82 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate.egg-info/PKG-INFO +148 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate.egg-info/SOURCES.txt +21 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate.egg-info/dependency_links.txt +1 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate.egg-info/entry_points.txt +2 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate.egg-info/requires.txt +8 -0
- pip_cve_gate-0.1.0/src/pip_cve_gate.egg-info/top_level.txt +1 -0
- pip_cve_gate-0.1.0/tests/test_cli.py +72 -0
- pip_cve_gate-0.1.0/tests/test_resolver.py +71 -0
- pip_cve_gate-0.1.0/tests/test_scanner.py +86 -0
|
@@ -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 @@
|
|
|
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,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 @@
|
|
|
1
|
+
|
|
@@ -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 == []
|