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.
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.3.0"
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()}