domaintools-api-test-upload 2.2.1__tar.gz → 2.2.2__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 (42) hide show
  1. {domaintools_api_test_upload-2.2.1/domaintools_api_test_upload.egg-info → domaintools_api_test_upload-2.2.2}/PKG-INFO +39 -4
  2. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/README.md +38 -3
  3. domaintools_api_test_upload-2.2.2/VERSION +1 -0
  4. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/_version.py +1 -1
  5. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/api.py +10 -4
  6. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/base_results.py +35 -37
  7. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/api.py +7 -2
  8. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/utils.py +1 -0
  9. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/results.py +27 -0
  10. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/utils.py +6 -2
  11. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2/domaintools_api_test_upload.egg-info}/PKG-INFO +39 -4
  12. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools_async/__init__.py +4 -18
  13. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/setup.py +2 -1
  14. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/tests/test_api.py +115 -61
  15. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/tests/test_utils.py +52 -52
  16. domaintools_api_test_upload-2.2.1/VERSION +0 -1
  17. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/LICENSE +0 -0
  18. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/__init__.py +0 -0
  19. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/__init__.py +0 -0
  20. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/__init__.py +0 -0
  21. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/accounts.py +0 -0
  22. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/detects.py +0 -0
  23. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/domains.py +0 -0
  24. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/feeds.py +0 -0
  25. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/ips.py +0 -0
  26. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/iris.py +0 -0
  27. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/commands/phisheye.py +0 -0
  28. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/constants.py +0 -0
  29. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/cli/main.py +0 -0
  30. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/constants.py +0 -0
  31. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/exceptions.py +0 -0
  32. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools/filters.py +0 -0
  33. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools_api_test_upload.egg-info/SOURCES.txt +0 -0
  34. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools_api_test_upload.egg-info/dependency_links.txt +0 -0
  35. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools_api_test_upload.egg-info/entry_points.txt +0 -0
  36. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools_api_test_upload.egg-info/requires.txt +0 -0
  37. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/domaintools_api_test_upload.egg-info/top_level.txt +0 -0
  38. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/pyproject.toml +0 -0
  39. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/setup.cfg +0 -0
  40. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/tests/test_async.py +0 -0
  41. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/tests/test_cli.py +0 -0
  42. {domaintools_api_test_upload-2.2.1 → domaintools_api_test_upload-2.2.2}/tests/test_filters.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: domaintools_api_test_upload
3
- Version: 2.2.1
3
+ Version: 2.2.2
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -272,16 +272,51 @@ Real-Time Threat Intelligence Feeds provide data on the different stages of the
272
272
  Custom parameters aside from the common `GET` Request parameters:
273
273
  - `endpoint` (choose either `download` or `feed` API endpoint - default is `feed`)
274
274
  ```python
275
- api = API(USERNAME, KEY)
275
+ api = API(USERNAME, KEY, always_sign_api_key=False)
276
276
  api.nod(endpoint="feed", **kwargs)
277
277
  ```
278
278
  - `header_authentication`: by default, we're using API Header Authentication. Set this False if you want to use API Key and Secret Authentication. Apparently, you can't use API Header Authentication for `download` endpoints so you need to set this to `False` when calling `download` API endpoints.
279
279
  ```python
280
- api = API(USERNAME, KEY)
280
+ api = API(USERNAME, KEY, always_sign_api_key=False)
281
281
  api.nod(header_authentication=False, **kwargs)
282
282
  ```
283
283
  - `output_format`: (choose either `csv` or `jsonl` - default is `jsonl`). Cannot be used in `domainrdap` feeds. Additionally, `csv` is not available for `download` endpoints.
284
284
  ```python
285
- api = API(USERNAME, KEY)
285
+ api = API(USERNAME, KEY, always_sign_api_key=False)
286
286
  api.nod(output_format="csv", **kwargs)
287
287
  ```
288
+
289
+ The Feed API standard access pattern is to periodically request the most recent feed data, as often as every 60 seconds. Specify the range of data you receive in one of two ways:
290
+
291
+ 1. With `sessionID`: Make a call and provide a new `sessionID` parameter of your choosing. The API will return the last hour of data by default.
292
+ - Each subsequent call to the API using your `sessionID` will return all data since the last.
293
+ - Any single request returns a maximum of 10M results. Requests that exceed 10M results will return a HTTP 206 response code; repeat the same request (with the same `sessionID`) to receive the next tranche of data until receiving a HTTP 200 response code.
294
+ 2. Or, specify the time range in one of two ways:
295
+ - Either an `after=-60` query parameter, where (in this example) -60 indicates the previous 60 seconds.
296
+ - Or `after` and `before` query parameters for a time range, with each parameter accepting an ISO-8601 UTC formatted timestamp (a UTC date and time of the format YYYY-MM-DDThh:mm:ssZ)
297
+
298
+ ## Handling iterative response from RTUF endpoints:
299
+
300
+ Since we may dealing with large feeds datasets, the python wrapper uses `generator` for efficient memory handling. Therefore, we need to iterate through the `generator` if we're accessing the partial results of the feeds data.
301
+
302
+ ### Single request because the requested data is within the maximum result:
303
+ ```python
304
+ from domaintools import API
305
+
306
+ api = API(USERNAME, KEY, always_sign_api_key=False)
307
+ results = api.nod(sessionID="my-session-id", after=-60)
308
+
309
+ for result in results.response() # generator that holds NOD feeds data for the past 60 seconds and is expected to request only once
310
+ # do things to result
311
+ ```
312
+
313
+ ## Multiple requests because the requested data is more than the maximum result per request:
314
+ ```python
315
+ from domaintools import API
316
+
317
+ api = API(USERNAME, KEY, always_sign_api_key=False)
318
+ results = api.nod(sessionID="my-session-id", after=-7200)
319
+
320
+ for partial_result in results.response() # generator that holds NOD feeds data for the past 2 hours and is expected to request multiple times
321
+ # do things to partial_result
322
+ ```
@@ -223,16 +223,51 @@ Real-Time Threat Intelligence Feeds provide data on the different stages of the
223
223
  Custom parameters aside from the common `GET` Request parameters:
