elf 0.1.0__py3-none-any.whl
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.
- elf/__init__.py +90 -0
- elf/core/__init__.py +32 -0
- elf/core/base_api_client.py +365 -0
- elf/core/exceptions.py +68 -0
- elf/py.typed +0 -0
- elf/sources/__init__.py +60 -0
- elf/sources/cisa_kev/__init__.py +30 -0
- elf/sources/cisa_kev/client.py +196 -0
- elf/sources/cisa_kev/models.py +161 -0
- elf/sources/first_epss/__init__.py +33 -0
- elf/sources/first_epss/client.py +1126 -0
- elf/sources/first_epss/models.py +179 -0
- elf/sources/nist_nvd/__init__.py +42 -0
- elf/sources/nist_nvd/client.py +519 -0
- elf/sources/nist_nvd/models.py +723 -0
- elf-0.1.0.dist-info/METADATA +344 -0
- elf-0.1.0.dist-info/RECORD +19 -0
- elf-0.1.0.dist-info/WHEEL +4 -0
- elf-0.1.0.dist-info/licenses/LICENSE +21 -0
elf/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The `elf` package provides tools and API clients for interacting with vulnerability management data.
|
|
2
|
+
|
|
3
|
+
This package enables seamless integration with data sources such as:
|
|
4
|
+
- NIST NVD (National Vulnerability Database)
|
|
5
|
+
- CISA KEV (Known Exploited Vulnerabilities)
|
|
6
|
+
- FIRST EPSS (Exploit Prediction Scoring System)
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
- API Clients:
|
|
10
|
+
- `CisaKevApiClient`
|
|
11
|
+
- `FirstEpssApiClient`
|
|
12
|
+
- `NistNvdApiClient`
|
|
13
|
+
- Core Components:
|
|
14
|
+
- `BaseApiClient`: Base class for API clients.
|
|
15
|
+
- Custom exceptions for error handling.
|
|
16
|
+
- Models:
|
|
17
|
+
- CISA KEV:
|
|
18
|
+
- `CisaKevCatalog`
|
|
19
|
+
- `CisaKevVulnerability`
|
|
20
|
+
- FIRST EPSS:
|
|
21
|
+
- `FirstEpssScoreResponse`
|
|
22
|
+
- `FirstEpssEpssScoreItem`
|
|
23
|
+
- `FirstEpssOrderOption`
|
|
24
|
+
- NIST NVD:
|
|
25
|
+
- `NistNvdCveResponse`
|
|
26
|
+
- `NistNvdCveHistoryResponse`
|
|
27
|
+
- `NistNvdCveItem`
|
|
28
|
+
- `NistNvdChange`
|
|
29
|
+
- `NistNvdChangeDetail`
|
|
30
|
+
|
|
31
|
+
Example Usage:
|
|
32
|
+
>>> from elf import CisaKevApiClient, FirstEpssApiClient, NistNvdApiClient, FirstEpssOrderOption
|
|
33
|
+
>>> async with CisaKevApiClient() as kev_client:
|
|
34
|
+
>>> kev_data = await kev_client.get_kev_json()
|
|
35
|
+
>>> async with FirstEpssApiClient() as epss_client:
|
|
36
|
+
>>> epss_data = await epss_client.get_scores_json(["CVE-2022-12345"], order=FirstEpssOrderOption.EPSS_DESC)
|
|
37
|
+
>>> async with NistNvdApiClient() as nvd_client:
|
|
38
|
+
>>> cve_data = await nvd_client.get_cve("CVE-2023-12345")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from .core import (
|
|
42
|
+
ApiClientDataError,
|
|
43
|
+
ApiClientError,
|
|
44
|
+
ApiClientHTTPError,
|
|
45
|
+
ApiClientNetworkError,
|
|
46
|
+
ApiClientTimeoutError,
|
|
47
|
+
BaseApiClient,
|
|
48
|
+
)
|
|
49
|
+
from .sources import (
|
|
50
|
+
CisaKevApiClient,
|
|
51
|
+
CisaKevCatalog,
|
|
52
|
+
CisaKevVulnerability,
|
|
53
|
+
FirstEpssApiClient,
|
|
54
|
+
FirstEpssEpssScoreItem,
|
|
55
|
+
FirstEpssOrderOption,
|
|
56
|
+
FirstEpssScoreResponse,
|
|
57
|
+
NistNvdApiClient,
|
|
58
|
+
NistNvdChange,
|
|
59
|
+
NistNvdChangeDetail,
|
|
60
|
+
NistNvdCveHistoryResponse,
|
|
61
|
+
NistNvdCveItem,
|
|
62
|
+
NistNvdCveResponse,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# Core Components
|
|
67
|
+
"BaseApiClient",
|
|
68
|
+
"ApiClientError",
|
|
69
|
+
"ApiClientHTTPError",
|
|
70
|
+
"ApiClientTimeoutError",
|
|
71
|
+
"ApiClientNetworkError",
|
|
72
|
+
"ApiClientDataError",
|
|
73
|
+
# API Clients
|
|
74
|
+
"CisaKevApiClient",
|
|
75
|
+
"FirstEpssApiClient",
|
|
76
|
+
"NistNvdApiClient",
|
|
77
|
+
# CISA KEV Models
|
|
78
|
+
"CisaKevCatalog",
|
|
79
|
+
"CisaKevVulnerability",
|
|
80
|
+
# FIRST EPSS Models
|
|
81
|
+
"FirstEpssScoreResponse",
|
|
82
|
+
"FirstEpssEpssScoreItem",
|
|
83
|
+
"FirstEpssOrderOption",
|
|
84
|
+
# NIST NVD Models
|
|
85
|
+
"NistNvdCveResponse",
|
|
86
|
+
"NistNvdCveHistoryResponse",
|
|
87
|
+
"NistNvdCveItem",
|
|
88
|
+
"NistNvdChange",
|
|
89
|
+
"NistNvdChangeDetail",
|
|
90
|
+
]
|
elf/core/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Core components of the `elf` package.
|
|
2
|
+
|
|
3
|
+
This module includes foundational utilities such as:
|
|
4
|
+
- `BaseApiClient`: An abstract base class for building API clients.
|
|
5
|
+
- Custom exceptions for error handling in API interactions.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
- `BaseApiClient`
|
|
9
|
+
- `ApiClientError`
|
|
10
|
+
- `ApiClientHTTPError`
|
|
11
|
+
- `ApiClientTimeoutError`
|
|
12
|
+
- `ApiClientNetworkError`
|
|
13
|
+
- `ApiClientDataError`
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .base_api_client import BaseApiClient
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
ApiClientDataError,
|
|
19
|
+
ApiClientError,
|
|
20
|
+
ApiClientHTTPError,
|
|
21
|
+
ApiClientNetworkError,
|
|
22
|
+
ApiClientTimeoutError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BaseApiClient",
|
|
27
|
+
"ApiClientError",
|
|
28
|
+
"ApiClientHTTPError",
|
|
29
|
+
"ApiClientTimeoutError",
|
|
30
|
+
"ApiClientNetworkError",
|
|
31
|
+
"ApiClientDataError",
|
|
32
|
+
]
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Base API client providing shared functionalities for all API clients.
|
|
2
|
+
|
|
3
|
+
This module defines the `BaseApiClient` abstract base class, which encapsulates
|
|
4
|
+
common functionalities such as:
|
|
5
|
+
|
|
6
|
+
- HTTP request handling with retries and exponential backoff
|
|
7
|
+
- Consistent timeout and error handling logic
|
|
8
|
+
- Structured logging for better observability
|
|
9
|
+
- Abstract response parsing, to be implemented by subclasses
|
|
10
|
+
|
|
11
|
+
Subclasses should override `_parse_response` to handle domain-specific parsing of
|
|
12
|
+
the HTTP responses into Pydantic models.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> class MyApiClient(BaseApiClient):
|
|
16
|
+
... async def _parse_response(self, response: httpx.Response) -> MyModel:
|
|
17
|
+
... return await self._handle_response(response, MyModel)
|
|
18
|
+
|
|
19
|
+
>>> async with MyApiClient(base_url="https://api.example.com") as client:
|
|
20
|
+
... data = await client._request("GET", "/data")
|
|
21
|
+
... parsed = await client._parse_response(data)
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import logging
|
|
29
|
+
from abc import ABC, abstractmethod
|
|
30
|
+
from types import TracebackType
|
|
31
|
+
from typing import Any, Literal, Self, TypeVar
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
from httpx import AsyncClient, HTTPStatusError, RequestError, TimeoutException
|
|
35
|
+
from pydantic import BaseModel, ValidationError
|
|
36
|
+
|
|
37
|
+
from elf.core.exceptions import (
|
|
38
|
+
ApiClientError,
|
|
39
|
+
ApiClientHTTPError,
|
|
40
|
+
ApiClientNetworkError,
|
|
41
|
+
ApiClientTimeoutError,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
R = TypeVar("R", bound=BaseModel)
|
|
45
|
+
|
|
46
|
+
# Commonly retryable status codes indicating transient server errors
|
|
47
|
+
RETRYABLE_STATUS_CODES = {500, 502, 503, 504}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BaseApiClient(ABC):
|
|
51
|
+
"""Abstract base class for API clients providing a common framework.
|
|
52
|
+
|
|
53
|
+
This class streamlines the implementation of API clients by handling:
|
|
54
|
+
- HTTP requests (async) with retries and exponential backoff
|
|
55
|
+
- Consistent timeout, error handling, and logging
|
|
56
|
+
- Async context management for the underlying HTTP client
|
|
57
|
+
|
|
58
|
+
Subclasses must implement:
|
|
59
|
+
- `_parse_response(response: httpx.Response) -> BaseModel`:
|
|
60
|
+
Method to parse API responses into domain-specific models.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
base_url (str): The base URL for the API endpoint (no trailing slash).
|
|
64
|
+
timeout (float): The request timeout in seconds.
|
|
65
|
+
retries (int): Maximum number of retry attempts for failed requests.
|
|
66
|
+
backoff_factor (float): Factor used to calculate exponential backoff.
|
|
67
|
+
client (AsyncClient): The underlying `httpx.AsyncClient` instance.
|
|
68
|
+
logger (logging.Logger): Logger instance for debugging and error reporting.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
base_url: str,
|
|
75
|
+
*,
|
|
76
|
+
timeout: float = 30.0,
|
|
77
|
+
headers: dict[str, str] | None = None,
|
|
78
|
+
retries: int = 3,
|
|
79
|
+
backoff_factor: float = 0.5,
|
|
80
|
+
client: AsyncClient | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Initialize the BaseApiClient.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
base_url: The base URL for the API (no trailing slash).
|
|
86
|
+
timeout: Timeout in seconds for each HTTP request.
|
|
87
|
+
headers: Optional default headers applied to each request.
|
|
88
|
+
retries: Number of retry attempts if a request fails.
|
|
89
|
+
backoff_factor: Multiplier for exponential backoff timing.
|
|
90
|
+
client: Optionally provide a custom httpx.AsyncClient instance.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
self.base_url = base_url.rstrip("/")
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
self.retries = retries
|
|
96
|
+
self.backoff_factor = backoff_factor
|
|
97
|
+
|
|
98
|
+
# Obtain (but do not forcibly configure) a logger.
|
|
99
|
+
# Users are expected to configure logging themselves if they wish.
|
|
100
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
101
|
+
|
|
102
|
+
self.client = client or AsyncClient(
|
|
103
|
+
base_url=self.base_url,
|
|
104
|
+
headers=headers or {},
|
|
105
|
+
timeout=httpx.Timeout(self.timeout),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
"""Close the underlying HTTP client and release resources.
|
|
110
|
+
|
|
111
|
+
This should be called when the client is no longer needed.
|
|
112
|
+
"""
|
|
113
|
+
await self.client.aclose()
|
|
114
|
+
|
|
115
|
+
async def __aenter__(self) -> Self:
|
|
116
|
+
"""Enter the async context manager.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Self: The instance of the client, ready for use.
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
await self.client.__aenter__()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
async def __aexit__(
|
|
126
|
+
self,
|
|
127
|
+
exc_type: type[BaseException] | None,
|
|
128
|
+
exc_val: BaseException | None,
|
|
129
|
+
exc_tb: TracebackType | None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Exit the async context manager and close the client."""
|
|
132
|
+
await self.client.__aexit__(exc_type, exc_val, exc_tb)
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
async def _parse_response(self, response: httpx.Response) -> BaseModel:
|
|
136
|
+
"""Parse the HTTP response into a Pydantic model instance.
|
|
137
|
+
|
|
138
|
+
Subclasses must implement this method to convert the raw HTTP response
|
|
139
|
+
into a strongly typed Pydantic model.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
response: The `httpx.Response` object to parse.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A Pydantic model instance representing the parsed response data.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ApiClientError: If parsing or validation fails.
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
async def _request(
|
|
154
|
+
self,
|
|
155
|
+
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"],
|
|
156
|
+
endpoint: str,
|
|
157
|
+
*,
|
|
158
|
+
params: dict[str, Any] | None = None,
|
|
159
|
+
headers: dict[str, str] | None = None,
|
|
160
|
+
response_format: Literal["json", "csv"] = "json",
|
|
161
|
+
) -> httpx.Response:
|
|
162
|
+
"""Perform an HTTP request with retries, backoff, and error handling.
|
|
163
|
+
|
|
164
|
+
This method handles:
|
|
165
|
+
- Sending the request using the specified HTTP method and endpoint.
|
|
166
|
+
- Adding query parameters and headers.
|
|
167
|
+
- Automatically retrying failed requests based on the configured retry count.
|
|
168
|
+
- Applying exponential backoff between retry attempts.
|
|
169
|
+
- Logging and raising appropriate exceptions on errors.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
method: The HTTP method, e.g., "GET", "POST", "PATCH".
|
|
173
|
+
endpoint: The API endpoint (relative to `base_url`).
|
|
174
|
+
params: Optional query parameters.
|
|
175
|
+
headers: Optional additional headers.
|
|
176
|
+
response_format: Expected response format ("json" or "csv").
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
An `httpx.Response` object from the successful request.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ApiClientHTTPError: If the server returns an unsuccessful status code after all retries.
|
|
183
|
+
ApiClientTimeoutError: If the request times out after all retries.
|
|
184
|
+
ApiClientNetworkError: If a network-related error occurs after all retries.
|
|
185
|
+
ApiClientError: For other unexpected errors.
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
url = endpoint.lstrip("/")
|
|
189
|
+
request_headers = self._prepare_headers(headers, response_format)
|
|
190
|
+
|
|
191
|
+
for attempt in range(1, self.retries + 1):
|
|
192
|
+
try:
|
|
193
|
+
response = await self._make_request(method, url, params, request_headers, attempt)
|
|
194
|
+
response.raise_for_status()
|
|
195
|
+
return response
|
|
196
|
+
|
|
197
|
+
except HTTPStatusError as e:
|
|
198
|
+
# Only retry on typical transient errors (5xx)
|
|
199
|
+
if e.response.status_code not in RETRYABLE_STATUS_CODES:
|
|
200
|
+
self._handle_http_status_error(e, method, url, attempt)
|
|
201
|
+
raise ApiClientHTTPError(e.response.status_code, e.response.text) from e
|
|
202
|
+
|
|
203
|
+
self._handle_http_status_error(e, method, url, attempt)
|
|
204
|
+
if attempt == self.retries:
|
|
205
|
+
# Final attempt failed
|
|
206
|
+
raise ApiClientHTTPError(e.response.status_code, e.response.text) from e
|
|
207
|
+
|
|
208
|
+
except TimeoutException as e:
|
|
209
|
+
self._handle_timeout_error(e, method, url, attempt)
|
|
210
|
+
if attempt == self.retries:
|
|
211
|
+
raise ApiClientTimeoutError("Request timed out") from e
|
|
212
|
+
|
|
213
|
+
except RequestError as e:
|
|
214
|
+
self._handle_request_error(e, method, url, attempt)
|
|
215
|
+
if attempt == self.retries:
|
|
216
|
+
raise ApiClientNetworkError("Network error occurred") from e
|
|
217
|
+
|
|
218
|
+
except ValueError as e:
|
|
219
|
+
# Typically indicates JSON decoding or data conversion issue
|
|
220
|
+
self._handle_value_error(e, method, url)
|
|
221
|
+
raise ApiClientError(f"Value error during request: {str(e)}") from e
|
|
222
|
+
|
|
223
|
+
# Exponential backoff before the next attempt
|
|
224
|
+
sleep_time = self.backoff_factor * (2 ** (attempt - 1))
|
|
225
|
+
self.logger.debug(f"Attempt {attempt} failed; retrying in {sleep_time:.2f} seconds...")
|
|
226
|
+
await asyncio.sleep(sleep_time)
|
|
227
|
+
|
|
228
|
+
# If all attempts somehow fall through without returning or raising,
|
|
229
|
+
# raise a generic error (should rarely happen in practice).
|
|
230
|
+
raise ApiClientError("Failed to obtain a valid response after all retries")
|
|
231
|
+
|
|
232
|
+
def _prepare_headers(
|
|
233
|
+
self, headers: dict[str, str] | None, response_format: Literal["json", "csv"]
|
|
234
|
+
) -> dict[str, str]:
|
|
235
|
+
"""Prepare request headers according to the desired response format.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
headers: Additional headers provided by the caller.
|
|
239
|
+
response_format: Expected response format ("json" or "csv").
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A dictionary of headers to be used for the request.
|
|
243
|
+
|
|
244
|
+
"""
|
|
245
|
+
accept_header = "text/csv" if response_format == "csv" else "application/json"
|
|
246
|
+
request_headers = {"Accept": accept_header}
|
|
247
|
+
if headers:
|
|
248
|
+
request_headers.update(headers)
|
|
249
|
+
return request_headers
|
|
250
|
+
|
|
251
|
+
async def _make_request(
|
|
252
|
+
self,
|
|
253
|
+
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"],
|
|
254
|
+
url: str,
|
|
255
|
+
params: dict[str, Any] | None,
|
|
256
|
+
headers: dict[str, str],
|
|
257
|
+
attempt: int,
|
|
258
|
+
) -> httpx.Response:
|
|
259
|
+
"""Execute the HTTP request with the given parameters.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
method: HTTP method to use.
|
|
263
|
+
url: The relative endpoint URL (relative to `base_url`).
|
|
264
|
+
params: Query parameters.
|
|
265
|
+
headers: Prepared headers for the request.
|
|
266
|
+
attempt: The current attempt number.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
An `httpx.Response` object.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
HTTPStatusError: For HTTP errors.
|
|
273
|
+
TimeoutException: If the request times out.
|
|
274
|
+
RequestError: For network or other request-related errors.
|
|
275
|
+
|
|
276
|
+
"""
|
|
277
|
+
self.logger.debug(
|
|
278
|
+
"Sending request",
|
|
279
|
+
extra={
|
|
280
|
+
"method": method,
|
|
281
|
+
"url": f"{self.base_url}/{url}",
|
|
282
|
+
"params": params,
|
|
283
|
+
"headers": headers,
|
|
284
|
+
"attempt": attempt,
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
return await self.client.request(method=method, url=url, params=params, headers=headers)
|
|
288
|
+
|
|
289
|
+
def _handle_http_status_error(
|
|
290
|
+
self, e: HTTPStatusError, method: str, url: str, attempt: int
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Handle and log HTTP status-related errors."""
|
|
293
|
+
self.logger.error(
|
|
294
|
+
"HTTP status error occurred",
|
|
295
|
+
extra={
|
|
296
|
+
"method": method,
|
|
297
|
+
"url": url,
|
|
298
|
+
"status_code": e.response.status_code,
|
|
299
|
+
"text": e.response.text[:500], # Log only part of body if large
|
|
300
|
+
"attempt": attempt,
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _handle_timeout_error(
|
|
305
|
+
self, e: TimeoutException, method: str, url: str, attempt: int
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Handle and log request timeout errors."""
|
|
308
|
+
self.logger.error(
|
|
309
|
+
"Request timed out",
|
|
310
|
+
extra={
|
|
311
|
+
"method": method,
|
|
312
|
+
"url": url,
|
|
313
|
+
"timeout": self.timeout,
|
|
314
|
+
"attempt": attempt,
|
|
315
|
+
},
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _handle_request_error(self, e: RequestError, method: str, url: str, attempt: int) -> None:
|
|
319
|
+
"""Handle and log network-related request errors."""
|
|
320
|
+
self.logger.error(
|
|
321
|
+
"Network error occurred",
|
|
322
|
+
extra={
|
|
323
|
+
"method": method,
|
|
324
|
+
"url": url,
|
|
325
|
+
"error": str(e),
|
|
326
|
+
"attempt": attempt,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _handle_value_error(self, e: ValueError, method: str, url: str) -> None:
|
|
331
|
+
"""Handle and log ValueErrors (e.g., JSON parse errors)."""
|
|
332
|
+
self.logger.error(
|
|
333
|
+
"Value error occurred while processing the response",
|
|
334
|
+
extra={"error": str(e), "method": method, "url": url},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
async def _handle_response(self, response: httpx.Response, model: type[R]) -> R:
|
|
338
|
+
"""Validate and parse the HTTP response into a Pydantic model.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
response: The `httpx.Response` object to parse.
|
|
342
|
+
model: A Pydantic model class used to validate and parse the JSON data.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
An instance of `model` created from the response data.
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
ApiClientError: If validation fails or data is malformed.
|
|
349
|
+
|
|
350
|
+
"""
|
|
351
|
+
try:
|
|
352
|
+
data = response.json()
|
|
353
|
+
return model.model_validate(data)
|
|
354
|
+
except ValidationError as e:
|
|
355
|
+
self.logger.error(
|
|
356
|
+
"Validation error occurred while parsing response",
|
|
357
|
+
extra={"error": e.errors(), "raw_response": response.text[:500]},
|
|
358
|
+
)
|
|
359
|
+
raise ApiClientError("Invalid data received from API") from e
|
|
360
|
+
except ValueError as e:
|
|
361
|
+
self.logger.error(
|
|
362
|
+
"JSON decoding error occurred",
|
|
363
|
+
extra={"error": str(e), "raw_response": response.text[:500]},
|
|
364
|
+
)
|
|
365
|
+
raise ApiClientError("Failed to decode JSON response") from e
|
elf/core/exceptions.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Custom exceptions for API clients.
|
|
2
|
+
|
|
3
|
+
Provides granular error handling for interacting with external services.
|
|
4
|
+
|
|
5
|
+
Classes:
|
|
6
|
+
ApiClientError
|
|
7
|
+
ApiClientHTTPError
|
|
8
|
+
ApiClientTimeoutError
|
|
9
|
+
ApiClientNetworkError
|
|
10
|
+
ApiClientDataError
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiClientError(Exception):
|
|
17
|
+
"""Base exception for general API client errors.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
message: A descriptive error message.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, *args: object) -> None:
|
|
25
|
+
"""Initialize the ApiClientError with a message.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
message: A descriptive error message.
|
|
29
|
+
*args: Additional arguments to pass to the base Exception class.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(message, *args)
|
|
33
|
+
self.message = message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiClientHTTPError(ApiClientError):
|
|
37
|
+
"""Exception raised for HTTP errors (4xx, 5xx).
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
status_code: The HTTP status code.
|
|
41
|
+
response_text: The response text from the server.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, status_code: int, response_text: str) -> None:
|
|
46
|
+
"""Initialize the ApiClientHTTPError with status code and response text.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
status_code: The HTTP status code.
|
|
50
|
+
response_text: The response text from the server.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
message = f"HTTP error {status_code}: {response_text}"
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.status_code = status_code
|
|
56
|
+
self.response_text = response_text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ApiClientTimeoutError(ApiClientError):
|
|
60
|
+
"""Exception raised for request timeouts."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ApiClientNetworkError(ApiClientError):
|
|
64
|
+
"""Exception raised for network-related errors."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ApiClientDataError(ApiClientError):
|
|
68
|
+
"""Exception raised for data-related issues (e.g., parsing or validation)."""
|
elf/py.typed
ADDED
|
File without changes
|
elf/sources/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Sources module for the `elf` package.
|
|
2
|
+
|
|
3
|
+
Provides API clients and data models for interacting with:
|
|
4
|
+
- CISA Known Exploited Vulnerabilities (KEV)
|
|
5
|
+
- FIRST Exploit Prediction Scoring System (EPSS)
|
|
6
|
+
- NIST National Vulnerability Database (NVD)
|
|
7
|
+
|
|
8
|
+
Exports:
|
|
9
|
+
- API Clients:
|
|
10
|
+
- `CisaKevApiClient`
|
|
11
|
+
- `FirstEpssApiClient`
|
|
12
|
+
- `NistNvdApiClient`
|
|
13
|
+
- Models:
|
|
14
|
+
- CISA KEV:
|
|
15
|
+
- `CisaKevCatalog`
|
|
16
|
+
- `CisaKevVulnerability`
|
|
17
|
+
- FIRST EPSS:
|
|
18
|
+
- `FirstEpssScoreResponse`
|
|
19
|
+
- `FirstEpssEpssScoreItem`
|
|
20
|
+
- `FirstEpssOrderOption`
|
|
21
|
+
- NIST NVD:
|
|
22
|
+
- `NistNvdCveResponse`
|
|
23
|
+
- `NistNvdCveHistoryResponse`
|
|
24
|
+
- `NistNvdCveItem`
|
|
25
|
+
- `NistNvdChange`
|
|
26
|
+
- `NistNvdChangeDetail`
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .cisa_kev.client import CisaKevApiClient
|
|
30
|
+
from .cisa_kev.models import CisaKevCatalog, CisaKevVulnerability
|
|
31
|
+
from .first_epss.client import FirstEpssApiClient
|
|
32
|
+
from .first_epss.models import FirstEpssEpssScoreItem, FirstEpssOrderOption, FirstEpssScoreResponse
|
|
33
|
+
from .nist_nvd.client import NistNvdApiClient
|
|
34
|
+
from .nist_nvd.models import (
|
|
35
|
+
NistNvdChange,
|
|
36
|
+
NistNvdChangeDetail,
|
|
37
|
+
NistNvdCveHistoryResponse,
|
|
38
|
+
NistNvdCveItem,
|
|
39
|
+
NistNvdCveResponse,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Clients
|
|
44
|
+
"CisaKevApiClient",
|
|
45
|
+
"FirstEpssApiClient",
|
|
46
|
+
"NistNvdApiClient",
|
|
47
|
+
# CISA KEV Models
|
|
48
|
+
"CisaKevCatalog",
|
|
49
|
+
"CisaKevVulnerability",
|
|
50
|
+
# FIRST EPSS Models
|
|
51
|
+
"FirstEpssScoreResponse",
|
|
52
|
+
"FirstEpssEpssScoreItem",
|
|
53
|
+
"FirstEpssOrderOption",
|
|
54
|
+
# NIST NVD Models
|
|
55
|
+
"NistNvdCveResponse",
|
|
56
|
+
"NistNvdCveHistoryResponse",
|
|
57
|
+
"NistNvdCveItem",
|
|
58
|
+
"NistNvdChange",
|
|
59
|
+
"NistNvdChangeDetail",
|
|
60
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""CISA KEV API module.
|
|
2
|
+
|
|
3
|
+
Provides tools for interacting with the CISA Known Exploited Vulnerabilities API.
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
- `CisaKevApiClient`: API client for fetching data from the KEV catalog.
|
|
7
|
+
- Data models for validating and interacting with KEV responses.
|
|
8
|
+
|
|
9
|
+
Exports:
|
|
10
|
+
- API Client:
|
|
11
|
+
- `CisaKevApiClient`
|
|
12
|
+
- Models:
|
|
13
|
+
- `CisaKevCatalog`: Represents the entire KEV catalog.
|
|
14
|
+
- `CisaKevVulnerability`: Represents individual vulnerability entries.
|
|
15
|
+
|
|
16
|
+
Example Usage:
|
|
17
|
+
>>> from elf.sources.cisa_kev import CisaKevApiClient
|
|
18
|
+
>>> async with CisaKevApiClient() as client:
|
|
19
|
+
>>> catalog = await client.get_kev_json()
|
|
20
|
+
>>> print(catalog.catalog_version)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .client import CisaKevApiClient
|
|
24
|
+
from .models import CisaKevCatalog, CisaKevVulnerability
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CisaKevApiClient",
|
|
28
|
+
"CisaKevCatalog",
|
|
29
|
+
"CisaKevVulnerability",
|
|
30
|
+
]
|