domaintools-api 2.2.0__tar.gz → 2.4.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 (43) hide show
  1. {domaintools_api-2.2.0/domaintools_api.egg-info → domaintools_api-2.4.0}/PKG-INFO +63 -4
  2. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/README.md +59 -1
  3. domaintools_api-2.4.0/VERSION +1 -0
  4. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/_version.py +1 -1
  5. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/api.py +158 -56
  6. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/base_results.py +67 -26
  7. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/api.py +50 -25
  8. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/commands/__init__.py +0 -1
  9. domaintools_api-2.4.0/domaintools/cli/commands/feeds.py +396 -0
  10. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/constants.py +3 -4
  11. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/utils.py +3 -21
  12. domaintools_api-2.4.0/domaintools/constants.py +37 -0
  13. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/results.py +27 -0
  14. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/utils.py +21 -20
  15. {domaintools_api-2.2.0 → domaintools_api-2.4.0/domaintools_api.egg-info}/PKG-INFO +63 -4
  16. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools_api.egg-info/SOURCES.txt +1 -1
  17. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools_api.egg-info/requires.txt +1 -1
  18. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools_async/__init__.py +10 -6
  19. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/pyproject.toml +5 -1
  20. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/tests/test_api.py +164 -72
  21. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/tests/test_utils.py +54 -52
  22. domaintools_api-2.2.0/VERSION +0 -1
  23. domaintools_api-2.2.0/domaintools/cli/commands/feeds.py +0 -138
  24. domaintools_api-2.2.0/domaintools/cli/commands/phisheye.py +0 -100
  25. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/LICENSE +0 -0
  26. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/__init__.py +0 -0
  27. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/__init__.py +0 -0
  28. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/commands/accounts.py +0 -0
  29. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/commands/detects.py +0 -0
  30. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/commands/domains.py +0 -0
  31. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/commands/ips.py +0 -0
  32. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/commands/iris.py +0 -0
  33. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/cli/main.py +0 -0
  34. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/exceptions.py +0 -0
  35. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools/filters.py +0 -0
  36. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools_api.egg-info/dependency_links.txt +0 -0
  37. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools_api.egg-info/entry_points.txt +0 -0
  38. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/domaintools_api.egg-info/top_level.txt +0 -0
  39. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/setup.cfg +0 -0
  40. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/setup.py +0 -0
  41. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/tests/test_async.py +0 -0
  42. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/tests/test_cli.py +0 -0
  43. {domaintools_api-2.2.0 → domaintools_api-2.4.0}/tests/test_filters.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: domaintools_api
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -40,12 +40,13 @@ Classifier: Topic :: Utilities
40
40
  Requires-Python: >=3.9
41
41
  Description-Content-Type: text/markdown
42
42
  License-File: LICENSE
43
- Requires-Dist: httpx
43
+ Requires-Dist: httpx==0.28.1
44
44
  Requires-Dist: rich
45
45
  Requires-Dist: typer
46
46
  Provides-Extra: test
47
47
  Requires-Dist: pytest; extra == "test"
48
48
  Requires-Dist: mock; extra == "test"
49
+ Dynamic: license-file
49
50
 
