techrecon 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- techrecon/__init__.py +41 -0
- techrecon/_transport.py +51 -0
- techrecon/client.py +473 -0
- techrecon/exceptions.py +98 -0
- techrecon/models.py +482 -0
- techrecon-0.1.0.dist-info/METADATA +232 -0
- techrecon-0.1.0.dist-info/RECORD +9 -0
- techrecon-0.1.0.dist-info/WHEEL +5 -0
- techrecon-0.1.0.dist-info/top_level.txt +1 -0
techrecon/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Python client for the TechRecon JA4 + web-crawl technology intelligence API.
|
|
2
|
+
|
|
3
|
+
from techrecon import TechRecon
|
|
4
|
+
|
|
5
|
+
client = TechRecon(api_key="trk_live_...")
|
|
6
|
+
result = client.lookup("t13d1516h2_8daaf6152771_b0da82dd1658")
|
|
7
|
+
|
|
8
|
+
See README.md for a full quickstart and https://api.techrecon.io/v1/docs for
|
|
9
|
+
the interactive API reference.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .client import TechRecon
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
APIError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
BadRequestError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
ForbiddenError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PayloadTooLargeError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
ServiceUnavailableError,
|
|
23
|
+
TechReconError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"TechRecon",
|
|
30
|
+
"TechReconError",
|
|
31
|
+
"APIError",
|
|
32
|
+
"AuthenticationError",
|
|
33
|
+
"ForbiddenError",
|
|
34
|
+
"NotFoundError",
|
|
35
|
+
"BadRequestError",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"PayloadTooLargeError",
|
|
38
|
+
"ServiceUnavailableError",
|
|
39
|
+
"ConflictError",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
techrecon/_transport.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Stdlib-only HTTP transport used by :class:`techrecon.client.TechRecon`.
|
|
2
|
+
|
|
3
|
+
The client depends on this module through a small protocol (any object with
|
|
4
|
+
a ``request`` method matching :meth:`Transport.request`), so tests can pass
|
|
5
|
+
a fake/mock transport instead of hitting the network. The default
|
|
6
|
+
:class:`Transport` uses only ``urllib.request`` from the standard library —
|
|
7
|
+
no third-party HTTP library is required to install or use this package.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Dict, NamedTuple, Optional
|
|
14
|
+
from urllib import error as urllib_error
|
|
15
|
+
from urllib import request as urllib_request
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HTTPResponse(NamedTuple):
|
|
19
|
+
"""The minimal response shape the client needs from a transport."""
|
|
20
|
+
|
|
21
|
+
status_code: int
|
|
22
|
+
body: bytes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Transport:
|
|
26
|
+
"""Default transport: issues requests with ``urllib.request``."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, timeout: float = 30.0) -> None:
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
|
|
31
|
+
def request(
|
|
32
|
+
self,
|
|
33
|
+
method: str,
|
|
34
|
+
url: str,
|
|
35
|
+
headers: Dict[str, str],
|
|
36
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
37
|
+
) -> HTTPResponse:
|
|
38
|
+
data = None
|
|
39
|
+
if json_body is not None:
|
|
40
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
41
|
+
|
|
42
|
+
req = urllib_request.Request(url, data=data, headers=headers, method=method)
|
|
43
|
+
try:
|
|
44
|
+
with urllib_request.urlopen(req, timeout=self.timeout) as resp:
|
|
45
|
+
return HTTPResponse(status_code=resp.status, body=resp.read())
|
|
46
|
+
except urllib_error.HTTPError as exc:
|
|
47
|
+
# HTTPError is also a valid response with a body; urlopen raises
|
|
48
|
+
# it instead of returning it for non-2xx statuses.
|
|
49
|
+
return HTTPResponse(status_code=exc.code, body=exc.read())
|
|
50
|
+
except urllib_error.URLError as exc:
|
|
51
|
+
raise ConnectionError(f"failed to reach {url}: {exc.reason}") from exc
|
techrecon/client.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Synchronous client for the TechRecon API.
|
|
2
|
+
|
|
3
|
+
Generated against docs/openapi.yaml (spec version 1.1.0). Covers the stable,
|
|
4
|
+
public API surface — endpoints marked ``x-internal: true`` in the spec
|
|
5
|
+
(self-serve signup/email-verify, Stripe billing, and the privacy admin
|
|
6
|
+
routes) are intentionally out of scope for this client; they are beta,
|
|
7
|
+
admin-only, or superseded, per the spec's own descriptions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
from urllib.parse import quote, urlencode
|
|
15
|
+
|
|
16
|
+
from . import models
|
|
17
|
+
from ._transport import Transport
|
|
18
|
+
from .exceptions import TechReconError, error_for_status
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.techrecon.io"
|
|
21
|
+
USER_AGENT = "techrecon-python/0.1.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TechRecon:
|
|
25
|
+
"""Client for the TechRecon JA4 + web-crawl technology intelligence API.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
api_key: Your TechRecon API key. Sent as the ``X-API-Key`` header on
|
|
29
|
+
every request (the spec also accepts it as an ``Authorization:
|
|
30
|
+
Bearer`` token, but this client always uses ``X-API-Key``).
|
|
31
|
+
base_url: API origin. Defaults to the production server
|
|
32
|
+
(``https://api.techrecon.io``); pass
|
|
33
|
+
``base_url="http://localhost:8080"`` for local development.
|
|
34
|
+
timeout: Per-request timeout in seconds (default 30).
|
|
35
|
+
transport: Advanced/testing hook — an object implementing the same
|
|
36
|
+
``request(method, url, headers, json_body)`` interface as
|
|
37
|
+
:class:`techrecon._transport.Transport`. Defaults to a stdlib
|
|
38
|
+
``urllib``-based transport; not normally needed by callers.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
>>> client = TechRecon(api_key="trk_live_...")
|
|
42
|
+
>>> result = client.lookup("t13d1516h2_8daaf6152771_b0da82dd1658")
|
|
43
|
+
>>> result["ja4_tech_match"]["technology"]
|
|
44
|
+
'nginx'
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
api_key: str,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
timeout: float = 30.0,
|
|
52
|
+
transport: Optional[Transport] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise ValueError("api_key is required")
|
|
56
|
+
self.api_key = api_key
|
|
57
|
+
self.base_url = base_url.rstrip("/")
|
|
58
|
+
self._transport = transport if transport is not None else Transport(timeout=timeout)
|
|
59
|
+
|
|
60
|
+
# -- internals -----------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _headers(self) -> Dict[str, str]:
|
|
63
|
+
return {
|
|
64
|
+
"X-API-Key": self.api_key,
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"User-Agent": USER_AGENT,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def _request(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
query: Optional[Dict[str, Any]] = None,
|
|
75
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
76
|
+
) -> Any:
|
|
77
|
+
url = f"{self.base_url}{path}"
|
|
78
|
+
if query:
|
|
79
|
+
filtered = {k: v for k, v in query.items() if v is not None}
|
|
80
|
+
if filtered:
|
|
81
|
+
url = f"{url}?{urlencode(filtered)}"
|
|
82
|
+
|
|
83
|
+
response = self._transport.request(
|
|
84
|
+
method=method, url=url, headers=self._headers(), json_body=json_body
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if response.status_code >= 400:
|
|
88
|
+
message = _extract_error_message(response.body)
|
|
89
|
+
raise error_for_status(response.status_code, message, response.body.decode("utf-8", "replace"))
|
|
90
|
+
|
|
91
|
+
if not response.body:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return json.loads(response.body)
|
|
95
|
+
except json.JSONDecodeError as exc:
|
|
96
|
+
raise TechReconError(
|
|
97
|
+
f"could not decode JSON response from {method} {path}: {exc}"
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
# -- Health / meta ---------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def health(self) -> Dict[str, str]:
|
|
103
|
+
"""``GET /healthz`` — liveness probe. No auth required."""
|
|
104
|
+
return self._request("GET", "/healthz")
|
|
105
|
+
|
|
106
|
+
def readiness(self) -> Dict[str, Any]:
|
|
107
|
+
"""``GET /readyz`` — readiness probe (JA4 DB loaded, circuit breaker closed)."""
|
|
108
|
+
return self._request("GET", "/readyz")
|
|
109
|
+
|
|
110
|
+
# -- JA4 Lookup --------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def lookup(self, ja4_fingerprint: str, ip: Optional[str] = None) -> models.LookupResult:
|
|
113
|
+
"""``POST /v1/lookup`` — look up a single JA4 fingerprint hash."""
|
|
114
|
+
body: Dict[str, Any] = {"ja4_fingerprint": ja4_fingerprint}
|
|
115
|
+
if ip is not None:
|
|
116
|
+
body["ip"] = ip
|
|
117
|
+
return self._request("POST", "/v1/lookup", json_body=body)
|
|
118
|
+
|
|
119
|
+
def bulk_lookup(self, requests: List[Dict[str, str]]) -> models.BulkLookupResponse:
|
|
120
|
+
"""``POST /v1/bulk-lookup`` — look up up to 1,000 JA4 fingerprints.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
requests: Each item is a dict like ``{"ja4_fingerprint": "...",
|
|
124
|
+
"ip": "..."}`` (``ip`` optional).
|
|
125
|
+
"""
|
|
126
|
+
return self._request("POST", "/v1/bulk-lookup", json_body={"requests": requests})
|
|
127
|
+
|
|
128
|
+
def stats(self) -> models.JA4Stats:
|
|
129
|
+
"""``GET /v1/stats`` — JA4 database hit/miss statistics."""
|
|
130
|
+
return self._request("GET", "/v1/stats")
|
|
131
|
+
|
|
132
|
+
def top_unknown(self) -> Dict[str, Any]:
|
|
133
|
+
"""``GET /v1/unknown`` — top 10 unmatched JA4 fingerprints since startup."""
|
|
134
|
+
return self._request("GET", "/v1/unknown")
|
|
135
|
+
|
|
136
|
+
# -- Domain ------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def get_domain(self, domain: str) -> models.DomainResponse:
|
|
139
|
+
"""``GET /v1/domain/{domain}`` — technology + enrichment data for one domain."""
|
|
140
|
+
return self._request("GET", f"/v1/domain/{quote(domain, safe='')}")
|
|
141
|
+
|
|
142
|
+
def bulk_domain_lookup(self, domains: List[str]) -> models.BulkDomainResponse:
|
|
143
|
+
"""``POST /v1/domains/bulk`` — detections for up to 100 domains at once."""
|
|
144
|
+
return self._request("POST", "/v1/domains/bulk", json_body={"domains": domains})
|
|
145
|
+
|
|
146
|
+
def get_domain_history(
|
|
147
|
+
self, domain: str, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
148
|
+
) -> models.DomainHistoryResponse:
|
|
149
|
+
"""``GET /v1/domain/{domain}/history`` — active + removed-technology history."""
|
|
150
|
+
return self._request(
|
|
151
|
+
"GET",
|
|
152
|
+
f"/v1/domain/{quote(domain, safe='')}/history",
|
|
153
|
+
query={"limit": limit, "cursor": cursor},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def get_domain_changes(
|
|
157
|
+
self,
|
|
158
|
+
domain: str,
|
|
159
|
+
since: Optional[str] = None,
|
|
160
|
+
technology: Optional[str] = None,
|
|
161
|
+
type: Optional[str] = None,
|
|
162
|
+
limit: Optional[int] = None,
|
|
163
|
+
cursor: Optional[str] = None,
|
|
164
|
+
) -> models.DomainChangesPage:
|
|
165
|
+
"""``GET /v1/domain/{domain}/changes`` — change timeline for one domain."""
|
|
166
|
+
return self._request(
|
|
167
|
+
"GET",
|
|
168
|
+
f"/v1/domain/{quote(domain, safe='')}/changes",
|
|
169
|
+
query={
|
|
170
|
+
"since": since,
|
|
171
|
+
"technology": technology,
|
|
172
|
+
"type": type,
|
|
173
|
+
"limit": limit,
|
|
174
|
+
"cursor": cursor,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# -- Change feed ---------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def get_changes(
|
|
181
|
+
self,
|
|
182
|
+
since: str,
|
|
183
|
+
technology: Optional[str] = None,
|
|
184
|
+
type: Optional[str] = None,
|
|
185
|
+
domain: Optional[str] = None,
|
|
186
|
+
limit: Optional[int] = None,
|
|
187
|
+
cursor: Optional[str] = None,
|
|
188
|
+
) -> models.ChangesPage:
|
|
189
|
+
"""``GET /v1/changes`` — technology change feed since an RFC3339 timestamp (max 30 days)."""
|
|
190
|
+
return self._request(
|
|
191
|
+
"GET",
|
|
192
|
+
"/v1/changes",
|
|
193
|
+
query={
|
|
194
|
+
"since": since,
|
|
195
|
+
"technology": technology,
|
|
196
|
+
"type": type,
|
|
197
|
+
"domain": domain,
|
|
198
|
+
"limit": limit,
|
|
199
|
+
"cursor": cursor,
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# -- Search --------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def search(
|
|
206
|
+
self,
|
|
207
|
+
technology: Optional[str] = None,
|
|
208
|
+
category: Optional[str] = None,
|
|
209
|
+
cursor: Optional[str] = None,
|
|
210
|
+
limit: Optional[int] = None,
|
|
211
|
+
min_confidence: Optional[float] = None,
|
|
212
|
+
country: Optional[str] = None,
|
|
213
|
+
hosting_provider: Optional[str] = None,
|
|
214
|
+
min_rank: Optional[int] = None,
|
|
215
|
+
max_rank: Optional[int] = None,
|
|
216
|
+
min_security_score: Optional[int] = None,
|
|
217
|
+
) -> models.SearchResponse:
|
|
218
|
+
"""``GET /v1/search`` — search domains by technology or category.
|
|
219
|
+
|
|
220
|
+
At least one of ``technology`` or ``category`` is required by the API.
|
|
221
|
+
"""
|
|
222
|
+
return self._request(
|
|
223
|
+
"GET",
|
|
224
|
+
"/v1/search",
|
|
225
|
+
query={
|
|
226
|
+
"technology": technology,
|
|
227
|
+
"category": category,
|
|
228
|
+
"cursor": cursor,
|
|
229
|
+
"limit": limit,
|
|
230
|
+
"min_confidence": min_confidence,
|
|
231
|
+
"country": country,
|
|
232
|
+
"hosting_provider": hosting_provider,
|
|
233
|
+
"min_rank": min_rank,
|
|
234
|
+
"max_rank": max_rank,
|
|
235
|
+
"min_security_score": min_security_score,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# -- Technology stats / trends --------------------------------------------
|
|
240
|
+
|
|
241
|
+
def get_tech_stats(
|
|
242
|
+
self, category: Optional[str] = None, limit: Optional[int] = None
|
|
243
|
+
) -> models.TechStatsResponse:
|
|
244
|
+
"""``GET /v1/technologies/stats`` — aggregated technology usage counts."""
|
|
245
|
+
return self._request(
|
|
246
|
+
"GET", "/v1/technologies/stats", query={"category": category, "limit": limit}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def get_tech_trend(self, tech: str, period: Optional[str] = None) -> models.TechTrendResponse:
|
|
250
|
+
"""``GET /v1/technologies/{tech}/trend`` — daily adoption snapshots (e.g. ``period="90d"``)."""
|
|
251
|
+
return self._request(
|
|
252
|
+
"GET", f"/v1/technologies/{quote(tech, safe='')}/trend", query={"period": period}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# -- Exports ---------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def create_export(
|
|
258
|
+
self,
|
|
259
|
+
technology: str,
|
|
260
|
+
format: str = "csv",
|
|
261
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
262
|
+
) -> models.CreateExportResponse:
|
|
263
|
+
"""``POST /v1/exports`` — start an async bulk export; poll ``get_export_status``."""
|
|
264
|
+
body: Dict[str, Any] = {"technology": technology, "format": format}
|
|
265
|
+
if filters is not None:
|
|
266
|
+
body["filters"] = filters
|
|
267
|
+
return self._request("POST", "/v1/exports", json_body=body)
|
|
268
|
+
|
|
269
|
+
def get_export_status(self, export_id: str) -> models.ExportJob:
|
|
270
|
+
"""``GET /v1/exports/{id}`` — status/metadata of an export job."""
|
|
271
|
+
return self._request("GET", f"/v1/exports/{quote(export_id, safe='')}")
|
|
272
|
+
|
|
273
|
+
def download_export(self, export_id: str) -> bytes:
|
|
274
|
+
"""``GET /v1/exports/{id}/download`` — download a completed export's raw bytes.
|
|
275
|
+
|
|
276
|
+
Returns 409 (raised as :class:`techrecon.exceptions.ConflictError`) if
|
|
277
|
+
the job hasn't finished yet. The caller is responsible for decoding
|
|
278
|
+
the bytes as CSV or JSONL per the job's ``format``.
|
|
279
|
+
"""
|
|
280
|
+
url = f"{self.base_url}/v1/exports/{quote(export_id, safe='')}/download"
|
|
281
|
+
response = self._transport.request(method="GET", url=url, headers=self._headers())
|
|
282
|
+
if response.status_code >= 400:
|
|
283
|
+
message = _extract_error_message(response.body)
|
|
284
|
+
raise error_for_status(
|
|
285
|
+
response.status_code, message, response.body.decode("utf-8", "replace")
|
|
286
|
+
)
|
|
287
|
+
return response.body
|
|
288
|
+
|
|
289
|
+
# -- System monitoring -------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def get_system_stats(self) -> models.SystemStats:
|
|
292
|
+
"""``GET /v1/system/stats`` — PostgreSQL pool metrics, domain/change counters."""
|
|
293
|
+
return self._request("GET", "/v1/system/stats")
|
|
294
|
+
|
|
295
|
+
def get_system_health(self) -> models.HealthResponse:
|
|
296
|
+
"""``GET /v1/system/health`` — crawler + queue + storage connectivity status."""
|
|
297
|
+
return self._request("GET", "/v1/system/health")
|
|
298
|
+
|
|
299
|
+
# -- Ingestion (system identity only) -----------------------------------------
|
|
300
|
+
|
|
301
|
+
def ingest_crawl_results(self, results: List[Dict[str, Any]]) -> models.IngestResponse:
|
|
302
|
+
"""``POST /v1/crawl-results`` — ingest a batch of crawl results.
|
|
303
|
+
|
|
304
|
+
Restricted to the system identity (the crawler fleet's API key); an
|
|
305
|
+
ordinary tenant key gets ``403`` (raised as
|
|
306
|
+
:class:`techrecon.exceptions.ForbiddenError`).
|
|
307
|
+
"""
|
|
308
|
+
return self._request("POST", "/v1/crawl-results", json_body={"results": results})
|
|
309
|
+
|
|
310
|
+
# -- Crawl request queue -------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def create_crawl_request(self, domain: str) -> models.CreateCrawlRequestResponse:
|
|
313
|
+
"""``POST /v1/crawl-requests`` — queue a domain for crawling."""
|
|
314
|
+
return self._request("POST", "/v1/crawl-requests", json_body={"domain": domain})
|
|
315
|
+
|
|
316
|
+
def list_pending_crawl_requests(self) -> models.PendingCrawlRequestsResponse:
|
|
317
|
+
"""``GET /v1/crawl-requests/pending`` — up to 50 pending requests (system identity only)."""
|
|
318
|
+
return self._request("GET", "/v1/crawl-requests/pending")
|
|
319
|
+
|
|
320
|
+
def get_crawl_request_status(self, request_id: int) -> models.CrawlRequest:
|
|
321
|
+
"""``GET /v1/crawl-requests/{id}`` — status of a specific crawl request."""
|
|
322
|
+
return self._request("GET", f"/v1/crawl-requests/{request_id}")
|
|
323
|
+
|
|
324
|
+
# -- Priority crawl -------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
def submit_priority_crawl(
|
|
327
|
+
self,
|
|
328
|
+
ips: List[str],
|
|
329
|
+
consumer: str,
|
|
330
|
+
priority: Optional[str] = None,
|
|
331
|
+
callback_url: Optional[str] = None,
|
|
332
|
+
ttl_days: Optional[int] = None,
|
|
333
|
+
) -> models.PriorityCrawlSubmitResponse:
|
|
334
|
+
"""``POST /v1/priority-crawl/submit`` — push up to 50,000 IPs ahead of organic crawl."""
|
|
335
|
+
body: Dict[str, Any] = {"ips": ips, "consumer": consumer}
|
|
336
|
+
if priority is not None:
|
|
337
|
+
body["priority"] = priority
|
|
338
|
+
if callback_url is not None:
|
|
339
|
+
body["callback_url"] = callback_url
|
|
340
|
+
if ttl_days is not None:
|
|
341
|
+
body["ttl_days"] = ttl_days
|
|
342
|
+
return self._request("POST", "/v1/priority-crawl/submit", json_body=body)
|
|
343
|
+
|
|
344
|
+
def get_priority_crawl_status(self, batch_id: str) -> models.PriorityCrawlStatusResponse:
|
|
345
|
+
"""``GET /v1/priority-crawl/status/{batch_id}`` — live queued/in-progress/completed counts."""
|
|
346
|
+
return self._request("GET", f"/v1/priority-crawl/status/{quote(batch_id, safe='')}")
|
|
347
|
+
|
|
348
|
+
# -- IP-keyed reverse lookup -----------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def ip_batch_lookup(
|
|
351
|
+
self, ips: List[str], include_categories: Optional[List[str]] = None
|
|
352
|
+
) -> models.IPBatchResponse:
|
|
353
|
+
"""``POST /v1/ip/batch`` — up to 1,000 IPs; tech stacks observed at each."""
|
|
354
|
+
body: Dict[str, Any] = {"ips": ips}
|
|
355
|
+
if include_categories is not None:
|
|
356
|
+
body["include_categories"] = include_categories
|
|
357
|
+
return self._request("POST", "/v1/ip/batch", json_body=body)
|
|
358
|
+
|
|
359
|
+
# -- Fingerprint pivots ------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def pivot_ja4(
|
|
362
|
+
self, ja4_hash: str, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
363
|
+
) -> models.FingerprintPivotPage:
|
|
364
|
+
"""``GET /v1/pivot/ja4/{hash}`` — domains observed presenting a JA4 fingerprint."""
|
|
365
|
+
return self._request(
|
|
366
|
+
"GET",
|
|
367
|
+
f"/v1/pivot/ja4/{quote(ja4_hash, safe='')}",
|
|
368
|
+
query={"limit": limit, "cursor": cursor},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def pivot_jarm(
|
|
372
|
+
self, jarm_hash: str, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
373
|
+
) -> models.FingerprintPivotPage:
|
|
374
|
+
"""``GET /v1/pivot/jarm/{hash}`` — domains observed presenting a JARM fingerprint."""
|
|
375
|
+
return self._request(
|
|
376
|
+
"GET",
|
|
377
|
+
f"/v1/pivot/jarm/{quote(jarm_hash, safe='')}",
|
|
378
|
+
query={"limit": limit, "cursor": cursor},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def pivot_favicon(
|
|
382
|
+
self, favicon_hash: str, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
383
|
+
) -> models.FingerprintPivotPage:
|
|
384
|
+
"""``GET /v1/pivot/favicon/{hash}`` — domains observed serving a favicon (mmh3 hash)."""
|
|
385
|
+
return self._request(
|
|
386
|
+
"GET",
|
|
387
|
+
f"/v1/pivot/favicon/{quote(favicon_hash, safe='')}",
|
|
388
|
+
query={"limit": limit, "cursor": cursor},
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# -- Alerts -----------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def create_alert(
|
|
394
|
+
self,
|
|
395
|
+
channel: str,
|
|
396
|
+
endpoint: str,
|
|
397
|
+
domain: Optional[str] = None,
|
|
398
|
+
tech_filter: Optional[str] = None,
|
|
399
|
+
) -> models.Alert:
|
|
400
|
+
"""``POST /v1/alerts`` — create a webhook/Slack alert rule.
|
|
401
|
+
|
|
402
|
+
``domain`` defaults to ``"*"`` (all domains) and ``tech_filter``
|
|
403
|
+
defaults to ``"*"`` (any change) per the spec. Alert quota (50 free
|
|
404
|
+
tier / 5000 customer tier) is enforced per API key and returns
|
|
405
|
+
``429`` (raised as :class:`techrecon.exceptions.RateLimitError`).
|
|
406
|
+
"""
|
|
407
|
+
body: Dict[str, Any] = {"channel": channel, "endpoint": endpoint}
|
|
408
|
+
if domain is not None:
|
|
409
|
+
body["domain"] = domain
|
|
410
|
+
if tech_filter is not None:
|
|
411
|
+
body["tech_filter"] = tech_filter
|
|
412
|
+
return self._request("POST", "/v1/alerts", json_body=body)
|
|
413
|
+
|
|
414
|
+
def list_alerts(self) -> models.ListAlertsResponse:
|
|
415
|
+
"""``GET /v1/alerts`` — all alert rules owned by the authenticated account."""
|
|
416
|
+
return self._request("GET", "/v1/alerts")
|
|
417
|
+
|
|
418
|
+
def delete_alert(self, alert_id: str) -> None:
|
|
419
|
+
"""``DELETE /v1/alerts/{id}`` — delete an alert rule you own."""
|
|
420
|
+
self._request("DELETE", f"/v1/alerts/{quote(alert_id, safe='')}")
|
|
421
|
+
|
|
422
|
+
# -- Auth (session) -----------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def login(self) -> models.LoginResponse:
|
|
425
|
+
"""``POST /v1/auth/login`` — exchange this client's API key for a session cookie.
|
|
426
|
+
|
|
427
|
+
Note: this client is API-key-only and does not manage cookies; the
|
|
428
|
+
``Set-Cookie: tr_session=...`` response header is not captured here.
|
|
429
|
+
Provided for completeness/parity with the spec's public surface.
|
|
430
|
+
"""
|
|
431
|
+
return self._request("POST", "/v1/auth/login", json_body={"api_key": self.api_key})
|
|
432
|
+
|
|
433
|
+
def logout(self) -> None:
|
|
434
|
+
"""``POST /v1/auth/logout`` — revoke the current session. Idempotent."""
|
|
435
|
+
self._request("POST", "/v1/auth/logout")
|
|
436
|
+
|
|
437
|
+
# -- Account / API keys / usage -------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def get_account(self) -> models.Account:
|
|
440
|
+
"""``GET /v1/account`` — the caller's own account."""
|
|
441
|
+
return self._request("GET", "/v1/account")
|
|
442
|
+
|
|
443
|
+
def list_api_keys(self) -> models.ListAPIKeysResponse:
|
|
444
|
+
"""``GET /v1/account/keys`` — display-safe metadata for the caller's API keys."""
|
|
445
|
+
return self._request("GET", "/v1/account/keys")
|
|
446
|
+
|
|
447
|
+
def issue_api_key(self, label: Optional[str] = None) -> models.NewAPIKey:
|
|
448
|
+
"""``POST /v1/account/keys`` — mint a new API key; the raw key is returned once."""
|
|
449
|
+
body: Dict[str, Any] = {}
|
|
450
|
+
if label is not None:
|
|
451
|
+
body["label"] = label
|
|
452
|
+
return self._request("POST", "/v1/account/keys", json_body=body or None)
|
|
453
|
+
|
|
454
|
+
def revoke_api_key(self, key_id: str) -> None:
|
|
455
|
+
"""``DELETE /v1/account/keys/{key_id}`` — revoke one of the caller's own API keys."""
|
|
456
|
+
self._request("DELETE", f"/v1/account/keys/{quote(key_id, safe='')}")
|
|
457
|
+
|
|
458
|
+
def get_account_usage(self) -> models.AccountUsage:
|
|
459
|
+
"""``GET /v1/account/usage`` — current UTC-month usage against the plan's quota."""
|
|
460
|
+
return self._request("GET", "/v1/account/usage")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _extract_error_message(body: bytes) -> str:
|
|
464
|
+
"""Pull the ``error`` field out of an ErrorResponse body; fall back gracefully."""
|
|
465
|
+
if not body:
|
|
466
|
+
return "no error detail returned"
|
|
467
|
+
try:
|
|
468
|
+
parsed = json.loads(body)
|
|
469
|
+
except json.JSONDecodeError:
|
|
470
|
+
return body.decode("utf-8", "replace")
|
|
471
|
+
if isinstance(parsed, dict) and "error" in parsed:
|
|
472
|
+
return str(parsed["error"])
|
|
473
|
+
return body.decode("utf-8", "replace")
|
techrecon/exceptions.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Typed exceptions raised by the TechRecon client.
|
|
2
|
+
|
|
3
|
+
All API errors share the same JSON body shape defined by
|
|
4
|
+
``components/schemas/ErrorResponse`` in docs/openapi.yaml::
|
|
5
|
+
|
|
6
|
+
{"error": "<message>"}
|
|
7
|
+
|
|
8
|
+
:class:`APIError` carries the parsed message plus the raw HTTP status code
|
|
9
|
+
and response body so callers can inspect anything the typed subclasses don't
|
|
10
|
+
surface directly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TechReconError(Exception):
|
|
19
|
+
"""Base class for all errors raised by this package."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIError(TechReconError):
|
|
23
|
+
"""Raised when the API responds with a non-2xx status code.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
status_code: The HTTP status code returned by the server.
|
|
27
|
+
message: The ``error`` field from the JSON error body, if present.
|
|
28
|
+
body: The raw response body text (for debugging when the body isn't
|
|
29
|
+
valid JSON or doesn't match the expected error shape).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, status_code: int, message: str, body: Optional[str] = None
|
|
34
|
+
) -> None:
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.message = message
|
|
37
|
+
self.body = body
|
|
38
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthenticationError(APIError):
|
|
42
|
+
"""401 Unauthorized — missing or invalid API key."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ForbiddenError(APIError):
|
|
46
|
+
"""403 Forbidden — authenticated but not permitted for this endpoint.
|
|
47
|
+
|
|
48
|
+
Also raised for account-suspended responses (delinquent billing), which
|
|
49
|
+
the API returns as 403 on write/quota-bearing endpoints.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NotFoundError(APIError):
|
|
54
|
+
"""404 Not Found — the requested resource does not exist."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BadRequestError(APIError):
|
|
58
|
+
"""400 Bad Request — invalid request body or missing parameter."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RateLimitError(APIError):
|
|
62
|
+
"""429 Too Many Requests — rate limit or quota exceeded.
|
|
63
|
+
|
|
64
|
+
The spec uses 429 uniformly for both per-key rate limiting (e.g.
|
|
65
|
+
``/v1/ip/batch``) and quota exhaustion (e.g. alert quota on
|
|
66
|
+
``POST /v1/alerts``, API-key cap on ``POST /v1/account/keys``). There is
|
|
67
|
+
no 402 response anywhere in docs/openapi.yaml; 429 is the quota signal.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PayloadTooLargeError(APIError):
|
|
72
|
+
"""413 Payload Too Large — request body exceeds a size/count limit."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ServiceUnavailableError(APIError):
|
|
76
|
+
"""503 Service Unavailable — a required storage backend isn't configured."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConflictError(APIError):
|
|
80
|
+
"""409 Conflict — e.g. downloading an export that isn't finished yet."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_STATUS_TO_EXCEPTION = {
|
|
84
|
+
400: BadRequestError,
|
|
85
|
+
401: AuthenticationError,
|
|
86
|
+
403: ForbiddenError,
|
|
87
|
+
404: NotFoundError,
|
|
88
|
+
409: ConflictError,
|
|
89
|
+
413: PayloadTooLargeError,
|
|
90
|
+
429: RateLimitError,
|
|
91
|
+
503: ServiceUnavailableError,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def error_for_status(status_code: int, message: str, body: Optional[str] = None) -> APIError:
|
|
96
|
+
"""Return the typed exception instance appropriate for an HTTP status code."""
|
|
97
|
+
exc_cls = _STATUS_TO_EXCEPTION.get(status_code, APIError)
|
|
98
|
+
return exc_cls(status_code, message, body)
|