Skip to content

Commit b0341cd

Browse files
authored
Merge pull request #196 from DomainTools/IDEV-2477
[IDEV-2477] added irisQL support in python wrapper
2 parents fd07d6c + 1976f6e commit b0341cd

14 files changed

Lines changed: 228 additions & 43 deletions

File tree

.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
```

domaintools/api.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -662,13 +662,14 @@ def iris_investigate(
662662
updated_after=None,
663663
include_domains_with_missing_field=None,
664664
exclude_domains_with_missing_field=None,
665+
irisql=None,
665666
**kwargs,
666667
):
667668
"""Returns back a list of domains based on the provided filters.
668669
669670
You can loop over results of your investigation as if it was a native Python list:
670671
671-
for result in api.iris_investigate(ip='199.30.228.112'): # Enables looping over all related results
672+
for result in api.iris_investigate(ip='199.30.228.112'):
672673
673674
api.iris_investigate(QUERY)['results_count'] Returns the number of results returned with this request
674675
api.iris_investigate(QUERY)['total_count'] Returns the number of results available within Iris
@@ -677,9 +678,27 @@ def iris_investigate(
677678
api.iris_investigate(QUERY)['position'] Returns the position key that can be used to retrieve the next page:
678679
next_page = api.iris_investigate(QUERY, position=api.iris_investigate(QUERY)['position'])
679680
680-
for enrichment in api.iris_enrich(i): # Enables looping over all returned enriched domains
681+
IrisQL mode (mutually exclusive with all other search parameters):
682+
683+
irisql: str: A raw IrisQL query string. Must begin with '# IrisQL-1.0'.
684+
Sent as a raw POST body (text/plain). When set, all domain/filter params are ignored.
685+
Pagination params (page_size, sort_by, position) are still supported via **kwargs.
686+
687+
Example:
688+
api.iris_investigate(irisql='# IrisQL-1.0\\nDOMAIN CONTAINS "phishing"', page_size=50, sort_by='risk_score')
681689
682690
"""
691+
if irisql is not None:
692+
if domains:
693+
print("Warning: irisql is set — ignoring 'domains' and other search parameters. IrisQL query takes precedence.")
694+
return self._results(
695+
"iris-investigate",
696+
"/v1/iris-investigate/",
697+
items_path=("results",),
698+
irisql=irisql,
699+
**kwargs,
700+
)
701+
683702
# We put search_hash in the signature definition so the CLI can see it as a valid arg
684703
if search_hash:
685704
kwargs["search_hash"] = search_hash

domaintools/base_results.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ def _make_request(self):
108108
"iris-enrich",
109109
"iris-detect-escalate-domains",
110110
]:
111+
if self.product == "iris-investigate" and "irisql" in self.kwargs:
112+
irisql_query = self.kwargs["irisql"]
113+
auth_keys = {"api_username", "timestamp", "signature", "api_key"}
114+
query_params = {k: v for k, v in self.kwargs.items() if k != "irisql" and k not in auth_keys}
115+
query_params.update(self.api.extra_request_params)
116+
return session.post(
117+
url=self.url,
118+
content=irisql_query,
119+
params=query_params,
120+
headers={**headers, "Content-Type": "text/plain", "X-Api-Key": self.api.key},
121+
)
111122
post_data = self.kwargs.copy()
112123
post_data.update(self.api.extra_request_params)
113124
return session.post(url=self.url, data=post_data, headers=headers)
@@ -277,6 +288,8 @@ def __exit__(self, *args):
277288

278289
@property
279290
def json(self):
291+
if self._data is not None:
292+
return self
280293
self.kwargs.pop("format", None)
281294
return self.__class__(
282295
format="json",

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/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_async/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,21 @@ async def _make_async_request(self, session):
4646
session_params_and_headers = self._get_session_params_and_headers()
4747
headers = session_params_and_headers.get("headers")
4848
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)
5264
elif self.product in ["iris-detect-manage-watchlist-domains"]:
5365
patch_data = self.kwargs.copy()
5466
patch_data.update(self.api.extra_request_params, headers=headers)

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Homepage = "https://github.com/domaintools/python_api"
4141
domaintools = "domaintools.cli:run"
4242

4343
[project.optional-dependencies]
44-
test = ["pytest", "mock"]
44+
test = ["pytest", "mock", "vcrpy", "pytest-asyncio", "pytest-cov", "yarl"]
4545

4646
[tool.setuptools]
4747
packages = [
@@ -58,5 +58,8 @@ domaintools = ["specs/*.yaml"]
5858
[tool.setuptools.dynamic]
5959
version = { "file" = "VERSION" }
6060

61+
[tool.pytest.ini_options]
62+
asyncio_mode = "auto"
63+
6164
[tool.black]
6265
line-length = 110

requirements/common.txt

Lines changed: 0 additions & 1 deletion
This file was deleted.

requirements/development.txt

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)