ghsa-client 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.
- ghsa_client-0.1.0/.gitignore +10 -0
- ghsa_client-0.1.0/LICENSE +21 -0
- ghsa_client-0.1.0/PKG-INFO +253 -0
- ghsa_client-0.1.0/README.md +214 -0
- ghsa_client-0.1.0/pyproject.toml +134 -0
- ghsa_client-0.1.0/src/ghsa_client/__init__.py +19 -0
- ghsa_client-0.1.0/src/ghsa_client/client.py +140 -0
- ghsa_client-0.1.0/src/ghsa_client/exceptions/__init__.py +5 -0
- ghsa_client-0.1.0/src/ghsa_client/exceptions/rate_limit.py +7 -0
- ghsa_client-0.1.0/src/ghsa_client/models/__init__.py +19 -0
- ghsa_client-0.1.0/src/ghsa_client/models/advisory.py +161 -0
- ghsa_client-0.1.0/src/ghsa_client/models/base.py +141 -0
- ghsa_client-0.1.0/src/ghsa_client/models/ghsa_id.py +72 -0
- ghsa_client-0.1.0/src/ghsa_client/py.typed +0 -0
- ghsa_client-0.1.0/tests/__init__.py +1 -0
- ghsa_client-0.1.0/tests/test_client.py +76 -0
- ghsa_client-0.1.0/tests/test_models.py +87 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Efi Weiss
|
|
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,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghsa-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python client library for the GitHub Security Advisory (GHSA) API
|
|
5
|
+
Project-URL: Homepage, https://github.com/auto-exploit/ghsa-client
|
|
6
|
+
Project-URL: Documentation, https://ghsa-client.readthedocs.io/
|
|
7
|
+
Project-URL: Repository, https://github.com/auto-exploit/ghsa-client.git
|
|
8
|
+
Project-URL: Issues, https://github.com/auto-exploit/ghsa-client/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/auto-exploit/ghsa-client/blob/main/CHANGELOG.md
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: advisory,cve,ghsa,github,security,vulnerability
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: pydantic>=2.0.0
|
|
27
|
+
Requires-Dist: requests>=2.25.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: black>=22.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: isort>=5.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
35
|
+
Provides-Extra: docs
|
|
36
|
+
Requires-Dist: mkdocs-material>=9.0.0; extra == 'docs'
|
|
37
|
+
Requires-Dist: mkdocs>=1.4.0; extra == 'docs'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# GHSA Client
|
|
41
|
+
|
|
42
|
+
A Python client library for the GitHub Security Advisory (GHSA) API, providing structured access to security advisory data.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Type-safe models**: Full Pydantic models for GHSA data structures
|
|
47
|
+
- **Rate limiting**: Built-in rate limit handling and retry logic
|
|
48
|
+
- **Flexible queries**: Search advisories with various filters
|
|
49
|
+
- **Comprehensive data**: Access to all GHSA fields including CVSS scores, CWE mappings, and package information
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install ghsa-client
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import logging
|
|
61
|
+
from ghsa_client import GHSAClient, GHSA_ID
|
|
62
|
+
|
|
63
|
+
# Set up logging
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
logger.setLevel(logging.INFO)
|
|
66
|
+
|
|
67
|
+
# Create client
|
|
68
|
+
client = GHSAClient(logger)
|
|
69
|
+
|
|
70
|
+
# Get a specific advisory
|
|
71
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
72
|
+
advisory = client.get_advisory(ghsa_id)
|
|
73
|
+
|
|
74
|
+
print(f"Advisory: {advisory.summary}")
|
|
75
|
+
print(f"Severity: {advisory.severity}")
|
|
76
|
+
print(f"CVSS Score: {advisory.cvss.score if advisory.cvss else 'N/A'}")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
### Authentication
|
|
82
|
+
|
|
83
|
+
The client automatically uses the `GITHUB_TOKEN` environment variable if available:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
export GITHUB_TOKEN=your_github_token_here
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Getting an Advisory
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from ghsa_client import GHSAClient, GHSA_ID
|
|
93
|
+
|
|
94
|
+
client = GHSAClient(logger)
|
|
95
|
+
advisory = client.get_advisory(GHSA_ID("GHSA-gq96-8w38-hhj2"))
|
|
96
|
+
|
|
97
|
+
# Access advisory properties
|
|
98
|
+
print(advisory.summary)
|
|
99
|
+
print(advisory.severity)
|
|
100
|
+
print(advisory.published_at)
|
|
101
|
+
print(advisory.vulnerabilities)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Searching Advisories
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Search by ecosystem
|
|
108
|
+
advisories = client.search_advisories(ecosystem="npm")
|
|
109
|
+
|
|
110
|
+
# Search by severity
|
|
111
|
+
advisories = client.search_advisories(severity="high")
|
|
112
|
+
|
|
113
|
+
# Search by date range
|
|
114
|
+
advisories = client.search_advisories(published="2024-01-01..2024-12-31")
|
|
115
|
+
|
|
116
|
+
# Get all advisories for a year
|
|
117
|
+
advisories = client.get_all_advisories_for_year(2024)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Rate Limiting
|
|
121
|
+
|
|
122
|
+
The client automatically handles GitHub's rate limits:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
# Check remaining rate limit
|
|
126
|
+
rate_limit = client.get_ratelimit_remaining()
|
|
127
|
+
print(f"Remaining requests: {rate_limit['resources']['core']['remaining']}")
|
|
128
|
+
|
|
129
|
+
# The client will automatically wait for rate limit reset when needed
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Models
|
|
133
|
+
|
|
134
|
+
### Advisory
|
|
135
|
+
|
|
136
|
+
The main model representing a GitHub Security Advisory:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from ghsa_client import Advisory
|
|
140
|
+
|
|
141
|
+
advisory: Advisory = client.get_advisory(ghsa_id)
|
|
142
|
+
|
|
143
|
+
# Core properties
|
|
144
|
+
advisory.ghsa_id # GHSA_ID object
|
|
145
|
+
advisory.cve_id # Optional CVE_ID object
|
|
146
|
+
advisory.summary # str
|
|
147
|
+
advisory.severity # str
|
|
148
|
+
advisory.published_at # str (ISO date)
|
|
149
|
+
advisory.description # Optional[str]
|
|
150
|
+
|
|
151
|
+
# Vulnerability data
|
|
152
|
+
advisory.vulnerabilities # List[Vulnerability]
|
|
153
|
+
advisory.affected_packages # List[Package] (computed property)
|
|
154
|
+
|
|
155
|
+
# CVSS data
|
|
156
|
+
advisory.cvss # Optional[CVSS]
|
|
157
|
+
advisory.cwes # Optional[List[str]]
|
|
158
|
+
|
|
159
|
+
# Repository information
|
|
160
|
+
advisory.source_code_location # Optional[str]
|
|
161
|
+
advisory.repository_url # str (property, raises if not found)
|
|
162
|
+
|
|
163
|
+
# References
|
|
164
|
+
advisory.references # List[str]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### GHSA_ID
|
|
168
|
+
|
|
169
|
+
Type-safe GHSA identifier with validation:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from ghsa_client import GHSA_ID, InvalidGHSAIDError
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
176
|
+
print(ghsa_id.id) # "GHSA-gq96-8w38-hhj2"
|
|
177
|
+
except InvalidGHSAIDError as e:
|
|
178
|
+
print(f"Invalid GHSA ID: {e}")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### CVE_ID
|
|
182
|
+
|
|
183
|
+
Type-safe CVE identifier with validation:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from ghsa_client import CVE_ID
|
|
187
|
+
|
|
188
|
+
cve_id = CVE_ID("CVE-2024-12345")
|
|
189
|
+
print(cve_id.id) # "CVE-2024-12345"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
## Error Handling
|
|
194
|
+
|
|
195
|
+
The client raises specific exceptions for different error conditions:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from ghsa_client import RateLimitExceeded, GHSAClient
|
|
199
|
+
import requests
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
advisory = client.get_advisory(ghsa_id)
|
|
203
|
+
except requests.HTTPError as e:
|
|
204
|
+
if e.response.status_code == 404:
|
|
205
|
+
print("Advisory not found")
|
|
206
|
+
else:
|
|
207
|
+
print(f"HTTP error: {e}")
|
|
208
|
+
except RateLimitExceeded as e:
|
|
209
|
+
print(f"Rate limit exceeded: {e}")
|
|
210
|
+
except requests.RequestException as e:
|
|
211
|
+
print(f"Network error: {e}")
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
### Setup
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
git clone https://github.com/auto-exploit/ghsa-client.git
|
|
220
|
+
cd ghsa-client
|
|
221
|
+
pip install -e ".[dev]"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Running Tests
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pytest
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Code Formatting
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
black .
|
|
234
|
+
isort .
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Type Checking
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
mypy src/ghsa_client
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
246
|
+
|
|
247
|
+
## Contributing
|
|
248
|
+
|
|
249
|
+
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
250
|
+
|
|
251
|
+
## Changelog
|
|
252
|
+
|
|
253
|
+
See [CHANGELOG.md](CHANGELOG.md) for a history of changes.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# GHSA Client
|
|
2
|
+
|
|
3
|
+
A Python client library for the GitHub Security Advisory (GHSA) API, providing structured access to security advisory data.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Type-safe models**: Full Pydantic models for GHSA data structures
|
|
8
|
+
- **Rate limiting**: Built-in rate limit handling and retry logic
|
|
9
|
+
- **Flexible queries**: Search advisories with various filters
|
|
10
|
+
- **Comprehensive data**: Access to all GHSA fields including CVSS scores, CWE mappings, and package information
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install ghsa-client
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import logging
|
|
22
|
+
from ghsa_client import GHSAClient, GHSA_ID
|
|
23
|
+
|
|
24
|
+
# Set up logging
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
logger.setLevel(logging.INFO)
|
|
27
|
+
|
|
28
|
+
# Create client
|
|
29
|
+
client = GHSAClient(logger)
|
|
30
|
+
|
|
31
|
+
# Get a specific advisory
|
|
32
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
33
|
+
advisory = client.get_advisory(ghsa_id)
|
|
34
|
+
|
|
35
|
+
print(f"Advisory: {advisory.summary}")
|
|
36
|
+
print(f"Severity: {advisory.severity}")
|
|
37
|
+
print(f"CVSS Score: {advisory.cvss.score if advisory.cvss else 'N/A'}")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
### Authentication
|
|
43
|
+
|
|
44
|
+
The client automatically uses the `GITHUB_TOKEN` environment variable if available:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export GITHUB_TOKEN=your_github_token_here
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Getting an Advisory
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from ghsa_client import GHSAClient, GHSA_ID
|
|
54
|
+
|
|
55
|
+
client = GHSAClient(logger)
|
|
56
|
+
advisory = client.get_advisory(GHSA_ID("GHSA-gq96-8w38-hhj2"))
|
|
57
|
+
|
|
58
|
+
# Access advisory properties
|
|
59
|
+
print(advisory.summary)
|
|
60
|
+
print(advisory.severity)
|
|
61
|
+
print(advisory.published_at)
|
|
62
|
+
print(advisory.vulnerabilities)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Searching Advisories
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# Search by ecosystem
|
|
69
|
+
advisories = client.search_advisories(ecosystem="npm")
|
|
70
|
+
|
|
71
|
+
# Search by severity
|
|
72
|
+
advisories = client.search_advisories(severity="high")
|
|
73
|
+
|
|
74
|
+
# Search by date range
|
|
75
|
+
advisories = client.search_advisories(published="2024-01-01..2024-12-31")
|
|
76
|
+
|
|
77
|
+
# Get all advisories for a year
|
|
78
|
+
advisories = client.get_all_advisories_for_year(2024)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Rate Limiting
|
|
82
|
+
|
|
83
|
+
The client automatically handles GitHub's rate limits:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# Check remaining rate limit
|
|
87
|
+
rate_limit = client.get_ratelimit_remaining()
|
|
88
|
+
print(f"Remaining requests: {rate_limit['resources']['core']['remaining']}")
|
|
89
|
+
|
|
90
|
+
# The client will automatically wait for rate limit reset when needed
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Models
|
|
94
|
+
|
|
95
|
+
### Advisory
|
|
96
|
+
|
|
97
|
+
The main model representing a GitHub Security Advisory:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from ghsa_client import Advisory
|
|
101
|
+
|
|
102
|
+
advisory: Advisory = client.get_advisory(ghsa_id)
|
|
103
|
+
|
|
104
|
+
# Core properties
|
|
105
|
+
advisory.ghsa_id # GHSA_ID object
|
|
106
|
+
advisory.cve_id # Optional CVE_ID object
|
|
107
|
+
advisory.summary # str
|
|
108
|
+
advisory.severity # str
|
|
109
|
+
advisory.published_at # str (ISO date)
|
|
110
|
+
advisory.description # Optional[str]
|
|
111
|
+
|
|
112
|
+
# Vulnerability data
|
|
113
|
+
advisory.vulnerabilities # List[Vulnerability]
|
|
114
|
+
advisory.affected_packages # List[Package] (computed property)
|
|
115
|
+
|
|
116
|
+
# CVSS data
|
|
117
|
+
advisory.cvss # Optional[CVSS]
|
|
118
|
+
advisory.cwes # Optional[List[str]]
|
|
119
|
+
|
|
120
|
+
# Repository information
|
|
121
|
+
advisory.source_code_location # Optional[str]
|
|
122
|
+
advisory.repository_url # str (property, raises if not found)
|
|
123
|
+
|
|
124
|
+
# References
|
|
125
|
+
advisory.references # List[str]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### GHSA_ID
|
|
129
|
+
|
|
130
|
+
Type-safe GHSA identifier with validation:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from ghsa_client import GHSA_ID, InvalidGHSAIDError
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
137
|
+
print(ghsa_id.id) # "GHSA-gq96-8w38-hhj2"
|
|
138
|
+
except InvalidGHSAIDError as e:
|
|
139
|
+
print(f"Invalid GHSA ID: {e}")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### CVE_ID
|
|
143
|
+
|
|
144
|
+
Type-safe CVE identifier with validation:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from ghsa_client import CVE_ID
|
|
148
|
+
|
|
149
|
+
cve_id = CVE_ID("CVE-2024-12345")
|
|
150
|
+
print(cve_id.id) # "CVE-2024-12345"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
## Error Handling
|
|
155
|
+
|
|
156
|
+
The client raises specific exceptions for different error conditions:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from ghsa_client import RateLimitExceeded, GHSAClient
|
|
160
|
+
import requests
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
advisory = client.get_advisory(ghsa_id)
|
|
164
|
+
except requests.HTTPError as e:
|
|
165
|
+
if e.response.status_code == 404:
|
|
166
|
+
print("Advisory not found")
|
|
167
|
+
else:
|
|
168
|
+
print(f"HTTP error: {e}")
|
|
169
|
+
except RateLimitExceeded as e:
|
|
170
|
+
print(f"Rate limit exceeded: {e}")
|
|
171
|
+
except requests.RequestException as e:
|
|
172
|
+
print(f"Network error: {e}")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
### Setup
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
git clone https://github.com/auto-exploit/ghsa-client.git
|
|
181
|
+
cd ghsa-client
|
|
182
|
+
pip install -e ".[dev]"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Running Tests
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
pytest
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Code Formatting
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
black .
|
|
195
|
+
isort .
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Type Checking
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
mypy src/ghsa_client
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
207
|
+
|
|
208
|
+
## Contributing
|
|
209
|
+
|
|
210
|
+
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
211
|
+
|
|
212
|
+
## Changelog
|
|
213
|
+
|
|
214
|
+
See [CHANGELOG.md](CHANGELOG.md) for a history of changes.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ghsa-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Python client library for the GitHub Security Advisory (GHSA) API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
keywords = ["security", "advisory", "github", "ghsa", "vulnerability", "cve"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
requires-python = ">=3.9"
|
|
27
|
+
dependencies = [
|
|
28
|
+
"pydantic>=2.0.0",
|
|
29
|
+
"requests>=2.25.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0.0",
|
|
35
|
+
"pytest-cov>=4.0.0",
|
|
36
|
+
"black>=22.0.0",
|
|
37
|
+
"isort>=5.0.0",
|
|
38
|
+
"mypy>=1.0.0",
|
|
39
|
+
"ruff>=0.1.0",
|
|
40
|
+
]
|
|
41
|
+
docs = [
|
|
42
|
+
"mkdocs>=1.4.0",
|
|
43
|
+
"mkdocs-material>=9.0.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/auto-exploit/ghsa-client"
|
|
48
|
+
Documentation = "https://ghsa-client.readthedocs.io/"
|
|
49
|
+
Repository = "https://github.com/auto-exploit/ghsa-client.git"
|
|
50
|
+
Issues = "https://github.com/auto-exploit/ghsa-client/issues"
|
|
51
|
+
Changelog = "https://github.com/auto-exploit/ghsa-client/blob/main/CHANGELOG.md"
|
|
52
|
+
|
|
53
|
+
[project.scripts]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/ghsa_client"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = [
|
|
60
|
+
"/src",
|
|
61
|
+
"/tests",
|
|
62
|
+
"/README.md",
|
|
63
|
+
"/pyproject.toml",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.black]
|
|
67
|
+
line-length = 88
|
|
68
|
+
target-version = ['py39']
|
|
69
|
+
include = '\.pyi?$'
|
|
70
|
+
extend-exclude = '''
|
|
71
|
+
/(
|
|
72
|
+
# directories
|
|
73
|
+
\.eggs
|
|
74
|
+
| \.git
|
|
75
|
+
| \.hg
|
|
76
|
+
| \.mypy_cache
|
|
77
|
+
| \.tox
|
|
78
|
+
| \.venv
|
|
79
|
+
| build
|
|
80
|
+
| dist
|
|
81
|
+
)/
|
|
82
|
+
'''
|
|
83
|
+
|
|
84
|
+
[tool.isort]
|
|
85
|
+
profile = "black"
|
|
86
|
+
multi_line_output = 3
|
|
87
|
+
line_length = 88
|
|
88
|
+
known_first_party = ["ghsa_client"]
|
|
89
|
+
|
|
90
|
+
[tool.mypy]
|
|
91
|
+
python_version = "3.9"
|
|
92
|
+
warn_return_any = true
|
|
93
|
+
warn_unused_configs = true
|
|
94
|
+
disallow_untyped_defs = true
|
|
95
|
+
disallow_incomplete_defs = true
|
|
96
|
+
check_untyped_defs = true
|
|
97
|
+
disallow_untyped_decorators = true
|
|
98
|
+
no_implicit_optional = true
|
|
99
|
+
warn_redundant_casts = true
|
|
100
|
+
warn_unused_ignores = true
|
|
101
|
+
warn_no_return = true
|
|
102
|
+
warn_unreachable = true
|
|
103
|
+
strict_equality = true
|
|
104
|
+
|
|
105
|
+
[tool.pytest.ini_options]
|
|
106
|
+
testpaths = ["tests"]
|
|
107
|
+
python_files = ["test_*.py"]
|
|
108
|
+
python_classes = ["Test*"]
|
|
109
|
+
python_functions = ["test_*"]
|
|
110
|
+
addopts = [
|
|
111
|
+
"--strict-markers",
|
|
112
|
+
"--strict-config",
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
[tool.ruff]
|
|
116
|
+
target-version = "py39"
|
|
117
|
+
line-length = 88
|
|
118
|
+
select = [
|
|
119
|
+
"E", # pycodestyle errors
|
|
120
|
+
"W", # pycodestyle warnings
|
|
121
|
+
"F", # pyflakes
|
|
122
|
+
"I", # isort
|
|
123
|
+
"B", # flake8-bugbear
|
|
124
|
+
"C4", # flake8-comprehensions
|
|
125
|
+
"UP", # pyupgrade
|
|
126
|
+
]
|
|
127
|
+
ignore = [
|
|
128
|
+
"E501", # line too long, handled by black
|
|
129
|
+
"B008", # do not perform function calls in argument defaults
|
|
130
|
+
"C901", # too complex
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
[tool.ruff.per-file-ignores]
|
|
134
|
+
"__init__.py" = ["F401"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""GitHub Security Advisory (GHSA) client library.
|
|
2
|
+
|
|
3
|
+
A Python library for interacting with the GitHub Security Advisory API,
|
|
4
|
+
providing structured access to security advisory data.
|
|
5
|
+
|
|
6
|
+
Main exports:
|
|
7
|
+
- GHSAClient: Main client for interacting with the GHSA API
|
|
8
|
+
- Advisory: Main model representing a GitHub Security Advisory
|
|
9
|
+
- GHSA_ID: Type-safe GHSA identifier with validation
|
|
10
|
+
- CVE_ID: Type-safe CVE identifier with validation
|
|
11
|
+
- RateLimitExceeded: Exception raised when API rate limit is exceeded
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import GHSAClient
|
|
15
|
+
from .models import Advisory, GHSA_ID, CVE_ID
|
|
16
|
+
from .exceptions import RateLimitExceeded
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__all__ = ["GHSAClient", "Advisory", "GHSA_ID", "CVE_ID", "RateLimitExceeded"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""GitHub Security Advisory (GHSA) API client."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import requests
|
|
5
|
+
import logging
|
|
6
|
+
from time import sleep, time
|
|
7
|
+
from typing import Any, Optional, cast
|
|
8
|
+
from .exceptions import RateLimitExceeded
|
|
9
|
+
from .models import Advisory, GHSA_ID
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GHSAClient:
|
|
13
|
+
"""Client for querying GitHub Security Advisory database via REST API."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
api_key: Optional[str] = None,
|
|
18
|
+
*,
|
|
19
|
+
blocking_rate_limit: bool = True,
|
|
20
|
+
logger: logging.Logger = logging.getLogger(__name__),
|
|
21
|
+
base_url: str = "https://api.github.com",
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initialize the GHSA client.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
api_key: Optional GitHub API key. If provided, enables much higher rate limits
|
|
27
|
+
(5000 requests/hour vs 60 requests/hour for unauthenticated requests).
|
|
28
|
+
Falls back to GITHUB_TOKEN environment variable if not provided.
|
|
29
|
+
blocking_rate_limit: If True, automatically waits for rate limit reset before
|
|
30
|
+
making requests. If False, raises RateLimitExceeded when rate limited.
|
|
31
|
+
logger: Logger instance for debug and error messages.
|
|
32
|
+
base_url: Base URL for GitHub API. Defaults to production API.
|
|
33
|
+
"""
|
|
34
|
+
self.base_url = base_url
|
|
35
|
+
self.session = requests.Session()
|
|
36
|
+
self.logger = logger
|
|
37
|
+
self.blocking_rate_limit = blocking_rate_limit
|
|
38
|
+
# Set up headers
|
|
39
|
+
headers = {
|
|
40
|
+
"Accept": "application/vnd.github+json",
|
|
41
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if api_key:
|
|
45
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
46
|
+
elif GITHUB_TOKEN := os.getenv("GITHUB_TOKEN"):
|
|
47
|
+
headers["Authorization"] = f"Bearer {GITHUB_TOKEN}"
|
|
48
|
+
|
|
49
|
+
self.session.headers.update(headers)
|
|
50
|
+
|
|
51
|
+
def _get_with_rate_limit_retry(
|
|
52
|
+
self, url: str, *args: Any, **kwargs: Any
|
|
53
|
+
) -> requests.Response:
|
|
54
|
+
for _ in range(3):
|
|
55
|
+
try:
|
|
56
|
+
if self.blocking_rate_limit:
|
|
57
|
+
self.wait_for_ratelimit()
|
|
58
|
+
response = self.session.get(url, *args, **kwargs)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
return response
|
|
61
|
+
except requests.HTTPError as e:
|
|
62
|
+
if e.response.status_code == 403 and e.response.text.startswith(
|
|
63
|
+
"rate limit exceeded"
|
|
64
|
+
):
|
|
65
|
+
sleep(1)
|
|
66
|
+
continue
|
|
67
|
+
raise e
|
|
68
|
+
|
|
69
|
+
raise RateLimitExceeded(f"Rate limit exceeded for advisory")
|
|
70
|
+
|
|
71
|
+
def get_advisory(self, ghsa_id: GHSA_ID) -> Advisory:
|
|
72
|
+
url = f"{self.base_url}/advisories/{ghsa_id}"
|
|
73
|
+
self.logger.debug(f"Requesting advisory from URL: {url}")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
response = self._get_with_rate_limit_retry(url)
|
|
77
|
+
return Advisory.model_validate(response.json())
|
|
78
|
+
except requests.HTTPError as e:
|
|
79
|
+
if e.response.status_code == 404:
|
|
80
|
+
self.logger.exception(f"Advisory {ghsa_id} not found")
|
|
81
|
+
else:
|
|
82
|
+
self.logger.exception(f"HTTP error retrieving advisory {ghsa_id}: {e}")
|
|
83
|
+
raise
|
|
84
|
+
except requests.RequestException:
|
|
85
|
+
self.logger.exception(f"Network error retrieving advisory {ghsa_id}")
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
def search_advisories(self, **filters: Any) -> list[Advisory]:
|
|
89
|
+
"""
|
|
90
|
+
Search for advisories with optional filters.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
**filters: Keyword arguments for filtering (ecosystem, severity, etc.)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List[Advisory]: List of matching advisories as structured dataclasses
|
|
97
|
+
"""
|
|
98
|
+
url = f"{self.base_url}/advisories"
|
|
99
|
+
self.logger.debug(f"Searching advisories with filters: {filters}")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
response = self._get_with_rate_limit_retry(url, params=filters)
|
|
103
|
+
raw_advisories = response.json()
|
|
104
|
+
advisories = [Advisory.model_validate(data) for data in raw_advisories]
|
|
105
|
+
|
|
106
|
+
self.logger.info(f"Found {len(advisories)} advisories matching filters")
|
|
107
|
+
|
|
108
|
+
return advisories
|
|
109
|
+
|
|
110
|
+
except requests.RequestException:
|
|
111
|
+
self.logger.exception("Error searching advisories")
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
def get_all_advisories_for_year(self, year: int) -> list[Advisory]:
|
|
115
|
+
"""
|
|
116
|
+
Returns a list of GHSA_IDs for all vulnerabilities published in a given year.
|
|
117
|
+
"""
|
|
118
|
+
return self.search_advisories(published=f"{year}-01-01..{year}-12-31")
|
|
119
|
+
|
|
120
|
+
def get_ratelimit_remaining(self) -> dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Returns the number of requests remaining in the current rate limit window.
|
|
123
|
+
"""
|
|
124
|
+
response = self.session.get(f"{self.base_url}/rate_limit")
|
|
125
|
+
response.raise_for_status()
|
|
126
|
+
return cast(dict[str, Any], response.json())
|
|
127
|
+
|
|
128
|
+
def wait_for_ratelimit(self) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Waits for the rate limit to reset.
|
|
131
|
+
"""
|
|
132
|
+
ratelimit_remaining = self.get_ratelimit_remaining()
|
|
133
|
+
if ratelimit_remaining["resources"]["core"]["remaining"] > 0:
|
|
134
|
+
return
|
|
135
|
+
reset_timestamp = ratelimit_remaining["resources"]["core"]["reset"]
|
|
136
|
+
self.logger.info(f"Rate limit reset in {reset_timestamp - time()} seconds")
|
|
137
|
+
sleep(reset_timestamp - time())
|
|
138
|
+
ratelimit_remaining = self.get_ratelimit_remaining()
|
|
139
|
+
if ratelimit_remaining["resources"]["core"]["remaining"] == 0:
|
|
140
|
+
raise RateLimitExceeded(f"Rate limit not reset")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Models for GHSA operations."""
|
|
2
|
+
|
|
3
|
+
from .ghsa_id import GHSA_ID, InvalidGHSAIDError
|
|
4
|
+
from .advisory import Advisory, NoSourceCodeLocationFound
|
|
5
|
+
from .base import CVE_ID, CVSS, CVSSVector, Package, Vulnerability, GitCommit, VersionPredicate
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"GHSA_ID",
|
|
9
|
+
"InvalidGHSAIDError",
|
|
10
|
+
"Advisory",
|
|
11
|
+
"NoSourceCodeLocationFound",
|
|
12
|
+
"CVE_ID",
|
|
13
|
+
"CVSS",
|
|
14
|
+
"CVSSVector",
|
|
15
|
+
"Package",
|
|
16
|
+
"Vulnerability",
|
|
17
|
+
"GitCommit",
|
|
18
|
+
"VersionPredicate",
|
|
19
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Advisory model for GHSA operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Any
|
|
4
|
+
from pydantic import BaseModel, field_validator, computed_field, model_validator
|
|
5
|
+
|
|
6
|
+
from .ghsa_id import GHSA_ID
|
|
7
|
+
from .base import CVE_ID, Vulnerability, CVSS, Package, GitCommit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NoSourceCodeLocationFound(Exception):
|
|
11
|
+
"""Raised when source code location is not found in advisory."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Advisory(BaseModel):
|
|
16
|
+
"""Represents a GitHub Security Advisory (GHSA)."""
|
|
17
|
+
|
|
18
|
+
ghsa_id: GHSA_ID
|
|
19
|
+
cve_id: Optional[CVE_ID] = None
|
|
20
|
+
summary: str
|
|
21
|
+
severity: str
|
|
22
|
+
published_at: str
|
|
23
|
+
vulnerabilities: list[Vulnerability]
|
|
24
|
+
description: Optional[str] = None
|
|
25
|
+
source_code_location: Optional[str] = None
|
|
26
|
+
cwes: Optional[list[str]] = None
|
|
27
|
+
references: list[str] = []
|
|
28
|
+
cvss: Optional[CVSS] = None
|
|
29
|
+
last_vulnerable_version: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
# Git commit information (populated by GitRepoHelper)
|
|
32
|
+
last_vulnerable_commit: Optional[GitCommit] = None
|
|
33
|
+
first_patched_commit: Optional[GitCommit] = None
|
|
34
|
+
|
|
35
|
+
@field_validator("ghsa_id", mode="before")
|
|
36
|
+
@classmethod
|
|
37
|
+
def validate_ghsa_id(cls, v: Any) -> GHSA_ID:
|
|
38
|
+
if isinstance(v, str):
|
|
39
|
+
return GHSA_ID(id=v)
|
|
40
|
+
if isinstance(v, GHSA_ID):
|
|
41
|
+
return v
|
|
42
|
+
if isinstance(v, dict):
|
|
43
|
+
return GHSA_ID.model_validate(v)
|
|
44
|
+
raise ValueError("Invalid value for ghsa_id")
|
|
45
|
+
|
|
46
|
+
@field_validator("cve_id", mode="before")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_cve_id(cls, v: Any) -> Optional[CVE_ID]:
|
|
49
|
+
if v is None:
|
|
50
|
+
return None
|
|
51
|
+
if isinstance(v, str):
|
|
52
|
+
return CVE_ID(id=v)
|
|
53
|
+
if isinstance(v, CVE_ID):
|
|
54
|
+
return v
|
|
55
|
+
if isinstance(v, dict):
|
|
56
|
+
return CVE_ID.model_validate(v)
|
|
57
|
+
raise ValueError("Invalid value for cve_id")
|
|
58
|
+
|
|
59
|
+
@field_validator("cwes", mode="before")
|
|
60
|
+
@classmethod
|
|
61
|
+
def parse_cwes(cls, v: Any) -> Optional[list[str]]:
|
|
62
|
+
if not v:
|
|
63
|
+
return None
|
|
64
|
+
cwes = []
|
|
65
|
+
for cwe_data in v:
|
|
66
|
+
if isinstance(cwe_data, dict):
|
|
67
|
+
cwe_id = cwe_data.get("cwe_id", "")
|
|
68
|
+
if cwe_id:
|
|
69
|
+
cwes.append(cwe_id)
|
|
70
|
+
elif isinstance(cwe_data, str):
|
|
71
|
+
cwes.append(cwe_data)
|
|
72
|
+
return cwes if cwes else None
|
|
73
|
+
|
|
74
|
+
@field_validator("cvss", mode="before")
|
|
75
|
+
@classmethod
|
|
76
|
+
def parse_cvss(cls, v: Any, info: Any) -> Optional[CVSS]:
|
|
77
|
+
if not v:
|
|
78
|
+
return None
|
|
79
|
+
# Let the CVSS model handle the validation and parsing
|
|
80
|
+
try:
|
|
81
|
+
return CVSS.model_validate(v)
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
@model_validator(mode="before")
|
|
86
|
+
@classmethod
|
|
87
|
+
def parse_cvss_severity(cls, data: Any) -> Any:
|
|
88
|
+
if isinstance(data, dict) and "cvss_severity" in data:
|
|
89
|
+
cvss_severity = data.pop("cvss_severity")
|
|
90
|
+
if cvss_severity:
|
|
91
|
+
if "cvss_v4" in cvss_severity:
|
|
92
|
+
data["cvss"] = CVSS(string=cvss_severity["cvss_v4"])
|
|
93
|
+
elif "cvss_v3" in cvss_severity:
|
|
94
|
+
data["cvss"] = CVSS(string=cvss_severity["cvss_v3"])
|
|
95
|
+
return data
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str:
|
|
98
|
+
return f"{self.ghsa_id}: {self.summary} ({self.severity})"
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
vulns_repr = f"[{len(self.vulnerabilities)} vulnerabilities]"
|
|
102
|
+
desc_preview = (
|
|
103
|
+
self.description[:100] + "..."
|
|
104
|
+
if self.description and len(self.description) > 100
|
|
105
|
+
else self.description
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
f"Advisory(\n"
|
|
110
|
+
f" ghsa_id={self.ghsa_id!r},\n"
|
|
111
|
+
f" cve_id={self.cve_id!r},\n"
|
|
112
|
+
f" summary={self.summary!r},\n"
|
|
113
|
+
f" severity={self.severity!r},\n"
|
|
114
|
+
f" published_at={self.published_at!r},\n"
|
|
115
|
+
f" vulnerabilities={vulns_repr},\n"
|
|
116
|
+
f" description={desc_preview!r},\n"
|
|
117
|
+
f" source_code_location={self.source_code_location!r},\n"
|
|
118
|
+
f" cwes={self.cwes!r},\n"
|
|
119
|
+
f" references={self.references!r}\n"
|
|
120
|
+
f")"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def has_cve(self) -> bool:
|
|
125
|
+
"""Check if the advisory has an associated CVE."""
|
|
126
|
+
return self.cve_id is not None and str(self.cve_id) != ""
|
|
127
|
+
|
|
128
|
+
@computed_field(return_type=str)
|
|
129
|
+
def vuln_id(self) -> str:
|
|
130
|
+
"""Canonical vulnerability ID for the system, always a CVE when available, else GHSA."""
|
|
131
|
+
if self.cve_id is not None:
|
|
132
|
+
return str(self.cve_id)
|
|
133
|
+
return str(self.ghsa_id)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def affected_packages(self) -> list[Package]:
|
|
137
|
+
"""Get all unique packages affected by this advisory."""
|
|
138
|
+
packages = []
|
|
139
|
+
seen = set()
|
|
140
|
+
for vuln in self.vulnerabilities:
|
|
141
|
+
key = (vuln.package.name, vuln.package.ecosystem)
|
|
142
|
+
if key not in seen:
|
|
143
|
+
packages.append(vuln.package)
|
|
144
|
+
seen.add(key)
|
|
145
|
+
return packages
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def repository_url(self) -> str:
|
|
149
|
+
"""Get the source code for the advisory."""
|
|
150
|
+
if not self.source_code_location:
|
|
151
|
+
self.source_code_location = self._get_repository_url_from_description()
|
|
152
|
+
if not self.source_code_location:
|
|
153
|
+
raise NoSourceCodeLocationFound(
|
|
154
|
+
"No source code location found in advisory"
|
|
155
|
+
)
|
|
156
|
+
return self.source_code_location
|
|
157
|
+
|
|
158
|
+
def _get_repository_url_from_description(self) -> Optional[str]:
|
|
159
|
+
"""Extract repository URL from description. Placeholder implementation."""
|
|
160
|
+
# TODO: Implement proper repository URL extraction from description
|
|
161
|
+
return None
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Base models for GHSA operations."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import ClassVar, Optional, Any, List
|
|
5
|
+
from pydantic import BaseModel, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CVE_ID(BaseModel):
|
|
9
|
+
"""Strongly-typed CVE identifier with validation.
|
|
10
|
+
CVE IDs follow the format: CVE-YYYY-NNNN+, where NNNN can be 4 or more digits.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
|
|
15
|
+
PATTERN: ClassVar[re.Pattern] = re.compile(r"^CVE-\d{4}-\d{4,}$", re.IGNORECASE)
|
|
16
|
+
|
|
17
|
+
def __init__(self, id: Optional[str] = None, **data: Any) -> None:
|
|
18
|
+
if id is not None:
|
|
19
|
+
data["id"] = id
|
|
20
|
+
elif "id" not in data:
|
|
21
|
+
raise ValueError("CVE ID cannot be None")
|
|
22
|
+
super().__init__(**data)
|
|
23
|
+
|
|
24
|
+
@field_validator("id", mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def validate_id(cls, value: Any) -> str:
|
|
27
|
+
if not isinstance(value, str):
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"CVE ID must be a string, got {type(value).__name__}"
|
|
30
|
+
)
|
|
31
|
+
normalized = value.strip()
|
|
32
|
+
if not normalized:
|
|
33
|
+
raise ValueError("CVE ID cannot be empty")
|
|
34
|
+
if not cls.PATTERN.match(normalized):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Invalid CVE ID format: '{normalized}'. Expected CVE-YYYY-NNNN (e.g., CVE-2024-12345)"
|
|
37
|
+
)
|
|
38
|
+
# Normalize to upper-case prefix and keep the rest as-is
|
|
39
|
+
parts = normalized.split("-", 2)
|
|
40
|
+
return f"CVE-{parts[1]}-{parts[2]}"
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
return self.id
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"CVE_ID('{self.id}')"
|
|
47
|
+
|
|
48
|
+
def __eq__(self, other: object) -> bool:
|
|
49
|
+
if isinstance(other, CVE_ID):
|
|
50
|
+
return self.id == other.id
|
|
51
|
+
if not isinstance(other, str):
|
|
52
|
+
return False
|
|
53
|
+
try:
|
|
54
|
+
other_cve = CVE_ID(id=other)
|
|
55
|
+
return self.id == other_cve.id
|
|
56
|
+
except ValueError:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def __hash__(self) -> int:
|
|
60
|
+
return hash(self.id)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CVSSVector(BaseModel):
|
|
64
|
+
"""CVSS vector representation."""
|
|
65
|
+
vector: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CVSS(BaseModel):
|
|
69
|
+
"""CVSS score representation."""
|
|
70
|
+
string: Optional[str] = None
|
|
71
|
+
score: Optional[float] = None
|
|
72
|
+
vector: Optional[CVSSVector] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Package(BaseModel):
|
|
76
|
+
"""Package representation."""
|
|
77
|
+
name: str
|
|
78
|
+
ecosystem: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class VersionPredicate(BaseModel):
|
|
82
|
+
"""Version predicate for vulnerability ranges."""
|
|
83
|
+
predicate: str
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_str(cls, predicate_str: str) -> "VersionPredicate":
|
|
87
|
+
"""Create a VersionPredicate from a string."""
|
|
88
|
+
return cls(predicate=predicate_str)
|
|
89
|
+
|
|
90
|
+
def __str__(self) -> str:
|
|
91
|
+
return self.predicate
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Vulnerability(BaseModel):
|
|
95
|
+
"""Represents a vulnerability within an advisory."""
|
|
96
|
+
|
|
97
|
+
package: Package
|
|
98
|
+
vulnerable_version_range: List[VersionPredicate] = []
|
|
99
|
+
first_patched_version: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
@field_validator("vulnerable_version_range", mode="before")
|
|
102
|
+
@classmethod
|
|
103
|
+
def parse_vulnerable_version_range(cls, v: Any) -> List[VersionPredicate]:
|
|
104
|
+
if not isinstance(v, str):
|
|
105
|
+
raise ValueError(f"Invalid vulnerable version range: {v}")
|
|
106
|
+
# Handle single predicate string
|
|
107
|
+
if "," not in v:
|
|
108
|
+
return [VersionPredicate.from_str(v)]
|
|
109
|
+
# Handle comma-separated predicates
|
|
110
|
+
return [
|
|
111
|
+
VersionPredicate.from_str(predicate.strip()) for predicate in v.split(",")
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def __str__(self) -> str:
|
|
115
|
+
patched = (
|
|
116
|
+
f" → {self.first_patched_version}"
|
|
117
|
+
if self.first_patched_version is not None
|
|
118
|
+
else ""
|
|
119
|
+
)
|
|
120
|
+
version_range_str = (
|
|
121
|
+
"[" + ", ".join(str(pred) for pred in self.vulnerable_version_range) + "]"
|
|
122
|
+
)
|
|
123
|
+
return f"{self.package}: {version_range_str}{patched}"
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
version_range_str = (
|
|
127
|
+
"[" + ", ".join(str(pred) for pred in self.vulnerable_version_range) + "]"
|
|
128
|
+
)
|
|
129
|
+
return (
|
|
130
|
+
f"Vulnerability(package={self.package!r}, "
|
|
131
|
+
f"vulnerable_version_range={version_range_str}, "
|
|
132
|
+
f"first_patched_version={self.first_patched_version!r})"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class GitCommit(BaseModel):
|
|
137
|
+
"""Git commit representation."""
|
|
138
|
+
sha: str
|
|
139
|
+
message: Optional[str] = None
|
|
140
|
+
author: Optional[str] = None
|
|
141
|
+
date: Optional[str] = None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""GHSA ID model with validation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import ClassVar, Optional, Any
|
|
5
|
+
from pydantic import BaseModel, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidGHSAIDError(Exception):
|
|
9
|
+
"""Raised when GHSA ID format is invalid."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GHSA_ID(BaseModel):
|
|
15
|
+
"""
|
|
16
|
+
A strongly-typed GHSA identifier with proper validation.
|
|
17
|
+
GHSA IDs follow the format: GHSA-xxxx-xxxx-xxxx where x is an alphanumeric character [0-9a-z].
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
|
|
22
|
+
PATTERN: ClassVar[re.Pattern] = re.compile(
|
|
23
|
+
r"^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$", re.IGNORECASE
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def __init__(self, id: Optional[str] = None, **data: Any) -> None:
|
|
27
|
+
if id is not None:
|
|
28
|
+
data["id"] = id
|
|
29
|
+
elif "id" not in data:
|
|
30
|
+
raise InvalidGHSAIDError("GHSA ID cannot be None")
|
|
31
|
+
super().__init__(**data)
|
|
32
|
+
|
|
33
|
+
@field_validator("id", mode="before")
|
|
34
|
+
@classmethod
|
|
35
|
+
def validate_id(cls, v: Any) -> str:
|
|
36
|
+
if not isinstance(v, str):
|
|
37
|
+
raise InvalidGHSAIDError(
|
|
38
|
+
f"GHSA ID must be a string, got {type(v).__name__}"
|
|
39
|
+
)
|
|
40
|
+
normalized_id = v.strip()
|
|
41
|
+
if not normalized_id:
|
|
42
|
+
raise InvalidGHSAIDError("GHSA ID cannot be empty")
|
|
43
|
+
if not cls.PATTERN.match(normalized_id):
|
|
44
|
+
raise InvalidGHSAIDError(
|
|
45
|
+
f"Invalid GHSA ID format: '{normalized_id}'. "
|
|
46
|
+
f"Expected format: GHSA-xxxx-xxxx-xxxx where x is alphanumeric (e.g., GHSA-gq96-8w38-hhj2)"
|
|
47
|
+
)
|
|
48
|
+
return "GHSA-" + normalized_id[5:].lower()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def ghsa_id(self) -> "GHSA_ID":
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return self.id
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return f"GHSA_ID('{self.id}')"
|
|
59
|
+
|
|
60
|
+
def __eq__(self, other: object) -> bool:
|
|
61
|
+
if isinstance(other, GHSA_ID):
|
|
62
|
+
return self.id == other.id
|
|
63
|
+
if not isinstance(other, str):
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
other_ghsa = GHSA_ID(id=other)
|
|
67
|
+
return self.id == other_ghsa.id
|
|
68
|
+
except (InvalidGHSAIDError, Exception):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def __hash__(self) -> int:
|
|
72
|
+
return hash(self.id)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for ghsa-client package."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Tests for GHSA client."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import logging
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
|
|
7
|
+
from ghsa_client import GHSAClient, GHSA_ID, RateLimitExceeded
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestGHSAClient:
|
|
11
|
+
def test_initialization_without_token(self) -> None:
|
|
12
|
+
"""Test client initialization without GitHub token."""
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
client = GHSAClient(logger=logger)
|
|
15
|
+
assert client.base_url == "https://api.github.com"
|
|
16
|
+
assert "Authorization" not in client.session.headers
|
|
17
|
+
|
|
18
|
+
def test_initialization_with_token(self) -> None:
|
|
19
|
+
"""Test client initialization with GitHub token."""
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
with patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}):
|
|
22
|
+
client = GHSAClient(logger=logger)
|
|
23
|
+
assert client.session.headers["Authorization"] == "Bearer test-token"
|
|
24
|
+
|
|
25
|
+
def test_initialization_with_custom_url(self) -> None:
|
|
26
|
+
"""Test client initialization with custom base URL."""
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
client = GHSAClient(logger=logger, base_url="https://custom.github.com")
|
|
29
|
+
assert client.base_url == "https://custom.github.com"
|
|
30
|
+
|
|
31
|
+
@patch('ghsa_client.client.requests.Session.get')
|
|
32
|
+
def test_get_advisory_success(self, mock_get: MagicMock) -> None:
|
|
33
|
+
"""Test successful advisory retrieval."""
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
client = GHSAClient(logger=logger)
|
|
36
|
+
|
|
37
|
+
# Mock rate limit response
|
|
38
|
+
mock_rate_limit_response = MagicMock()
|
|
39
|
+
mock_rate_limit_response.json.return_value = {
|
|
40
|
+
"resources": {
|
|
41
|
+
"core": {
|
|
42
|
+
"remaining": 5000,
|
|
43
|
+
"reset": 1234567890
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
mock_rate_limit_response.raise_for_status.return_value = None
|
|
48
|
+
|
|
49
|
+
# Mock advisory response
|
|
50
|
+
mock_advisory_response = MagicMock()
|
|
51
|
+
mock_advisory_response.json.return_value = {
|
|
52
|
+
"ghsa_id": "GHSA-gq96-8w38-hhj2",
|
|
53
|
+
"summary": "Test advisory",
|
|
54
|
+
"severity": "high",
|
|
55
|
+
"published_at": "2024-01-01T00:00:00Z",
|
|
56
|
+
"vulnerabilities": []
|
|
57
|
+
}
|
|
58
|
+
mock_advisory_response.raise_for_status.return_value = None
|
|
59
|
+
|
|
60
|
+
# Configure mock to return different responses for different URLs
|
|
61
|
+
def side_effect(*args: object, **kwargs: object) -> MagicMock:
|
|
62
|
+
url = str(args[0]) if args else ""
|
|
63
|
+
if "rate_limit" in url:
|
|
64
|
+
return mock_rate_limit_response
|
|
65
|
+
else:
|
|
66
|
+
return mock_advisory_response
|
|
67
|
+
|
|
68
|
+
mock_get.side_effect = side_effect
|
|
69
|
+
|
|
70
|
+
# Test
|
|
71
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
72
|
+
advisory = client.get_advisory(ghsa_id)
|
|
73
|
+
|
|
74
|
+
assert advisory.ghsa_id.id == "GHSA-gq96-8w38-hhj2"
|
|
75
|
+
assert advisory.summary == "Test advisory"
|
|
76
|
+
assert advisory.severity == "high"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Tests for GHSA models."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from ghsa_client import GHSA_ID, Advisory
|
|
5
|
+
from ghsa_client.models import CVE_ID, InvalidGHSAIDError, Package, Vulnerability, VersionPredicate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestGHSA_ID:
|
|
9
|
+
def test_valid_ghsa_id(self) -> None:
|
|
10
|
+
"""Test valid GHSA ID creation."""
|
|
11
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
12
|
+
assert ghsa_id.id == "GHSA-gq96-8w38-hhj2"
|
|
13
|
+
|
|
14
|
+
def test_invalid_ghsa_id_format(self) -> None:
|
|
15
|
+
"""Test invalid GHSA ID format raises error."""
|
|
16
|
+
with pytest.raises(InvalidGHSAIDError):
|
|
17
|
+
GHSA_ID("invalid-id")
|
|
18
|
+
|
|
19
|
+
def test_ghsa_id_string_conversion(self) -> None:
|
|
20
|
+
"""Test GHSA ID string conversion."""
|
|
21
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
22
|
+
assert str(ghsa_id) == "GHSA-gq96-8w38-hhj2"
|
|
23
|
+
|
|
24
|
+
def test_ghsa_id_equality(self) -> None:
|
|
25
|
+
"""Test GHSA ID equality."""
|
|
26
|
+
ghsa_id1 = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
27
|
+
ghsa_id2 = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
28
|
+
assert ghsa_id1 == ghsa_id2
|
|
29
|
+
assert ghsa_id1 == "GHSA-gq96-8w38-hhj2"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestCVE_ID:
|
|
33
|
+
def test_valid_cve_id(self) -> None:
|
|
34
|
+
"""Test valid CVE ID creation."""
|
|
35
|
+
cve_id = CVE_ID("CVE-2024-12345")
|
|
36
|
+
assert cve_id.id == "CVE-2024-12345"
|
|
37
|
+
|
|
38
|
+
def test_invalid_cve_id_format(self) -> None:
|
|
39
|
+
"""Test invalid CVE ID format raises error."""
|
|
40
|
+
with pytest.raises(ValueError):
|
|
41
|
+
CVE_ID("invalid-id")
|
|
42
|
+
|
|
43
|
+
def test_cve_id_string_conversion(self) -> None:
|
|
44
|
+
"""Test CVE ID string conversion."""
|
|
45
|
+
cve_id = CVE_ID("CVE-2024-12345")
|
|
46
|
+
assert str(cve_id) == "CVE-2024-12345"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestAdvisory:
|
|
50
|
+
def test_advisory_creation(self) -> None:
|
|
51
|
+
"""Test advisory model creation."""
|
|
52
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
53
|
+
package = Package(name="test-package", ecosystem="npm")
|
|
54
|
+
vulnerability = Vulnerability(package=package)
|
|
55
|
+
|
|
56
|
+
advisory = Advisory(
|
|
57
|
+
ghsa_id=ghsa_id,
|
|
58
|
+
summary="Test advisory",
|
|
59
|
+
severity="high",
|
|
60
|
+
published_at="2024-01-01T00:00:00Z",
|
|
61
|
+
vulnerabilities=[vulnerability]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert advisory.ghsa_id == ghsa_id
|
|
65
|
+
assert advisory.summary == "Test advisory"
|
|
66
|
+
assert advisory.severity == "high"
|
|
67
|
+
assert len(advisory.vulnerabilities) == 1
|
|
68
|
+
|
|
69
|
+
def test_advisory_has_cve_property(self) -> None:
|
|
70
|
+
"""Test advisory has_cve property."""
|
|
71
|
+
ghsa_id = GHSA_ID("GHSA-gq96-8w38-hhj2")
|
|
72
|
+
package = Package(name="test-package", ecosystem="npm")
|
|
73
|
+
vulnerability = Vulnerability(package=package)
|
|
74
|
+
|
|
75
|
+
# Advisory without CVE
|
|
76
|
+
advisory = Advisory(
|
|
77
|
+
ghsa_id=ghsa_id,
|
|
78
|
+
summary="Test advisory",
|
|
79
|
+
severity="high",
|
|
80
|
+
published_at="2024-01-01T00:00:00Z",
|
|
81
|
+
vulnerabilities=[vulnerability]
|
|
82
|
+
)
|
|
83
|
+
assert not advisory.has_cve
|
|
84
|
+
|
|
85
|
+
# Advisory with CVE
|
|
86
|
+
advisory.cve_id = CVE_ID("CVE-2024-12345")
|
|
87
|
+
assert advisory.has_cve
|