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.
Files changed (31) hide show
  1. contrastapi-1.23.0/.gitignore +34 -0
  2. {contrastapi-1.22.4 → contrastapi-1.23.0}/PKG-INFO +7 -6
  3. {contrastapi-1.22.4 → contrastapi-1.23.0}/README.md +6 -5
  4. contrastapi-1.23.0/contrastapi/_version.py +1 -0
  5. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/client.py +6 -3
  6. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/exceptions.py +2 -1
  7. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/models.py +57 -0
  8. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/cve.py +18 -0
  9. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/domain.py +30 -0
  10. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/intel.py +15 -0
  11. contrastapi-1.23.0/contrastapi/namespaces/sigma.py +42 -0
  12. {contrastapi-1.22.4 → contrastapi-1.23.0}/pyproject.toml +1 -1
  13. {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_namespaces.py +2 -2
  14. {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_transport.py +2 -2
  15. contrastapi-1.23.0/tests/test_v1_23_endpoints.py +152 -0
  16. contrastapi-1.22.4/.gitignore +0 -20
  17. contrastapi-1.22.4/contrastapi/_version.py +0 -1
  18. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/__init__.py +0 -0
  19. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/_transport.py +0 -0
  20. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/__init__.py +0 -0
  21. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/atlas.py +0 -0
  22. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/d3fend.py +0 -0
  23. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/ioc.py +0 -0
  24. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/namespaces/security.py +0 -0
  25. {contrastapi-1.22.4 → contrastapi-1.23.0}/contrastapi/shortcuts.py +0 -0
  26. {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/__init__.py +0 -0
  27. {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/conftest.py +0 -0
  28. {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_remaining_namespaces.py +0 -0
  29. {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_review_fixes.py +0 -0
  30. {contrastapi-1.22.4 → contrastapi-1.23.0}/tests/test_review_round2_fixes.py +0 -0
  31. {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.22.4
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
- 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).
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>") # 1000 req/hr (Pro tier)
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 | 14 (adds `username`) |
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
- 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).
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>") # 1000 req/hr (Pro tier)
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 | 14 (adds `username`) |
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 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.
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 != value or value in (float("inf"), float("-inf"))):
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)})
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "contrastapi"
7
- version = "1.22.4"
7
+ version = "1.23.0"
8
8
  description = "Official Python SDK for ContrastAPI — security intelligence for developers and AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.3"})
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.22.3"
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.22.3"
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.3"})
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
@@ -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"