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 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
@@ -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
+ ]