contrastapi 1.22.4__tar.gz → 1.23.0__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.23.0/.gitignore +34 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/PKG-INFO +7 -6
- {contrastapi-1.22.4 → contrastapi-1.23.0}/README.md +6 -5
- contrastapi-1.23.0/contrastapi/_version.py +1 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/client.py +6 -3
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/exceptions.py +2 -1
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/models.py +57 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/cve.py +18 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/domain.py +30 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/intel.py +15 -0
- contrastapi-1.23.0/contrastapi/namespaces/sigma.py +42 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/pyproject.toml +1 -1
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_namespaces.py +2 -2
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_transport.py +2 -2
- contrastapi-1.23.0/tests/test_v1_23_endpoints.py +152 -0
- contrastapi-1.22.4/.gitignore +0 -20
- contrastapi-1.22.4/contrastapi/_version.py +0 -1
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/__init__.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/_transport.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/__init__.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/atlas.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/d3fend.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/ioc.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/security.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/shortcuts.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/__init__.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/conftest.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_remaining_namespaces.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_review_fixes.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_review_round2_fixes.py +0 -0
- {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_shortcuts.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
!scripts/refresh_sigma.sh
|
|
15
|
+
plans/
|
|
16
|
+
.npmrc
|
|
17
|
+
node_modules/
|
|
18
|
+
research_landing*.md
|
|
19
|
+
.coverage
|
|
20
|
+
deploy/
|
|
21
|
+
.aider*
|
|
22
|
+
|
|
23
|
+
# SDK build artifacts (regenerated per release)
|
|
24
|
+
sdk/python/dist/
|
|
25
|
+
|
|
26
|
+
sdk/node/publish_v*.txt
|
|
27
|
+
sdk/python/publish_v*.txt
|
|
28
|
+
|
|
29
|
+
# One-shot CLI verification reports
|
|
30
|
+
cli_prompt_verify.md
|
|
31
|
+
|
|
32
|
+
# Local-dev fallback artifacts (BASE_DIR-relative when env vars unset)
|
|
33
|
+
app/mcp_tools.jsonl
|
|
34
|
+
app/glama.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contrastapi
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
4
4
|
Summary: Official Python SDK for ContrastAPI — security intelligence for developers and AI agents
|
|
5
5
|
Project-URL: Homepage, https://contrastcyber.com
|
|
6
6
|
Project-URL: Documentation, https://api.contrastcyber.com/docs
|
|
@@ -32,7 +32,7 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
|
|
33
33
|
Official Python client for [ContrastAPI](https://api.contrastcyber.com) — security intelligence for developers and AI agents.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
53 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 (30 req/hr).
|
|
36
36
|
|
|
37
37
|
## Install
|
|
38
38
|
|
|
@@ -100,7 +100,7 @@ Shortcuts swallow per-leg `ContrastAPIError` so partial failures still return wh
|
|
|
100
100
|
Pass an API key as the first positional argument or as `api_key=`:
|
|
101
101
|
|
|
102
102
|
```python
|
|
103
|
-
client = ContrastAPI("cc_<your-key>") #
|
|
103
|
+
client = ContrastAPI("cc_<your-key>") # 500 req/hr (Pro tier)
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Get a key at [contrastcyber.com/pricing](https://contrastcyber.com/pricing).
|
|
@@ -144,15 +144,16 @@ except RateLimitError as exc:
|
|
|
144
144
|
| `ioc` | `lookup`, `hash`, `phishing`, `bulk` |
|
|
145
145
|
| `atlas` | `technique`, `technique_search`, `bulk_technique_lookup`, `case_study`, `case_study_search` |
|
|
146
146
|
| `d3fend` | `defense`, `defense_search`, `defense_for_attack`, `coverage` |
|
|
147
|
-
| `domain` | `report`, `dns`, `whois`, `subdomains`, `certs`, `ssl`, `tech`, `threat`, `monitor`, `vulns`, `audit`, `wayback`, `bulk` |
|
|
147
|
+
| `domain` | `report`, `dns`, `whois`, `subdomains`, `certs`, `ssl`, `tech`, `threat`, `monitor`, `vulns`, `audit`, `wayback`, `robots`, `redirect`, `brand`, `seo`, `bulk` |
|
|
148
148
|
| `ip` | `lookup`, `threat_report` |
|
|
149
149
|
| `asn` | `lookup` |
|
|
150
|
-
| `email` | `mx`, `disposable` |
|
|
150
|
+
| `email` | `mx`, `disposable`, `security_posture`, `verify` |
|
|
151
151
|
| `phone` | `lookup` |
|
|
152
152
|
| `password` | `check` (k-anonymity SHA-1 prefix) |
|
|
153
153
|
| `username` | `lookup` |
|
|
154
154
|
| `check` | `secrets`, `injection`, `headers`, `dependencies` |
|
|
155
155
|
| `scan` | `headers` (live HTTP scan) |
|
|
156
|
+
| `sigma` | `lookup` (by UUID), `bulk` (≤50 rule IDs) |
|
|
156
157
|
|
|
157
158
|
The async client (`AsyncContrastAPI`) exposes the same namespace surface 1:1 — every method is `async def`.
|
|
158
159
|
|
|
@@ -162,7 +163,7 @@ The async client (`AsyncContrastAPI`) exposes the same namespace surface 1:1 —
|
|
|
162
163
|
| --- | --- | --- |
|
|
163
164
|
| Sync | ✅ (Promise-based) | ✅ (`ContrastAPI`) |
|
|
164
165
|
| Async | (Promise model) | ✅ (`AsyncContrastAPI`) |
|
|
165
|
-
| Namespace count | 13 |
|
|
166
|
+
| Namespace count | 13 | 15 (adds `username`, `sigma`) |
|
|
166
167
|
| `bulk_technique_lookup` (ATLAS) | (added in v1.4.0) | ✅ |
|
|
167
168
|
| `wayback` archive lookup | (added in v1.4.0) | ✅ |
|
|
168
169
|
| Typed errors | `Error` subclasses | full hierarchy with envelope fields |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Official Python client for [ContrastAPI](https://api.contrastcyber.com) — security intelligence for developers and AI agents.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
53 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 (30 req/hr).
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -70,7 +70,7 @@ Shortcuts swallow per-leg `ContrastAPIError` so partial failures still return wh
|
|
|
70
70
|
Pass an API key as the first positional argument or as `api_key=`:
|
|
71
71
|
|
|
72
72
|
```python
|
|
73
|
-
client = ContrastAPI("cc_<your-key>") #
|
|
73
|
+
client = ContrastAPI("cc_<your-key>") # 500 req/hr (Pro tier)
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
Get a key at [contrastcyber.com/pricing](https://contrastcyber.com/pricing).
|
|
@@ -114,15 +114,16 @@ except RateLimitError as exc:
|
|
|
114
114
|
| `ioc` | `lookup`, `hash`, `phishing`, `bulk` |
|
|
115
115
|
| `atlas` | `technique`, `technique_search`, `bulk_technique_lookup`, `case_study`, `case_study_search` |
|
|
116
116
|
| `d3fend` | `defense`, `defense_search`, `defense_for_attack`, `coverage` |
|
|
117
|
-
| `domain` | `report`, `dns`, `whois`, `subdomains`, `certs`, `ssl`, `tech`, `threat`, `monitor`, `vulns`, `audit`, `wayback`, `bulk` |
|
|
117
|
+
| `domain` | `report`, `dns`, `whois`, `subdomains`, `certs`, `ssl`, `tech`, `threat`, `monitor`, `vulns`, `audit`, `wayback`, `robots`, `redirect`, `brand`, `seo`, `bulk` |
|
|
118
118
|
| `ip` | `lookup`, `threat_report` |
|
|
119
119
|
| `asn` | `lookup` |
|
|
120
|
-
| `email` | `mx`, `disposable` |
|
|
120
|
+
| `email` | `mx`, `disposable`, `security_posture`, `verify` |
|
|
121
121
|
| `phone` | `lookup` |
|
|
122
122
|
| `password` | `check` (k-anonymity SHA-1 prefix) |
|
|
123
123
|
| `username` | `lookup` |
|
|
124
124
|
| `check` | `secrets`, `injection`, `headers`, `dependencies` |
|
|
125
125
|
| `scan` | `headers` (live HTTP scan) |
|
|
126
|
+
| `sigma` | `lookup` (by UUID), `bulk` (≤50 rule IDs) |
|
|
126
127
|
|
|
127
128
|
The async client (`AsyncContrastAPI`) exposes the same namespace surface 1:1 — every method is `async def`.
|
|
128
129
|
|
|
@@ -132,7 +133,7 @@ The async client (`AsyncContrastAPI`) exposes the same namespace surface 1:1 —
|
|
|
132
133
|
| --- | --- | --- |
|
|
133
134
|
| Sync | ✅ (Promise-based) | ✅ (`ContrastAPI`) |
|
|
134
135
|
| Async | (Promise model) | ✅ (`AsyncContrastAPI`) |
|
|
135
|
-
| Namespace count | 13 |
|
|
136
|
+
| Namespace count | 13 | 15 (adds `username`, `sigma`) |
|
|
136
137
|
| `bulk_technique_lookup` (ATLAS) | (added in v1.4.0) | ✅ |
|
|
137
138
|
| `wayback` archive lookup | (added in v1.4.0) | ✅ |
|
|
138
139
|
| Typed errors | `Error` subclasses | full hierarchy with envelope fields |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.23.0"
|
|
@@ -9,9 +9,9 @@ Both classes accept the same options (`api_key`, `base_url`, `timeout`,
|
|
|
9
9
|
`allow_insecure`) and expose the same namespace property names. Choose whichever
|
|
10
10
|
matches your application's I/O model.
|
|
11
11
|
|
|
12
|
-
All
|
|
13
|
-
password, username, check, scan) are exposed as instance attributes. The
|
|
14
|
-
client mirrors the sync surface 1:1 — same names, async methods.
|
|
12
|
+
All 15 namespaces (cve, cwe, ioc, atlas, d3fend, domain, ip, asn, email, phone,
|
|
13
|
+
password, username, check, scan, sigma) are exposed as instance attributes. The
|
|
14
|
+
async client mirrors the sync surface 1:1 — same names, async methods.
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
@@ -41,6 +41,7 @@ from .namespaces.intel import (
|
|
|
41
41
|
)
|
|
42
42
|
from .namespaces.ioc import AsyncIoc, Ioc
|
|
43
43
|
from .namespaces.security import AsyncCheck, AsyncScan, Check, Scan
|
|
44
|
+
from .namespaces.sigma import AsyncSigma, Sigma
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
class ContrastAPI:
|
|
@@ -86,6 +87,7 @@ class ContrastAPI:
|
|
|
86
87
|
self.username = Username(self._transport)
|
|
87
88
|
self.check = Check(self._transport)
|
|
88
89
|
self.scan = Scan(self._transport)
|
|
90
|
+
self.sigma = Sigma(self._transport)
|
|
89
91
|
|
|
90
92
|
@property
|
|
91
93
|
def version(self) -> str:
|
|
@@ -150,6 +152,7 @@ class AsyncContrastAPI:
|
|
|
150
152
|
self.username = AsyncUsername(self._transport)
|
|
151
153
|
self.check = AsyncCheck(self._transport)
|
|
152
154
|
self.scan = AsyncScan(self._transport)
|
|
155
|
+
self.sigma = AsyncSigma(self._transport)
|
|
153
156
|
|
|
154
157
|
@property
|
|
155
158
|
def version(self) -> str:
|
|
@@ -8,6 +8,7 @@ the parsed `ErrorDetail` envelope (dict) from the wire — `.code`, `.message`,
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import math
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
# Wire-boundary defensive caps mirror server-side `_ERROR_MESSAGE_MAX_LEN` /
|
|
@@ -67,7 +68,7 @@ def _coerce_retry_after(value: Any) -> int | None:
|
|
|
67
68
|
return None
|
|
68
69
|
# Reject NaN — `int(float('nan'))` raises ValueError but inf raises
|
|
69
70
|
# OverflowError; both must be silently dropped to None per defensive policy.
|
|
70
|
-
if isinstance(value, float) and (value
|
|
71
|
+
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
|
71
72
|
return None
|
|
72
73
|
try:
|
|
73
74
|
as_int = int(value)
|
|
@@ -70,6 +70,9 @@ class CveResponse(TypedDict, total=False):
|
|
|
70
70
|
cvss_v3: float
|
|
71
71
|
cvss_breakdown: dict[str, Any]
|
|
72
72
|
cwe_id: str
|
|
73
|
+
cwes: list[str] # v1.28.0 (B4A) — all CWE identifiers, Primary first
|
|
74
|
+
vulnerability_status: str # v1.29.0 (B5) — NVD lifecycle (Analyzed/Modified/Rejected/...)
|
|
75
|
+
cve_tags: list[str] # v1.29.0 (B5) — NVD cveTags (e.g. 'disputed')
|
|
73
76
|
epss: dict[str, Any]
|
|
74
77
|
kev: dict[str, Any]
|
|
75
78
|
affected_products: list[dict[str, Any]]
|
|
@@ -78,9 +81,16 @@ class CveResponse(TypedDict, total=False):
|
|
|
78
81
|
modified: str
|
|
79
82
|
references: list[str]
|
|
80
83
|
total_references: int
|
|
84
|
+
total_references_unique: int # v1.29.0 (B6A) — unique-URL count from refs_with_tags
|
|
85
|
+
references_full: list[dict[str, Any]] # v1.29.0 (B6B) — [{url, tags, source}] when ?include_reference_tags=true
|
|
81
86
|
patch_available: bool
|
|
82
87
|
patch_url: str
|
|
83
88
|
sources: list[str]
|
|
89
|
+
cvss_v2: float # v1.29.0 (B7) — CVSS v2.0 base score (always emitted)
|
|
90
|
+
cvss_v2_vector: str # v1.29.0 (B7) — CVSS v2.0 vector string
|
|
91
|
+
severity_sources: list[dict[str, Any]] # v1.29.0 (B7) — per-source severity breakdown
|
|
92
|
+
severity_consensus: str # v1.29.0 (B7) — majority-bucket consensus
|
|
93
|
+
severity_disagreement: bool # v1.29.0 (B7) — true when sources disagree
|
|
84
94
|
verdict: Verdict
|
|
85
95
|
next_calls: list[PivotHint]
|
|
86
96
|
|
|
@@ -103,6 +113,8 @@ class KevDetailResponse(TypedDict, total=False):
|
|
|
103
113
|
cve_id: str
|
|
104
114
|
in_kev: bool
|
|
105
115
|
date_added: str
|
|
116
|
+
date_updated: str # v1.29.0 (B5) — feed update timestamp
|
|
117
|
+
date_removed: str # v1.29.0 (B5) — soft-delete lifecycle (null when still active)
|
|
106
118
|
due_date: str
|
|
107
119
|
required_action: str
|
|
108
120
|
known_ransomware_use: bool
|
|
@@ -147,6 +159,51 @@ class ExploitResponse(TypedDict, total=False):
|
|
|
147
159
|
next_calls: list[PivotHint]
|
|
148
160
|
|
|
149
161
|
|
|
162
|
+
# --- v1.29.1: composite risk score + CVSS v3.x parser ---
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CvssMetrics(TypedDict, total=False):
|
|
166
|
+
attack_vector: str
|
|
167
|
+
attack_complexity: str
|
|
168
|
+
privileges_required: str
|
|
169
|
+
user_interaction: str
|
|
170
|
+
scope: str
|
|
171
|
+
confidentiality_impact: str
|
|
172
|
+
integrity_impact: str
|
|
173
|
+
availability_impact: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CvssDetailsResponse(TypedDict, total=False):
|
|
177
|
+
"""v1.29.1 — `GET /v1/cvss/details?vector=...`."""
|
|
178
|
+
|
|
179
|
+
version: str
|
|
180
|
+
vector: str
|
|
181
|
+
base_score: float
|
|
182
|
+
base_severity: str
|
|
183
|
+
metrics: CvssMetrics
|
|
184
|
+
temporal_score: float | None
|
|
185
|
+
environmental_score: float | None
|
|
186
|
+
summary: str
|
|
187
|
+
verdict: Verdict
|
|
188
|
+
next_calls: list[PivotHint]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class RiskScoreResponse(TypedDict, total=False):
|
|
192
|
+
"""v1.29.1 — `GET /v1/cve/{cve_id}/risk_score`."""
|
|
193
|
+
|
|
194
|
+
cve_id: str
|
|
195
|
+
score: float
|
|
196
|
+
label: str # CRITICAL / HIGH / MEDIUM / LOW
|
|
197
|
+
urgency: str
|
|
198
|
+
has_public_poc: bool
|
|
199
|
+
components: dict[str, Any]
|
|
200
|
+
boosters_applied: list[str]
|
|
201
|
+
recommendation: str
|
|
202
|
+
summary: str
|
|
203
|
+
verdict: Verdict
|
|
204
|
+
next_calls: list[PivotHint]
|
|
205
|
+
|
|
206
|
+
|
|
150
207
|
# --- Bulk parity (v1.21.0+ unified status enum: ok/error/not_found/invalid_format) ---
|
|
151
208
|
|
|
152
209
|
|
|
@@ -10,9 +10,11 @@ from ..models import (
|
|
|
10
10
|
BulkCveResponse,
|
|
11
11
|
CveResponse,
|
|
12
12
|
CveSearchResponse,
|
|
13
|
+
CvssDetailsResponse,
|
|
13
14
|
CweLookupResponse,
|
|
14
15
|
ExploitResponse,
|
|
15
16
|
KevDetailResponse,
|
|
17
|
+
RiskScoreResponse,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
|
|
@@ -82,6 +84,14 @@ class Cve:
|
|
|
82
84
|
def exploit(self, cve_id: str) -> ExploitResponse:
|
|
83
85
|
return self._t.get(f"/v1/exploit/{_enc(cve_id)}")
|
|
84
86
|
|
|
87
|
+
def risk_score(self, cve_id: str) -> RiskScoreResponse:
|
|
88
|
+
"""v1.29.1 — Composite risk score (CVSS+EPSS+KEV+PoC fusion, 0-100)."""
|
|
89
|
+
return self._t.get(f"/v1/cve/{_enc(cve_id)}/risk_score")
|
|
90
|
+
|
|
91
|
+
def cvss_details(self, vector: str) -> CvssDetailsResponse:
|
|
92
|
+
"""v1.29.1 — Parse a CVSS v3.x vector into per-metric breakdown + recomputed score."""
|
|
93
|
+
return self._t.get("/v1/cvss/details", params={"vector": vector})
|
|
94
|
+
|
|
85
95
|
def bulk(self, cve_ids: list[str]) -> BulkCveResponse:
|
|
86
96
|
return self._t.post("/v1/cves/bulk", json_body={"cve_ids": _validate_bulk_ids(cve_ids)})
|
|
87
97
|
|
|
@@ -113,6 +123,14 @@ class AsyncCve:
|
|
|
113
123
|
async def exploit(self, cve_id: str) -> ExploitResponse:
|
|
114
124
|
return await self._t.aget(f"/v1/exploit/{_enc(cve_id)}")
|
|
115
125
|
|
|
126
|
+
async def risk_score(self, cve_id: str) -> RiskScoreResponse:
|
|
127
|
+
"""v1.29.1 — Composite risk score (CVSS+EPSS+KEV+PoC fusion, 0-100)."""
|
|
128
|
+
return await self._t.aget(f"/v1/cve/{_enc(cve_id)}/risk_score")
|
|
129
|
+
|
|
130
|
+
async def cvss_details(self, vector: str) -> CvssDetailsResponse:
|
|
131
|
+
"""v1.29.1 — Parse a CVSS v3.x vector into per-metric breakdown + recomputed score."""
|
|
132
|
+
return await self._t.aget("/v1/cvss/details", params={"vector": vector})
|
|
133
|
+
|
|
116
134
|
async def bulk(self, cve_ids: list[str]) -> BulkCveResponse:
|
|
117
135
|
return await self._t.apost("/v1/cves/bulk", json_body={"cve_ids": _validate_bulk_ids(cve_ids)})
|
|
118
136
|
|
|
@@ -27,6 +27,12 @@ def _enc(value: str) -> str:
|
|
|
27
27
|
return quote(str(value), safe="")
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def _enc_path(value: str) -> str:
|
|
31
|
+
if value is None or value == "":
|
|
32
|
+
raise ValueError("Missing required parameter")
|
|
33
|
+
return "/".join(quote(seg, safe="") for seg in str(value).split("/"))
|
|
34
|
+
|
|
35
|
+
|
|
30
36
|
def _validate_domains(domains: list[str]) -> list[str]:
|
|
31
37
|
if not isinstance(domains, list) or not all(isinstance(d, str) for d in domains):
|
|
32
38
|
raise ValueError("domains must be a list of strings")
|
|
@@ -81,6 +87,18 @@ class Domain:
|
|
|
81
87
|
def wayback(self, domain: str) -> WaybackResponse:
|
|
82
88
|
return self._t.get(f"/v1/archive/{_enc(domain)}")
|
|
83
89
|
|
|
90
|
+
def robots(self, domain: str) -> dict[str, Any]:
|
|
91
|
+
return self._t.get(f"/v1/robots/{_enc(domain)}")
|
|
92
|
+
|
|
93
|
+
def redirect(self, url: str) -> dict[str, Any]:
|
|
94
|
+
return self._t.get(f"/v1/redirect/{_enc_path(url)}")
|
|
95
|
+
|
|
96
|
+
def brand(self, domain: str) -> dict[str, Any]:
|
|
97
|
+
return self._t.get(f"/v1/brand/{_enc(domain)}")
|
|
98
|
+
|
|
99
|
+
def seo(self, domain: str) -> dict[str, Any]:
|
|
100
|
+
return self._t.get(f"/v1/seo/{_enc(domain)}")
|
|
101
|
+
|
|
84
102
|
def bulk(self, domains: list[str]) -> BulkDomainResponse:
|
|
85
103
|
return self._t.post("/v1/domains/bulk", json_body={"domains": _validate_domains(domains)})
|
|
86
104
|
|
|
@@ -125,5 +143,17 @@ class AsyncDomain:
|
|
|
125
143
|
async def wayback(self, domain: str) -> WaybackResponse:
|
|
126
144
|
return await self._t.aget(f"/v1/archive/{_enc(domain)}")
|
|
127
145
|
|
|
146
|
+
async def robots(self, domain: str) -> dict[str, Any]:
|
|
147
|
+
return await self._t.aget(f"/v1/robots/{_enc(domain)}")
|
|
148
|
+
|
|
149
|
+
async def redirect(self, url: str) -> dict[str, Any]:
|
|
150
|
+
return await self._t.aget(f"/v1/redirect/{_enc_path(url)}")
|
|
151
|
+
|
|
152
|
+
async def brand(self, domain: str) -> dict[str, Any]:
|
|
153
|
+
return await self._t.aget(f"/v1/brand/{_enc(domain)}")
|
|
154
|
+
|
|
155
|
+
async def seo(self, domain: str) -> dict[str, Any]:
|
|
156
|
+
return await self._t.aget(f"/v1/seo/{_enc(domain)}")
|
|
157
|
+
|
|
128
158
|
async def bulk(self, domains: list[str]) -> BulkDomainResponse:
|
|
129
159
|
return await self._t.apost("/v1/domains/bulk", json_body={"domains": _validate_domains(domains)})
|
|
@@ -4,6 +4,7 @@ ip, asn, email, phone, password, username.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
from typing import Any
|
|
7
8
|
from urllib.parse import quote
|
|
8
9
|
|
|
9
10
|
from .._transport import _AsyncTransport, _SyncTransport
|
|
@@ -82,6 +83,13 @@ class Email:
|
|
|
82
83
|
def disposable(self, email: str) -> DisposableResponse:
|
|
83
84
|
return self._t.get(f"/v1/email/disposable/{_enc(email)}")
|
|
84
85
|
|
|
86
|
+
def security_posture(self, domain: str, *, selectors: str | None = None) -> dict[str, Any]:
|
|
87
|
+
params = {"selectors": selectors} if selectors else None
|
|
88
|
+
return self._t.get(f"/v1/email/security-posture/{_enc(domain)}", params=params)
|
|
89
|
+
|
|
90
|
+
def verify(self, email: str) -> dict[str, Any]:
|
|
91
|
+
return self._t.get(f"/v1/email/verify/{_enc(email)}")
|
|
92
|
+
|
|
85
93
|
|
|
86
94
|
class AsyncEmail:
|
|
87
95
|
def __init__(self, transport: _AsyncTransport) -> None:
|
|
@@ -93,6 +101,13 @@ class AsyncEmail:
|
|
|
93
101
|
async def disposable(self, email: str) -> DisposableResponse:
|
|
94
102
|
return await self._t.aget(f"/v1/email/disposable/{_enc(email)}")
|
|
95
103
|
|
|
104
|
+
async def security_posture(self, domain: str, *, selectors: str | None = None) -> dict[str, Any]:
|
|
105
|
+
params = {"selectors": selectors} if selectors else None
|
|
106
|
+
return await self._t.aget(f"/v1/email/security-posture/{_enc(domain)}", params=params)
|
|
107
|
+
|
|
108
|
+
async def verify(self, email: str) -> dict[str, Any]:
|
|
109
|
+
return await self._t.aget(f"/v1/email/verify/{_enc(email)}")
|
|
110
|
+
|
|
96
111
|
|
|
97
112
|
# --- Phone ---
|
|
98
113
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Sigma namespace — detection-rule lookup by UUID + bulk lookup (≤50)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .._transport import _AsyncTransport, _SyncTransport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _enc(value: str) -> str:
|
|
12
|
+
if value is None or value == "":
|
|
13
|
+
raise ValueError("Missing required parameter")
|
|
14
|
+
return quote(str(value), safe="")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_rule_ids(rule_ids: list[str]) -> list[str]:
|
|
18
|
+
if not isinstance(rule_ids, list) or not all(isinstance(r, str) for r in rule_ids):
|
|
19
|
+
raise ValueError("rule_ids must be a list of strings")
|
|
20
|
+
return rule_ids
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Sigma:
|
|
24
|
+
def __init__(self, transport: _SyncTransport) -> None:
|
|
25
|
+
self._t = transport
|
|
26
|
+
|
|
27
|
+
def lookup(self, rule_id: str) -> dict[str, Any]:
|
|
28
|
+
return self._t.get(f"/v1/sigma/{_enc(rule_id)}")
|
|
29
|
+
|
|
30
|
+
def bulk(self, rule_ids: list[str]) -> dict[str, Any]:
|
|
31
|
+
return self._t.post("/v1/sigma/bulk", json_body={"rule_ids": _validate_rule_ids(rule_ids)})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncSigma:
|
|
35
|
+
def __init__(self, transport: _AsyncTransport) -> None:
|
|
36
|
+
self._t = transport
|
|
37
|
+
|
|
38
|
+
async def lookup(self, rule_id: str) -> dict[str, Any]:
|
|
39
|
+
return await self._t.aget(f"/v1/sigma/{_enc(rule_id)}")
|
|
40
|
+
|
|
41
|
+
async def bulk(self, rule_ids: list[str]) -> dict[str, Any]:
|
|
42
|
+
return await self._t.apost("/v1/sigma/bulk", json_body={"rule_ids": _validate_rule_ids(rule_ids)})
|
|
@@ -302,7 +302,7 @@ def test_d3fend_coverage_validation():
|
|
|
302
302
|
@respx.mock
|
|
303
303
|
def test_status_endpoint():
|
|
304
304
|
route = respx.get("https://api.contrastcyber.com/v1/status").mock(
|
|
305
|
-
return_value=httpx.Response(200, json={"status": "ok", "version": "1.22.
|
|
305
|
+
return_value=httpx.Response(200, json={"status": "ok", "version": "1.22.5"})
|
|
306
306
|
)
|
|
307
307
|
with ContrastAPI() as client:
|
|
308
308
|
result = client.status()
|
|
@@ -322,4 +322,4 @@ def test_usage_endpoint():
|
|
|
322
322
|
|
|
323
323
|
def test_client_exposes_version_string():
|
|
324
324
|
with ContrastAPI() as client:
|
|
325
|
-
assert client.version == "1.
|
|
325
|
+
assert client.version == "1.23.0"
|
|
@@ -22,7 +22,7 @@ from contrastapi import (
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def test_version_is_pinned():
|
|
25
|
-
assert __version__ == "1.
|
|
25
|
+
assert __version__ == "1.23.0"
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def test_default_base_url_is_https():
|
|
@@ -70,7 +70,7 @@ def test_get_returns_json_body():
|
|
|
70
70
|
@respx.mock
|
|
71
71
|
def test_user_agent_header_sent():
|
|
72
72
|
route = respx.get("https://api.contrastcyber.com/v1/status").mock(
|
|
73
|
-
return_value=httpx.Response(200, json={"status": "ok", "version": "1.22.
|
|
73
|
+
return_value=httpx.Response(200, json={"status": "ok", "version": "1.22.5"})
|
|
74
74
|
)
|
|
75
75
|
with ContrastAPI() as client:
|
|
76
76
|
client.status()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""v1.23.0 — tests for the 8 endpoints added for API-surface parity:
|
|
2
|
+
domain.robots / redirect / brand / seo, email.security_posture / verify,
|
|
3
|
+
sigma.lookup / bulk. Mirrors test_namespaces.py: URL construction + body shape,
|
|
4
|
+
sync + async parity samples.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import respx
|
|
11
|
+
from contrastapi import AsyncContrastAPI, ContrastAPI
|
|
12
|
+
|
|
13
|
+
BASE = "https://api.contrastcyber.com"
|
|
14
|
+
RULE_ID = "11111111-2222-3333-4444-555555555555"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- domain web-intel ---------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@respx.mock
|
|
21
|
+
def test_domain_robots_url():
|
|
22
|
+
route = respx.get(f"{BASE}/v1/robots/example.com").mock(
|
|
23
|
+
return_value=httpx.Response(200, json={"domain": "example.com"})
|
|
24
|
+
)
|
|
25
|
+
with ContrastAPI() as client:
|
|
26
|
+
client.domain.robots("example.com")
|
|
27
|
+
assert route.called
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@respx.mock
|
|
31
|
+
def test_domain_redirect_preserves_path_separators():
|
|
32
|
+
"""`/v1/redirect/{url:path}` — slashes in the URL survive the path encoder."""
|
|
33
|
+
target = "https://bit.ly/3xyz"
|
|
34
|
+
route = respx.get(url__regex=rf"{BASE}/v1/redirect/.+").mock(
|
|
35
|
+
return_value=httpx.Response(200, json={"hops": []})
|
|
36
|
+
)
|
|
37
|
+
with ContrastAPI() as client:
|
|
38
|
+
client.domain.redirect(target)
|
|
39
|
+
request_url = str(route.calls.last.request.url)
|
|
40
|
+
assert "bit.ly/3xyz" in request_url
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@respx.mock
|
|
44
|
+
def test_domain_brand_url():
|
|
45
|
+
route = respx.get(f"{BASE}/v1/brand/example.com").mock(
|
|
46
|
+
return_value=httpx.Response(200, json={"domain": "example.com"})
|
|
47
|
+
)
|
|
48
|
+
with ContrastAPI() as client:
|
|
49
|
+
client.domain.brand("example.com")
|
|
50
|
+
assert route.called
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@respx.mock
|
|
54
|
+
def test_domain_seo_url():
|
|
55
|
+
route = respx.get(f"{BASE}/v1/seo/example.com").mock(
|
|
56
|
+
return_value=httpx.Response(200, json={"domain": "example.com"})
|
|
57
|
+
)
|
|
58
|
+
with ContrastAPI() as client:
|
|
59
|
+
client.domain.seo("example.com")
|
|
60
|
+
assert route.called
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- email -------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@respx.mock
|
|
67
|
+
def test_email_security_posture_url():
|
|
68
|
+
route = respx.get(f"{BASE}/v1/email/security-posture/example.com").mock(
|
|
69
|
+
return_value=httpx.Response(200, json={"domain": "example.com"})
|
|
70
|
+
)
|
|
71
|
+
with ContrastAPI() as client:
|
|
72
|
+
client.email.security_posture("example.com")
|
|
73
|
+
assert route.called
|
|
74
|
+
assert dict(route.calls.last.request.url.params) == {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@respx.mock
|
|
78
|
+
def test_email_security_posture_selectors_query():
|
|
79
|
+
route = respx.get(f"{BASE}/v1/email/security-posture/example.com").mock(
|
|
80
|
+
return_value=httpx.Response(200, json={"domain": "example.com"})
|
|
81
|
+
)
|
|
82
|
+
with ContrastAPI() as client:
|
|
83
|
+
client.email.security_posture("example.com", selectors="selector1,selector2")
|
|
84
|
+
assert dict(route.calls.last.request.url.params) == {"selectors": "selector1,selector2"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@respx.mock
|
|
88
|
+
def test_email_verify_url():
|
|
89
|
+
route = respx.get(f"{BASE}/v1/email/verify/user%40example.com").mock(
|
|
90
|
+
return_value=httpx.Response(200, json={"email": "user@example.com"})
|
|
91
|
+
)
|
|
92
|
+
with ContrastAPI() as client:
|
|
93
|
+
client.email.verify("user@example.com")
|
|
94
|
+
assert route.called
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- sigma -------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@respx.mock
|
|
101
|
+
def test_sigma_lookup_url():
|
|
102
|
+
route = respx.get(f"{BASE}/v1/sigma/{RULE_ID}").mock(
|
|
103
|
+
return_value=httpx.Response(200, json={"rule": {}})
|
|
104
|
+
)
|
|
105
|
+
with ContrastAPI() as client:
|
|
106
|
+
client.sigma.lookup(RULE_ID)
|
|
107
|
+
assert route.called
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@respx.mock
|
|
111
|
+
def test_sigma_bulk_body():
|
|
112
|
+
route = respx.post(f"{BASE}/v1/sigma/bulk").mock(
|
|
113
|
+
return_value=httpx.Response(200, json={"items": []})
|
|
114
|
+
)
|
|
115
|
+
with ContrastAPI() as client:
|
|
116
|
+
client.sigma.bulk([RULE_ID, "bad-id"])
|
|
117
|
+
import json
|
|
118
|
+
|
|
119
|
+
assert json.loads(route.calls.last.request.content) == {"rule_ids": [RULE_ID, "bad-id"]}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- async parity ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@respx.mock
|
|
126
|
+
async def test_async_sigma_lookup_url():
|
|
127
|
+
route = respx.get(f"{BASE}/v1/sigma/{RULE_ID}").mock(
|
|
128
|
+
return_value=httpx.Response(200, json={"rule": {}})
|
|
129
|
+
)
|
|
130
|
+
async with AsyncContrastAPI() as client:
|
|
131
|
+
await client.sigma.lookup(RULE_ID)
|
|
132
|
+
assert route.called
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@respx.mock
|
|
136
|
+
async def test_async_domain_seo_url():
|
|
137
|
+
route = respx.get(f"{BASE}/v1/seo/example.com").mock(
|
|
138
|
+
return_value=httpx.Response(200, json={"domain": "example.com"})
|
|
139
|
+
)
|
|
140
|
+
async with AsyncContrastAPI() as client:
|
|
141
|
+
await client.domain.seo("example.com")
|
|
142
|
+
assert route.called
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@respx.mock
|
|
146
|
+
async def test_async_email_security_posture_url():
|
|
147
|
+
route = respx.get(f"{BASE}/v1/email/security-posture/example.com").mock(
|
|
148
|
+
return_value=httpx.Response(200, json={"domain": "example.com"})
|
|
149
|
+
)
|
|
150
|
+
async with AsyncContrastAPI() as client:
|
|
151
|
+
await client.email.security_posture("example.com")
|
|
152
|
+
assert route.called
|
contrastapi-1.22.4/.gitignore
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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*
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.22.3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|