breachspider 0.3.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.
- breachspider/__init__.py +97 -0
- breachspider/_version.py +3 -0
- breachspider/client.py +486 -0
- breachspider/exceptions.py +164 -0
- breachspider/models.py +615 -0
- breachspider/py.typed +0 -0
- breachspider/resources/__init__.py +15 -0
- breachspider/resources/catalog.py +51 -0
- breachspider/resources/correlate.py +107 -0
- breachspider/resources/cves.py +64 -0
- breachspider/resources/environments.py +68 -0
- breachspider/resources/reports.py +33 -0
- breachspider/resources/watchlist.py +43 -0
- breachspider/resources/windows.py +195 -0
- breachspider-0.3.0.dist-info/METADATA +346 -0
- breachspider-0.3.0.dist-info/RECORD +19 -0
- breachspider-0.3.0.dist-info/WHEEL +5 -0
- breachspider-0.3.0.dist-info/licenses/LICENSE +21 -0
- breachspider-0.3.0.dist-info/top_level.txt +1 -0
breachspider/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""BreachSpider -- official Python SDK for the BreachSpider ICS/OT CVE API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import breachspider
|
|
6
|
+
bs = breachspider.Client.demo() # read-only, no signup
|
|
7
|
+
vendor = bs.catalog.vendor("schneider electric")
|
|
8
|
+
for cve in bs.cves.by_vendor(vendor.slug, severity="CRITICAL", kev=True):
|
|
9
|
+
print(cve.cve_id, cve.title)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .client import Client, Quota
|
|
16
|
+
from .models import (
|
|
17
|
+
CVE,
|
|
18
|
+
Vendor,
|
|
19
|
+
Product,
|
|
20
|
+
Environment,
|
|
21
|
+
Asset,
|
|
22
|
+
Report,
|
|
23
|
+
WatchlistItem,
|
|
24
|
+
SORT_MODES,
|
|
25
|
+
Fix,
|
|
26
|
+
FixGroup,
|
|
27
|
+
FixPlan,
|
|
28
|
+
FixPlanStep,
|
|
29
|
+
FixSummaryItem,
|
|
30
|
+
CvePage,
|
|
31
|
+
CorrelatedCVE,
|
|
32
|
+
CorrelateResult,
|
|
33
|
+
CorrelateResponse,
|
|
34
|
+
CheckResult,
|
|
35
|
+
WindowsCVE,
|
|
36
|
+
WindowsHostResult,
|
|
37
|
+
RejectedHost,
|
|
38
|
+
WindowsResponse,
|
|
39
|
+
)
|
|
40
|
+
from .exceptions import (
|
|
41
|
+
BreachSpiderError,
|
|
42
|
+
APIConnectionError,
|
|
43
|
+
APIError,
|
|
44
|
+
BadRequestError,
|
|
45
|
+
UnknownParameterError,
|
|
46
|
+
AuthenticationError,
|
|
47
|
+
ForbiddenError,
|
|
48
|
+
InsufficientScopeError,
|
|
49
|
+
CapExceededError,
|
|
50
|
+
NotFoundError,
|
|
51
|
+
ValidationError,
|
|
52
|
+
RateLimitError,
|
|
53
|
+
ServerError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"__version__",
|
|
58
|
+
"Client",
|
|
59
|
+
"Quota",
|
|
60
|
+
# models
|
|
61
|
+
"CVE",
|
|
62
|
+
"Vendor",
|
|
63
|
+
"Product",
|
|
64
|
+
"Environment",
|
|
65
|
+
"Asset",
|
|
66
|
+
"Report",
|
|
67
|
+
"WatchlistItem",
|
|
68
|
+
"SORT_MODES",
|
|
69
|
+
"Fix",
|
|
70
|
+
"FixGroup",
|
|
71
|
+
"FixPlan",
|
|
72
|
+
"FixPlanStep",
|
|
73
|
+
"FixSummaryItem",
|
|
74
|
+
"CvePage",
|
|
75
|
+
"CorrelatedCVE",
|
|
76
|
+
"CorrelateResult",
|
|
77
|
+
"CorrelateResponse",
|
|
78
|
+
"CheckResult",
|
|
79
|
+
"WindowsCVE",
|
|
80
|
+
"WindowsHostResult",
|
|
81
|
+
"RejectedHost",
|
|
82
|
+
"WindowsResponse",
|
|
83
|
+
# exceptions
|
|
84
|
+
"BreachSpiderError",
|
|
85
|
+
"APIConnectionError",
|
|
86
|
+
"APIError",
|
|
87
|
+
"BadRequestError",
|
|
88
|
+
"UnknownParameterError",
|
|
89
|
+
"AuthenticationError",
|
|
90
|
+
"ForbiddenError",
|
|
91
|
+
"InsufficientScopeError",
|
|
92
|
+
"CapExceededError",
|
|
93
|
+
"NotFoundError",
|
|
94
|
+
"ValidationError",
|
|
95
|
+
"RateLimitError",
|
|
96
|
+
"ServerError",
|
|
97
|
+
]
|
breachspider/_version.py
ADDED
breachspider/client.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""The BreachSpider API client.
|
|
2
|
+
|
|
3
|
+
Design notes
|
|
4
|
+
------------
|
|
5
|
+
* The API key is held privately and is **never** rendered. ``__repr__`` shows
|
|
6
|
+
only the auth *kind* (live/demo), and every exception message is passed
|
|
7
|
+
through :meth:`_redact` before it leaves the client.
|
|
8
|
+
* Network failures are re-raised as :class:`APIConnectionError` so a
|
|
9
|
+
``requests`` ``PreparedRequest`` (which holds the Authorization header) can
|
|
10
|
+
never escape.
|
|
11
|
+
* ``429`` responses (origin or the Cloudflare edge limit that trips around 37
|
|
12
|
+
rapid requests from one IP) are retried with exponential backoff, honoring
|
|
13
|
+
``Retry-After``. Paginated iteration also sleeps ``page_delay`` between
|
|
14
|
+
pages so a naive loop over thousands of CVEs stays under the edge limit.
|
|
15
|
+
* ``X-RateLimit-*`` headers are parsed after every call and exposed via
|
|
16
|
+
:attr:`quota`, the only way to see usage while metering is observe-only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Dict, Iterator, Optional, Tuple, Union
|
|
24
|
+
from urllib.parse import urlsplit, parse_qs
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
from . import exceptions as exc
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://breachspider.com"
|
|
32
|
+
API_PREFIX = "/api/v1"
|
|
33
|
+
|
|
34
|
+
#: Max page size the API accepts on /cves (Pydantic le=100), used as the
|
|
35
|
+
#: pagination default because fewer pages == fewer edge-limit risks.
|
|
36
|
+
MAX_PER_PAGE = 100
|
|
37
|
+
#: Max limit the assets endpoint accepts (silently clamped above this).
|
|
38
|
+
MAX_ASSET_LIMIT = 500
|
|
39
|
+
#: Default per-request timeout in seconds (every request always sets a timeout).
|
|
40
|
+
DEFAULT_TIMEOUT = 30.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Quota:
|
|
45
|
+
"""Snapshot of the ``X-RateLimit-*`` headers from the most recent call.
|
|
46
|
+
|
|
47
|
+
``limit`` and ``remaining`` are ints, or the string ``"unlimited"`` on
|
|
48
|
+
unlimited tiers. ``None`` means the header was absent (e.g. the call was
|
|
49
|
+
made with a demo token, which is not metered).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
limit: Optional[Union[int, str]] = None
|
|
53
|
+
used: Optional[int] = None
|
|
54
|
+
remaining: Optional[Union[int, str]] = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_unlimited(self) -> bool:
|
|
58
|
+
return self.limit == "unlimited"
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _num(value: Optional[str]) -> Optional[Union[int, str]]:
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
if value.strip().lower() == "unlimited":
|
|
65
|
+
return "unlimited"
|
|
66
|
+
try:
|
|
67
|
+
return int(value)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_headers(cls, headers: Any) -> Optional["Quota"]:
|
|
73
|
+
limit = headers.get("X-RateLimit-Limit")
|
|
74
|
+
used = headers.get("X-RateLimit-Used")
|
|
75
|
+
remaining = headers.get("X-RateLimit-Remaining")
|
|
76
|
+
if limit is None and used is None and remaining is None:
|
|
77
|
+
return None
|
|
78
|
+
used_int: Optional[int]
|
|
79
|
+
try:
|
|
80
|
+
used_int = int(used) if used is not None else None
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
used_int = None
|
|
83
|
+
return cls(limit=cls._num(limit), used=used_int, remaining=cls._num(remaining))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Client:
|
|
87
|
+
"""Entry point. ``Client(api_key="bs_live_...")`` or ``Client.demo()``.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
api_key:
|
|
92
|
+
A ``bs_live_`` key or a ``bs_demo_`` token.
|
|
93
|
+
base_url:
|
|
94
|
+
Defaults to production.
|
|
95
|
+
timeout:
|
|
96
|
+
Per-request timeout in seconds.
|
|
97
|
+
page_delay:
|
|
98
|
+
Seconds to sleep between pages during iteration (edge-limit safety).
|
|
99
|
+
Set to ``0`` to disable.
|
|
100
|
+
max_retries:
|
|
101
|
+
Attempts on ``429``/connection errors before giving up.
|
|
102
|
+
backoff_factor:
|
|
103
|
+
Base for exponential backoff: sleep = ``backoff_factor * 2**attempt``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
api_key: str,
|
|
109
|
+
*,
|
|
110
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
111
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
112
|
+
page_delay: float = 0.25,
|
|
113
|
+
max_retries: int = 5,
|
|
114
|
+
backoff_factor: float = 0.5,
|
|
115
|
+
session: Optional[requests.Session] = None,
|
|
116
|
+
user_agent: Optional[str] = None,
|
|
117
|
+
_is_demo: bool = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
if not api_key or not isinstance(api_key, str):
|
|
120
|
+
raise ValueError("api_key is required")
|
|
121
|
+
self._api_key = api_key
|
|
122
|
+
self._is_demo = _is_demo or api_key.startswith("bs_demo_")
|
|
123
|
+
self.base_url = base_url.rstrip("/")
|
|
124
|
+
self.timeout = timeout
|
|
125
|
+
self.page_delay = max(0.0, page_delay)
|
|
126
|
+
self.max_retries = max(0, max_retries)
|
|
127
|
+
self.backoff_factor = backoff_factor
|
|
128
|
+
self.user_agent = user_agent or f"breachspider-python/{__version__}"
|
|
129
|
+
self._session = session or requests.Session()
|
|
130
|
+
self._quota: Optional[Quota] = None
|
|
131
|
+
|
|
132
|
+
# Lazy import to avoid a package-level import cycle.
|
|
133
|
+
from .resources.cves import CVEs
|
|
134
|
+
from .resources.catalog import Catalog
|
|
135
|
+
from .resources.environments import Environments
|
|
136
|
+
from .resources.watchlist import Watchlist
|
|
137
|
+
from .resources.reports import Reports
|
|
138
|
+
from .resources.correlate import Correlate
|
|
139
|
+
from .resources.windows import Windows
|
|
140
|
+
|
|
141
|
+
self.cves = CVEs(self)
|
|
142
|
+
self.catalog = Catalog(self)
|
|
143
|
+
self.environments = Environments(self)
|
|
144
|
+
self.watchlist = Watchlist(self)
|
|
145
|
+
self.reports = Reports(self)
|
|
146
|
+
self.correlate = Correlate(self) # API v1: stateless asset -> CVE correlation
|
|
147
|
+
self.windows = Windows(self) # API v2: Windows patch-level results
|
|
148
|
+
|
|
149
|
+
# -- constructors -------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def demo(cls, *, base_url: str = DEFAULT_BASE_URL, **kwargs: Any) -> "Client":
|
|
153
|
+
"""Mint a fresh read-only demo token (24h) and return a client using it.
|
|
154
|
+
|
|
155
|
+
No signup required. The demo token is scoped to the public demo org
|
|
156
|
+
and is not metered.
|
|
157
|
+
"""
|
|
158
|
+
base = base_url.rstrip("/")
|
|
159
|
+
timeout = float(kwargs.get("timeout") or DEFAULT_TIMEOUT)
|
|
160
|
+
try:
|
|
161
|
+
resp = requests.post(
|
|
162
|
+
f"{base}{API_PREFIX}/auth/demo-token",
|
|
163
|
+
headers={
|
|
164
|
+
# The endpoint requires an Origin header.
|
|
165
|
+
"Origin": base,
|
|
166
|
+
"User-Agent": f"breachspider-python/{__version__}",
|
|
167
|
+
},
|
|
168
|
+
timeout=timeout,
|
|
169
|
+
)
|
|
170
|
+
except requests.RequestException as e:
|
|
171
|
+
raise exc.APIConnectionError(
|
|
172
|
+
f"Could not reach the demo-token endpoint: {type(e).__name__}"
|
|
173
|
+
) from None
|
|
174
|
+
if resp.status_code >= 400:
|
|
175
|
+
raise exc.APIError(
|
|
176
|
+
"Failed to mint a demo token.",
|
|
177
|
+
status_code=resp.status_code,
|
|
178
|
+
code="DEMO_TOKEN_FAILED",
|
|
179
|
+
)
|
|
180
|
+
token = (resp.json() or {}).get("token")
|
|
181
|
+
if not token:
|
|
182
|
+
raise exc.APIError("Demo token endpoint returned no token.", status_code=502)
|
|
183
|
+
return cls(token, base_url=base_url, _is_demo=True, **kwargs)
|
|
184
|
+
|
|
185
|
+
# -- introspection (key-safe) ------------------------------------------
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def is_demo(self) -> bool:
|
|
189
|
+
return self._is_demo
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def quota(self) -> Optional[Quota]:
|
|
193
|
+
"""Quota snapshot from the most recent metered call, or ``None``."""
|
|
194
|
+
return self._quota
|
|
195
|
+
|
|
196
|
+
def _redact(self, text: str) -> str:
|
|
197
|
+
"""Defensive: strip the key from any string before it leaves the SDK."""
|
|
198
|
+
if self._api_key and self._api_key in text:
|
|
199
|
+
text = text.replace(self._api_key, "***redacted***")
|
|
200
|
+
return text
|
|
201
|
+
|
|
202
|
+
def __repr__(self) -> str:
|
|
203
|
+
kind = "demo" if self._is_demo else "live"
|
|
204
|
+
return f"<breachspider.Client base_url={self.base_url!r} auth={kind} key=***redacted***>"
|
|
205
|
+
|
|
206
|
+
__str__ = __repr__
|
|
207
|
+
|
|
208
|
+
# -- request plumbing ---------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def _url(self, path: str) -> str:
|
|
211
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
212
|
+
return path
|
|
213
|
+
if path.startswith("/api/"):
|
|
214
|
+
return f"{self.base_url}{path}"
|
|
215
|
+
return f"{self.base_url}{API_PREFIX}/{path.lstrip('/')}"
|
|
216
|
+
|
|
217
|
+
def request(
|
|
218
|
+
self,
|
|
219
|
+
method: str,
|
|
220
|
+
path: str,
|
|
221
|
+
*,
|
|
222
|
+
params: Optional[Dict[str, Any]] = None,
|
|
223
|
+
json: Optional[Dict[str, Any]] = None,
|
|
224
|
+
data: Optional[Dict[str, Any]] = None,
|
|
225
|
+
files: Optional[Dict[str, Any]] = None,
|
|
226
|
+
accept_statuses: Tuple[int, ...] = (),
|
|
227
|
+
) -> Dict[str, Any]:
|
|
228
|
+
"""Perform one request, map errors, update quota, return parsed JSON.
|
|
229
|
+
|
|
230
|
+
``data``/``files`` send a multipart form (used by the v2 CSV upload).
|
|
231
|
+
``accept_statuses`` lists error statuses whose body is a normal result
|
|
232
|
+
rather than an error envelope (API v2 returns 422 with ``data.rejected``
|
|
233
|
+
when every host in a call is refused).
|
|
234
|
+
"""
|
|
235
|
+
url = self._url(path)
|
|
236
|
+
headers = {
|
|
237
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
238
|
+
"Accept": "application/json",
|
|
239
|
+
"User-Agent": self.user_agent,
|
|
240
|
+
}
|
|
241
|
+
# requests drops params whose value is None, which is exactly what we want.
|
|
242
|
+
clean_params = None if params is None else {k: v for k, v in params.items() if v is not None}
|
|
243
|
+
|
|
244
|
+
attempt = 0
|
|
245
|
+
while True:
|
|
246
|
+
try:
|
|
247
|
+
resp = self._session.request(
|
|
248
|
+
method,
|
|
249
|
+
url,
|
|
250
|
+
params=clean_params,
|
|
251
|
+
json=json,
|
|
252
|
+
data=data,
|
|
253
|
+
files=files,
|
|
254
|
+
headers=headers,
|
|
255
|
+
timeout=self.timeout,
|
|
256
|
+
)
|
|
257
|
+
except requests.RequestException as e:
|
|
258
|
+
# Retry transient network errors; never surface the request object.
|
|
259
|
+
if attempt < self.max_retries:
|
|
260
|
+
time.sleep(self._backoff(attempt))
|
|
261
|
+
attempt += 1
|
|
262
|
+
continue
|
|
263
|
+
raise exc.APIConnectionError(
|
|
264
|
+
self._redact(f"Request to {url} failed: {type(e).__name__}")
|
|
265
|
+
) from None
|
|
266
|
+
|
|
267
|
+
q = Quota.from_headers(resp.headers)
|
|
268
|
+
if q is not None:
|
|
269
|
+
self._quota = q
|
|
270
|
+
|
|
271
|
+
if resp.status_code == 429 and attempt < self.max_retries:
|
|
272
|
+
retry_after = _retry_after_seconds(resp.headers, resp)
|
|
273
|
+
time.sleep(retry_after if retry_after is not None else self._backoff(attempt))
|
|
274
|
+
attempt += 1
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
if resp.status_code >= 400:
|
|
278
|
+
if resp.status_code in accept_statuses:
|
|
279
|
+
try:
|
|
280
|
+
body = resp.json()
|
|
281
|
+
except ValueError:
|
|
282
|
+
body = None
|
|
283
|
+
if isinstance(body, dict) and isinstance(body.get("data"), dict):
|
|
284
|
+
return body
|
|
285
|
+
raise self._to_exception(resp)
|
|
286
|
+
|
|
287
|
+
if resp.status_code == 204 or not resp.content:
|
|
288
|
+
return {}
|
|
289
|
+
try:
|
|
290
|
+
return resp.json()
|
|
291
|
+
except ValueError:
|
|
292
|
+
raise exc.APIError(
|
|
293
|
+
"Response was not valid JSON.",
|
|
294
|
+
status_code=resp.status_code,
|
|
295
|
+
code="BAD_RESPONSE",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _backoff(self, attempt: int) -> float:
|
|
299
|
+
return min(self.backoff_factor * (2 ** attempt), 30.0)
|
|
300
|
+
|
|
301
|
+
def _to_exception(self, resp: "requests.Response") -> exc.APIError:
|
|
302
|
+
status = resp.status_code
|
|
303
|
+
code: Optional[str] = None
|
|
304
|
+
message = f"HTTP {status}"
|
|
305
|
+
detail: Any = None
|
|
306
|
+
request_id: Optional[str] = None
|
|
307
|
+
try:
|
|
308
|
+
body = resp.json()
|
|
309
|
+
except ValueError:
|
|
310
|
+
body = None
|
|
311
|
+
if isinstance(body, dict):
|
|
312
|
+
err = body.get("error")
|
|
313
|
+
if isinstance(err, dict):
|
|
314
|
+
code = err.get("code")
|
|
315
|
+
message = err.get("message") or message
|
|
316
|
+
detail = err.get("detail")
|
|
317
|
+
request_id = (body.get("api") or {}).get("request_id")
|
|
318
|
+
message = self._redact(message)
|
|
319
|
+
|
|
320
|
+
kw = dict(status_code=status, code=code, detail=detail, request_id=request_id)
|
|
321
|
+
|
|
322
|
+
if status == 400:
|
|
323
|
+
if code == "UNKNOWN_PARAMETER" or (isinstance(detail, dict) and detail.get("accepted_parameters")):
|
|
324
|
+
return exc.UnknownParameterError(message, **kw)
|
|
325
|
+
return exc.BadRequestError(message, **kw)
|
|
326
|
+
if status == 401:
|
|
327
|
+
return exc.AuthenticationError(message, **kw)
|
|
328
|
+
if status == 403:
|
|
329
|
+
marker = detail.get("error") if isinstance(detail, dict) else None
|
|
330
|
+
if marker == "insufficient_scope" or code == "insufficient_scope":
|
|
331
|
+
return exc.InsufficientScopeError(message, **kw)
|
|
332
|
+
if marker == "cap_exceeded" or code == "cap_exceeded":
|
|
333
|
+
return exc.CapExceededError(message, **kw)
|
|
334
|
+
return exc.ForbiddenError(message, **kw)
|
|
335
|
+
if status == 404:
|
|
336
|
+
return exc.NotFoundError(message, **kw)
|
|
337
|
+
if status == 422:
|
|
338
|
+
return exc.ValidationError(message, **kw)
|
|
339
|
+
if status == 429:
|
|
340
|
+
return exc.RateLimitError(message, retry_after=_retry_after_seconds(resp.headers, resp), **kw)
|
|
341
|
+
if status >= 500:
|
|
342
|
+
return exc.ServerError(message, **kw)
|
|
343
|
+
return exc.APIError(message, **kw)
|
|
344
|
+
|
|
345
|
+
# -- pagination engine --------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def paginate(
|
|
348
|
+
self,
|
|
349
|
+
path: str,
|
|
350
|
+
*,
|
|
351
|
+
params: Optional[Dict[str, Any]] = None,
|
|
352
|
+
container: Optional[str] = None,
|
|
353
|
+
style: str = "auto",
|
|
354
|
+
page_size_param: str = "per_page",
|
|
355
|
+
page_size: Optional[int] = None,
|
|
356
|
+
) -> Iterator[Dict[str, Any]]:
|
|
357
|
+
"""Yield every item across all pages, absorbing the API's shape zoo.
|
|
358
|
+
|
|
359
|
+
``style``:
|
|
360
|
+
* ``"links"`` -- follow ``_links.next`` (used by /cves).
|
|
361
|
+
* ``"page"`` -- increment ``page`` until ``page >= pages``.
|
|
362
|
+
* ``"offset"`` -- advance ``offset`` by ``limit`` (assets/audit).
|
|
363
|
+
* ``"auto"`` -- detect per response.
|
|
364
|
+
|
|
365
|
+
``container`` is the key holding the list; auto-detected among
|
|
366
|
+
``data``/``items``/``environments``/``assets``/``tickets`` when omitted.
|
|
367
|
+
"""
|
|
368
|
+
params = dict(params or {})
|
|
369
|
+
if page_size is not None:
|
|
370
|
+
params[page_size_param] = page_size
|
|
371
|
+
|
|
372
|
+
first = True
|
|
373
|
+
offset = int(params.get("offset", 0) or 0)
|
|
374
|
+
while True:
|
|
375
|
+
if not first and self.page_delay:
|
|
376
|
+
time.sleep(self.page_delay)
|
|
377
|
+
body = self.request("GET", path, params=params)
|
|
378
|
+
items, meta = _extract(body, container)
|
|
379
|
+
for item in items:
|
|
380
|
+
yield item
|
|
381
|
+
first = False
|
|
382
|
+
|
|
383
|
+
nxt = _next_request(body, meta, style, params, offset, len(items), page_size_param, path)
|
|
384
|
+
if nxt is None:
|
|
385
|
+
return
|
|
386
|
+
path, params, offset = nxt
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# -- module helpers (no key involved) --------------------------------------
|
|
390
|
+
|
|
391
|
+
_CONTAINERS = ("data", "items", "environments", "assets", "tickets", "results")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _extract(body: Any, container: Optional[str]) -> Tuple[list, Dict[str, Any]]:
|
|
395
|
+
"""Return (list_of_items, pagination_meta) from a response body."""
|
|
396
|
+
if isinstance(body, list):
|
|
397
|
+
return body, {}
|
|
398
|
+
if not isinstance(body, dict):
|
|
399
|
+
return [], {}
|
|
400
|
+
key = container
|
|
401
|
+
if key is None:
|
|
402
|
+
for candidate in _CONTAINERS:
|
|
403
|
+
if isinstance(body.get(candidate), list):
|
|
404
|
+
key = candidate
|
|
405
|
+
break
|
|
406
|
+
items = body.get(key) if key else None
|
|
407
|
+
if not isinstance(items, list):
|
|
408
|
+
items = []
|
|
409
|
+
return items, body
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _next_request(
|
|
413
|
+
body: Dict[str, Any],
|
|
414
|
+
meta: Dict[str, Any],
|
|
415
|
+
style: str,
|
|
416
|
+
params: Dict[str, Any],
|
|
417
|
+
offset: int,
|
|
418
|
+
got: int,
|
|
419
|
+
page_size_param: str,
|
|
420
|
+
current_path: str,
|
|
421
|
+
) -> Optional[Tuple[str, Dict[str, Any], int]]:
|
|
422
|
+
"""Compute the next (path, params, offset) or None when exhausted.
|
|
423
|
+
|
|
424
|
+
Only the ``_links.next`` style changes the path; every other style keeps
|
|
425
|
+
``current_path`` and advances the params.
|
|
426
|
+
"""
|
|
427
|
+
links = meta.get("_links") if isinstance(meta, dict) else None
|
|
428
|
+
pagination = meta.get("pagination") if isinstance(meta, dict) else None
|
|
429
|
+
|
|
430
|
+
if style in ("auto", "links") and isinstance(links, dict):
|
|
431
|
+
if links.get("next"):
|
|
432
|
+
# _links.next is a site-absolute path with query already encoded.
|
|
433
|
+
return links["next"], {}, offset
|
|
434
|
+
if "next" in links: # next explicitly null -> last page
|
|
435
|
+
return None
|
|
436
|
+
if style in ("auto", "links") and isinstance(pagination, dict) and "has_next" in pagination:
|
|
437
|
+
if not pagination.get("has_next"):
|
|
438
|
+
return None
|
|
439
|
+
nxt = dict(params)
|
|
440
|
+
nxt["page"] = int(pagination.get("page", 1)) + 1
|
|
441
|
+
return current_path, nxt, offset
|
|
442
|
+
|
|
443
|
+
# page-number style: {page, pages} at top level (e.g. /cves/vendor/{slug})
|
|
444
|
+
page = meta.get("page")
|
|
445
|
+
pages = meta.get("pages")
|
|
446
|
+
if style in ("auto", "page") and isinstance(page, int) and isinstance(pages, int):
|
|
447
|
+
if got == 0 or page >= pages:
|
|
448
|
+
return None
|
|
449
|
+
nxt = dict(params)
|
|
450
|
+
nxt["page"] = page + 1
|
|
451
|
+
return current_path, nxt, offset
|
|
452
|
+
|
|
453
|
+
# offset/limit style (assets): advance until fewer than limit returned.
|
|
454
|
+
if style in ("auto", "offset") and ("limit" in params or page_size_param == "limit"):
|
|
455
|
+
limit = int(params.get("limit", got) or got or 1)
|
|
456
|
+
if got < limit or got == 0:
|
|
457
|
+
return None
|
|
458
|
+
nxt = dict(params)
|
|
459
|
+
nxt["offset"] = offset + got
|
|
460
|
+
return current_path, nxt, offset + got
|
|
461
|
+
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _retry_after_seconds(headers: Any, resp: Any = None) -> Optional[float]:
|
|
466
|
+
"""Seconds to wait before retrying: the ``Retry-After`` header, else the
|
|
467
|
+
per-key limiter's ``error.detail.retry_after`` in the response body."""
|
|
468
|
+
val = headers.get("Retry-After")
|
|
469
|
+
if val is None and resp is not None:
|
|
470
|
+
try:
|
|
471
|
+
body = resp.json()
|
|
472
|
+
detail = (body.get("error") or {}).get("detail") if isinstance(body, dict) else None
|
|
473
|
+
if isinstance(detail, dict):
|
|
474
|
+
val = detail.get("retry_after")
|
|
475
|
+
except (ValueError, AttributeError):
|
|
476
|
+
val = None
|
|
477
|
+
if val is None:
|
|
478
|
+
return None
|
|
479
|
+
try:
|
|
480
|
+
return float(val)
|
|
481
|
+
except (TypeError, ValueError):
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _query_of(url: str) -> Dict[str, Any]: # pragma: no cover - helper
|
|
486
|
+
return {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()}
|