contrastapi 1.22.3__tar.gz
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.
- contrastapi-1.22.3/.gitignore +20 -0
- contrastapi-1.22.3/PKG-INFO +194 -0
- contrastapi-1.22.3/README.md +164 -0
- contrastapi-1.22.3/contrastapi/__init__.py +39 -0
- contrastapi-1.22.3/contrastapi/_transport.py +170 -0
- contrastapi-1.22.3/contrastapi/_version.py +1 -0
- contrastapi-1.22.3/contrastapi/client.py +171 -0
- contrastapi-1.22.3/contrastapi/exceptions.py +176 -0
- contrastapi-1.22.3/contrastapi/models.py +661 -0
- contrastapi-1.22.3/contrastapi/namespaces/__init__.py +0 -0
- contrastapi-1.22.3/contrastapi/namespaces/atlas.py +134 -0
- contrastapi-1.22.3/contrastapi/namespaces/cve.py +125 -0
- contrastapi-1.22.3/contrastapi/namespaces/d3fend.py +124 -0
- contrastapi-1.22.3/contrastapi/namespaces/domain.py +129 -0
- contrastapi-1.22.3/contrastapi/namespaces/intel.py +151 -0
- contrastapi-1.22.3/contrastapi/namespaces/ioc.py +61 -0
- contrastapi-1.22.3/contrastapi/namespaces/security.py +94 -0
- contrastapi-1.22.3/contrastapi/shortcuts.py +182 -0
- contrastapi-1.22.3/pyproject.toml +48 -0
- contrastapi-1.22.3/tests/__init__.py +0 -0
- contrastapi-1.22.3/tests/conftest.py +15 -0
- contrastapi-1.22.3/tests/test_namespaces.py +325 -0
- contrastapi-1.22.3/tests/test_remaining_namespaces.py +330 -0
- contrastapi-1.22.3/tests/test_review_fixes.py +177 -0
- contrastapi-1.22.3/tests/test_review_round2_fixes.py +371 -0
- contrastapi-1.22.3/tests/test_shortcuts.py +274 -0
- contrastapi-1.22.3/tests/test_transport.py +289 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
*.pyo
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
*.db
|
|
6
|
+
*.db-wal
|
|
7
|
+
*.db-shm
|
|
8
|
+
venv/
|
|
9
|
+
.env
|
|
10
|
+
test_*.db
|
|
11
|
+
.mcpregistry_*
|
|
12
|
+
scripts/*
|
|
13
|
+
!scripts/backfill_mitre_fields.py
|
|
14
|
+
plans/
|
|
15
|
+
.npmrc
|
|
16
|
+
node_modules/
|
|
17
|
+
research_landing*.md
|
|
18
|
+
.coverage
|
|
19
|
+
deploy/
|
|
20
|
+
.aider*
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contrastapi
|
|
3
|
+
Version: 1.22.3
|
|
4
|
+
Summary: Official Python SDK for ContrastAPI — security intelligence for developers and AI agents
|
|
5
|
+
Project-URL: Homepage, https://contrastcyber.com
|
|
6
|
+
Project-URL: Documentation, https://api.contrastcyber.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/UPinar/contrastapi
|
|
8
|
+
Project-URL: Issues, https://github.com/UPinar/contrastapi/issues
|
|
9
|
+
Author-email: ContrastCyber <contact@contrastcyber.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: ai-security,api,atlas,attack,cve,cwe,d3fend,dns,domain,kev,mcp,mitre,osint,security,ssl,threat-intelligence,vulnerability,whois
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx<1.0,>=0.25
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
28
|
+
Requires-Dist: respx>=0.20; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# ContrastAPI Python SDK
|
|
32
|
+
|
|
33
|
+
Official Python client for [ContrastAPI](https://api.contrastcyber.com) — security intelligence for developers and AI agents.
|
|
34
|
+
|
|
35
|
+
42 MCP tools / 50+ HTTP endpoints: CVE / EPSS / KEV / CWE, MITRE ATLAS (AI/ML attacks) + bulk drill, MITRE D3FEND defenses, domain audit, IOC + threat intel, OSINT, code-security checks, and more. No API key required for the free tier (100 req/hr).
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install contrastapi
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.10+. Depends only on `httpx>=0.25`.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
### Sync
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from contrastapi import ContrastAPI
|
|
51
|
+
from contrastapi.models import CveResponse, AuditResponse
|
|
52
|
+
|
|
53
|
+
with ContrastAPI() as client: # keyless, free tier
|
|
54
|
+
cve: CveResponse = client.cve.lookup("CVE-2021-44228")
|
|
55
|
+
print(cve["kev"]["in_kev"]) # True (IDE autocompletes "kev", "epss"...)
|
|
56
|
+
|
|
57
|
+
techs = client.atlas.bulk_technique_lookup(["AML.T0051", "AML.T0043"])
|
|
58
|
+
print(techs["successful"]) # 2
|
|
59
|
+
|
|
60
|
+
audit: AuditResponse = client.domain.audit("example.com")
|
|
61
|
+
print(audit["score"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every response method has a typed return — `client.cve.lookup(...)` returns `CveResponse`, `client.atlas.technique(...)` returns `AtlasTechniqueResponse`, etc. You never need to write the type annotation; IDEs (VSCode/PyCharm) infer it from the method signature and offer autocomplete on response keys. Import from `contrastapi.models` only if you want to annotate explicitly or pass responses across function boundaries.
|
|
65
|
+
|
|
66
|
+
### Async
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import asyncio
|
|
70
|
+
from contrastapi import AsyncContrastAPI
|
|
71
|
+
|
|
72
|
+
async def main():
|
|
73
|
+
async with AsyncContrastAPI(api_key="cc_...") as client:
|
|
74
|
+
defenses = await client.d3fend.defense_for_attack("T1059")
|
|
75
|
+
print(len(defenses["defenses"]))
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Shortcuts (multi-call helpers)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from contrastapi import ContrastAPI, audit_full, enrich_batch, triage_ioc
|
|
84
|
+
|
|
85
|
+
with ContrastAPI() as client:
|
|
86
|
+
# Auto-route IP/hash/domain to the right enrichment leg
|
|
87
|
+
report = triage_ioc(client, "8.8.8.8") # ioc + threat_report
|
|
88
|
+
|
|
89
|
+
# Audit + subdomains + tech + per-subdomain SSL (capped)
|
|
90
|
+
audit = audit_full(client, "example.com", ssl_subdomains=5)
|
|
91
|
+
|
|
92
|
+
# Auto-detect CVE vs IOC and bulk-route
|
|
93
|
+
enriched = enrich_batch(client, ["CVE-2021-44228", "8.8.8.8", "evil.com"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Shortcuts swallow per-leg `ContrastAPIError` so partial failures still return whatever succeeded — see `result["errors"]` for the failure map.
|
|
97
|
+
|
|
98
|
+
## Authentication
|
|
99
|
+
|
|
100
|
+
Pass an API key as the first positional argument or as `api_key=`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
client = ContrastAPI("cc_<your-key>") # 1000 req/hr (Pro tier)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Get a key at [contrastcyber.com/pricing](https://contrastcyber.com/pricing).
|
|
107
|
+
|
|
108
|
+
## Exception model
|
|
109
|
+
|
|
110
|
+
The SDK maps server error codes (v1.22.2+ wire envelope) to typed exceptions:
|
|
111
|
+
|
|
112
|
+
| Exception | Status | Server code |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `InvalidArgumentError` | 400, 422 | `invalid_argument` |
|
|
115
|
+
| `AuthRequiredError` | 401 | `auth_required` |
|
|
116
|
+
| `TierLimitError` | 403 | `tier_limit` |
|
|
117
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
118
|
+
| `RateLimitError` | 429 | `rate_limit_exceeded` |
|
|
119
|
+
| `UpstreamError` | 502 | `upstream_error` |
|
|
120
|
+
| `UpstreamTimeoutError` | 504 | `upstream_timeout` |
|
|
121
|
+
| `TransportError` | n/a | (network failure, before HTTP) |
|
|
122
|
+
| `ContrastAPIError` | * | base / unknown |
|
|
123
|
+
|
|
124
|
+
Every exception carries the parsed envelope:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from contrastapi import ContrastAPI, RateLimitError
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
client.cve.lookup("CVE-2021-44228")
|
|
131
|
+
except RateLimitError as exc:
|
|
132
|
+
print(exc.message) # "Hourly limit reached"
|
|
133
|
+
print(exc.retry_after_seconds) # 60 (capped at 3600)
|
|
134
|
+
print(exc.upgrade_url) # "https://contrastcyber.com/pricing"
|
|
135
|
+
print(exc.extras) # back-compat top-level fields (tier, limit, ...)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Namespaces
|
|
139
|
+
|
|
140
|
+
| Namespace | Methods |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `cve` | `lookup`, `search`, `leading`, `kev`, `exploit`, `bulk` |
|
|
143
|
+
| `cwe` | `lookup` |
|
|
144
|
+
| `ioc` | `lookup`, `hash`, `phishing`, `bulk` |
|
|
145
|
+
| `atlas` | `technique`, `technique_search`, `bulk_technique_lookup`, `case_study`, `case_study_search` |
|
|
146
|
+
| `d3fend` | `defense`, `defense_search`, `defense_for_attack`, `coverage` |
|
|
147
|
+
| `domain` | `report`, `dns`, `whois`, `subdomains`, `certs`, `ssl`, `tech`, `threat`, `monitor`, `vulns`, `audit`, `wayback`, `bulk` |
|
|
148
|
+
| `ip` | `lookup`, `threat_report` |
|
|
149
|
+
| `asn` | `lookup` |
|
|
150
|
+
| `email` | `mx`, `disposable` |
|
|
151
|
+
| `phone` | `lookup` |
|
|
152
|
+
| `password` | `check` (k-anonymity SHA-1 prefix) |
|
|
153
|
+
| `username` | `lookup` |
|
|
154
|
+
| `check` | `secrets`, `injection`, `headers`, `dependencies` |
|
|
155
|
+
| `scan` | `headers` (live HTTP scan) |
|
|
156
|
+
|
|
157
|
+
The async client (`AsyncContrastAPI`) exposes the same namespace surface 1:1 — every method is `async def`.
|
|
158
|
+
|
|
159
|
+
## Parity with the Node SDK
|
|
160
|
+
|
|
161
|
+
| Surface | Node SDK | Python SDK |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| Sync | ✅ (Promise-based) | ✅ (`ContrastAPI`) |
|
|
164
|
+
| Async | (Promise model) | ✅ (`AsyncContrastAPI`) |
|
|
165
|
+
| Namespace count | 13 | 14 (adds `username`) |
|
|
166
|
+
| `bulk_technique_lookup` (ATLAS) | (added in v1.4.0) | ✅ |
|
|
167
|
+
| `wayback` archive lookup | (added in v1.4.0) | ✅ |
|
|
168
|
+
| Typed errors | `Error` subclasses | full hierarchy with envelope fields |
|
|
169
|
+
| Shortcuts | — | ✅ `triage_ioc`, `audit_full`, `enrich_batch` |
|
|
170
|
+
| Response models | `Promise<any>` | ✅ `TypedDict` (IDE autocomplete, no runtime cost) |
|
|
171
|
+
|
|
172
|
+
## Configuration
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
client = ContrastAPI(
|
|
176
|
+
api_key="cc_...", # optional; keyless = free tier
|
|
177
|
+
base_url="https://api.contrastcyber.com", # override for self-host
|
|
178
|
+
timeout=30.0, # seconds; clamped to [1, 120]
|
|
179
|
+
allow_insecure=False, # set True to allow http:// (dev only)
|
|
180
|
+
)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The transport hard-caps response bodies at 10 MB, sends a `User-Agent: contrastapi-python/<version>` header, and refuses to send your API key over plaintext HTTP even when `allow_insecure=True`.
|
|
184
|
+
|
|
185
|
+
## Links
|
|
186
|
+
|
|
187
|
+
- API docs: https://api.contrastcyber.com/docs
|
|
188
|
+
- Pricing & API key: https://contrastcyber.com/pricing
|
|
189
|
+
- Source: https://github.com/UPinar/contrastapi
|
|
190
|
+
- Issues: https://github.com/UPinar/contrastapi/issues
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT — see [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# ContrastAPI Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for [ContrastAPI](https://api.contrastcyber.com) — security intelligence for developers and AI agents.
|
|
4
|
+
|
|
5
|
+
42 MCP tools / 50+ HTTP endpoints: CVE / EPSS / KEV / CWE, MITRE ATLAS (AI/ML attacks) + bulk drill, MITRE D3FEND defenses, domain audit, IOC + threat intel, OSINT, code-security checks, and more. No API key required for the free tier (100 req/hr).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install contrastapi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.10+. Depends only on `httpx>=0.25`.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
### Sync
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from contrastapi import ContrastAPI
|
|
21
|
+
from contrastapi.models import CveResponse, AuditResponse
|
|
22
|
+
|
|
23
|
+
with ContrastAPI() as client: # keyless, free tier
|
|
24
|
+
cve: CveResponse = client.cve.lookup("CVE-2021-44228")
|
|
25
|
+
print(cve["kev"]["in_kev"]) # True (IDE autocompletes "kev", "epss"...)
|
|
26
|
+
|
|
27
|
+
techs = client.atlas.bulk_technique_lookup(["AML.T0051", "AML.T0043"])
|
|
28
|
+
print(techs["successful"]) # 2
|
|
29
|
+
|
|
30
|
+
audit: AuditResponse = client.domain.audit("example.com")
|
|
31
|
+
print(audit["score"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Every response method has a typed return — `client.cve.lookup(...)` returns `CveResponse`, `client.atlas.technique(...)` returns `AtlasTechniqueResponse`, etc. You never need to write the type annotation; IDEs (VSCode/PyCharm) infer it from the method signature and offer autocomplete on response keys. Import from `contrastapi.models` only if you want to annotate explicitly or pass responses across function boundaries.
|
|
35
|
+
|
|
36
|
+
### Async
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from contrastapi import AsyncContrastAPI
|
|
41
|
+
|
|
42
|
+
async def main():
|
|
43
|
+
async with AsyncContrastAPI(api_key="cc_...") as client:
|
|
44
|
+
defenses = await client.d3fend.defense_for_attack("T1059")
|
|
45
|
+
print(len(defenses["defenses"]))
|
|
46
|
+
|
|
47
|
+
asyncio.run(main())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Shortcuts (multi-call helpers)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from contrastapi import ContrastAPI, audit_full, enrich_batch, triage_ioc
|
|
54
|
+
|
|
55
|
+
with ContrastAPI() as client:
|
|
56
|
+
# Auto-route IP/hash/domain to the right enrichment leg
|
|
57
|
+
report = triage_ioc(client, "8.8.8.8") # ioc + threat_report
|
|
58
|
+
|
|
59
|
+
# Audit + subdomains + tech + per-subdomain SSL (capped)
|
|
60
|
+
audit = audit_full(client, "example.com", ssl_subdomains=5)
|
|
61
|
+
|
|
62
|
+
# Auto-detect CVE vs IOC and bulk-route
|
|
63
|
+
enriched = enrich_batch(client, ["CVE-2021-44228", "8.8.8.8", "evil.com"])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Shortcuts swallow per-leg `ContrastAPIError` so partial failures still return whatever succeeded — see `result["errors"]` for the failure map.
|
|
67
|
+
|
|
68
|
+
## Authentication
|
|
69
|
+
|
|
70
|
+
Pass an API key as the first positional argument or as `api_key=`:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
client = ContrastAPI("cc_<your-key>") # 1000 req/hr (Pro tier)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Get a key at [contrastcyber.com/pricing](https://contrastcyber.com/pricing).
|
|
77
|
+
|
|
78
|
+
## Exception model
|
|
79
|
+
|
|
80
|
+
The SDK maps server error codes (v1.22.2+ wire envelope) to typed exceptions:
|
|
81
|
+
|
|
82
|
+
| Exception | Status | Server code |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| `InvalidArgumentError` | 400, 422 | `invalid_argument` |
|
|
85
|
+
| `AuthRequiredError` | 401 | `auth_required` |
|
|
86
|
+
| `TierLimitError` | 403 | `tier_limit` |
|
|
87
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
88
|
+
| `RateLimitError` | 429 | `rate_limit_exceeded` |
|
|
89
|
+
| `UpstreamError` | 502 | `upstream_error` |
|
|
90
|
+
| `UpstreamTimeoutError` | 504 | `upstream_timeout` |
|
|
91
|
+
| `TransportError` | n/a | (network failure, before HTTP) |
|
|
92
|
+
| `ContrastAPIError` | * | base / unknown |
|
|
93
|
+
|
|
94
|
+
Every exception carries the parsed envelope:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from contrastapi import ContrastAPI, RateLimitError
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
client.cve.lookup("CVE-2021-44228")
|
|
101
|
+
except RateLimitError as exc:
|
|
102
|
+
print(exc.message) # "Hourly limit reached"
|
|
103
|
+
print(exc.retry_after_seconds) # 60 (capped at 3600)
|
|
104
|
+
print(exc.upgrade_url) # "https://contrastcyber.com/pricing"
|
|
105
|
+
print(exc.extras) # back-compat top-level fields (tier, limit, ...)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Namespaces
|
|
109
|
+
|
|
110
|
+
| Namespace | Methods |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `cve` | `lookup`, `search`, `leading`, `kev`, `exploit`, `bulk` |
|
|
113
|
+
| `cwe` | `lookup` |
|
|
114
|
+
| `ioc` | `lookup`, `hash`, `phishing`, `bulk` |
|
|
115
|
+
| `atlas` | `technique`, `technique_search`, `bulk_technique_lookup`, `case_study`, `case_study_search` |
|
|
116
|
+
| `d3fend` | `defense`, `defense_search`, `defense_for_attack`, `coverage` |
|
|
117
|
+
| `domain` | `report`, `dns`, `whois`, `subdomains`, `certs`, `ssl`, `tech`, `threat`, `monitor`, `vulns`, `audit`, `wayback`, `bulk` |
|
|
118
|
+
| `ip` | `lookup`, `threat_report` |
|
|
119
|
+
| `asn` | `lookup` |
|
|
120
|
+
| `email` | `mx`, `disposable` |
|
|
121
|
+
| `phone` | `lookup` |
|
|
122
|
+
| `password` | `check` (k-anonymity SHA-1 prefix) |
|
|
123
|
+
| `username` | `lookup` |
|
|
124
|
+
| `check` | `secrets`, `injection`, `headers`, `dependencies` |
|
|
125
|
+
| `scan` | `headers` (live HTTP scan) |
|
|
126
|
+
|
|
127
|
+
The async client (`AsyncContrastAPI`) exposes the same namespace surface 1:1 — every method is `async def`.
|
|
128
|
+
|
|
129
|
+
## Parity with the Node SDK
|
|
130
|
+
|
|
131
|
+
| Surface | Node SDK | Python SDK |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| Sync | ✅ (Promise-based) | ✅ (`ContrastAPI`) |
|
|
134
|
+
| Async | (Promise model) | ✅ (`AsyncContrastAPI`) |
|
|
135
|
+
| Namespace count | 13 | 14 (adds `username`) |
|
|
136
|
+
| `bulk_technique_lookup` (ATLAS) | (added in v1.4.0) | ✅ |
|
|
137
|
+
| `wayback` archive lookup | (added in v1.4.0) | ✅ |
|
|
138
|
+
| Typed errors | `Error` subclasses | full hierarchy with envelope fields |
|
|
139
|
+
| Shortcuts | — | ✅ `triage_ioc`, `audit_full`, `enrich_batch` |
|
|
140
|
+
| Response models | `Promise<any>` | ✅ `TypedDict` (IDE autocomplete, no runtime cost) |
|
|
141
|
+
|
|
142
|
+
## Configuration
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
client = ContrastAPI(
|
|
146
|
+
api_key="cc_...", # optional; keyless = free tier
|
|
147
|
+
base_url="https://api.contrastcyber.com", # override for self-host
|
|
148
|
+
timeout=30.0, # seconds; clamped to [1, 120]
|
|
149
|
+
allow_insecure=False, # set True to allow http:// (dev only)
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The transport hard-caps response bodies at 10 MB, sends a `User-Agent: contrastapi-python/<version>` header, and refuses to send your API key over plaintext HTTP even when `allow_insecure=True`.
|
|
154
|
+
|
|
155
|
+
## Links
|
|
156
|
+
|
|
157
|
+
- API docs: https://api.contrastcyber.com/docs
|
|
158
|
+
- Pricing & API key: https://contrastcyber.com/pricing
|
|
159
|
+
- Source: https://github.com/UPinar/contrastapi
|
|
160
|
+
- Issues: https://github.com/UPinar/contrastapi/issues
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT — see [LICENSE](../../LICENSE).
|
|
@@ -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"
|