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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -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,5 @@
1
+ """Exceptions specific to GHSA operations."""
2
+
3
+ from .rate_limit import RateLimitExceeded
4
+
5
+ __all__ = ["RateLimitExceeded"]
@@ -0,0 +1,7 @@
1
+ """Rate limit exception for GHSA API operations."""
2
+
3
+
4
+ class RateLimitExceeded(Exception):
5
+ """Raised when API rate limit is exceeded."""
6
+
7
+ pass
@@ -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