domaintools-api 2.7.4__tar.gz → 2.8.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 (49) hide show
  1. {domaintools_api-2.7.4/domaintools_api.egg-info → domaintools_api-2.8.0}/PKG-INFO +46 -5
  2. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/README.md +41 -4
  3. domaintools_api-2.8.0/VERSION +1 -0
  4. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/_version.py +1 -1
  5. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/api.py +72 -2
  6. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/base_results.py +24 -6
  7. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/api.py +10 -17
  8. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/commands/domains.py +46 -0
  9. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/commands/iris.py +5 -0
  10. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/constants.py +1 -0
  11. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/utils.py +1 -0
  12. {domaintools_api-2.7.4 → domaintools_api-2.8.0/domaintools_api.egg-info}/PKG-INFO +46 -5
  13. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools_api.egg-info/requires.txt +4 -0
  14. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools_async/__init__.py +15 -3
  15. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/pyproject.toml +4 -1
  16. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/tests/test_api.py +113 -0
  17. domaintools_api-2.8.0/tests/test_async.py +91 -0
  18. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/tests/test_cli.py +8 -1
  19. domaintools_api-2.7.4/VERSION +0 -1
  20. domaintools_api-2.7.4/tests/test_async.py +0 -50
  21. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/LICENSE +0 -0
  22. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/__init__.py +0 -0
  23. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/__init__.py +0 -0
  24. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/commands/__init__.py +0 -0
  25. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/commands/accounts.py +0 -0
  26. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/commands/detects.py +0 -0
  27. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/commands/feeds.py +0 -0
  28. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/commands/ips.py +0 -0
  29. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/cli/main.py +0 -0
  30. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/constants.py +0 -0
  31. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/decorators.py +0 -0
  32. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/docstring_patcher.py +0 -0
  33. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/exceptions.py +0 -0
  34. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/filters.py +0 -0
  35. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/request_validator.py +0 -0
  36. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/results.py +0 -0
  37. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/specs/iris-openapi.yaml +0 -0
  38. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools/utils.py +0 -0
  39. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools_api.egg-info/SOURCES.txt +0 -0
  40. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools_api.egg-info/dependency_links.txt +0 -0
  41. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools_api.egg-info/entry_points.txt +0 -0
  42. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/domaintools_api.egg-info/top_level.txt +0 -0
  43. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/setup.cfg +0 -0
  44. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/setup.py +0 -0
  45. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/tests/test_decorators.py +0 -0
  46. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/tests/test_docstring_patcher.py +0 -0
  47. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/tests/test_filters.py +0 -0
  48. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/tests/test_request_validator.py +0 -0
  49. {domaintools_api-2.7.4 → domaintools_api-2.8.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: domaintools_api
3
- Version: 2.7.4
3
+ Version: 2.8.0
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -50,6 +50,10 @@ Requires-Dist: typer
50
50
  Provides-Extra: test
51
51
  Requires-Dist: pytest; extra == "test"
52
52
  Requires-Dist: mock; extra == "test"
53
+ Requires-Dist: vcrpy; extra == "test"
54
+ Requires-Dist: pytest-asyncio; extra == "test"
55
+ Requires-Dist: pytest-cov; extra == "test"
56
+ Requires-Dist: yarl; extra == "test"
53
57
  Dynamic: license-file
54
58
 
55
59
  ![domaintools](https://github.com/DomainTools/python_api/raw/main/artwork/logo.png)
@@ -199,6 +203,32 @@ You can get the status code of a response outside of exception handling by doing
199
203
  api.domain_profile('google.com').status == 200
200
204
  ```
201
205
 
206
+ IrisQL
207
+ ===================
208
+
209
+ IrisQL is a query language for Iris Investigate that lets you express complex, multi-field searches in a single request. Pass the query as a raw string via the `irisql` parameter. The query must begin with `# IrisQL-1.0`.
210
+
211
+ ```python
212
+ query = """# IrisQL-1.0
213
+ DOMAIN CONTAINS "phishing"
214
+ AND
215
+ RISK_SCORE GREATER_THAN 85
216
+ """
217
+
218
+ results = api.iris_investigate(irisql=query)
219
+ print(results["results_count"])
220
+ for domain in results:
221
+ print(domain["domain"])
222
+ ```
223
+
224
+ Pagination parameters (`page_size`, `sort_by`, `position`) are supported alongside IrisQL via `**kwargs`:
225
+
226
+ ```python
227
+ results = api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
228
+ ```
229
+
230
+ When `irisql` is set, any domain or filter parameters passed alongside it are silently ignored. IrisQL uses header-based authentication (`X-Api-Key`) automatically.
231
+
202
232
  Using the API Asynchronously
203
233
  ===================
204
234
 
@@ -255,6 +285,18 @@ Optionally, you can specify the desired format (html, xml, json, or list) of the
255
285
  domaintools domain_search google --max_length 10 -u $TEST_USER -k $TEST_KEY -f html
256
286
  ```
257
287
 
288
+ IrisQL queries are supported via the `--irisql` flag on `iris_investigate`. The query must begin with `# IrisQL-1.0` on its own line:
289
+
290
+ ```bash
291
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' -u $TEST_USER -k $TEST_KEY
292
+ ```
293
+
294
+ Pagination parameters can be passed alongside the IrisQL query:
295
+
296
+ ```bash
297
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' --page-size 50 --sort-by risk_score -u $TEST_USER -k $TEST_KEY
298
+ ```
299
+
258
300
  To avoid having to type in your API key repeatedly, you can specify them in `~/.dtapi` separated by a new line:
259
301
 
260
302
  ```bash
@@ -343,12 +385,11 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
343
385
  source venv/bin/activate
344
386
  ```
345
387
 
346
- - Install dependencies.
388
+ - Install dependencies (with test extras):
347
389
  ```bash
348
- pip install -r requirements/development.txt
390
+ pip install -e ".[test]"
349
391
  ```
350
-
351
- - From the python_api project root directory, install the package.
392
+ Or without test dependencies:
352
393
  ```bash
353
394
  pip install -e .
354
395
  ```
@@ -145,6 +145,32 @@ You can get the status code of a response outside of exception handling by doing
145
145
  api.domain_profile('google.com').status == 200
146
146
  ```
147
147
 
148
+ IrisQL
149
+ ===================
150
+
151
+ IrisQL is a query language for Iris Investigate that lets you express complex, multi-field searches in a single request. Pass the query as a raw string via the `irisql` parameter. The query must begin with `# IrisQL-1.0`.
152
+
153
+ ```python
154
+ query = """# IrisQL-1.0
155
+ DOMAIN CONTAINS "phishing"
156
+ AND
157
+ RISK_SCORE GREATER_THAN 85
158
+ """
159
+
160
+ results = api.iris_investigate(irisql=query)
161
+ print(results["results_count"])
162
+ for domain in results:
163
+ print(domain["domain"])
164
+ ```
165
+
166
+ Pagination parameters (`page_size`, `sort_by`, `position`) are supported alongside IrisQL via `**kwargs`:
167
+
168
+ ```python
169
+ results = api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
170
+ ```
171
+
172
+ When `irisql` is set, any domain or filter parameters passed alongside it are silently ignored. IrisQL uses header-based authentication (`X-Api-Key`) automatically.
173
+
148
174
  Using the API Asynchronously
149
175
  ===================
150
176
 
@@ -201,6 +227,18 @@ Optionally, you can specify the desired format (html, xml, json, or list) of the
201
227
  domaintools domain_search google --max_length 10 -u $TEST_USER -k $TEST_KEY -f html
202
228
  ```
203
229
 
230
+ IrisQL queries are supported via the `--irisql` flag on `iris_investigate`. The query must begin with `# IrisQL-1.0` on its own line:
231
+
232
+ ```bash
233
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' -u $TEST_USER -k $TEST_KEY
234
+ ```
235
+
236
+ Pagination parameters can be passed alongside the IrisQL query:
237
+
238
+ ```bash
239
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' --page-size 50 --sort-by risk_score -u $TEST_USER -k $TEST_KEY
240
+ ```
241
+
204
242
  To avoid having to type in your API key repeatedly, you can specify them in `~/.dtapi` separated by a new line:
205
243
 
206
244
  ```bash
@@ -289,12 +327,11 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
289
327
  source venv/bin/activate
290
328
  ```
291
329
 
292
- - Install dependencies.
330
+ - Install dependencies (with test extras):
293
331
  ```bash
294
- pip install -r requirements/development.txt
332
+ pip install -e ".[test]"
295
333
  ```
296
-
297
- - From the python_api project root directory, install the package.
334
+ Or without test dependencies:
298
335
  ```bash
299
336
  pip install -e .
300
337
  ```
@@ -0,0 +1 @@
1
+ 2.8.0
@@ -20,4 +20,4 @@ OTHER DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  """
22
22
 
23
- current = "2.7.4"
23
+ current = "2.8.0"
@@ -275,6 +275,57 @@ class API(object):
275
275
  """Returns a profile for the specified domain name"""
276
276
  return self._results("domain-profile", "/v1/{0}".format(query))
277
277
 
278
+ def domain_history(
279
+ self,
280
+ query,
281
+ include_fields=None,
282
+ exclude_fields=None,
283
+ page_size=None,
284
+ offset=None,
285
+ next=None,
286
+ parsed_whois=None,
287
+ parsed_domain_rdap=None,
288
+ **kwargs,
289
+ ):
290
+ """Returns the history of changes for a given domain name.
291
+
292
+ Results are returned in reverse chronological order. Each change event includes
293
+ a timestamp, the field that changed, and the complete before/after domain state.
294
+
295
+ Args:
296
+ query: The apex domain name to retrieve history for (e.g. "domaintools.com").
297
+ include_fields: Comma-separated list of exact field names. Only change events
298
+ matching these fields appear in results. Cannot be combined with
299
+ exclude_fields. Supports aggregate prefixes (e.g. "all_ssl", "all_ip").
300
+ Example: "ip,registrar,all_ssl"
301
+ exclude_fields: Comma-separated list of exact field names. Change events
302
+ matching these fields are omitted. Cannot be combined with include_fields.
303
+ Example: "all_web_trackers,all_ssl"
304
+ page_size: Number of change events per page. Maximum and default is 100.
305
+ offset: 0-indexed starting point for pagination. Increment by page_size for
306
+ each subsequent page.
307
+ next: When True, includes a next URL in the response for cursor-based
308
+ pagination. Auth parameters must still be included when following it.
309
+ parsed_whois: When True, includes the full parsed WHOIS record in the
310
+ before/after objects of each change event.
311
+ parsed_domain_rdap: When True, includes the full parsed Domain RDAP record
312
+ in the before/after objects of each change event.
313
+ """
314
+ return self._results(
315
+ "domain-history",
316
+ "/v1/domain-history",
317
+ domain=query,
318
+ include_fields=include_fields,
319
+ exclude_fields=exclude_fields,
320
+ page_size=page_size,
321
+ offset=offset,
322
+ next=next,
323
+ parsed_whois=parsed_whois,
324
+ parsed_domain_rdap=parsed_domain_rdap,
325
+ items_path=("changes",),
326
+ **kwargs,
327
+ )
328
+
278
329
  def domain_search(
279
330
  self,
280
331
  query,
@@ -662,13 +713,14 @@ class API(object):
662
713
  updated_after=None,
663
714
  include_domains_with_missing_field=None,
664
715
  exclude_domains_with_missing_field=None,
716
+ irisql=None,
665
717
  **kwargs,
666
718
  ):
667
719
  """Returns back a list of domains based on the provided filters.
668
720
 
669
721
  You can loop over results of your investigation as if it was a native Python list:
670
722
 
671
- for result in api.iris_investigate(ip='199.30.228.112'): # Enables looping over all related results
723
+ for result in api.iris_investigate(ip='199.30.228.112'):
672
724
 
673
725
  api.iris_investigate(QUERY)['results_count'] Returns the number of results returned with this request
674
726
  api.iris_investigate(QUERY)['total_count'] Returns the number of results available within Iris
@@ -677,9 +729,27 @@ class API(object):
677
729
  api.iris_investigate(QUERY)['position'] Returns the position key that can be used to retrieve the next page:
678
730
  next_page = api.iris_investigate(QUERY, position=api.iris_investigate(QUERY)['position'])
679
731
 
680
- for enrichment in api.iris_enrich(i): # Enables looping over all returned enriched domains
732
+ IrisQL mode (mutually exclusive with all other search parameters):
733
+
734
+ irisql: str: A raw IrisQL query string. Must begin with '# IrisQL-1.0'.
735
+ Sent as a raw POST body (text/plain). When set, all domain/filter params are ignored.
736
+ Pagination params (page_size, sort_by, position) are still supported via **kwargs.
737
+
738
+ Example:
739
+ api.iris_investigate(irisql='# IrisQL-1.0\\nDOMAIN CONTAINS "phishing"', page_size=50, sort_by='risk_score')
681
740
 
682
741
  """
742
+ if irisql is not None:
743
+ if domains:
744
+ print("Warning: irisql is set — ignoring 'domains' and other search parameters. IrisQL query takes precedence.")
745
+ return self._results(
746
+ "iris-investigate",
747
+ "/v1/iris-investigate/",
748
+ items_path=("results",),
749
+ irisql=irisql,
750
+ **kwargs,
751
+ )
752
+
683
753
  # We put search_hash in the signature definition so the CLI can see it as a valid arg
684
754
  if search_hash:
685
755
  kwargs["search_hash"] = search_hash
@@ -25,7 +25,6 @@ from domaintools.exceptions import (
25
25
  RequestUriTooLongException,
26
26
  )
27
27
 
28
-
29
28
  try: # pragma: no cover
30
29
  from collections.abc import MutableMapping, MutableSequence
31
30
  except ImportError: # pragma: no cover
@@ -108,6 +107,19 @@ class Results(MutableMapping, MutableSequence):
108
107
  "iris-enrich",
109
108
  "iris-detect-escalate-domains",
110
109
  ]:
110
+ if self.product == "iris-investigate" and "irisql" in self.kwargs:
111
+ irisql_query = self.kwargs["irisql"]
112
+ auth_keys = {"api_username", "timestamp", "signature", "api_key"}
113
+ query_params = {
114
+ k: v for k, v in self.kwargs.items() if k != "irisql" and k not in auth_keys
115
+ }
116
+ query_params.update(self.api.extra_request_params)
117
+ return session.post(
118
+ url=self.url,
119
+ content=irisql_query,
120
+ params=query_params,
121
+ headers={**headers, "Content-Type": "text/plain", "X-Api-Key": self.api.key},
122
+ )
111
123
  post_data = self.kwargs.copy()
112
124
  post_data.update(self.api.extra_request_params)
113
125
  return session.post(url=self.url, data=post_data, headers=headers)
@@ -153,13 +165,13 @@ class Results(MutableMapping, MutableSequence):
153
165
  self._data = results.json()
154
166
  else:
155
167
  self._data = results.text
156
-
157
168
  self.check_limit_exceeded()
158
169
 
159
170
  return self._data
160
171
 
161
172
  def check_limit_exceeded(self):
162
173
  limit_exceeded, reason = False, ""
174
+
163
175
  if isinstance(self._data, dict) and (
164
176
  "response" in self._data
165
177
  and "limit_exceeded" in self._data["response"]
@@ -167,7 +179,13 @@ class Results(MutableMapping, MutableSequence):
167
179
  ):
168
180
  limit_exceeded, reason = True, self._data["response"]["message"]
169
181
  elif "response" in self._data and "limit_exceeded" in self._data:
170
- limit_exceeded = True
182
+ # check for xml format, and return the actual error message
183
+ if self.kwargs.get("format") == "xml" and isinstance(self._data, str):
184
+ if re.search(r"<limit_exceeded>1</limit_exceeded>", self._data):
185
+ msg = re.search(r"<message>(.*?)</message>", self._data)
186
+ limit_exceeded, reason = True, msg.group(1) if msg else ""
187
+ else:
188
+ limit_exceeded = True
171
189
 
172
190
  if limit_exceeded:
173
191
  raise ServiceException(503, f"Limit Exceeded {reason}")
@@ -277,6 +295,8 @@ class Results(MutableMapping, MutableSequence):
277
295
 
278
296
  @property
279
297
  def json(self):
298
+ if self._data is not None:
299
+ return self
280
300
  self.kwargs.pop("format", None)
281
301
  return self.__class__(
282
302
  format="json",
@@ -341,9 +361,7 @@ class Results(MutableMapping, MutableSequence):
341
361
  )
342
362
 
343
363
  def as_list(self):
344
- return "\n".join(
345
- [json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()]
346
- )
364
+ return "\n".join([json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()])
347
365
 
348
366
  def __str__(self):
349
367
  return str(
@@ -230,28 +230,21 @@ class DTCLICommand:
230
230
  description=f"Preparing results with format of {response_format}...",
231
231
  )
232
232
 
233
+ if name not in ("available_api_calls",) and not getattr(response, "product", None) in RTTF_PRODUCTS_LIST:
234
+ response.data()
235
+
233
236
  output = cls._get_formatted_output(
234
237
  cmd_name=name, response=response, out_format=response_format
235
238
  )
236
239
 
237
- if isinstance(out_file, _io.TextIOWrapper):
238
- progress.update(
239
- task_id,
240
- description=f"Printing the results with format of {response_format}...",
241
- )
242
- # use rich `print` command to prettify the ouput in sys.stdout
243
- if name not in ("available_api_calls",) and response.product in RTTF_PRODUCTS_LIST:
244
- for feeds in response.response():
245
- print(feeds)
246
- else:
247
- print(response)
240
+ if isinstance(out_file, _io.TextIOWrapper):
241
+ if name not in ("available_api_calls",) and response.product in RTTF_PRODUCTS_LIST:
242
+ for feeds in response.response():
243
+ print(feeds)
248
244
  else:
249
- progress.update(
250
- task_id,
251
- description=f"Writing results to {out_file}",
252
- )
253
- # if it's a file then write
254
- out_file.write(output if output.endswith("\n") else output + "\n")
245
+ print(output)
246
+ else:
247
+ out_file.write(output if output.endswith("\n") else output + "\n")
255
248
  except Exception as e:
256
249
  if isinstance(e, ServiceException):
257
250
  code = typer.style(getattr(e, "code", 400), fg=typer.colors.BRIGHT_RED)
@@ -87,6 +87,51 @@ def domain_profile(
87
87
  DTCLICommand.run(name=c.DOMAIN_PROFILE, params=ctx.params)
88
88
 
89
89
 
90
+ @dt_cli.command(
91
+ name=c.DOMAIN_HISTORY,
92
+ help=get_cli_helptext_by_name(command_name=c.DOMAIN_HISTORY),
93
+ )
94
+ def domain_history(
95
+ ctx: typer.Context,
96
+ query: str = typer.Option(..., "-q", "--query", help="The apex domain name to retrieve history for (e.g. domaintools.com)."),
97
+ include_fields: str = typer.Option(None, "--include-fields", help="Comma-separated list of exact field names. Only change events matching these fields appear in results. Cannot be combined with --exclude-fields. Example: ip,registrar,all_ssl"),
98
+ exclude_fields: str = typer.Option(None, "--exclude-fields", help="Comma-separated list of exact field names. Change events matching these fields are omitted. Cannot be combined with --include-fields. Example: all_web_trackers,all_ssl"),
99
+ page_size: int = typer.Option(None, "--page-size", help="Number of change events per page. Maximum is 100 (default: 100)."),
100
+ offset: int = typer.Option(None, "--offset", help="0-indexed starting point for pagination. Increment by page-size for each subsequent page."),
101
+ next: bool = typer.Option(None, "--next", help="When true, includes a next URL in the response for cursor-based pagination."),
102
+ parsed_whois: bool = typer.Option(None, "--parsed-whois", help="When true, includes the full parsed WHOIS record in the before/after objects of each change event."),
103
+ parsed_domain_rdap: bool = typer.Option(None, "--parsed-domain-rdap", help="When true, includes the full parsed Domain RDAP record in the before/after objects of each change event."),
104
+ user: str = typer.Option(None, "-u", "--user", help="Domaintools API Username."),
105
+ key: str = typer.Option(None, "-k", "--key", help="DomainTools API key"),
106
+ creds_file: str = typer.Option(
107
+ "~/.dtapi",
108
+ "-c",
109
+ "--credfile",
110
+ help="Optional file with API username and API key, one per line.",
111
+ ),
112
+ rate_limit: bool = typer.Option(
113
+ False,
114
+ "-l",
115
+ "--rate-limit",
116
+ help="Rate limit API calls against the API based on per minute limits.",
117
+ ),
118
+ format: str = typer.Option(
119
+ "json",
120
+ "-f",
121
+ "--format",
122
+ help="Output format in {'list', 'json', 'xml', 'html'}",
123
+ callback=DTCLICommand.validate_format_input,
124
+ ),
125
+ out_file: typer.FileTextWrite = typer.Option(sys.stdout, "-o", "--out-file", help="Output file (defaults to stdout)"),
126
+ no_verify_ssl: bool = typer.Option(
127
+ False,
128
+ "--no-verify-ssl",
129
+ help="Skip verification of SSL certificate when making HTTPs API calls",
130
+ ),
131
+ ):
132
+ DTCLICommand.run(name=c.DOMAIN_HISTORY, params=ctx.params)
133
+
134
+
90
135
  @dt_cli.command(
91
136
  name=c.DOMAIN_SEARCH,
92
137
  help=get_cli_helptext_by_name(command_name=c.DOMAIN_SEARCH),
@@ -666,6 +711,7 @@ def risk_evidence(
666
711
 
667
712
  __all__ = [
668
713
  "brand_monitor",
714
+ "domain_history",
669
715
  "domain_profile",
670
716
  "domain_search",
671
717
  "name_server_monitor",
@@ -23,6 +23,11 @@ def iris_investigate(
23
23
  create_date: str = typer.Option(None, "--create-date", help="The create date."),
24
24
  active: bool = typer.Option(None, "--active", help="The domains that are in active state"),
25
25
  search_hash: str = typer.Option(None, "--search-hash", help="The search hash to use"),
26
+ irisql: str = typer.Option(
27
+ None,
28
+ "--irisql",
29
+ help="IrisQL query string (must begin with '# IrisQL-1.0'). Mutually exclusive with domain/filter params. Pagination kwargs (--page-size, --sort-by, --position) are still supported.",
30
+ ),
26
31
  src_file: str = typer.Option(
27
32
  None,
28
33
  "-s",
@@ -4,6 +4,7 @@ AVAILABLE_API_CALLS = "available_api_calls"
4
4
 
5
5
  # domains
6
6
  BRAND_MONITOR = "brand_monitor"
7
+ DOMAIN_HISTORY = "domain_history"
7
8
  DOMAIN_PROFILE = "domain_profile"
8
9
  DOMAIN_SEARCH = "domain_search"
9
10
  HOSTING_HISTORY = "hosting_history"
@@ -35,6 +35,7 @@ _command_help_mapper = {
35
35
  c.IRIS_INVESTIGATE: _iris_investigate_helptext(),
36
36
  c.IRIS_ENRICH: "Returns back enriched data related to the specified domains using our Iris Enrich service.",
37
37
  c.BRAND_MONITOR: "Pass in one or more terms as a list or separated by the pipe character ( | )",
38
+ c.DOMAIN_HISTORY: "Returns the history of changes for a given domain name.",
38
39
  c.DOMAIN_PROFILE: "Returns a profile for the specified domain name",
39
40
  c.DOMAIN_SEARCH: """Each term in the query string must be at least three characters long. Pass in a list or use spaces to separate multiple terms.""",
40
41
  c.HOSTING_HISTORY: "Returns the hosting history from the given domain name.",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: domaintools_api
3
- Version: 2.7.4
3
+ Version: 2.8.0
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -50,6 +50,10 @@ Requires-Dist: typer
50
50
  Provides-Extra: test
51
51
  Requires-Dist: pytest; extra == "test"
52
52
  Requires-Dist: mock; extra == "test"
53
+ Requires-Dist: vcrpy; extra == "test"
54
+ Requires-Dist: pytest-asyncio; extra == "test"
55
+ Requires-Dist: pytest-cov; extra == "test"
56
+ Requires-Dist: yarl; extra == "test"
53
57
  Dynamic: license-file
54
58
 
55
59
  ![domaintools](https://github.com/DomainTools/python_api/raw/main/artwork/logo.png)
@@ -199,6 +203,32 @@ You can get the status code of a response outside of exception handling by doing
199
203
  api.domain_profile('google.com').status == 200
200
204
  ```
201
205
 
206
+ IrisQL
207
+ ===================
208
+
209
+ IrisQL is a query language for Iris Investigate that lets you express complex, multi-field searches in a single request. Pass the query as a raw string via the `irisql` parameter. The query must begin with `# IrisQL-1.0`.
210
+
211
+ ```python
212
+ query = """# IrisQL-1.0
213
+ DOMAIN CONTAINS "phishing"
214
+ AND
215
+ RISK_SCORE GREATER_THAN 85
216
+ """
217
+
218
+ results = api.iris_investigate(irisql=query)
219
+ print(results["results_count"])
220
+ for domain in results:
221
+ print(domain["domain"])
222
+ ```
223
+
224
+ Pagination parameters (`page_size`, `sort_by`, `position`) are supported alongside IrisQL via `**kwargs`:
225
+
226
+ ```python
227
+ results = api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
228
+ ```
229
+
230
+ When `irisql` is set, any domain or filter parameters passed alongside it are silently ignored. IrisQL uses header-based authentication (`X-Api-Key`) automatically.
231
+
202
232
  Using the API Asynchronously
203
233
  ===================
204
234
 
@@ -255,6 +285,18 @@ Optionally, you can specify the desired format (html, xml, json, or list) of the
255
285
  domaintools domain_search google --max_length 10 -u $TEST_USER -k $TEST_KEY -f html
256
286
  ```
257
287
 
288
+ IrisQL queries are supported via the `--irisql` flag on `iris_investigate`. The query must begin with `# IrisQL-1.0` on its own line:
289
+
290
+ ```bash
291
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' -u $TEST_USER -k $TEST_KEY
292
+ ```
293
+
294
+ Pagination parameters can be passed alongside the IrisQL query:
295
+
296
+ ```bash
297
+ domaintools iris_investigate --irisql $'# IrisQL-1.0\nDOMAIN CONTAINS "phishing"' --page-size 50 --sort-by risk_score -u $TEST_USER -k $TEST_KEY
298
+ ```
299
+
258
300
  To avoid having to type in your API key repeatedly, you can specify them in `~/.dtapi` separated by a new line:
259
301
 
260
302
  ```bash
@@ -343,12 +385,11 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
343
385
  source venv/bin/activate
344
386
  ```
345
387
 
346
- - Install dependencies.
388
+ - Install dependencies (with test extras):
347
389
  ```bash
348
- pip install -r requirements/development.txt
390
+ pip install -e ".[test]"
349
391
  ```
350
-
351
- - From the python_api project root directory, install the package.
392
+ Or without test dependencies:
352
393
  ```bash
353
394
  pip install -e .
354
395
  ```
@@ -6,3 +6,7 @@ typer
6
6
  [test]
7
7
  pytest
8
8
  mock
9
+ vcrpy
10
+ pytest-asyncio
11
+ pytest-cov
12
+ yarl
@@ -46,9 +46,21 @@ class AsyncResults(Results):
46
46
  session_params_and_headers = self._get_session_params_and_headers()
47
47
  headers = session_params_and_headers.get("headers")
48
48
  if self.product in ["iris-investigate", "iris-enrich", "iris-detect-escalate-domains"]:
49
- post_data = self.kwargs.copy()
50
- post_data.update(self.api.extra_request_params)
51
- results = await session.post(url=self.url, data=post_data, headers=headers)
49
+ if self.product == "iris-investigate" and "irisql" in self.kwargs:
50
+ irisql_query = self.kwargs["irisql"]
51
+ auth_keys = {"api_username", "timestamp", "signature", "api_key"}
52
+ query_params = {k: v for k, v in self.kwargs.items() if k != "irisql" and k not in auth_keys}
53
+ query_params.update(self.api.extra_request_params)
54
+ results = await session.post(
55
+ url=self.url,
56
+ content=irisql_query,
57
+ params=query_params,
58
+ headers={**headers, "Content-Type": "text/plain", "X-Api-Key": self.api.key},
59
+ )
60
+ else:
61
+ post_data = self.kwargs.copy()
62
+ post_data.update(self.api.extra_request_params)
63
+ results = await session.post(url=self.url, data=post_data, headers=headers)
52
64
  elif self.product in ["iris-detect-manage-watchlist-domains"]:
53
65
  patch_data = self.kwargs.copy()
54
66
  patch_data.update(self.api.extra_request_params, headers=headers)
@@ -41,7 +41,7 @@ Homepage = "https://github.com/domaintools/python_api"
41
41
  domaintools = "domaintools.cli:run"
42
42
 
43
43
  [project.optional-dependencies]
44
- test = ["pytest", "mock"]
44
+ test = ["pytest", "mock", "vcrpy", "pytest-asyncio", "pytest-cov", "yarl"]
45
45
 
46
46
  [tool.setuptools]
47
47
  packages = [
@@ -58,5 +58,8 @@ domaintools = ["specs/*.yaml"]
58
58
  [tool.setuptools.dynamic]
59
59
  version = { "file" = "VERSION" }
60
60
 
61
+ [tool.pytest.ini_options]
62
+ asyncio_mode = "auto"
63
+
61
64
  [tool.black]
62
65
  line-length = 110
@@ -1,6 +1,7 @@
1
1
  """Tests the Python interface for DomainTools APIs"""
2
2
 
3
3
  from os import environ
4
+ from unittest.mock import MagicMock, patch, call
4
5
 
5
6
  import json
6
7
  import pytest
@@ -10,6 +11,7 @@ from inspect import isgenerator
10
11
 
11
12
  from domaintools import API, exceptions
12
13
  from tests.settings import api, feeds_api, vcr
14
+ from tests.responses.iris_investigate_data import domaintools as iris_investigate_fixture
13
15
 
14
16
 
15
17
  @vcr.use_cassette
@@ -68,6 +70,22 @@ def test_domain_profile():
68
70
  assert "ip_address" in history
69
71
 
70
72
 
73
+ @vcr.use_cassette
74
+ def test_domain_history():
75
+ api_call = api.domain_history("chat.com", include_fields="registrar")
76
+ with api_call as result:
77
+ assert "domain" in result
78
+ assert "count" in result
79
+ assert "registration_source" in result
80
+ assert "changes" in result
81
+
82
+ for change in api_call:
83
+ assert "timestamp" in change
84
+ assert "field" in change
85
+ assert "before" in change
86
+ assert "after" in change
87
+
88
+
71
89
  @vcr.use_cassette
72
90
  def test_domain_search():
73
91
  api_call = api.domain_search("google")
@@ -447,6 +465,73 @@ def test_iris_investigate():
447
465
  assert result["domain"] in ["amazon.com", "google.com"]
448
466
 
449
467
 
468
+ def test_iris_investigate_irisql_calls_results_with_irisql():
469
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
470
+ with patch.object(api, "_results") as mock_results:
471
+ api.iris_investigate(irisql=query)
472
+ mock_results.assert_called_once_with(
473
+ "iris-investigate",
474
+ "/v1/iris-investigate/",
475
+ items_path=("results",),
476
+ irisql=query,
477
+ )
478
+
479
+
480
+ def test_iris_investigate_irisql_with_pagination_kwargs():
481
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
482
+ with patch.object(api, "_results") as mock_results:
483
+ api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score", position=0)
484
+ mock_results.assert_called_once_with(
485
+ "iris-investigate",
486
+ "/v1/iris-investigate/",
487
+ items_path=("results",),
488
+ irisql=query,
489
+ page_size=50,
490
+ sort_by="risk_score",
491
+ position=0,
492
+ )
493
+
494
+
495
+ def test_iris_investigate_irisql_warns_and_ignores_domains(capsys):
496
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
497
+ with patch.object(api, "_results"):
498
+ api.iris_investigate(irisql=query, domains=["google.com"])
499
+ captured = capsys.readouterr()
500
+ assert "Warning" in captured.out
501
+ assert "irisql" in captured.out
502
+
503
+
504
+ def test_iris_investigate_irisql_does_not_require_other_params():
505
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
506
+ with patch.object(api, "_results"):
507
+ api.iris_investigate(irisql=query)
508
+
509
+
510
+ def test_iris_investigate_irisql_request_uses_raw_body():
511
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
512
+ mock_response = MagicMock()
513
+ mock_response.status_code = 200
514
+ mock_response.json.return_value = {"response": {"results": [], "results_count": 0}}
515
+
516
+ with patch("domaintools.base_results.Client") as mock_client:
517
+ mock_session = MagicMock()
518
+ mock_client.return_value.__enter__.return_value = mock_session
519
+ mock_session.post.return_value = mock_response
520
+
521
+ result = api.iris_investigate(irisql=query)
522
+ result.data()
523
+
524
+ _, kwargs = mock_session.post.call_args
525
+ assert kwargs.get("content") == query
526
+ assert "data" not in kwargs
527
+ assert kwargs.get("headers", {}).get("Content-Type") == "text/plain"
528
+ assert kwargs.get("headers", {}).get("X-Api-Key") == api.key
529
+ for auth_key in ("api_username", "timestamp", "signature", "api_key"):
530
+ assert auth_key not in kwargs.get("params", {})
531
+
532
+
533
+
534
+
450
535
  @vcr.use_cassette
451
536
  def test_iris_detect_monitors():
452
537
  with pytest.raises(ValueError):
@@ -517,6 +602,34 @@ def test_limit_exceeded():
517
602
  response.response()
518
603
 
519
604
 
605
+ def test_limit_exceeded_xml():
606
+ xml_response = """<response>
607
+ <error>
608
+ <code>413</code>
609
+ <message>Maximum 10000 returned - you may need to refine your query.</message>
610
+ </error>
611
+ <limit_exceeded>1</limit_exceeded>
612
+ <has_more_results>1</has_more_results>
613
+ <message>Maximum 10000 returned - you may need to refine your query.</message>
614
+ <missing_domains/>
615
+ </response>"""
616
+
617
+ mock_response = MagicMock()
618
+ mock_response.status_code = 200
619
+ mock_response.text = xml_response
620
+
621
+ with patch("domaintools.base_results.Client") as mock_client:
622
+ mock_session = MagicMock()
623
+ mock_client.return_value.__enter__.return_value = mock_session
624
+ mock_session.post.return_value = mock_response
625
+
626
+ with pytest.raises(exceptions.ServiceException) as exc_info:
627
+ result = api.iris_investigate(ip="8.8.8.8", format="xml")
628
+ result.data()
629
+
630
+ assert "Maximum 10000 returned" in str(exc_info.value)
631
+
632
+
520
633
  @vcr.use_cassette
521
634
  def test_newly_observed_domains_feed():
522
635
  results = feeds_api.nod(after="-60", top=5)
@@ -0,0 +1,91 @@
1
+ """Tests async interaction support for DomainTools APIs"""
2
+
3
+ import asyncio
4
+ import pytest
5
+ from unittest.mock import MagicMock, AsyncMock, patch
6
+
7
+ from tests.settings import api, vcr
8
+
9
+
10
+ @vcr.use_cassette
11
+ @pytest.mark.asyncio
12
+ async def test_async_iteration():
13
+ results = await api.domain_search("google")
14
+ assert results
15
+
16
+ list_of_domains = []
17
+ for domain in results:
18
+ list_of_domains += domain
19
+
20
+ for domain in list_of_domains:
21
+ assert type(domain) == str
22
+
23
+
24
+ @vcr.use_cassette
25
+ @pytest.mark.asyncio
26
+ async def test_async_context_manager():
27
+ results = await api.domain_search("google")
28
+ assert results
29
+
30
+
31
+ @vcr.use_cassette
32
+ @pytest.mark.asyncio
33
+ async def test_async_simple_await():
34
+ results = await api.domain_search("google")
35
+ assert results
36
+
37
+
38
+ @vcr.use_cassette
39
+ @pytest.mark.asyncio
40
+ async def test_async_simple_await_post():
41
+ investigation_results = await api.iris_investigate(domains=["amazon.com", "google.com"])
42
+ assert investigation_results["results_count"]
43
+ for result in investigation_results:
44
+ assert result["domain"] in ["amazon.com", "google.com"]
45
+
46
+
47
+ @vcr.use_cassette
48
+ @pytest.mark.asyncio
49
+ async def test_async_simple_await_patch():
50
+ detect_results = await api.iris_detect_manage_watchlist_domains(watchlist_domain_ids=["gae08rdVWG"], state="watched")
51
+ assert detect_results["watchlist_domains"][0]["state"] == "watched"
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_async_irisql_uses_raw_body():
56
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
57
+ mock_response = MagicMock()
58
+ mock_response.status_code = 200
59
+ mock_response.json.return_value = {"response": {"results": [], "results_count": 0}}
60
+
61
+ with patch("domaintools_async.AsyncClient") as mock_client:
62
+ mock_session = AsyncMock()
63
+ mock_client.return_value.__aenter__.return_value = mock_session
64
+ mock_session.post.return_value = mock_response
65
+
66
+ result = await api.iris_investigate(irisql=query)
67
+
68
+ _, kwargs = mock_session.post.call_args
69
+ assert kwargs.get("content") == query
70
+ assert "data" not in kwargs
71
+ assert kwargs.get("headers", {}).get("Content-Type") == "text/plain"
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_async_irisql_with_pagination_kwargs():
76
+ query = "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\""
77
+ mock_response = MagicMock()
78
+ mock_response.status_code = 200
79
+ mock_response.json.return_value = {"response": {"results": [], "results_count": 0}}
80
+
81
+ with patch("domaintools_async.AsyncClient") as mock_client:
82
+ mock_session = AsyncMock()
83
+ mock_client.return_value.__aenter__.return_value = mock_session
84
+ mock_session.post.return_value = mock_response
85
+
86
+ result = await api.iris_investigate(irisql=query, page_size=50, sort_by="risk_score")
87
+
88
+ _, kwargs = mock_session.post.call_args
89
+ assert kwargs.get("content") == query
90
+ assert "page_size" in kwargs.get("params", {})
91
+ assert "sort_by" in kwargs.get("params", {})
@@ -27,9 +27,16 @@ def test_valid_command():
27
27
 
28
28
  def test_invalid_command():
29
29
  result = runner.invoke(dt_cli, ["test_invalid_command"])
30
- assert "No such command 'test_invalid_command'." in result.stdout
30
+ assert "No such command 'test_invalid_command'." in result.output
31
31
 
32
32
 
33
33
  def test_no_creds_file_not_found():
34
34
  result = runner.invoke(dt_cli, ["iris_investigate", "--domain", "domaintools.com"])
35
35
  assert "No such file or directory" in result.stdout
36
+
37
+
38
+ def test_iris_investigate_irisql_flag_accepted():
39
+ result = runner.invoke(dt_cli, ["iris_investigate", "--irisql", "# IrisQL-1.0\nDOMAIN CONTAINS \"phishing\"", "--help"])
40
+ assert "--irisql" in result.stdout
41
+
42
+
@@ -1 +0,0 @@
1
- 2.7.4
@@ -1,50 +0,0 @@
1
- """Tests async interaction support for DomainTools APIs"""
2
-
3
- import asyncio
4
- import pytest
5
-
6
- from tests.settings import api, vcr
7
-
8
-
9
- @vcr.use_cassette
10
- @pytest.mark.asyncio
11
- async def test_async_iteration():
12
- results = await api.domain_search("google")
13
- assert results
14
-
15
- list_of_domains = []
16
- for domain in results:
17
- list_of_domains += domain
18
-
19
- for domain in list_of_domains:
20
- assert type(domain) == str
21
-
22
-
23
- @vcr.use_cassette
24
- @pytest.mark.asyncio
25
- async def test_async_context_manager():
26
- results = await api.domain_search("google")
27
- assert results
28
-
29
-
30
- @vcr.use_cassette
31
- @pytest.mark.asyncio
32
- async def test_async_simple_await():
33
- results = await api.domain_search("google")
34
- assert results
35
-
36
-
37
- @vcr.use_cassette
38
- @pytest.mark.asyncio
39
- async def test_async_simple_await_post():
40
- investigation_results = await api.iris_investigate(domains=["amazon.com", "google.com"])
41
- assert investigation_results["results_count"]
42
- for result in investigation_results:
43
- assert result["domain"] in ["amazon.com", "google.com"]
44
-
45
-
46
- @vcr.use_cassette
47
- @pytest.mark.asyncio
48
- async def test_async_simple_await_patch():
49
- detect_results = await api.iris_detect_manage_watchlist_domains(watchlist_domain_ids=["gae08rdVWG"], state="watched")
50
- assert detect_results["watchlist_domains"][0]["state"] == "watched"
File without changes