224
224
  - `endpoint` (choose either `download` or `feed` API endpoint - default is `feed`)
225
225
  ```python
226
- api = API(USERNAME, KEY)
226
+ api = API(USERNAME, KEY, always_sign_api_key=False)
227
227
  api.nod(endpoint="feed", **kwargs)
228
228
  ```
229
229
  - `header_authentication`: by default, we're using API Header Authentication. Set this False if you want to use API Key and Secret Authentication. Apparently, you can't use API Header Authentication for `download` endpoints so you need to set this to `False` when calling `download` API endpoints.
230
230
  ```python
231
- api = API(USERNAME, KEY)
231
+ api = API(USERNAME, KEY, always_sign_api_key=False)
232
232
  api.nod(header_authentication=False, **kwargs)
233
233
  ```
234
234
  - `output_format`: (choose either `csv` or `jsonl` - default is `jsonl`). Cannot be used in `domainrdap` feeds. Additionally, `csv` is not available for `download` endpoints.
235
235
  ```python
236
- api = API(USERNAME, KEY)
236
+ api = API(USERNAME, KEY, always_sign_api_key=False)
237
237
  api.nod(output_format="csv", **kwargs)
238
238
  ```
239
+
240
+ The Feed API standard access pattern is to periodically request the most recent feed data, as often as every 60 seconds. Specify the range of data you receive in one of two ways:
241
+
242
+ 1. With `sessionID`: Make a call and provide a new `sessionID` parameter of your choosing. The API will return the last hour of data by default.
243
+ - Each subsequent call to the API using your `sessionID` will return all data since the last.
244
+ - Any single request returns a maximum of 10M results. Requests that exceed 10M results will return a HTTP 206 response code; repeat the same request (with the same `sessionID`) to receive the next tranche of data until receiving a HTTP 200 response code.
245
+ 2. Or, specify the time range in one of two ways:
246
+ - Either an `after=-60` query parameter, where (in this example) -60 indicates the previous 60 seconds.
247
+ - Or `after` and `before` query parameters for a time range, with each parameter accepting an ISO-8601 UTC formatted timestamp (a UTC date and time of the format YYYY-MM-DDThh:mm:ssZ)
248
+
249
+ ## Handling iterative response from RTUF endpoints:
250
+
251
+ Since we may dealing with large feeds datasets, the python wrapper uses `generator` for efficient memory handling. Therefore, we need to iterate through the `generator` if we're accessing the partial results of the feeds data.
252
+
253
+ ### Single request because the requested data is within the maximum result:
254
+ ```python
255
+ from domaintools import API
256
+
257
+ api = API(USERNAME, KEY, always_sign_api_key=False)
258
+ results = api.nod(sessionID="my-session-id", after=-60)
259
+
260
+ for result in results.response() # generator that holds NOD feeds data for the past 60 seconds and is expected to request only once
261
+ # do things to result
262
+ ```
263
+
264
+ ## Multiple requests because the requested data is more than the maximum result per request:
265
+ ```python
266
+ from domaintools import API
267
+
268
+ api = API(USERNAME, KEY, always_sign_api_key=False)
269
+ results = api.nod(sessionID="my-session-id", after=-7200)
270
+
271
+ for partial_result in results.response() # generator that holds NOD feeds data for the past 2 hours and is expected to request multiple times
272
+ # do things to partial_result
273
+ ```
@@ -0,0 +1 @@
1
+ 2.2.2
@@ -20,4 +20,4 @@ OTHER DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  """
22
22
 
23
- current = "2.2.1"
23
+ current = "2.2.2"
@@ -1,6 +1,7 @@
1
1
  from datetime import datetime, timedelta, timezone
2
2
  from hashlib import sha1, sha256, md5
3
3
  from hmac import new as hmac
4
+
4
5
  import re
5
6
 
6
7
  from domaintools.constants import Endpoint, ENDPOINT_TO_SOURCE_MAP, FEEDS_PRODUCTS_LIST, OutputFormat
@@ -11,6 +12,7 @@ from domaintools.results import (
11
12
  ParsedDomainRdap,
12
13
  Reputation,
13
14
  Results,
15
+ FeedsResults,
14
16
  )
15
17
  from domaintools.filters import (
16
18
  filter_by_riskscore,
@@ -1065,7 +1067,7 @@ class API(object):
1065
1067
  **kwargs,
1066
1068
  )
1067
1069
 
1068
- def nod(self, **kwargs):
1070
+ def nod(self, **kwargs) -> FeedsResults:
1069
1071
  """Returns back list of the newly observed domains feed"""
1070
1072
  validate_feeds_parameters(kwargs)
1071
1073
  endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
@@ -1078,10 +1080,11 @@ class API(object):
1078
1080
  f"newly-observed-domains-feed-({source.value})",
1079
1081
  f"v1/{endpoint}/nod/",
1080
1082
  response_path=(),
1083
+ cls=FeedsResults,
1081
1084
  **kwargs,
1082
1085
  )
1083
1086
 
1084
- def nad(self, **kwargs):
1087
+ def nad(self, **kwargs) -> FeedsResults:
1085
1088
  """Returns back list of the newly active domains feed"""
1086
1089
  validate_feeds_parameters(kwargs)
1087
1090
  endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
@@ -1094,10 +1097,11 @@ class API(object):
1094
1097
  f"newly-active-domains-feed-({source})",
1095
1098
  f"v1/{endpoint}/nad/",
1096
1099
  response_path=(),
1100
+ cls=FeedsResults,
1097
1101
  **kwargs,
1098
1102
  )
1099
1103
 
1100
- def domainrdap(self, **kwargs):
1104
+ def domainrdap(self, **kwargs) -> FeedsResults:
1101
1105
  """Returns changes to global domain registration information, populated by the Registration Data Access Protocol (RDAP)"""
1102
1106
  validate_feeds_parameters(kwargs)
1103
1107
  endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
@@ -1107,10 +1111,11 @@ class API(object):
1107
1111
  f"domain-registration-data-access-protocol-feed-({source})",
1108
1112
  f"v1/{endpoint}/domainrdap/",
1109
1113
  response_path=(),
1114
+ cls=FeedsResults,
1110
1115
  **kwargs,
1111
1116
  )
1112
1117
 
1113
- def domaindiscovery(self, **kwargs):
1118
+ def domaindiscovery(self, **kwargs) -> FeedsResults:
1114
1119
  """Returns new domains as they are either discovered in domain registration information, observed by our global sensor network, or reported by trusted third parties"""
1115
1120
  validate_feeds_parameters(kwargs)
1116
1121
  endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
@@ -1123,5 +1128,6 @@ class API(object):
1123
1128
  f"real-time-domain-discovery-feed-({source})",
1124
1129
  f"v1/{endpoint}/domaindiscovery/",
1125
1130
  response_path=(),
1131
+ cls=FeedsResults,
1126
1132
  **kwargs,
1127
1133
  )
@@ -53,8 +53,6 @@ class Results(MutableMapping, MutableSequence):
53
53
  self._response = None
54
54
  self._items_list = None
55
55
  self._data = None
56
- self._limit_exceeded = None
57
- self._limit_exceeded_message = None
58
56
 
59
57
  def _wait_time(self):
60
58
  if not self.api.rate_limit or not self.product in self.api.limits:
@@ -77,6 +75,23 @@ class Results(MutableMapping, MutableSequence):
77
75
 
78
76
  return wait_for
79
77
 
78
+ def _get_session_params(self):
79
+ parameters = deepcopy(self.kwargs)
80
+ parameters.pop("output_format", None)
81
+ parameters.pop(
82
+ "format", None
83
+ ) # For some unknownn reasons, even if "format" is not included in the cli params for feeds endpoint, it is being populated thus we need to remove it. Happens only if using CLI.
84
+ headers = {}
85
+ if self.kwargs.get("output_format", OutputFormat.JSONL.value) == OutputFormat.CSV.value:
86
+ parameters["headers"] = int(bool(self.kwargs.get("headers", False)))
87
+ headers["accept"] = HEADER_ACCEPT_KEY_CSV_FORMAT
88
+
89
+ header_api_key = parameters.pop("X-Api-Key", None)
90
+ if header_api_key:
91
+ headers["X-Api-Key"] = header_api_key
92
+
93
+ return {"parameters": parameters, "headers": headers}
94
+
80
95
  def _make_request(self):
81
96
 
82
97
  with Client(verify=self.api.verify_ssl, proxy=self.api.proxy_url, timeout=None) as session:
@@ -93,20 +108,9 @@ class Results(MutableMapping, MutableSequence):
93
108
  patch_data.update(self.api.extra_request_params)
94
109
  return session.patch(url=self.url, json=patch_data)
95
110
  elif self.product in FEEDS_PRODUCTS_LIST:
96
- parameters = deepcopy(self.kwargs)
97
- parameters.pop("output_format", None)
98
- parameters.pop(
99
- "format", None
100
- ) # For some unknownn reasons, even if "format" is not included in the cli params for feeds endpoint, it is being populated thus we need to remove it. Happens only if using CLI.
101
- headers = {}
102
- if self.kwargs.get("output_format", OutputFormat.JSONL.value) == OutputFormat.CSV.value:
103
- parameters["headers"] = int(bool(self.kwargs.get("headers", False)))
104
- headers["accept"] = HEADER_ACCEPT_KEY_CSV_FORMAT
105
-
106
- header_api_key = parameters.pop("X-Api-Key", None)
107
- if header_api_key:
108
- headers["X-Api-Key"] = header_api_key
109
-
111
+ session_params = self._get_session_params()
112
+ parameters = session_params.get("parameters")
113
+ headers = session_params.get("headers")
110
114
  return session.get(url=self.url, params=parameters, headers=headers, **self.api.extra_request_params)
111
115
  else:
112
116
  return session.get(url=self.url, params=self.kwargs, **self.api.extra_request_params)
@@ -136,32 +140,26 @@ class Results(MutableMapping, MutableSequence):
136
140
  if self._data is None:
137
141
  results = self._get_results()
138
142
  self.setStatus(results.status_code, results)
139
- if (
140
- self.kwargs.get("format", "json") == "json"
141
- and self.product not in FEEDS_PRODUCTS_LIST # Special handling of feeds products' data to preserve the result in jsonline format
142
- ):
143
+ if self.kwargs.get("format", "json") == "json":
143
144
  self._data = results.json()
144
145
  else:
145
146
  self._data = results.text
146
- limit_exceeded, message = self.check_limit_exceeded()
147
147
 
148
- if limit_exceeded:
149
- self._limit_exceeded = True
150
- self._limit_exceeded_message = message
148
+ self.check_limit_exceeded()
151
149
 
152
- if self._limit_exceeded is True:
153
- raise ServiceException(503, "Limit Exceeded{}".format(self._limit_exceeded_message))
154
- else:
155
- return self._data
150
+ return self._data
156
151
 
157
152
  def check_limit_exceeded(self):
158
- if self.kwargs.get("format", "json") == "json" and self.product not in FEEDS_PRODUCTS_LIST:
159
- if "response" in self._data and "limit_exceeded" in self._data["response"] and self._data["response"]["limit_exceeded"] is True:
160
- return True, self._data["response"]["message"]
161
- # TODO: handle html, xml response errors better.
153
+ limit_exceeded, reason = False, ""
154
+ if isinstance(self._data, dict) and (
155
+ "response" in self._data and "limit_exceeded" in self._data["response"] and self._data["response"]["limit_exceeded"] is True
156
+ ):
157
+ limit_exceeded, reason = True, self._data["response"]["message"]
162
158
  elif "response" in self._data and "limit_exceeded" in self._data:
163
- return True, "limit exceeded"
164
- return False, ""
159
+ limit_exceeded = True
160
+
161
+ if limit_exceeded:
162
+ raise ServiceException(503, f"Limit Exceeded {reason}")
165
163
 
166
164
  @property
167
165
  def status(self):
@@ -172,7 +170,7 @@ class Results(MutableMapping, MutableSequence):
172
170
 
173
171
  def setStatus(self, code, response=None):
174
172
  self._status = code
175
- if code == 200:
173
+ if code == 200 or (self.product in FEEDS_PRODUCTS_LIST and code == 206):
176
174
  return
177
175
 
178
176
  reason = None
@@ -184,9 +182,9 @@ class Results(MutableMapping, MutableSequence):
184
182
  if callable(reason):
185
183
  reason = reason()
186
184
 
187
- if code == 400:
185
+ if code in (400, 422):
188
186
  raise BadRequestException(code, reason)
189
- elif code == 403:
187
+ elif code in (401, 403):
190
188
  raise NotAuthorizedException(code, reason)
191
189
  elif code == 404:
192
190
  raise NotFoundException(code, reason)
@@ -9,7 +9,7 @@ from typing import Optional, Dict, Tuple
9
9
  from rich.progress import Progress, SpinnerColumn, TextColumn
10
10
 
11
11
  from domaintools.api import API
12
- from domaintools.constants import Endpoint, OutputFormat
12
+ from domaintools.constants import Endpoint, FEEDS_PRODUCTS_LIST, OutputFormat
13
13
  from domaintools.cli.utils import get_file_extension
14
14
  from domaintools.exceptions import ServiceException
15
15
  from domaintools._version import current as version
@@ -110,6 +110,8 @@ class DTCLICommand:
110
110
  def _get_formatted_output(cls, cmd_name: str, response, out_format: str = "json"):
111
111
  if cmd_name in ("available_api_calls",):
112
112
  return "\n".join(response)
113
+ if response.product in FEEDS_PRODUCTS_LIST:
114
+ return "\n".join([data for data in response.response()])
113
115
  return str(getattr(response, out_format) if out_format != "list" else response.as_list())
114
116
 
115
117
  @classmethod
@@ -227,7 +229,10 @@ class DTCLICommand:
227
229
 
228
230
  if isinstance(out_file, _io.TextIOWrapper):
229
231
  # use rich `print` command to prettify the ouput in sys.stdout
230
- print(response)
232
+ if response.product in FEEDS_PRODUCTS_LIST:
233
+ print(output)
234
+ else:
235
+ print(response)
231
236
  else:
232
237
  # if it's a file then write
233
238
  out_file.write(output if output.endswith("\n") else output + "\n")
@@ -85,6 +85,7 @@ _command_help_mapper = {
85
85
  c.FEEDS_NAD: "Returns back newly active domains feed.",
86
86
  c.FEEDS_NOD: "Returns back newly observed domains feed.",
87
87
  c.FEEDS_DOMAINRDAP: "Returns changes to global domain registration information, populated by the Registration Data Access Protocol (RDAP).",
88
+ c.FEEDS_DOMAINDISCOVERY: "Returns new domains as they are either discovered in domain registration information, observed by our global sensor network, or reported by trusted third parties.",
88
89
  }
89
90
 
90
91
 
@@ -11,6 +11,8 @@ except ImportError: # pragma: no cover
11
11
  from ordereddict import OrderedDict
12
12
 
13
13
  from itertools import zip_longest
14
+ from typing import Generator
15
+
14
16
  from domaintools_async import AsyncResults as Results
15
17
 
16
18
 
@@ -141,3 +143,28 @@ class ParsedDomainRdap(Results):
141
143
  flat[f"contact_{contact_key}_{i}"] = " | ".join(contact_value) if type(contact_value) in (list, tuple) else contact_value
142
144
 
143
145
  return flat
146
+
147
+
148
+ class FeedsResults(Results):
149
+ """Returns the generator for feeds results"""
150
+
151
+ def response(self) -> Generator:
152
+ status_code = None
153
+ while status_code != 200:
154
+ resp_data = self.data()
155
+ status_code = self.status
156
+ yield resp_data
157
+
158
+ self._data = None # clear the data here
159
+ if not self.kwargs.get("sessionID"):
160
+ # we'll only do iterative request for queries that has sessionID.
161
+ # Otherwise, we will have an infinite request if sessionID was not provided but the required data asked is more than the maximum (1 hour of data)
162
+ break
163
+
164
+ def data(self):
165
+ results = self._get_results()
166
+ self.setStatus(results.status_code, results)
167
+ self._data = results.text
168
+ self.check_limit_exceeded()
169
+
170
+ return self._data
@@ -177,8 +177,12 @@ def validate_feeds_parameters(params):
177
177
  after = params.get("after")
178
178
  before = params.get("before")
179
179
  if not (sessionID or after or before):
180
- raise ValueError("sessionID or after or before must be defined")
180
+ raise ValueError("sessionID or after or before must be provided")
181
181
 
182
182
  format = params.get("output_format")
183
- if params.get("endpoint") == Endpoint.DOWNLOAD.value and format == OutputFormat.CSV.value:
183
+ endpoint = params.get("endpoint")
184
+ if endpoint == Endpoint.DOWNLOAD.value and format == OutputFormat.CSV.value:
184
185
  raise ValueError(f"{format} format is not available in {Endpoint.DOWNLOAD.value} API.")
186
+
187
+ if endpoint == Endpoint.DOWNLOAD.value and params.get("header_authentication", True):
188
+ raise ValueError(f"{Endpoint.DOWNLOAD.value} API does not support header authentication. Provide api_key in the parameter")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: domaintools_api_test_upload
3
- Version: 2.2.1
3
+ Version: 2.2.2
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -272,16 +272,51 @@ Real-Time Threat Intelligence Feeds provide data on the different stages of the
272
272
  Custom parameters aside from the common `GET` Request parameters:
273
273
  - `endpoint` (choose either `download` or `feed` API endpoint - default is `feed`)
274
274
  ```python
275
- api = API(USERNAME, KEY)
275
+ api = API(USERNAME, KEY, always_sign_api_key=False)
276
276
  api.nod(endpoint="feed", **kwargs)
277
277
  ```
278
278
  - `header_authentication`: by default, we're using API Header Authentication. Set this False if you want to use API Key and Secret Authentication. Apparently, you can't use API Header Authentication for `download` endpoints so you need to set this to `False` when calling `download` API endpoints.
279
279
  ```python
280
- api = API(USERNAME, KEY)
280
+ api = API(USERNAME, KEY, always_sign_api_key=False)
281
281
  api.nod(header_authentication=False, **kwargs)
282
282
  ```
283
283
  - `output_format`: (choose either `csv` or `jsonl` - default is `jsonl`). Cannot be used in `domainrdap` feeds. Additionally, `csv` is not available for `download` endpoints.
284
284
  ```python
285
- api = API(USERNAME, KEY)
285
+ api = API(USERNAME, KEY, always_sign_api_key=False)
286
286
  api.nod(output_format="csv", **kwargs)
287
287
  ```
288
+
289
+ The Feed API standard access pattern is to periodically request the most recent feed data, as often as every 60 seconds. Specify the range of data you receive in one of two ways:
290
+
291
+ 1. With `sessionID`: Make a call and provide a new `sessionID` parameter of your choosing. The API will return the last hour of data by default.
292
+ - Each subsequent call to the API using your `sessionID` will return all data since the last.
293
+ - Any single request returns a maximum of 10M results. Requests that exceed 10M results will return a HTTP 206 response code; repeat the same request (with the same `sessionID`) to receive the next tranche of data until receiving a HTTP 200 response code.
294
+ 2. Or, specify the time range in one of two ways:
295
+ - Either an `after=-60` query parameter, where (in this example) -60 indicates the previous 60 seconds.
296
+ - Or `after` and `before` query parameters for a time range, with each parameter accepting an ISO-8601 UTC formatted timestamp (a UTC date and time of the format YYYY-MM-DDThh:mm:ssZ)
297
+
298
+ ## Handling iterative response from RTUF endpoints:
299
+
300
+ Since we may dealing with large feeds datasets, the python wrapper uses `generator` for efficient memory handling. Therefore, we need to iterate through the `generator` if we're accessing the partial results of the feeds data.
301
+
302
+ ### Single request because the requested data is within the maximum result:
303
+ ```python
304
+ from domaintools import API
305
+
306
+ api = API(USERNAME, KEY, always_sign_api_key=False)
307
+ results = api.nod(sessionID="my-session-id", after=-60)
308
+
309
+ for result in results.response() # generator that holds NOD feeds data for the past 60 seconds and is expected to request only once
310
+ # do things to result
311
+ ```
312
+
313
+ ## Multiple requests because the requested data is more than the maximum result per request:
314
+ ```python
315
+ from domaintools import API
316
+
317
+ api = API(USERNAME, KEY, always_sign_api_key=False)
318
+ results = api.nod(sessionID="my-session-id", after=-7200)
319
+
320
+ for partial_result in results.response() # generator that holds NOD feeds data for the past 2 hours and is expected to request multiple times
321
+ # do things to partial_result
322
+ ```
@@ -52,20 +52,9 @@ class AsyncResults(Results):
52
52
  patch_data.update(self.api.extra_request_params)
53
53
  results = await session.patch(url=self.url, json=patch_data)
54
54
  elif self.product in FEEDS_PRODUCTS_LIST:
55
- parameters = deepcopy(self.kwargs)
56
- parameters.pop("output_format", None)
57
- parameters.pop(
58
- "format", None
59
- ) # For some unknownn reasons, even if "format" is not included in the cli params for feeds endpoint, it is being populated thus we need to remove it. Happens only if using CLI.
60
- headers = {}
61
- if self.kwargs.get("output_format", OutputFormat.JSONL.value) == OutputFormat.CSV.value:
62
- parameters["headers"] = int(bool(self.kwargs.get("headers", False)))
63
- headers["accept"] = HEADER_ACCEPT_KEY_CSV_FORMAT
64
-
65
- header_api_key = parameters.pop("X-Api-Key", None)
66
- if header_api_key:
67
- headers["X-Api-Key"] = header_api_key
68
-
55
+ session_params = self._get_session_params()
56
+ parameters = session_params.get("parameters")
57
+ headers = session_params.get("headers")
69
58
  results = await session.get(url=self.url, params=parameters, headers=headers, **self.api.extra_request_params)
70
59
  else:
71
60
  results = await session.get(url=self.url, params=self.kwargs, **self.api.extra_request_params)
@@ -75,11 +64,8 @@ class AsyncResults(Results):
75
64
  self._data = results.json()
76
65
  else:
77
66
  self._data = results.text()
78
- limit_exceeded, message = self.check_limit_exceeded()
79
67
 
80
- if limit_exceeded:
81
- self._limit_exceeded = True
82
- self._limit_exceeded_message = message
68
+ self.check_limit_exceeded()
83
69
 
84
70
  async def __awaitable__(self):
85
71
  if self._data is None:
@@ -37,7 +37,8 @@ if CYTHON:
37
37
  return module_names
38
38
 
39
39
  ext_modules = [
40
- Extension("domaintools." + ext, [path.join("domaintools", ext + ".py")]) for ext in list_modules(path.join(MYDIR, "domaintools"))
40
+ Extension("domaintools." + ext, [path.join("domaintools", ext + ".py")])
41
+ for ext in list_modules(path.join(MYDIR, "domaintools"))
41
42
  ]
42
43
  cmdclass["build_ext"] = build_ext
43
44
 
@@ -5,8 +5,10 @@ from os import environ
5
5
  import json
6
6
  import pytest
7
7
 
8
+ from inspect import isgenerator
9
+
8
10
  from domaintools import API, exceptions
9
- from tests.settings import api, vcr
11
+ from tests.settings import api, feeds_api, vcr
10
12
 
11
13
 
12
14
  @vcr.use_cassette
@@ -529,87 +531,139 @@ def test_limit_exceeded():
529
531
 
530
532
  @vcr.use_cassette
531
533
  def test_newly_observed_domains_feed():
532
- results = api.nod(after="-60")
533
- response = results.response()
534
- rows = response.strip().split("\n")
534
+ results = feeds_api.nod(after="-60", header_authentication=False)
535
+ for response in results.response():
536
+ assert results.status == 200
537
+
538
+ rows = response.strip().split("\n")
539
+ assert response is not None
540
+ assert len(rows) >= 1
541
+
542
+ for row in rows:
543
+ feed_result = json.loads(row)
544
+ assert "timestamp" in feed_result.keys()
545
+ assert "domain" in feed_result.keys()
546
+
547
+
548
+ @vcr.use_cassette
549
+ def test_newly_observed_domains_feed_pagination():
550
+ results = feeds_api.nod(sessionID="integrations-testing", after="2025-01-16T10:20:00Z")
551
+ page_count = 0
552
+ for response in results.response():
553
+ rows = response.strip().split("\n")
554
+ assert response is not None
555
+ assert len(rows) >= 1
556
+
557
+ page_count += 1
535
558
 
536
- assert response is not None
537
- assert results.status == 200
538
- assert len(rows) >= 1
559
+ for row in rows:
560
+ feed_result = json.loads(row)
561
+ assert "timestamp" in feed_result.keys()
562
+ assert "domain" in feed_result.keys()
539
563
 
540
- for row in rows:
541
- feed_result = json.loads(row)
542
- assert "timestamp" in feed_result.keys()
543
- assert "domain" in feed_result.keys()
564
+ assert page_count > 1
544
565
 
545
566
 
546
567
  @vcr.use_cassette
547
568
  def test_newly_active_domains_feed():
548
- results = api.nad(after="-60")
549
- response = results.response()
550
- rows = response.strip().split("\n")
569
+ results = feeds_api.nad(after="-60", header_authentication=False)
570
+ for response in results.response():
571
+ assert results.status == 200
551
572
 
552
- assert response is not None
553
- assert results.status == 200
554
- assert len(rows) >= 1
573
+ rows = response.strip().split("\n")
574
+ assert response is not None
575
+ assert len(rows) >= 1
555
576
 
556
- for row in rows:
557
- feed_result = json.loads(row)
558
- assert "timestamp" in feed_result.keys()
559
- assert "domain" in feed_result.keys()
577
+ for row in rows:
578
+ feed_result = json.loads(row)
579
+ assert "timestamp" in feed_result.keys()
580
+ assert "domain" in feed_result.keys()
560
581
 
561
582
 
562
583
  @vcr.use_cassette
563
584
  def test_domainrdap_feed():
564
- results = api.domainrdap(after="-60", sessiondID="integrations-testing", top=5)
565
- response = results.response()
566
- rows = response.strip().split("\n")
585
+ results = feeds_api.domainrdap(after="-60", top=2, header_authenticationn=False)
586
+ for response in results.response():
587
+ assert results.status == 200
567
588
 
568
- assert response is not None
569
- assert results.status == 200
570
- assert len(rows) == 5
589
+ rows = response.strip().split("\n")
571
590
 
572
- for row in rows:
573
- feed_result = json.loads(row)
574
- assert "timestamp" in feed_result.keys()
575
- assert "domain" in feed_result.keys()
576
- assert "parsed_record" in feed_result.keys()
577
- assert "domain" in feed_result["parsed_record"]["parsed_fields"]
578
- assert "emails" in feed_result["parsed_record"]["parsed_fields"]
579
- assert "contacts" in feed_result["parsed_record"]["parsed_fields"]
591
+ assert response is not None
592
+ assert len(rows) == 2
593
+
594
+ for row in rows:
595
+ feed_result = json.loads(row)
596
+ assert "timestamp" in feed_result.keys()
597
+ assert "domain" in feed_result.keys()
598
+ assert "parsed_record" in feed_result.keys()
599
+ assert "domain" in feed_result["parsed_record"]["parsed_fields"]
600
+ assert "emails" in feed_result["parsed_record"]["parsed_fields"]
601
+ assert "contacts" in feed_result["parsed_record"]["parsed_fields"]
580
602
 
581
603
 
582
604
  @vcr.use_cassette
583
605
  def test_domain_discovery_feed():
584
- results = api.domaindiscovery(after="-60")
585
- response = results.response()
586
- rows = response.strip().split("\n")
606
+ results = feeds_api.domaindiscovery(after="-60", header_authentication=False)
607
+ for response in results.response():
608
+ assert results.status == 200
587
609
 
588
- assert response is not None
589
- assert results.status == 200
590
- assert len(rows) >= 1
610
+ rows = response.strip().split("\n")
611
+ assert response is not None
612
+ assert len(rows) >= 1
591
613
 
592
- for row in rows:
593
- feed_result = json.loads(row)
594
- assert "timestamp" in feed_result.keys()
595
- assert "domain" in feed_result.keys()
614
+ for row in rows:
615
+ feed_result = json.loads(row)
616
+ assert "timestamp" in feed_result.keys()
617
+ assert "domain" in feed_result.keys()
596
618
 
597
619
 
598
620
  @vcr.use_cassette
599
621
  def test_domainrdap_feed_not_api_header_auth():
600
- results = api.domainrdap(after="-60", sessiondID="integrations-testing", top=5, header_authentication=False)
601
- response = results.response()
602
- rows = response.strip().split("\n")
603
-
604
- assert response is not None
605
- assert results.status == 200
606
- assert len(rows) == 5
607
-
608
- for row in rows:
609
- feed_result = json.loads(row)
610
- assert "timestamp" in feed_result.keys()
611
- assert "domain" in feed_result.keys()
612
- assert "parsed_record" in feed_result.keys()
613
- assert "domain" in feed_result["parsed_record"]["parsed_fields"]
614
- assert "emails" in feed_result["parsed_record"]["parsed_fields"]
615
- assert "contacts" in feed_result["parsed_record"]["parsed_fields"]
622
+ results = feeds_api.domainrdap(after="-60", sessiondID="integrations-testing", top=5, header_authenticationn=False)
623
+ for response in results.response():
624
+ assert results.status == 200
625
+
626
+ rows = response.strip().split("\n")
627
+
628
+ assert response is not None
629
+ assert len(rows) == 5
630
+
631
+ for row in rows:
632
+ feed_result = json.loads(row)
633
+ assert "timestamp" in feed_result.keys()
634
+ assert "domain" in feed_result.keys()
635
+ assert "parsed_record" in feed_result.keys()
636
+ assert "domain" in feed_result["parsed_record"]["parsed_fields"]
637
+ assert "emails" in feed_result["parsed_record"]["parsed_fields"]
638
+ assert "contacts" in feed_result["parsed_record"]["parsed_fields"]
639
+
640
+
641
+ @vcr.use_cassette
642
+ def test_verify_response_is_a_generator():
643
+ results = feeds_api.domaindiscovery(after="-60", header_authenticationn=False)
644
+
645
+ assert isgenerator(results.response())
646
+
647
+
648
+ @vcr.use_cassette
649
+ def test_feeds_endpoint_should_raise_error_if_download_api_using_header_auth():
650
+ with pytest.raises(ValueError) as excinfo:
651
+ feeds_api.domaindiscovery(after="-60", endpoint="download")
652
+
653
+ assert str(excinfo.value) == "download API does not support header authentication. Provide api_key in the parameter"
654
+
655
+
656
+ @vcr.use_cassette
657
+ def test_feeds_endpoint_should_raise_error_if_no_required_params():
658
+ with pytest.raises(ValueError) as excinfo:
659
+ feeds_api.domaindiscovery()
660
+
661
+ assert str(excinfo.value) == "sessionID or after or before must be provided"
662
+
663
+
664
+ @vcr.use_cassette
665
+ def test_feeds_endpoint_should_raise_error_if_asked_csv_format_for_download_api():
666
+ with pytest.raises(ValueError) as excinfo:
667
+ feeds_api.domaindiscovery(after="-60", output_format="csv", endpoint="download")
668
+
669
+ assert str(excinfo.value) == "csv format is not available in download API."
@@ -1,4 +1,6 @@
1
1
  import json
2
+ import pytest
3
+
2
4
  from datetime import datetime, timedelta
3
5
 
4
6
  from tests.responses import iris_investigate_data
@@ -15,24 +17,18 @@ def test_get_domain_age():
15
17
  def test_get_threat_component():
16
18
  threat_components = [
17
19
  {"name": "threat_profile_malware", "risk_score": 31},
18
- {"name": "threat_profile_spam", "risk_score": 73, "threats": ["spam"]}
20
+ {"name": "threat_profile_spam", "risk_score": 73, "threats": ["spam"]},
19
21
  ]
20
22
  result = utils.get_threat_component(threat_components, "threat_profile_malware")
21
23
  assert result.get("risk_score") == 31
22
24
 
23
25
 
24
26
  def test_investigate_average_risk_score():
25
- domains = [
26
- {"domain_risk": {"risk_score": 25}},
27
- {"domain_risk": {"risk_score": 27}}
28
- ]
27
+ domains = [{"domain_risk": {"risk_score": 25}}, {"domain_risk": {"risk_score": 27}}]
29
28
  result = utils.get_average_risk_score(domains)
30
29
  assert result == 26
31
30
 
32
- domains = [
33
- {"domain_risk": {"risk_score": 25}},
34
- {}
35
- ]
31
+ domains = [{"domain_risk": {"risk_score": 25}}, {}]
36
32
  result = utils.get_average_risk_score(domains)
37
33
  assert result == 25
38
34
 
@@ -42,17 +38,11 @@ def test_investigate_average_risk_score():
42
38
 
43
39
 
44
40
  def test_detect_average_risk_score():
45
- domains = [
46
- {"risk_score": 25},
47
- {"risk_score": 27}
48
- ]
41
+ domains = [{"risk_score": 25}, {"risk_score": 27}]
49
42
  result = utils.get_average_risk_score(domains)
50
43
  assert result == 26
51
44
 
52
- domains = [
53
- {"risk_score": 25},
54
- {"risk_score": None}
55
- ]
45
+ domains = [{"risk_score": 25}, {"risk_score": None}]
56
46
  result = utils.get_average_risk_score(domains)
57
47
  assert result == 25
58
48
 
@@ -65,24 +55,15 @@ def test_investigate_average_age():
65
55
  two_days_ago = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")
66
56
  five_days_ago = (datetime.now() - timedelta(days=5)).strftime("%Y-%m-%d")
67
57
 
68
- domains = [
69
- {"create_date": {"value": two_days_ago}},
70
- {"create_date": {"value": five_days_ago}}
71
- ]
58
+ domains = [{"create_date": {"value": two_days_ago}}, {"create_date": {"value": five_days_ago}}]
72
59
  result = utils.get_average_age(domains)
73
60
  assert result == 3
74
61
 
75
- domains = [
76
- {"create_date": {"value": two_days_ago}},
77
- {}
78
- ]
62
+ domains = [{"create_date": {"value": two_days_ago}}, {}]
79
63
  result = utils.get_average_age(domains)
80
64
  assert result == 2
81
65
 
82
- domains = [
83
- {"create_date": {"value": two_days_ago}},
84
- {"create_date": {"value": ""}}
85
- ]
66
+ domains = [{"create_date": {"value": two_days_ago}}, {"create_date": {"value": ""}}]
86
67
  result = utils.get_average_age(domains)
87
68
  assert result == 2
88
69
 
@@ -94,17 +75,11 @@ def test_investigate_average_age():
94
75
  def test_detect_average_age():
95
76
  two_days_ago = int((datetime.now() - timedelta(days=2)).strftime("%Y%m%d"))
96
77
  five_days_ago = int((datetime.now() - timedelta(days=5)).strftime("%Y%m%d"))
97
- domains = [
98
- {"create_date": two_days_ago},
99
- {"create_date": five_days_ago}
100
- ]
78
+ domains = [{"create_date": two_days_ago}, {"create_date": five_days_ago}]
101
79
  result = utils.get_average_age(domains)
102
80
  assert result == 3
103
81
 
104
- domains = [
105
- {"create_date": two_days_ago},
106
- {"create_date": None}
107
- ]
82
+ domains = [{"create_date": two_days_ago}, {"create_date": None}]
108
83
  result = utils.get_average_age(domains)
109
84
  assert result == 2
110
85
 
@@ -121,26 +96,51 @@ def test_data_prune():
121
96
 
122
97
  def test_find_emails():
123
98
  emails = utils.find_emails(json.dumps(iris_investigate_data.domaintools()))
124
- assert emails == {'abuse@enom.com', 'hostmaster@nsone.net'}
99
+ assert emails == {"abuse@enom.com", "hostmaster@nsone.net"}
125
100
 
126
101
 
127
102
  def test_find_ips():
128
103
  ips = utils.find_ips(json.dumps(iris_investigate_data.domaintools()))
129
- assert ips == {'142.250.115.26',
130
- '142.250.141.27',
131
- '198.51.44.4',
132
- '198.51.44.68',
133
- '198.51.45.4',
134
- '198.51.45.68',
135
- '199.30.228.112',
136
- '64.233.171.26',
137
- '74.125.142.26'}
104
+ assert ips == {
105
+ "142.250.115.26",
106
+ "142.250.141.27",
107
+ "198.51.44.4",
108
+ "198.51.44.68",
109
+ "198.51.45.4",
110
+ "198.51.45.68",
111
+ "199.30.228.112",
112
+ "64.233.171.26",
113
+ "74.125.142.26",
114
+ }
115
+
138
116
 
139
117
  def test_get_pivots():
140
118
  pivots = utils.get_pivots(iris_investigate_data.domaintools().get("results"), "")
141
- assert pivots == [
142
- ['IP ADDRESS', ('199.30.228.112', 4)],
143
- ['IP ASN', (17318, 111)],
144
- ['IP ISP', ('DomainTools LLC', 222)]
145
- ]
119
+ assert pivots == [["IP ADDRESS", ("199.30.228.112", 4)], ["IP ASN", (17318, 111)], ["IP ISP", ("DomainTools LLC", 222)]]
120
+
121
+
122
+ def test_validate_feeds_parameters_should_raise_error_if_download_api_using_header_auth(test_feeds_params):
123
+ test_feeds_params["output_format"] = "jsonl"
124
+
125
+ with pytest.raises(ValueError) as excinfo:
126
+ utils.validate_feeds_parameters(test_feeds_params)
127
+
128
+ assert str(excinfo.value) == "download API does not support header authentication. Provide api_key in the parameter"
129
+
130
+
131
+ def test_validate_feeds_parameters_should_raise_error_if_no_required_params(test_feeds_params):
132
+ test_feeds_params.pop("sessionID", None)
133
+ test_feeds_params.pop("after", None)
134
+ test_feeds_params.pop("before", None)
135
+
136
+ with pytest.raises(ValueError) as excinfo:
137
+ utils.validate_feeds_parameters(test_feeds_params)
138
+
139
+ assert str(excinfo.value) == "sessionID or after or before must be provided"
140
+
141
+
142
+ def test_validate_feeds_parameters_should_raise_error_if_asked_csv_format_for_download_api(test_feeds_params):
143
+ with pytest.raises(ValueError) as excinfo:
144
+ utils.validate_feeds_parameters(test_feeds_params)
146
145
 
146
+ assert str(excinfo.value) == "csv format is not available in download API."
@@ -1 +0,0 @@
1
- 2.2.1