50
51
  ![domaintools](https://github.com/DomainTools/python_api/raw/main/artwork/logo.png)
51
52
  ===================
@@ -260,5 +261,63 @@ API_KEY
260
261
  Python Version Support Policy
261
262
  ===================
262
263
 
263
- Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
264
+ Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
264
265
  for the DomainTools Python support policy.
266
+
267
+
268
+ Real-Time Threat Intelligence Feeds
269
+ ===================
270
+
271
+ Real-Time Threat Intelligence Feeds provide data on the different stages of the domain lifecycle: from first-observed in the wild, to newly re-activated after a period of quiet. Access current feed data in real-time or retrieve historical feed data through separate APIs.
272
+
273
+ Custom parameters aside from the common `GET` Request parameters:
274
+ - `endpoint` (choose either `download` or `feed` API endpoint - default is `feed`)
275
+ ```python
276
+ api = API(USERNAME, KEY, always_sign_api_key=False)
277
+ api.nod(endpoint="feed", **kwargs)
278
+ ```
279
+ - `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 this will be defaulted to `False` even without explicitly setting it.
280
+ ```python
281
+ api = API(USERNAME, KEY, always_sign_api_key=False)
282
+ api.nod(header_authentication=False, **kwargs)
283
+ ```
284
+ - `output_format`: (choose either `csv` or `jsonl` - default is `jsonl`). Cannot be used in `domainrdap` feeds. Additionally, `csv` is not available for `download` endpoints.
285
+ ```python
286
+ api = API(USERNAME, KEY, always_sign_api_key=False)
287
+ api.nod(output_format="csv", **kwargs)
288
+ ```
289
+
290
+ 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:
291
+
292
+ 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.
293
+ - Each subsequent call to the API using your `sessionID` will return all data since the last.
294
+ - 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.
295
+ 2. Or, specify the time range in one of two ways:
296
+ - Either an `after=-60` query parameter, where (in this example) -60 indicates the previous 60 seconds.
297
+ - 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)
298
+
299
+ ## Handling iterative response from RTUF endpoints:
300
+
301
+ 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.
302
+
303
+ ### Single request because the requested data is within the maximum result:
304
+ ```python
305
+ from domaintools import API
306
+
307
+ api = API(USERNAME, KEY, always_sign_api_key=False)
308
+ results = api.nod(sessionID="my-session-id", after=-60)
309
+
310
+ for result in results.response() # generator that holds NOD feeds data for the past 60 seconds and is expected to request only once
311
+ # do things to result
312
+ ```
313
+
314
+ ## Multiple requests because the requested data is more than the maximum result per request:
315
+ ```python
316
+ from domaintools import API
317
+
318
+ api = API(USERNAME, KEY, always_sign_api_key=False)
319
+ results = api.nod(sessionID="my-session-id", after=-7200)
320
+
321
+ for partial_result in results.response() # generator that holds NOD feeds data for the past 2 hours and is expected to request multiple times
322
+ # do things to partial_result
323
+ ```
@@ -211,5 +211,63 @@ API_KEY
211
211
  Python Version Support Policy
212
212
  ===================
213
213
 
214
- Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
214
+ Please see the [supported versions](https://github.com/DomainTools/python_api/raw/main/PYTHON_SUPPORT.md) document
215
215
  for the DomainTools Python support policy.
216
+
217
+
218
+ Real-Time Threat Intelligence Feeds
219
+ ===================
220
+
221
+ Real-Time Threat Intelligence Feeds provide data on the different stages of the domain lifecycle: from first-observed in the wild, to newly re-activated after a period of quiet. Access current feed data in real-time or retrieve historical feed data through separate APIs.
222
+
223
+ Custom parameters aside from the common `GET` Request parameters:
224
+ - `endpoint` (choose either `download` or `feed` API endpoint - default is `feed`)
225
+ ```python
226
+ api = API(USERNAME, KEY, always_sign_api_key=False)
227
+ api.nod(endpoint="feed", **kwargs)
228
+ ```
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 this will be defaulted to `False` even without explicitly setting it.
230
+ ```python
231
+ api = API(USERNAME, KEY, always_sign_api_key=False)
232
+ api.nod(header_authentication=False, **kwargs)
233
+ ```
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
+ ```python
236
+ api = API(USERNAME, KEY, always_sign_api_key=False)
237
+ api.nod(output_format="csv", **kwargs)
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.4.0
@@ -20,4 +20,4 @@ OTHER DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  """
22
22
 
23
- current = "2.2.0"
23
+ current = "2.4.0"
@@ -1,8 +1,10 @@
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
 
7
+ from domaintools.constants import Endpoint, ENDPOINT_TO_SOURCE_MAP, FEEDS_PRODUCTS_LIST, OutputFormat
6
8
  from domaintools._version import current as version
7
9
  from domaintools.results import (
8
10
  GroupedIterable,
@@ -10,6 +12,7 @@ from domaintools.results import (
10
12
  ParsedDomainRdap,
11
13
  Reputation,
12
14
  Results,
15
+ FeedsResults,
13
16
  )
14
17
  from domaintools.filters import (
15
18
  filter_by_riskscore,
@@ -18,6 +21,8 @@ from domaintools.filters import (
18
21
  filter_by_field,
19
22
  DTResultFilter,
20
23
  )
24
+ from domaintools.utils import validate_feeds_parameters
25
+
21
26
 
22
27
  AVAILABLE_KEY_SIGN_HASHES = ["sha1", "sha256", "md5"]
23
28
 
@@ -84,11 +89,8 @@ class API(object):
84
89
 
85
90
  if not https:
86
91
  raise Exception("The DomainTools API endpoints no longer support http traffic. Please make sure https=True.")
87
- if proxy_url:
88
- if isinstance(proxy_url, str):
89
- self.proxy_url = {"http://": proxy_url, "https://": proxy_url}
90
- else:
91
- raise Exception("Proxy URL must be a string. For example: '127.0.0.1:8888'")
92
+ if proxy_url and not isinstance(proxy_url, str):
93
+ raise Exception("Proxy URL must be a string. For example: '127.0.0.1:8888'")
92
94
 
93
95
  def _build_api_url(self, api_url=None, api_port=None):
94
96
  """Build the API url based on the given url and port. Defaults to `https://api.domaintools.com`"""
@@ -122,14 +124,18 @@ class API(object):
122
124
  uri = "/".join((self._rest_api_url, path.lstrip("/")))
123
125
  parameters = self.default_parameters.copy()
124
126
  parameters["api_username"] = self.username
125
- self.handle_api_key(path, parameters)
127
+ header_authentication = kwargs.pop("header_authentication", True) # Used only by Real-Time Threat Intelligence Feeds endpoints for now
128
+ self.handle_api_key(product, path, parameters, header_authentication)
126
129
  parameters.update({key: str(value).lower() if value in (True, False) else value for key, value in kwargs.items() if value is not None})
127
130
 
128
131
  return cls(self, product, uri, **parameters)
129
132
 
130
- def handle_api_key(self, path, parameters):
133
+ def handle_api_key(self, product, path, parameters, header_authentication):
131
134
  if self.https and not self.always_sign_api_key:
132
- parameters["api_key"] = self.key
135
+ if product in FEEDS_PRODUCTS_LIST and header_authentication:
136
+ parameters["X-Api-Key"] = self.key
137
+ else:
138
+ parameters["api_key"] = self.key
133
139
  else:
134
140
  if self.key_sign_hash and self.key_sign_hash in AVAILABLE_KEY_SIGN_HASHES:
135
141
  signing_hash = eval(self.key_sign_hash)
@@ -405,38 +411,6 @@ class API(object):
405
411
  **kwargs,
406
412
  )
407
413
 
408
- def phisheye(self, query, days_back=None, **kwargs):
409
- """Returns domain results for the specified term for today or the specified number of days_back.
410
- Terms must be setup for monitoring via the web interface: https://research.domaintools.com/phisheye.
411
-
412
- NOTE: Properties of a domain are only provided if we have been able to obtain them.
413
- Many domains will have incomplete data because that information isn't available in their Whois records,
414
- or they don't have DNS results for a name server or IP address.
415
- """
416
- return self._results(
417
- "phisheye",
418
- "/v1/phisheye",
419
- query=query,
420
- days_back=days_back,
421
- items_path=("domains",),
422
- **kwargs,
423
- )
424
-
425
- def phisheye_term_list(self, include_inactive=False, **kwargs):
426
- """Provides a list of terms that are set up for this account.
427
- This call is not charged against your API usage limit.
428
-
429
- NOTE: The terms must be configured in the PhishEye web interface: https://research.domaintools.com/phisheye.
430
- There is no API call to set up the terms.
431
- """
432
- return self._results(
433
- "phisheye_term_list",
434
- "/v1/phisheye/term-list",
435
- include_inactive=include_inactive,
436
- items_path=("terms",),
437
- **kwargs,
438
- )
439
-
440
414
  def iris(
441
415
  self,
442
416
  domain=None,
@@ -1058,30 +1032,158 @@ class API(object):
1058
1032
  **kwargs,
1059
1033
  )
1060
1034
 
1061
- def nod(self, **kwargs):
1062
- """Returns back list of the newly observed domains feed"""
1063
- sessionID = kwargs.get("sessionID")
1064
- after = kwargs.get("after")
1065
- if not (sessionID or after):
1066
- raise ValueError("sessionID or after (can be both) must be defined")
1035
+ def nod(self, **kwargs) -> FeedsResults:
1036
+ """Returns back list of the newly observed domains feed.
1037
+ Apex-level domains (e.g. example.com but not www.example.com) that we observe for the first time, and have not observed previously with our global DNS sensor network.
1038
+
1039
+ domain: str: Filter for an exact domain or a substring contained within a domain by prefixing or suffixing your substring with "*". Check the documentation for examples
1040
+
1041
+ before: str: Filter for records before the given time value inclusive or time offset relative to now
1042
+
1043
+ after: str: Filter for records after the given time value inclusive or time offset relative to now
1044
+
1045
+ headers: bool: Use in combination with Accept: text/csv headers to control if headers are sent or not
1046
+
1047
+ sessionID: str: A custom string to distinguish between different sessions
1048
+
1049
+ top: int: Limit the number of results to the top N, where N is the value of this parameter.
1050
+ """
1051
+ validate_feeds_parameters(kwargs)
1052
+ endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
1053
+ source = ENDPOINT_TO_SOURCE_MAP.get(endpoint)
1054
+ if endpoint == Endpoint.DOWNLOAD.value or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
1055
+ # headers param is allowed only in Feed API and CSV format
1056
+ kwargs.pop("headers", None)
1067
1057
 
1068
1058
  return self._results(
1069
- "newly-observed-domains-feed-(api)",
1070
- "v1/feed/nod/",
1059
+ f"newly-observed-domains-feed-({source.value})",
1060
+ f"v1/{endpoint}/nod/",
1071
1061
  response_path=(),
1062
+ cls=FeedsResults,
1072
1063
  **kwargs,
1073
1064
  )
1074
1065
 
1075
- def nad(self, **kwargs):
1076
- """Returns back list of the newly active domains feed"""
1077
- sessionID = kwargs.get("sessionID")
1078
- after = kwargs.get("after")
1079
- if not (sessionID or after):
1080
- raise ValueError("sessionID or after (can be both) must be defined")
1066
+ def nad(self, **kwargs) -> FeedsResults:
1067
+ """Returns back list of the newly active domains feed. Contains domains that have been observed after having not been seen for at least 10 days in passive DNS.
1068
+ Apex-level domains (e.g. example.com but not www.example.com) that we observe based on the latest lifecycle of the domain. A domain may be seen either for the first time ever, or again after at least 10 days of inactivity (no observed resolutions in DNS).
1069
+ Populated with our global passive DNS (pDNS) sensor network.
1070
+
1071
+ domain: str: Filter for an exact domain or a substring contained within a domain by prefixing or suffixing your substring with "*". Check the documentation for examples
1072
+
1073
+ before: str: Filter for records before the given time value inclusive or time offset relative to now
1074
+
1075
+ after: str: Filter for records after the given time value inclusive or time offset relative to now
1076
+
1077
+ headers: bool: Use in combination with Accept: text/csv headers to control if headers are sent or not
1078
+
1079
+ sessionID: str: A custom string to distinguish between different sessions
1080
+
1081
+ top: int: Limit the number of results to the top N, where N is the value of this parameter.
1082
+ """
1083
+ validate_feeds_parameters(kwargs)
1084
+ endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
1085
+ source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1086
+ if endpoint == Endpoint.DOWNLOAD.value or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
1087
+ # headers param is allowed only in Feed API and CSV format
1088
+ kwargs.pop("headers", None)
1089
+
1090
+ return self._results(
1091
+ f"newly-active-domains-feed-({source})",
1092
+ f"v1/{endpoint}/nad/",
1093
+ response_path=(),
1094
+ cls=FeedsResults,
1095
+ **kwargs,
1096
+ )
1097
+
1098
+ def domainrdap(self, **kwargs) -> FeedsResults:
1099
+ """Returns changes to global domain registration information, populated by the Registration Data Access Protocol (RDAP).
1100
+ Compliments the 5-Minute WHOIS Feed as registries and registrars switch from Whois to RDAP.
1101
+ Contains parsed and raw RDAP-format domain registration data, emitted as soon as they are collected and parsed into a normalized structure.
1102
+
1103
+ domain: str: Filter for an exact domain or a substring contained within a domain by prefixing or suffixing your substring with "*". Check the documentation for examples
1104
+
1105
+ before: str: Filter for records before the given time value inclusive or time offset relative to now
1106
+
1107
+ after: str: Filter for records after the given time value inclusive or time offset relative to now
1108
+
1109
+ headers: bool: Use in combination with Accept: text/csv headers to control if headers are sent or not
1110
+
1111
+ sessionID: str: A custom string to distinguish between different sessions
1112
+
1113
+ top: int: Limit the number of results to the top N, where N is the value of this parameter.
1114
+ """
1115
+ validate_feeds_parameters(kwargs)
1116
+ endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
1117
+ source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1118
+
1119
+ return self._results(
1120
+ f"domain-registration-data-access-protocol-feed-({source})",
1121
+ f"v1/{endpoint}/domainrdap/",
1122
+ response_path=(),
1123
+ cls=FeedsResults,
1124
+ **kwargs,
1125
+ )
1126
+
1127
+ def domaindiscovery(self, **kwargs) -> FeedsResults:
1128
+ """Returns new domains as they are either discovered in domain registration information, observed by our global sensor network, or reported by trusted third parties".
1129
+ Contains domains that are newly-discovered by Domain Tools in both passive and active DNS sources, emitted as soon as they are first observed.
1130
+ New domains as they are either discovered in domain registration information, observed by our global sensor network, or reported by trusted third parties.
1131
+
1132
+ domain: str: Filter for an exact domain or a substring contained within a domain by prefixing or suffixing your substring with "*". Check the documentation for examples
1133
+
1134
+ before: str: Filter for records before the given time value inclusive or time offset relative to now
1135
+
1136
+ after: str: Filter for records after the given time value inclusive or time offset relative to now
1137
+
1138
+ headers: bool: Use in combination with Accept: text/csv headers to control if headers are sent or not
1139
+
1140
+ sessionID: str: A custom string to distinguish between different sessions
1141
+
1142
+ top: int: Limit the number of results to the top N, where N is the value of this parameter.
1143
+ """
1144
+ validate_feeds_parameters(kwargs)
1145
+ endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
1146
+ source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1147
+ if endpoint == Endpoint.DOWNLOAD.value or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
1148
+ # headers param is allowed only in Feed API and CSV format
1149
+ kwargs.pop("headers", None)
1150
+
1151
+ return self._results(
1152
+ f"real-time-domain-discovery-feed-({source})",
1153
+ f"v1/{endpoint}/domaindiscovery/",
1154
+ response_path=(),
1155
+ cls=FeedsResults,
1156
+ **kwargs,
1157
+ )
1158
+
1159
+ def noh(self, **kwargs) -> FeedsResults:
1160
+ """Returns back list of the newly observed hostnames feed.
1161
+ Contains fully qualified domain names (i.e. host names) that have never been seen before in passive DNS, emitted as soon as they are first observed.
1162
+ Hostname resolutions that we observe for the first time with our global DNS sensor network.
1163
+
1164
+ domain: str: Filter for an exact domain or a substring contained within a domain by prefixing or suffixing your substring with "*". Check the documentation for examples
1165
+
1166
+ before: str: Filter for records before the given time value inclusive or time offset relative to now
1167
+
1168
+ after: str: Filter for records after the given time value inclusive or time offset relative to now
1169
+
1170
+ headers: bool: Use in combination with Accept: text/csv headers to control if headers are sent or not
1171
+
1172
+ sessionID: str: A custom string to distinguish between different sessions
1173
+
1174
+ top: int: Limit the number of results to the top N, where N is the value of this parameter.
1175
+ """
1176
+ validate_feeds_parameters(kwargs)
1177
+ endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
1178
+ source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1179
+ if endpoint == Endpoint.DOWNLOAD.value or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
1180
+ # headers param is allowed only in Feed API and CSV format
1181
+ kwargs.pop("headers", None)
1081
1182
 
1082
1183
  return self._results(
1083
- "newly-active-domains-feed-(api)",
1084
- "v1/feed/nad/",
1184
+ f"newly-observed-hosts-feed-({source})",
1185
+ f"v1/{endpoint}/noh/",
1085
1186
  response_path=(),
1187
+ cls=FeedsResults,
1086
1188
  **kwargs,
1087
1189
  )
@@ -4,8 +4,12 @@ import json
4
4
  import re
5
5
  import time
6
6
  import logging
7
+
8
+ from copy import deepcopy
7
9
  from datetime import datetime
10
+ from httpx import Client
8
11
 
12
+ from domaintools.constants import FEEDS_PRODUCTS_LIST, OutputFormat, HEADER_ACCEPT_KEY_CSV_FORMAT
9
13
  from domaintools.exceptions import (
10
14
  BadRequestException,
11
15
  InternalServerErrorException,
@@ -16,9 +20,7 @@ from domaintools.exceptions import (
16
20
  IncompleteResponseException,
17
21
  RequestUriTooLongException,
18
22
  )
19
- from domaintools.utils import get_feeds_products_list
20
23
 
21
- from httpx import Client
22
24
 
23
25
  try: # pragma: no cover
24
26
  from collections.abc import MutableMapping, MutableSequence
@@ -51,8 +53,6 @@ class Results(MutableMapping, MutableSequence):
51
53
  self._response = None
52
54
  self._items_list = None
53
55
  self._data = None
54
- self._limit_exceeded = None
55
- self._limit_exceeded_message = None
56
56
 
57
57
  def _wait_time(self):
58
58
  if not self.api.rate_limit or not self.product in self.api.limits:
@@ -75,6 +75,23 @@ class Results(MutableMapping, MutableSequence):
75
75
 
76
76
  return wait_for
77
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
+
78
95
  def _make_request(self):
79
96
 
80
97
  with Client(verify=self.api.verify_ssl, proxy=self.api.proxy_url, timeout=None) as session:
@@ -90,6 +107,11 @@ class Results(MutableMapping, MutableSequence):
90
107
  patch_data = self.kwargs.copy()
91
108
  patch_data.update(self.api.extra_request_params)
92
109
  return session.patch(url=self.url, json=patch_data)
110
+ elif self.product in FEEDS_PRODUCTS_LIST:
111
+ session_params = self._get_session_params()
112
+ parameters = session_params.get("parameters")
113
+ headers = session_params.get("headers")
114
+ return session.get(url=self.url, params=parameters, headers=headers, **self.api.extra_request_params)
93
115
  else:
94
116
  return session.get(url=self.url, params=self.kwargs, **self.api.extra_request_params)
95
117
 
@@ -118,33 +140,26 @@ class Results(MutableMapping, MutableSequence):
118
140
  if self._data is None:
119
141
  results = self._get_results()
120
142
  self.setStatus(results.status_code, results)
121
- if (
122
- self.kwargs.get("format", "json") == "json"
123
- and self.product
124
- not in get_feeds_products_list() # Special handling of feeds products' data to preserve the result in jsonline format
125
- ):
143
+ if self.kwargs.get("format", "json") == "json":
126
144
  self._data = results.json()
127
145
  else:
128
146
  self._data = results.text
129
- limit_exceeded, message = self.check_limit_exceeded()
130
147
 
131
- if limit_exceeded:
132
- self._limit_exceeded = True
133
- self._limit_exceeded_message = message
148
+ self.check_limit_exceeded()
134
149
 
135
- if self._limit_exceeded is True:
136
- raise ServiceException(503, "Limit Exceeded{}".format(self._limit_exceeded_message))
137
- else:
138
- return self._data
150
+ return self._data
139
151
 
140
152
  def check_limit_exceeded(self):
141
- if self.kwargs.get("format", "json") == "json":
142
- if "response" in self._data and "limit_exceeded" in self._data["response"] and self._data["response"]["limit_exceeded"] is True:
143
- return True, self._data["response"]["message"]
144
- # 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"]
145
158
  elif "response" in self._data and "limit_exceeded" in self._data:
146
- return True, "limit exceeded"
147
- return False, ""
159
+ limit_exceeded = True
160
+
161
+ if limit_exceeded:
162
+ raise ServiceException(503, f"Limit Exceeded {reason}")
148
163
 
149
164
  @property
150
165
  def status(self):
@@ -155,7 +170,7 @@ class Results(MutableMapping, MutableSequence):
155
170
 
156
171
  def setStatus(self, code, response=None):
157
172
  self._status = code
158
- if code == 200:
173
+ if code == 200 or (self.product in FEEDS_PRODUCTS_LIST and code == 206):
159
174
  return
160
175
 
161
176
  reason = None
@@ -167,9 +182,9 @@ class Results(MutableMapping, MutableSequence):
167
182
  if callable(reason):
168
183
  reason = reason()
169
184
 
170
- if code == 400:
185
+ if code in (400, 422):
171
186
  raise BadRequestException(code, reason)
172
- elif code == 403:
187
+ elif code in (401, 403):
173
188
  raise NotAuthorizedException(code, reason)
174
189
  elif code == 404:
175
190
  raise NotFoundException(code, reason)
@@ -259,6 +274,32 @@ class Results(MutableMapping, MutableSequence):
259
274
  **self.kwargs,
260
275
  )
261
276
 
277
+ @property
278
+ def jsonl(self):
279
+ self.kwargs.pop("format", None)
280
+ return self.__class__(
281
+ format="jsonl",
282
+ product=self.product,
283
+ url=self.url,
284
+ items_path=self.items_path,
285
+ response_path=self.response_path,
286
+ api=self.api,
287
+ **self.kwargs,
288
+ )
289
+
290
+ @property
291
+ def csv(self):
292
+ self.kwargs.pop("format", None)
293
+ return self.__class__(
294
+ format="csv",
295
+ product=self.product,
296
+ url=self.url,
297
+ items_path=self.items_path,
298
+ response_path=self.response_path,
299
+ api=self.api,
300
+ **self.kwargs,
301
+ )
302
+
262
303
  @property
263
304
  def xml(self):
264
305
  self.kwargs.pop("format", None)