Skip to content

Commit b35d12c

Browse files
authored
Merge pull request #199 from DomainTools/release-2.8.0
DT Python Wrapper Release 2.8.0
2 parents fd07d6c + 33a704f commit b35d12c

60 files changed

Lines changed: 12142 additions & 51 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build-publish.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ jobs:
4545
- name: Install dependencies
4646
run: |
4747
python -m pip install --upgrade pip
48-
pip install -e .
49-
pip install -r ./requirements/development.txt
48+
pip install -e ".[test]"
5049
5150
- name: Setup E2E environment
5251
run: |

README.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,32 @@ You can get the status code of a response outside of exception handling by doing
145145
api.domain_profile('google.com').status == 200
146146
```
147147

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+
148174
Using the API Asynchronously
149175
===================
150176

@@ -201,6 +227,18 @@ Optionally, you can specify the desired format (html, xml, json, or list) of the
201227
domaintools domain_search google --max_length 10 -u $TEST_USER -k $TEST_KEY -f html
202228
```
203229

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+
204242
To avoid having to type in your API key repeatedly, you can specify them in `~/.dtapi` separated by a new line:
205243

206244
```bash
@@ -289,12 +327,11 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
289327
source venv/bin/activate
290328
```
291329

292-
- Install dependencies.
330+
- Install dependencies (with test extras):
293331
```bash
294-
pip install -r requirements/development.txt
332+
pip install -e ".[test]"
295333
```
296-
297-
- From the python_api project root directory, install the package.
334+
Or without test dependencies:
298335
```bash
299336
pip install -e .
300337
```

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.7.4
1+
2.8.0

domaintools/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@
2020
2121
"""
2222

23-
current = "2.7.4"
23+
current = "2.8.0"

domaintools/api.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,57 @@ def domain_profile(self, query, **kwargs):
275275
"""Returns a profile for the specified domain name"""
276276
return self._results("domain-profile", "/v1/{0}".format(query))
277277

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+
278329
def domain_search(
279330
self,
280331
query,
@@ -662,13 +713,14 @@ def iris_investigate(
662713
updated_after=None,
663714
include_domains_with_missing_field=None,
664715
exclude_domains_with_missing_field=None,
716+
irisql=None,
665717
**kwargs,
666718
):
667719
"""Returns back a list of domains based on the provided filters.
668720
669721
You can loop over results of your investigation as if it was a native Python list:
670722
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'):
672724
673725
api.iris_investigate(QUERY)['results_count'] Returns the number of results returned with this request
674726
api.iris_investigate(QUERY)['total_count'] Returns the number of results available within Iris
@@ -677,9 +729,27 @@ def iris_investigate(
677729
api.iris_investigate(QUERY)['position'] Returns the position key that can be used to retrieve the next page:
678730
next_page = api.iris_investigate(QUERY, position=api.iris_investigate(QUERY)['position'])
679731
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')
681740
682741
"""
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+
683753
# We put search_hash in the signature definition so the CLI can see it as a valid arg
684754
if search_hash:
685755
kwargs["search_hash"] = search_hash

domaintools/base_results.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
RequestUriTooLongException,
2626
)
2727

28-
2928
try: # pragma: no cover
3029
from collections.abc import MutableMapping, MutableSequence
3130
except ImportError: # pragma: no cover
@@ -108,6 +107,19 @@ def _make_request(self):
108107
"iris-enrich",
109108
"iris-detect-escalate-domains",
110109
]:
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+
)
111123
post_data = self.kwargs.copy()
112124
post_data.update(self.api.extra_request_params)
113125
return session.post(url=self.url, data=post_data, headers=headers)
@@ -153,21 +165,27 @@ def data(self):
153165
self._data = results.json()
154166
else:
155167
self._data = results.text
156-
157168
self.check_limit_exceeded()
158169

159170
return self._data
160171

161172
def check_limit_exceeded(self):
162173
limit_exceeded, reason = False, ""
174+
163175
if isinstance(self._data, dict) and (
164176
"response" in self._data
165177
and "limit_exceeded" in self._data["response"]
166178
and self._data["response"]["limit_exceeded"] is True
167179
):
168180
limit_exceeded, reason = True, self._data["response"]["message"]
169181
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
171189

172190
if limit_exceeded:
173191
raise ServiceException(503, f"Limit Exceeded {reason}")
@@ -277,6 +295,8 @@ def __exit__(self, *args):
277295

278296
@property
279297
def json(self):
298+
if self._data is not None:
299+
return self
280300
self.kwargs.pop("format", None)
281301
return self.__class__(
282302
format="json",
@@ -341,9 +361,7 @@ def html(self):
341361
)
342362

343363
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()])
347365

348366
def __str__(self):
349367
return str(

domaintools/cli/api.py

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -230,28 +230,21 @@ def run(cls, name: str, params: Optional[Dict] = {}, **kwargs):
230230
description=f"Preparing results with format of {response_format}...",
231231
)
232232

233+
if name not in ("available_api_calls",) and not getattr(response, "product", None) in RTTF_PRODUCTS_LIST:
234+
response.data()
235+
233236
output = cls._get_formatted_output(
234237
cmd_name=name, response=response, out_format=response_format
235238
)
236239

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)
248244
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")
255248
except Exception as e:
256249
if isinstance(e, ServiceException):
257250
code = typer.style(getattr(e, "code", 400), fg=typer.colors.BRIGHT_RED)

domaintools/cli/commands/domains.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,51 @@ def domain_profile(
8787
DTCLICommand.run(name=c.DOMAIN_PROFILE, params=ctx.params)
8888

8989

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+
90135
@dt_cli.command(
91136
name=c.DOMAIN_SEARCH,
92137
help=get_cli_helptext_by_name(command_name=c.DOMAIN_SEARCH),
@@ -666,6 +711,7 @@ def risk_evidence(
666711

667712
__all__ = [
668713
"brand_monitor",
714+
"domain_history",
669715
"domain_profile",
670716
"domain_search",
671717
"name_server_monitor",

domaintools/cli/commands/iris.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ def iris_investigate(
2323
create_date: str = typer.Option(None, "--create-date", help="The create date."),
2424
active: bool = typer.Option(None, "--active", help="The domains that are in active state"),
2525
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+
),
2631
src_file: str = typer.Option(
2732
None,
2833
"-s",

domaintools/cli/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# domains
66
BRAND_MONITOR = "brand_monitor"
7+
DOMAIN_HISTORY = "domain_history"
78
DOMAIN_PROFILE = "domain_profile"
89
DOMAIN_SEARCH = "domain_search"
910
HOSTING_HISTORY = "hosting_history"

0 commit comments

Comments
 (0)