contrastapi 1.22.3__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,39 @@
1
+ """ContrastAPI Python SDK.
2
+
3
+ >>> from contrastapi import ContrastAPI
4
+ >>> client = ContrastAPI()
5
+ >>> client.cve.lookup("CVE-2021-44228")
6
+ """
7
+
8
+ from ._version import __version__
9
+ from .client import AsyncContrastAPI, ContrastAPI
10
+ from .exceptions import (
11
+ AuthRequiredError,
12
+ ContrastAPIError,
13
+ InvalidArgumentError,
14
+ NotFoundError,
15
+ RateLimitError,
16
+ TierLimitError,
17
+ TransportError,
18
+ UpstreamError,
19
+ UpstreamTimeoutError,
20
+ )
21
+ from .shortcuts import audit_full, enrich_batch, triage_ioc
22
+
23
+ __all__ = [
24
+ "AsyncContrastAPI",
25
+ "AuthRequiredError",
26
+ "ContrastAPI",
27
+ "ContrastAPIError",
28
+ "InvalidArgumentError",
29
+ "NotFoundError",
30
+ "RateLimitError",
31
+ "TierLimitError",
32
+ "TransportError",
33
+ "UpstreamError",
34
+ "UpstreamTimeoutError",
35
+ "__version__",
36
+ "audit_full",
37
+ "enrich_batch",
38
+ "triage_ioc",
39
+ ]
@@ -0,0 +1,170 @@
1
+ """HTTP transport for ContrastAPI SDK — sync (httpx.Client) and async (httpx.AsyncClient).
2
+
3
+ Both transports share:
4
+ * Base URL + API key handling (HTTPS-only unless allow_insecure=True).
5
+ * Error envelope parsing via exceptions._parse_error.
6
+ * Response size cap (10 MB) to mirror Node SDK behaviour.
7
+ * Identical User-Agent (`contrastapi-python/<version>`).
8
+
9
+ Design note: keeping sync and async classes side-by-side in one module avoids
10
+ the duplicated-stub problem and makes the parity guarantee obvious. Namespace
11
+ classes accept the transport instance and call `transport.get(...)` /
12
+ `transport.post(...)` (or `aget` / `apost` for the async variant) — the rest is
13
+ just URL construction.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+ from urllib.parse import urlsplit
21
+
22
+ import httpx
23
+
24
+ from ._version import __version__
25
+ from .exceptions import ContrastAPIError, TransportError, _parse_error
26
+
27
+ DEFAULT_BASE_URL = "https://api.contrastcyber.com"
28
+ DEFAULT_TIMEOUT = 30.0
29
+ MIN_TIMEOUT = 1.0
30
+ MAX_TIMEOUT = 120.0
31
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # 10 MB — parity with Node SDK
32
+ USER_AGENT = f"contrastapi-python/{__version__}"
33
+
34
+
35
+ def _normalize_base_url(base_url: str, *, allow_insecure: bool) -> str:
36
+ parts = urlsplit(base_url)
37
+ if parts.scheme not in ("https", "http"):
38
+ raise ValueError(f"Unsupported scheme: {parts.scheme!r}")
39
+ if parts.scheme == "http" and not allow_insecure:
40
+ raise ValueError("Only HTTPS is allowed. Pass allow_insecure=True to override.")
41
+ return base_url.rstrip("/")
42
+
43
+
44
+ def _build_headers(api_key: str | None, base_scheme: str, has_body: bool) -> dict[str, str]:
45
+ headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
46
+ if api_key:
47
+ if base_scheme != "https":
48
+ raise ValueError("Refusing to send API key over insecure connection")
49
+ headers["X-API-Key"] = api_key
50
+ if has_body:
51
+ headers["Content-Type"] = "application/json"
52
+ return headers
53
+
54
+
55
+ def _clamp_timeout(value: float | None) -> float:
56
+ if value is None:
57
+ return DEFAULT_TIMEOUT
58
+ return max(MIN_TIMEOUT, min(float(value), MAX_TIMEOUT))
59
+
60
+
61
+ def _decode_response(response: httpx.Response) -> Any:
62
+ """Parse JSON body with size cap. Raises ContrastAPIError on bad JSON or oversize."""
63
+ content = response.content
64
+ if len(content) > MAX_RESPONSE_BYTES:
65
+ raise ContrastAPIError(
66
+ f"Response too large ({len(content)} bytes > {MAX_RESPONSE_BYTES})",
67
+ status_code=response.status_code,
68
+ )
69
+ try:
70
+ return json.loads(content) if content else None
71
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
72
+ raise ContrastAPIError(
73
+ f"Invalid JSON response (HTTP {response.status_code}): {exc}",
74
+ status_code=response.status_code,
75
+ ) from exc
76
+
77
+
78
+ def _handle_response(response: httpx.Response) -> Any:
79
+ body = _decode_response(response)
80
+ if response.status_code >= 400:
81
+ raise _parse_error(response.status_code, body)
82
+ return body
83
+
84
+
85
+ class _SyncTransport:
86
+ def __init__(
87
+ self,
88
+ *,
89
+ base_url: str = DEFAULT_BASE_URL,
90
+ api_key: str | None = None,
91
+ timeout: float | None = None,
92
+ allow_insecure: bool = False,
93
+ client: httpx.Client | None = None,
94
+ ) -> None:
95
+ self.base_url = _normalize_base_url(base_url, allow_insecure=allow_insecure)
96
+ self._scheme = urlsplit(self.base_url).scheme
97
+ self.api_key = api_key
98
+ self.timeout = _clamp_timeout(timeout)
99
+ self._owns_client = client is None
100
+ self._client = client or httpx.Client(timeout=self.timeout)
101
+
102
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
103
+ headers = _build_headers(self.api_key, self._scheme, has_body=False)
104
+ try:
105
+ response = self._client.get(self.base_url + path, params=params, headers=headers)
106
+ except httpx.HTTPError as exc:
107
+ raise TransportError(str(exc)) from exc
108
+ return _handle_response(response)
109
+
110
+ def post(self, path: str, *, json_body: Any | None = None) -> Any:
111
+ headers = _build_headers(self.api_key, self._scheme, has_body=json_body is not None)
112
+ try:
113
+ response = self._client.post(self.base_url + path, json=json_body, headers=headers)
114
+ except httpx.HTTPError as exc:
115
+ raise TransportError(str(exc)) from exc
116
+ return _handle_response(response)
117
+
118
+ def close(self) -> None:
119
+ if self._owns_client:
120
+ self._client.close()
121
+
122
+ def __enter__(self) -> _SyncTransport:
123
+ return self
124
+
125
+ def __exit__(self, *exc_info: Any) -> None:
126
+ self.close()
127
+
128
+
129
+ class _AsyncTransport:
130
+ def __init__(
131
+ self,
132
+ *,
133
+ base_url: str = DEFAULT_BASE_URL,
134
+ api_key: str | None = None,
135
+ timeout: float | None = None,
136
+ allow_insecure: bool = False,
137
+ client: httpx.AsyncClient | None = None,
138
+ ) -> None:
139
+ self.base_url = _normalize_base_url(base_url, allow_insecure=allow_insecure)
140
+ self._scheme = urlsplit(self.base_url).scheme
141
+ self.api_key = api_key
142
+ self.timeout = _clamp_timeout(timeout)
143
+ self._owns_client = client is None
144
+ self._client = client or httpx.AsyncClient(timeout=self.timeout)
145
+
146
+ async def aget(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
147
+ headers = _build_headers(self.api_key, self._scheme, has_body=False)
148
+ try:
149
+ response = await self._client.get(self.base_url + path, params=params, headers=headers)
150
+ except httpx.HTTPError as exc:
151
+ raise TransportError(str(exc)) from exc
152
+ return _handle_response(response)
153
+
154
+ async def apost(self, path: str, *, json_body: Any | None = None) -> Any:
155
+ headers = _build_headers(self.api_key, self._scheme, has_body=json_body is not None)
156
+ try:
157
+ response = await self._client.post(self.base_url + path, json=json_body, headers=headers)
158
+ except httpx.HTTPError as exc:
159
+ raise TransportError(str(exc)) from exc
160
+ return _handle_response(response)
161
+
162
+ async def aclose(self) -> None:
163
+ if self._owns_client:
164
+ await self._client.aclose()
165
+
166
+ async def __aenter__(self) -> _AsyncTransport:
167
+ return self
168
+
169
+ async def __aexit__(self, *exc_info: Any) -> None:
170
+ await self.aclose()
@@ -0,0 +1 @@
1
+ __version__ = "1.22.3"
contrastapi/client.py ADDED
@@ -0,0 +1,171 @@
1
+ """ContrastAPI / AsyncContrastAPI — top-level clients.
2
+
3
+ Two parallel surfaces:
4
+ * `ContrastAPI` (sync) → uses `httpx.Client`, namespace methods are plain `def`.
5
+ * `AsyncContrastAPI` (async) → uses `httpx.AsyncClient`, namespace methods are
6
+ `async def`.
7
+
8
+ Both classes accept the same options (`api_key`, `base_url`, `timeout`,
9
+ `allow_insecure`) and expose the same namespace property names. Choose whichever
10
+ matches your application's I/O model.
11
+
12
+ All 14 namespaces (cve, cwe, ioc, atlas, d3fend, domain, ip, asn, email, phone,
13
+ password, username, check, scan) are exposed as instance attributes. The async
14
+ client mirrors the sync surface 1:1 — same names, async methods.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from ._transport import DEFAULT_BASE_URL, _AsyncTransport, _SyncTransport
22
+ from ._version import __version__
23
+ from .models import StatusResponse, UsageResponse
24
+ from .namespaces.atlas import AsyncAtlas, Atlas
25
+ from .namespaces.cve import AsyncCve, AsyncCwe, Cve, Cwe
26
+ from .namespaces.d3fend import AsyncD3fend, D3fend
27
+ from .namespaces.domain import AsyncDomain, Domain
28
+ from .namespaces.intel import (
29
+ Asn,
30
+ AsyncAsn,
31
+ AsyncEmail,
32
+ AsyncIp,
33
+ AsyncPassword,
34
+ AsyncPhone,
35
+ AsyncUsername,
36
+ Email,
37
+ Ip,
38
+ Password,
39
+ Phone,
40
+ Username,
41
+ )
42
+ from .namespaces.ioc import AsyncIoc, Ioc
43
+ from .namespaces.security import AsyncCheck, AsyncScan, Check, Scan
44
+
45
+
46
+ class ContrastAPI:
47
+ """Synchronous ContrastAPI client.
48
+
49
+ Usage:
50
+ >>> from contrastapi import ContrastAPI
51
+ >>> client = ContrastAPI() # keyless (free tier)
52
+ >>> cve = client.cve.lookup("CVE-2021-44228")
53
+ >>> assert cve["kev"]["in_kev"]
54
+ >>> client.close()
55
+
56
+ Or as context manager:
57
+ >>> with ContrastAPI(api_key="cc_...") as client:
58
+ ... defenses = client.d3fend.defense_for_attack("T1059")
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ api_key: str | None = None,
64
+ *,
65
+ base_url: str = DEFAULT_BASE_URL,
66
+ timeout: float | None = None,
67
+ allow_insecure: bool = False,
68
+ ) -> None:
69
+ self._transport = _SyncTransport(
70
+ base_url=base_url,
71
+ api_key=api_key,
72
+ timeout=timeout,
73
+ allow_insecure=allow_insecure,
74
+ )
75
+ self.cve = Cve(self._transport)
76
+ self.cwe = Cwe(self._transport)
77
+ self.ioc = Ioc(self._transport)
78
+ self.atlas = Atlas(self._transport)
79
+ self.d3fend = D3fend(self._transport)
80
+ self.domain = Domain(self._transport)
81
+ self.ip = Ip(self._transport)
82
+ self.asn = Asn(self._transport)
83
+ self.email = Email(self._transport)
84
+ self.phone = Phone(self._transport)
85
+ self.password = Password(self._transport)
86
+ self.username = Username(self._transport)
87
+ self.check = Check(self._transport)
88
+ self.scan = Scan(self._transport)
89
+
90
+ @property
91
+ def version(self) -> str:
92
+ return __version__
93
+
94
+ def status(self) -> StatusResponse:
95
+ """GET /v1/status — returns service health + version."""
96
+ return self._transport.get("/v1/status")
97
+
98
+ def usage(self) -> UsageResponse:
99
+ """GET /v1/usage — returns rate-limit window for current API key (or anonymous IP)."""
100
+ return self._transport.get("/v1/usage")
101
+
102
+ def close(self) -> None:
103
+ self._transport.close()
104
+
105
+ def __enter__(self) -> ContrastAPI:
106
+ return self
107
+
108
+ def __exit__(self, *exc_info: Any) -> None:
109
+ self.close()
110
+
111
+
112
+ class AsyncContrastAPI:
113
+ """Asynchronous ContrastAPI client. Mirrors ContrastAPI namespace surface.
114
+
115
+ Usage:
116
+ >>> import asyncio
117
+ >>> from contrastapi import AsyncContrastAPI
118
+ >>> async def main():
119
+ ... async with AsyncContrastAPI() as client:
120
+ ... tech = await client.atlas.technique("AML.T0051")
121
+ ... print(tech["name"])
122
+ >>> asyncio.run(main())
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ api_key: str | None = None,
128
+ *,
129
+ base_url: str = DEFAULT_BASE_URL,
130
+ timeout: float | None = None,
131
+ allow_insecure: bool = False,
132
+ ) -> None:
133
+ self._transport = _AsyncTransport(
134
+ base_url=base_url,
135
+ api_key=api_key,
136
+ timeout=timeout,
137
+ allow_insecure=allow_insecure,
138
+ )
139
+ self.cve = AsyncCve(self._transport)
140
+ self.cwe = AsyncCwe(self._transport)
141
+ self.ioc = AsyncIoc(self._transport)
142
+ self.atlas = AsyncAtlas(self._transport)
143
+ self.d3fend = AsyncD3fend(self._transport)
144
+ self.domain = AsyncDomain(self._transport)
145
+ self.ip = AsyncIp(self._transport)
146
+ self.asn = AsyncAsn(self._transport)
147
+ self.email = AsyncEmail(self._transport)
148
+ self.phone = AsyncPhone(self._transport)
149
+ self.password = AsyncPassword(self._transport)
150
+ self.username = AsyncUsername(self._transport)
151
+ self.check = AsyncCheck(self._transport)
152
+ self.scan = AsyncScan(self._transport)
153
+
154
+ @property
155
+ def version(self) -> str:
156
+ return __version__
157
+
158
+ async def status(self) -> StatusResponse:
159
+ return await self._transport.aget("/v1/status")
160
+
161
+ async def usage(self) -> UsageResponse:
162
+ return await self._transport.aget("/v1/usage")
163
+
164
+ async def aclose(self) -> None:
165
+ await self._transport.aclose()
166
+
167
+ async def __aenter__(self) -> AsyncContrastAPI:
168
+ return self
169
+
170
+ async def __aexit__(self, *exc_info: Any) -> None:
171
+ await self.aclose()
@@ -0,0 +1,176 @@
1
+ """Exception hierarchy for ContrastAPI SDK.
2
+
3
+ Mirrors the server-side `app.exceptions.AppException` tree so SDK users can
4
+ catch typed exceptions instead of inspecting status codes. Each instance carries
5
+ the parsed `ErrorDetail` envelope (dict) from the wire — `.code`, `.message`,
6
+ `.retry_after_seconds`, `.upgrade_url`, `.docs_url`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ # Wire-boundary defensive caps mirror server-side `_ERROR_MESSAGE_MAX_LEN` /
14
+ # `_RETRY_AFTER_MAX_SECONDS` (see app/main.py). Server already enforces these,
15
+ # but the SDK refuses to trust an untrusted response payload.
16
+ _MESSAGE_MAX_LEN = 500
17
+ _RETRY_AFTER_MAX_SECONDS = 3600
18
+
19
+
20
+ class ContrastAPIError(Exception):
21
+ """Base exception for all ContrastAPI SDK errors."""
22
+
23
+ code: str = "internal_error"
24
+ status_code: int | None = None
25
+
26
+ def __init__(
27
+ self,
28
+ message: Any,
29
+ *,
30
+ status_code: int | None = None,
31
+ code: str | None = None,
32
+ retry_after_seconds: int | None = None,
33
+ upgrade_url: str | None = None,
34
+ docs_url: str | None = None,
35
+ extras: dict[str, Any] | None = None,
36
+ ) -> None:
37
+ # CRIT (review): coerce to str — server response could send None / int / dict.
38
+ coerced = "" if message is None else str(message)
39
+ if len(coerced) > _MESSAGE_MAX_LEN:
40
+ coerced = coerced[:_MESSAGE_MAX_LEN]
41
+ super().__init__(coerced)
42
+ self.message = coerced
43
+ if status_code is not None:
44
+ self.status_code = status_code
45
+ if code is not None:
46
+ self.code = code
47
+ self.retry_after_seconds = _coerce_retry_after(retry_after_seconds)
48
+ self.upgrade_url = upgrade_url
49
+ self.docs_url = docs_url
50
+ self.extras = extras or {}
51
+
52
+
53
+ def _coerce_retry_after(value: Any) -> int | None:
54
+ """Validate + clamp retry_after_seconds to [0, 3600].
55
+
56
+ Server sends int per ErrorDetail schema, but a malformed/proxied response
57
+ could deliver str/float/list/bool/inf/NaN. Defensive: ignore garbage
58
+ rather than crash user code that does `time.sleep(exc.retry_after_seconds)`.
59
+ """
60
+ if value is None:
61
+ return None
62
+ # Reject bool early — `int(True) == 1` would silently produce 1-second
63
+ # sleeps from a `{"retry_after_seconds": true}` upstream response. bool
64
+ # is also a subclass of int, so the `isinstance(value, bool)` guard must
65
+ # run before the int() coercion below.
66
+ if isinstance(value, bool):
67
+ return None
68
+ # Reject NaN — `int(float('nan'))` raises ValueError but inf raises
69
+ # OverflowError; both must be silently dropped to None per defensive policy.
70
+ if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
71
+ return None
72
+ try:
73
+ as_int = int(value)
74
+ except (TypeError, ValueError, OverflowError):
75
+ return None
76
+ return max(0, min(as_int, _RETRY_AFTER_MAX_SECONDS))
77
+
78
+
79
+ class InvalidArgumentError(ContrastAPIError):
80
+ code = "invalid_argument"
81
+ status_code = 400
82
+
83
+
84
+ class AuthRequiredError(ContrastAPIError):
85
+ code = "auth_required"
86
+ status_code = 401
87
+
88
+
89
+ class TierLimitError(ContrastAPIError):
90
+ code = "tier_limit"
91
+ status_code = 403
92
+
93
+
94
+ class NotFoundError(ContrastAPIError):
95
+ code = "not_found"
96
+ status_code = 404
97
+
98
+
99
+ class RateLimitError(ContrastAPIError):
100
+ code = "rate_limit_exceeded"
101
+ status_code = 429
102
+
103
+
104
+ class UpstreamError(ContrastAPIError):
105
+ code = "upstream_error"
106
+ status_code = 502
107
+
108
+
109
+ class UpstreamTimeoutError(ContrastAPIError):
110
+ code = "upstream_timeout"
111
+ status_code = 504
112
+
113
+
114
+ class TransportError(ContrastAPIError):
115
+ """Raised when the HTTP layer fails before a response is received
116
+ (DNS error, connection refused, client timeout, TLS handshake fail).
117
+ """
118
+
119
+ code = "transport_error"
120
+
121
+
122
+ _CODE_TO_EXCEPTION: dict[str, type[ContrastAPIError]] = {
123
+ "invalid_argument": InvalidArgumentError,
124
+ "auth_required": AuthRequiredError,
125
+ "tier_limit": TierLimitError,
126
+ "not_found": NotFoundError,
127
+ "rate_limit_exceeded": RateLimitError,
128
+ "upstream_error": UpstreamError,
129
+ "upstream_timeout": UpstreamTimeoutError,
130
+ }
131
+
132
+
133
+ _STATUS_TO_EXCEPTION: dict[int, type[ContrastAPIError]] = {
134
+ 400: InvalidArgumentError,
135
+ 401: AuthRequiredError,
136
+ 403: TierLimitError,
137
+ 404: NotFoundError,
138
+ 422: InvalidArgumentError,
139
+ 429: RateLimitError,
140
+ 502: UpstreamError,
141
+ 504: UpstreamTimeoutError,
142
+ }
143
+
144
+
145
+ def _parse_error(status_code: int, body: Any) -> ContrastAPIError:
146
+ """Convert a wire error envelope into the right typed exception.
147
+
148
+ Wire shape (v1.22.2+):
149
+ {"error": {"code": "...", "message": "...", "retry_after_seconds": 60,
150
+ "upgrade_url": "...", "docs_url": "..."}, ...top-level extensions}
151
+
152
+ Falls back to status-code mapping if the body lacks a parseable envelope.
153
+ """
154
+ if not isinstance(body, dict):
155
+ cls = _STATUS_TO_EXCEPTION.get(status_code, ContrastAPIError)
156
+ return cls(f"HTTP {status_code}", status_code=status_code)
157
+
158
+ err = body.get("error") if isinstance(body.get("error"), dict) else None
159
+ if err is None:
160
+ message = body.get("detail") or body.get("message") or f"HTTP {status_code}"
161
+ cls = _STATUS_TO_EXCEPTION.get(status_code, ContrastAPIError)
162
+ return cls(str(message), status_code=status_code, extras=body)
163
+
164
+ code = err.get("code") or "upstream_error"
165
+ message = err.get("message") or f"HTTP {status_code}"
166
+ cls = _CODE_TO_EXCEPTION.get(code, _STATUS_TO_EXCEPTION.get(status_code, ContrastAPIError))
167
+ extras = {k: v for k, v in body.items() if k != "error"}
168
+ return cls(
169
+ str(message),
170
+ status_code=status_code,
171
+ code=code,
172
+ retry_after_seconds=err.get("retry_after_seconds"),
173
+ upgrade_url=err.get("upgrade_url"),
174
+ docs_url=err.get("docs_url"),
175
+ extras=extras,
176
+ )