From 7585abb3ce7ebdbb23baf5abe5f4353f418edc8c Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:29:22 +0100 Subject: [PATCH 1/8] Refactor: Extract BaseClient from Client to share logic between clients --- leakix/__init__.py | 2 +- leakix/base.py | 73 ++++++++++++++++++++++++++++++++++++++++ leakix/client.py | 83 +++++++--------------------------------------- 3 files changed, 86 insertions(+), 72 deletions(-) create mode 100644 leakix/base.py diff --git a/leakix/__init__.py b/leakix/__init__.py index 31c406e..8be0ddc 100644 --- a/leakix/__init__.py +++ b/leakix/__init__.py @@ -1,7 +1,7 @@ from importlib.metadata import version +from leakix.base import HostResult as HostResult from leakix.client import Client as Client -from leakix.client import HostResult as HostResult from leakix.client import Scope as Scope from leakix.domain import L9Subdomain as L9Subdomain from leakix.field import ( diff --git a/leakix/base.py b/leakix/base.py new file mode 100644 index 0000000..42a23d1 --- /dev/null +++ b/leakix/base.py @@ -0,0 +1,73 @@ +"""Shared logic between sync and async LeakIX clients.""" + +import dataclasses +from importlib.metadata import version +from typing import Any, cast + +from l9format import l9format +from l9format.l9format import Model + +from leakix.domain import L9Subdomain +from leakix.plugin import APIResult +from leakix.response import AbstractResponse + +DEFAULT_URL = "https://leakix.net" + + +@dataclasses.dataclass +class HostResult(Model): + Services: list[l9format.L9Event] | None = None + Leaks: list[l9format.L9Event] | None = None + + +class BaseClient: + """Shared initialization and response transformation logic.""" + + MAX_RESULTS_PER_PAGE = 20 + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = DEFAULT_URL, + ) -> None: + self.api_key = api_key + self.base_url = base_url if base_url else DEFAULT_URL + self.headers: dict[str, str] = { + "Accept": "application/json", + "User-agent": f"leakix-client-python/{version('leakix')}", + } + if api_key: + self.headers["api-key"] = api_key + + @staticmethod + def _parse_events(response: AbstractResponse) -> AbstractResponse: + """Parse raw JSON dicts into L9Event objects on a success response.""" + if response.is_success(): + response.response_json = [ + l9format.L9Event.from_dict(res) for res in response.response_json + ] + return response + + @staticmethod + def _parse_host_result(response: AbstractResponse) -> AbstractResponse: + """Parse a host/domain response into {services, leaks} format.""" + if response.is_success(): + data: dict[str, Any] = response.json() + formatted = cast(HostResult, HostResult.from_dict(data)) + response.response_json = { + "services": formatted.Services, + "leaks": formatted.Leaks, + } + return response + + @staticmethod + def _parse_plugins(response: AbstractResponse) -> AbstractResponse: + if response.is_success(): + response.response_json = [APIResult.from_dict(d) for d in response.json()] + return response + + @staticmethod + def _parse_subdomains(response: AbstractResponse) -> AbstractResponse: + if response.is_success(): + response.response_json = [L9Subdomain.from_dict(d) for d in response.json()] + return response diff --git a/leakix/client.py b/leakix/client.py index 067d0e2..187ae2d 100644 --- a/leakix/client.py +++ b/leakix/client.py @@ -1,15 +1,12 @@ -import dataclasses import json from enum import Enum -from importlib.metadata import version -from typing import Any, cast +from typing import Any import requests from l9format import l9format -from l9format.l9format import Model -from leakix.domain import L9Subdomain -from leakix.plugin import APIResult +from leakix.base import BaseClient +from leakix.base import HostResult as HostResult from leakix.query import EmptyQuery, Query from leakix.response import ( AbstractResponse, @@ -24,32 +21,7 @@ class Scope(Enum): LEAK = "leak" -@dataclasses.dataclass -class HostResult(Model): - Services: list[l9format.L9Event] | None = None - Leaks: list[l9format.L9Event] | None = None - - -DEFAULT_URL = "https://leakix.net" - - -class Client: - MAX_RESULTS_PER_PAGE = 20 - - def __init__( - self, - api_key: str | None = None, - base_url: str | None = DEFAULT_URL, - ) -> None: - self.api_key = api_key - self.base_url = base_url if base_url else DEFAULT_URL - self.headers: dict[str, str] = { - "Accept": "application/json", - "User-agent": f"leakix-client-python/{version('leakix')}", - } - if api_key: - self.headers["api-key"] = api_key - +class Client(BaseClient): def __get(self, url: str, params: dict[str, Any] | None) -> AbstractResponse: r = requests.get( url, @@ -101,7 +73,7 @@ def get( else: serialized_query = " ".join(q.serialize() for q in queries) url = f"{self.base_url}/search" - r = self.__get( + return self.__get( url=url, params={ "scope": scope.value, @@ -109,34 +81,18 @@ def get( "page": page, }, ) - return r def get_service( self, queries: list[Query] | None = None, page: int = 0 ) -> AbstractResponse: - """ - Shortcut for `get` with the scope `Scope.Service`. - - """ - r = self.get(Scope.SERVICE, queries=queries, page=page) - if r.is_success(): - r.response_json = [ - l9format.L9Event.from_dict(res) for res in r.response_json - ] - return r + """Shortcut for `get` with the scope `Scope.SERVICE`.""" + return self._parse_events(self.get(Scope.SERVICE, queries=queries, page=page)) def get_leak( self, queries: list[Query] | None = None, page: int = 0 ) -> AbstractResponse: - """ - Shortcut for `get` with the scope `Scope.Leak`. - """ - r = self.get(Scope.LEAK, queries=queries, page=page) - if r.is_success(): - r.response_json = [ - l9format.L9Event.from_dict(res) for res in r.response_json - ] - return r + """Shortcut for `get` with the scope `Scope.LEAK`.""" + return self._parse_events(self.get(Scope.LEAK, queries=queries, page=page)) def get_host(self, ipv4: str) -> AbstractResponse: """ @@ -144,16 +100,7 @@ def get_host(self, ipv4: str) -> AbstractResponse: moment. """ url = f"{self.base_url}/host/{ipv4}" - r = self.__get(url, params=None) - if r.is_success(): - response_json = r.json() - formatted_result = cast(HostResult, HostResult.from_dict(response_json)) - response_json = { - "services": formatted_result.Services, - "leaks": formatted_result.Leaks, - } - r.response_json = response_json - return r + return self._parse_host_result(self.__get(url, params=None)) def get_plugins(self) -> AbstractResponse: """ @@ -166,10 +113,7 @@ def get_plugins(self) -> AbstractResponse: For the paid plans, have a look at https://leakix.net/plans. """ url = f"{self.base_url}/api/plugins" - r = self.__get(url, params=None) - if r.is_success(): - r.response_json = [APIResult.from_dict(d) for d in r.json()] - return r + return self._parse_plugins(self.__get(url, params=None)) def get_subdomains(self, domain: str) -> AbstractResponse: """ @@ -178,10 +122,7 @@ def get_subdomains(self, domain: str) -> AbstractResponse: To get back a JSON/Python dictionary, use the method `to_dict` on the individual element of the response object. """ url = f"{self.base_url}/api/subdomains/{domain}" - r = self.__get(url, params=None) - if r.is_success(): - r.response_json = [L9Subdomain.from_dict(d) for d in r.json()] - return r + return self._parse_subdomains(self.__get(url, params=None)) def bulk_export(self, queries: list[Query] | None = None) -> AbstractResponse: url = f"{self.base_url}/bulk/search" From 7c8ca1ab3b0e91bed8280fc850d781b922feaa8b Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:30:30 +0100 Subject: [PATCH 2/8] Feat: Add serialize_queries helper and widen Query to AbstractQuery --- leakix/client.py | 35 ++++++++++++++--------------------- leakix/query.py | 7 +++++++ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/leakix/client.py b/leakix/client.py index 187ae2d..3c7bd27 100644 --- a/leakix/client.py +++ b/leakix/client.py @@ -7,7 +7,7 @@ from leakix.base import BaseClient from leakix.base import HostResult as HostResult -from leakix.query import EmptyQuery, Query +from leakix.query import AbstractQuery, serialize_queries from leakix.response import ( AbstractResponse, ErrorResponse, @@ -41,7 +41,7 @@ def __get(self, url: str, params: dict[str, Any] | None) -> AbstractResponse: def get( self, scope: Scope, - queries: list[Query] | None = None, + queries: list[AbstractQuery] | None = None, page: int = 0, ) -> AbstractResponse: """ @@ -68,10 +68,7 @@ def get( """ if page < 0: raise ValueError("Page argument must be a positive integer") - if queries is None or len(queries) == 0: - serialized_query = EmptyQuery().serialize() - else: - serialized_query = " ".join(q.serialize() for q in queries) + serialized_query = serialize_queries(queries) url = f"{self.base_url}/search" return self.__get( url=url, @@ -83,13 +80,13 @@ def get( ) def get_service( - self, queries: list[Query] | None = None, page: int = 0 + self, queries: list[AbstractQuery] | None = None, page: int = 0 ) -> AbstractResponse: """Shortcut for `get` with the scope `Scope.SERVICE`.""" return self._parse_events(self.get(Scope.SERVICE, queries=queries, page=page)) def get_leak( - self, queries: list[Query] | None = None, page: int = 0 + self, queries: list[AbstractQuery] | None = None, page: int = 0 ) -> AbstractResponse: """Shortcut for `get` with the scope `Scope.LEAK`.""" return self._parse_events(self.get(Scope.LEAK, queries=queries, page=page)) @@ -124,13 +121,11 @@ def get_subdomains(self, domain: str) -> AbstractResponse: url = f"{self.base_url}/api/subdomains/{domain}" return self._parse_subdomains(self.__get(url, params=None)) - def bulk_export(self, queries: list[Query] | None = None) -> AbstractResponse: + def bulk_export( + self, queries: list[AbstractQuery] | None = None + ) -> AbstractResponse: url = f"{self.base_url}/bulk/search" - if queries is None or len(queries) == 0: - serialized_query = EmptyQuery().serialize() - else: - serialized_query = " ".join(q.serialize() for q in queries) - params = {"q": serialized_query} + params = {"q": serialize_queries(queries)} r = requests.get(url, params=params, headers=self.headers, stream=True) if r.status_code == 200: response_json = [] @@ -146,7 +141,7 @@ def bulk_export(self, queries: list[Query] | None = None) -> AbstractResponse: return ErrorResponse(response=r, response_json=r.json()) def bulk_export_last_event( - self, queries: list[Query] | None = None + self, queries: list[AbstractQuery] | None = None ) -> AbstractResponse: response = self.bulk_export(queries) if response.is_success(): @@ -160,13 +155,11 @@ def bulk_export_last_event( aggreg.events = [sorted_events[0]] return response - def bulk_service(self, queries: list[Query] | None = None) -> AbstractResponse: + def bulk_service( + self, queries: list[AbstractQuery] | None = None + ) -> AbstractResponse: url = f"{self.base_url}/bulk/service" - if queries is None or len(queries) == 0: - serialized_query = EmptyQuery().serialize() - else: - serialized_query = " ".join(q.serialize() for q in queries) - params = {"q": serialized_query} + params = {"q": serialize_queries(queries)} r = requests.get(url, params=params, headers=self.headers, stream=True) if r.status_code == 200: response_json = [] diff --git a/leakix/query.py b/leakix/query.py index d06dbbd..7574862 100644 --- a/leakix/query.py +++ b/leakix/query.py @@ -75,3 +75,10 @@ def __init__(self, raw_q: str) -> None: def serialize(self) -> str: return self.raw_q + + +def serialize_queries(queries: list[AbstractQuery] | None) -> str: + """Serialize a list of queries into a query string for the API.""" + if queries is None or len(queries) == 0: + return EmptyQuery().serialize() + return " ".join(q.serialize() for q in queries) From fe51f158abfd489c8ffbbffc6360929501392dad Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:30:47 +0100 Subject: [PATCH 3/8] Fix: Return SuccessResponse for 204 No Content instead of ErrorResponse --- leakix/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/leakix/client.py b/leakix/client.py index 3c7bd27..baa00dc 100644 --- a/leakix/client.py +++ b/leakix/client.py @@ -34,7 +34,7 @@ def __get(self, url: str, params: dict[str, Any] | None) -> AbstractResponse: elif r.status_code == 429: return RateLimitResponse(response=r) elif r.status_code == 204: - return ErrorResponse(response=r, response_json=[], status_code=200) + return SuccessResponse(response=r, response_json=[]) else: return ErrorResponse(response=r, response_json=r.json()) @@ -136,7 +136,7 @@ def bulk_export( elif r.status_code == 429: return RateLimitResponse(response=r) elif r.status_code == 204: - return ErrorResponse(response=r, response_json=[], status_code=200) + return SuccessResponse(response=r, response_json=[]) else: return ErrorResponse(response=r, response_json=r.json()) @@ -170,6 +170,6 @@ def bulk_service( elif r.status_code == 429: return RateLimitResponse(response=r) elif r.status_code == 204: - return ErrorResponse(response=r, response_json=[], status_code=200) + return SuccessResponse(response=r, response_json=[]) else: return ErrorResponse(response=r, response_json=r.json()) From 91c65ab32e6d72f85c19914a4b5cb123274ba2ea Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:31:32 +0100 Subject: [PATCH 4/8] Feat: Add search(), get_domain(), and streaming bulk_export_stream() --- leakix/client.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/leakix/client.py b/leakix/client.py index baa00dc..95aba3f 100644 --- a/leakix/client.py +++ b/leakix/client.py @@ -1,6 +1,7 @@ import json +from collections.abc import Iterator from enum import Enum -from typing import Any +from typing import Any, cast import requests from l9format import l9format @@ -173,3 +174,48 @@ def bulk_service( return SuccessResponse(response=r, response_json=[]) else: return ErrorResponse(response=r, response_json=r.json()) + + def get_domain(self, domain: str) -> AbstractResponse: + """ + Returns the list of services and associated leaks for a given domain. + """ + url = f"{self.base_url}/domain/{domain}" + return self._parse_host_result(self.__get(url, params=None)) + + def search( + self, query: str, scope: Scope = Scope.LEAK, page: int = 0 + ) -> AbstractResponse: + """ + Simple search using a raw query string (same syntax as the website). + + Example: + >>> client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK) + >>> client.search("+country:FR +port:22", scope=Scope.SERVICE) + """ + if page < 0: + raise ValueError("Page argument must be a positive integer") + url = f"{self.base_url}/search" + r = self.__get( + url=url, + params={"scope": scope.value, "q": query, "page": page}, + ) + return self._parse_events(r) + + def bulk_export_stream( + self, queries: list[AbstractQuery] | None = None + ) -> Iterator[l9format.L9Aggregation]: + """ + Streaming version of bulk_export. Yields L9Aggregation objects one by one. + More memory efficient for large result sets. + """ + url = f"{self.base_url}/bulk/search" + params = {"q": serialize_queries(queries)} + r = requests.get(url, params=params, headers=self.headers, stream=True) + if r.status_code != 200: + return + for line in r.iter_lines(): + json_event = json.loads(line) + yield cast( + l9format.L9Aggregation, + l9format.L9Aggregation.from_dict(json_event), + ) From 0ec6884dd0fdc6448cabd6181aec3af42e0624e5 Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:32:04 +0100 Subject: [PATCH 5/8] Feat: Add AsyncClient with httpx for async API access --- leakix/__init__.py | 2 + leakix/async_client.py | 188 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 +- 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 leakix/async_client.py diff --git a/leakix/__init__.py b/leakix/__init__.py index 8be0ddc..e955232 100644 --- a/leakix/__init__.py +++ b/leakix/__init__.py @@ -1,5 +1,6 @@ from importlib.metadata import version +from leakix.async_client import AsyncClient as AsyncClient from leakix.base import HostResult as HostResult from leakix.client import Client as Client from leakix.client import Scope as Scope @@ -71,6 +72,7 @@ __all__ = [ "__version__", + "AsyncClient", "Client", "HostResult", "L9Subdomain", diff --git a/leakix/async_client.py b/leakix/async_client.py new file mode 100644 index 0000000..89c85a5 --- /dev/null +++ b/leakix/async_client.py @@ -0,0 +1,188 @@ +"""Async LeakIX API client using httpx.""" + +import json +from collections.abc import AsyncIterator +from typing import Any, cast + +import httpx +from l9format import l9format + +from leakix.base import DEFAULT_URL, BaseClient +from leakix.client import Scope +from leakix.query import AbstractQuery, serialize_queries +from leakix.response import ( + AbstractResponse, + ErrorResponse, + RateLimitResponse, + SuccessResponse, +) + +DEFAULT_TIMEOUT = 30.0 + + +class AsyncClient(BaseClient): + """Async client for the LeakIX API. + + Mirrors the sync Client API but uses httpx for async operations. + All methods return AbstractResponse for consistency with the sync client. + """ + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = DEFAULT_URL, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url) + self.timeout = timeout + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create the HTTP client.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers=self.headers, + timeout=self.timeout, + ) + return self._client + + async def close(self) -> None: + """Close the HTTP client.""" + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> "AsyncClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + async def __get( + self, path: str, params: dict[str, Any] | None = None + ) -> AbstractResponse: + """Make a GET request and return an AbstractResponse.""" + client = await self._get_client() + r = await client.get(path, params=params) + if r.status_code == 200: + response_json = r.json() if r.content else [] + return SuccessResponse(response=r, response_json=response_json) + elif r.status_code == 429: + return RateLimitResponse(response=r) + elif r.status_code == 204: + return SuccessResponse(response=r, response_json=[]) + else: + return ErrorResponse(response=r, response_json=r.json()) + + async def get( + self, + scope: Scope, + queries: list[AbstractQuery] | None = None, + page: int = 0, + ) -> AbstractResponse: + """Search LeakIX for services or leaks.""" + if page < 0: + raise ValueError("Page argument must be a positive integer") + serialized_query = serialize_queries(queries) + return await self.__get( + "/search", + params={"scope": scope.value, "q": serialized_query, "page": page}, + ) + + async def get_service( + self, queries: list[AbstractQuery] | None = None, page: int = 0 + ) -> AbstractResponse: + """Shortcut for get with scope=Scope.SERVICE.""" + return self._parse_events( + await self.get(Scope.SERVICE, queries=queries, page=page) + ) + + async def get_leak( + self, queries: list[AbstractQuery] | None = None, page: int = 0 + ) -> AbstractResponse: + """Shortcut for get with scope=Scope.LEAK.""" + return self._parse_events( + await self.get(Scope.LEAK, queries=queries, page=page) + ) + + async def search( + self, query: str, scope: Scope = Scope.LEAK, page: int = 0 + ) -> AbstractResponse: + """ + Simple search using a raw query string (same syntax as the website). + + Example: + >>> await client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK) + """ + if page < 0: + raise ValueError("Page argument must be a positive integer") + r = await self.__get( + "/search", + params={"scope": scope.value, "q": query, "page": page}, + ) + return self._parse_events(r) + + async def get_host(self, ipv4: str) -> AbstractResponse: + """Returns the list of services and associated leaks for a given host.""" + return self._parse_host_result(await self.__get(f"/host/{ipv4}")) + + async def get_domain(self, domain: str) -> AbstractResponse: + """Returns the list of services and associated leaks for a given domain.""" + return self._parse_host_result(await self.__get(f"/domain/{domain}")) + + async def get_plugins(self) -> AbstractResponse: + """Returns the list of plugins the authenticated user has access to.""" + return self._parse_plugins(await self.__get("/api/plugins")) + + async def get_subdomains(self, domain: str) -> AbstractResponse: + """Returns the list of subdomains for a given domain.""" + return self._parse_subdomains(await self.__get(f"/api/subdomains/{domain}")) + + async def bulk_export( + self, queries: list[AbstractQuery] | None = None + ) -> AbstractResponse: + """Bulk export leaks (Pro API feature).""" + serialized_query = serialize_queries(queries) + client = await self._get_client() + async with client.stream( + "GET", "/bulk/search", params={"q": serialized_query} + ) as r: + if r.status_code == 200: + response_json = [] + async for line in r.aiter_lines(): + if line: + json_event = json.loads(line) + response_json.append( + l9format.L9Aggregation.from_dict(json_event) + ) + return SuccessResponse(response=r, response_json=response_json) + elif r.status_code == 429: + return RateLimitResponse(response=r) + elif r.status_code == 204: + return SuccessResponse(response=r, response_json=[]) + else: + await r.aread() + return ErrorResponse(response=r, response_json=r.json()) + + async def bulk_export_stream( + self, queries: list[AbstractQuery] | None = None + ) -> AsyncIterator[l9format.L9Aggregation]: + """ + Streaming version of bulk_export. Yields L9Aggregation objects one by one. + More memory efficient for large result sets. + """ + serialized_query = serialize_queries(queries) + client = await self._get_client() + async with client.stream( + "GET", "/bulk/search", params={"q": serialized_query} + ) as r: + if r.status_code != 200: + return + async for line in r.aiter_lines(): + if line: + json_event = json.loads(line) + yield cast( + l9format.L9Aggregation, + l9format.L9Aggregation.from_dict(json_event), + ) diff --git a/pyproject.toml b/pyproject.toml index 648c835..b96d25f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,15 @@ [project] name = "leakix" -version = "0.1.10" +version = "0.2.0" description = "Official python client for LeakIX (https://leakix.net)" -authors = [{ name = "Danny Willems", email = "danny@leakix.net" }] +authors = [ + { name = "Danny Willems", email = "danny@leakix.net" }, + { name = "Valentin Lobstein", email = "valentin@leakix.net" }, +] requires-python = ">=3.11" dependencies = [ "requests", + "httpx>=0.28.0", "l9format==2.0.0", "fire>=0.5,<0.8", ] From 76d84cc463ac2968c7694c7d036f7d6837360eaa Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:34:34 +0100 Subject: [PATCH 6/8] Docs: Add async examples and update sync examples --- example/example_async_client.py | 37 +++++++++++++++++++++++++++++++++ example/example_client.py | 36 +++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 example/example_async_client.py diff --git a/example/example_async_client.py b/example/example_async_client.py new file mode 100644 index 0000000..1e50456 --- /dev/null +++ b/example/example_async_client.py @@ -0,0 +1,37 @@ +"""Example usage of the async LeakIX client.""" + +import asyncio + +import decouple + +from leakix import AsyncClient, Scope + + +API_KEY = decouple.config("API_KEY") + + +async def example_search_services(): + """Search for services using a raw query string.""" + async with AsyncClient(api_key=API_KEY) as client: + response = await client.search("+country:FR +port:22", scope=Scope.SERVICE) + assert response.status_code() == 200 + for event in response.json(): + print(f"{event.ip}:{event.port} - {event.summary}") + + +async def example_search_leaks(): + """Search for leaks using a raw query string.""" + async with AsyncClient(api_key=API_KEY) as client: + response = await client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK) + assert response.status_code() == 200 + for event in response.json(): + print(f"{event.host} - {event.summary}") + + +async def main(): + await example_search_services() + await example_search_leaks() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/example/example_client.py b/example/example_client.py index 8dc2289..fecb8a4 100644 --- a/example/example_client.py +++ b/example/example_client.py @@ -2,7 +2,7 @@ import decouple -from leakix import Client +from leakix import Client, Scope from leakix.field import CountryField, Operator, PluginField, TimeField from leakix.plugin import Plugin from leakix.query import MustNotQuery, MustQuery, RawQuery @@ -118,6 +118,36 @@ def example_get_subdomains(): print(response.json()) +def example_search_simple(): + """Simple search using query string syntax (same as the website).""" + response = CLIENT.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK) + for event in response.json(): + print(event.ip) + + +def example_search_service(): + """Search for services with multiple filters.""" + response = CLIENT.search("+country:FR +port:22", scope=Scope.SERVICE) + for event in response.json(): + print(event.ip, event.port) + + +def example_get_domain(): + """Get services and leaks for a domain.""" + response = CLIENT.get_domain("example.com") + if response.is_success(): + print("Services:", response.json()["services"]) + print("Leaks:", response.json()["leaks"]) + + +def example_bulk_export_stream(): + """Streaming bulk export - memory efficient for large datasets.""" + query = MustQuery(field=PluginField(Plugin.GitConfigHttpPlugin)) + for aggregation in CLIENT.bulk_export_stream(queries=[query]): + for event in aggregation.events: + print(event.ip) + + if __name__ == "__main__": example_get_host_filter_plugin() example_get_service_filter_plugin() @@ -131,3 +161,7 @@ def example_get_subdomains(): example_bulk_service() example_bulk_export_last_event() example_get_subdomains() + example_search_simple() + example_search_service() + example_get_domain() + example_bulk_export_stream() From 44b55b6dfb01121f77ca3cffd566959019e6bb53 Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:35:10 +0100 Subject: [PATCH 7/8] Docs: Update CHANGELOG for v0.2.0 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecbcbcf..74fcb68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,26 @@ and this project adheres to ## [Unreleased] +### Added + +- AsyncClient with full async/await support using httpx +- Simple `search()` API accepting raw query strings +- `get_domain()` method for domain lookups +- Streaming `bulk_export_stream()` for memory-efficient exports +- `serialize_queries()` helper to reduce query serialization duplication +- Async example in `example/example_async_client.py` + ### Changed +- Use `__get` in both sync and async clients for uniform internal API +- Widen query type from `Query` to `AbstractQuery` to accept `RawQuery` directly - Updated l9format requirement from =1.3.2 to =1.4.0 ([ae676d9]) - Updated l9format requirement from =1.4.0 to =2.0.0 ([df916e5], [#68]) +### Fixed + +- Return `SuccessResponse` for HTTP 204 No Content instead of `ErrorResponse` + ### Added - Add Python 3.11, 3.12, and 3.14 support ([d111628]) From 9e5468f97411094d4213f064014157c5beb917cc Mon Sep 17 00:00:00 2001 From: Valentin Lobstein Date: Tue, 17 Mar 2026 11:36:50 +0100 Subject: [PATCH 8/8] Fix: Sort imports in async example --- example/example_async_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/example/example_async_client.py b/example/example_async_client.py index 1e50456..9484005 100644 --- a/example/example_async_client.py +++ b/example/example_async_client.py @@ -6,7 +6,6 @@ from leakix import AsyncClient, Scope - API_KEY = decouple.config("API_KEY")