1- import dataclasses
21import json
2+ from collections .abc import Iterator
33from enum import Enum
4- from importlib .metadata import version
54from typing import Any , cast
65
76import requests
87from l9format import l9format
9- from l9format .l9format import Model
108
11- from leakix .domain import L9Subdomain
12- from leakix .plugin import APIResult
13- from leakix .query import EmptyQuery , Query
9+ from leakix .base import BaseClient
10+ from leakix .base import HostResult as HostResult
11+ from leakix .query import Query , serialize_queries
1412from leakix .response import (
1513 AbstractResponse ,
1614 ErrorResponse ,
@@ -24,32 +22,7 @@ class Scope(Enum):
2422 LEAK = "leak"
2523
2624
27- @dataclasses .dataclass
28- class HostResult (Model ):
29- Services : list [l9format .L9Event ] | None = None
30- Leaks : list [l9format .L9Event ] | None = None
31-
32-
33- DEFAULT_URL = "https://leakix.net"
34-
35-
36- class Client :
37- MAX_RESULTS_PER_PAGE = 20
38-
39- def __init__ (
40- self ,
41- api_key : str | None = None ,
42- base_url : str | None = DEFAULT_URL ,
43- ) -> None :
44- self .api_key = api_key
45- self .base_url = base_url if base_url else DEFAULT_URL
46- self .headers : dict [str , str ] = {
47- "Accept" : "application/json" ,
48- "User-agent" : f"leakix-client-python/{ version ('leakix' )} " ,
49- }
50- if api_key :
51- self .headers ["api-key" ] = api_key
52-
25+ class Client (BaseClient ):
5326 def __get (self , url : str , params : dict [str , Any ] | None ) -> AbstractResponse :
5427 r = requests .get (
5528 url ,
@@ -96,64 +69,36 @@ def get(
9669 """
9770 if page < 0 :
9871 raise ValueError ("Page argument must be a positive integer" )
99- if queries is None or len (queries ) == 0 :
100- serialized_query = EmptyQuery ().serialize ()
101- else :
102- serialized_query = " " .join (q .serialize () for q in queries )
72+ serialized_query = serialize_queries (queries )
10373 url = f"{ self .base_url } /search"
104- r = self .__get (
74+ return self .__get (
10575 url = url ,
10676 params = {
10777 "scope" : scope .value ,
10878 "q" : serialized_query ,
10979 "page" : page ,
11080 },
11181 )
112- return r
11382
11483 def get_service (
11584 self , queries : list [Query ] | None = None , page : int = 0
11685 ) -> AbstractResponse :
117- """
118- Shortcut for `get` with the scope `Scope.Service`.
119-
120- """
121- r = self .get (Scope .SERVICE , queries = queries , page = page )
122- if r .is_success ():
123- r .response_json = [
124- l9format .L9Event .from_dict (res ) for res in r .response_json
125- ]
126- return r
86+ """Shortcut for `get` with the scope `Scope.SERVICE`."""
87+ return self ._parse_events (self .get (Scope .SERVICE , queries = queries , page = page ))
12788
12889 def get_leak (
12990 self , queries : list [Query ] | None = None , page : int = 0
13091 ) -> AbstractResponse :
131- """
132- Shortcut for `get` with the scope `Scope.Leak`.
133- """
134- r = self .get (Scope .LEAK , queries = queries , page = page )
135- if r .is_success ():
136- r .response_json = [
137- l9format .L9Event .from_dict (res ) for res in r .response_json
138- ]
139- return r
92+ """Shortcut for `get` with the scope `Scope.LEAK`."""
93+ return self ._parse_events (self .get (Scope .LEAK , queries = queries , page = page ))
14094
14195 def get_host (self , ipv4 : str ) -> AbstractResponse :
14296 """
14397 Returns the list of services and associated leaks for a given host. Only the ipv4 format is supported at the
14498 moment.
14599 """
146100 url = f"{ self .base_url } /host/{ ipv4 } "
147- r = self .__get (url , params = None )
148- if r .is_success ():
149- response_json = r .json ()
150- formatted_result = cast (HostResult , HostResult .from_dict (response_json ))
151- response_json = {
152- "services" : formatted_result .Services ,
153- "leaks" : formatted_result .Leaks ,
154- }
155- r .response_json = response_json
156- return r
101+ return self ._parse_host_result (self .__get (url , params = None ))
157102
158103 def get_plugins (self ) -> AbstractResponse :
159104 """
@@ -166,10 +111,7 @@ def get_plugins(self) -> AbstractResponse:
166111 For the paid plans, have a look at https://leakix.net/plans.
167112 """
168113 url = f"{ self .base_url } /api/plugins"
169- r = self .__get (url , params = None )
170- if r .is_success ():
171- r .response_json = [APIResult .from_dict (d ) for d in r .json ()]
172- return r
114+ return self ._parse_plugins (self .__get (url , params = None ))
173115
174116 def get_subdomains (self , domain : str ) -> AbstractResponse :
175117 """
@@ -178,18 +120,11 @@ def get_subdomains(self, domain: str) -> AbstractResponse:
178120 To get back a JSON/Python dictionary, use the method `to_dict` on the individual element of the response object.
179121 """
180122 url = f"{ self .base_url } /api/subdomains/{ domain } "
181- r = self .__get (url , params = None )
182- if r .is_success ():
183- r .response_json = [L9Subdomain .from_dict (d ) for d in r .json ()]
184- return r
123+ return self ._parse_subdomains (self .__get (url , params = None ))
185124
186125 def bulk_export (self , queries : list [Query ] | None = None ) -> AbstractResponse :
187126 url = f"{ self .base_url } /bulk/search"
188- if queries is None or len (queries ) == 0 :
189- serialized_query = EmptyQuery ().serialize ()
190- else :
191- serialized_query = " " .join (q .serialize () for q in queries )
192- params = {"q" : serialized_query }
127+ params = {"q" : serialize_queries (queries )}
193128 r = requests .get (url , params = params , headers = self .headers , stream = True )
194129 if r .status_code == 200 :
195130 response_json = []
@@ -221,11 +156,7 @@ def bulk_export_last_event(
221156
222157 def bulk_service (self , queries : list [Query ] | None = None ) -> AbstractResponse :
223158 url = f"{ self .base_url } /bulk/service"
224- if queries is None or len (queries ) == 0 :
225- serialized_query = EmptyQuery ().serialize ()
226- else :
227- serialized_query = " " .join (q .serialize () for q in queries )
228- params = {"q" : serialized_query }
159+ params = {"q" : serialize_queries (queries )}
229160 r = requests .get (url , params = params , headers = self .headers , stream = True )
230161 if r .status_code == 200 :
231162 response_json = []
@@ -239,3 +170,68 @@ def bulk_service(self, queries: list[Query] | None = None) -> AbstractResponse:
239170 return ErrorResponse (response = r , response_json = [], status_code = 200 )
240171 else :
241172 return ErrorResponse (response = r , response_json = r .json ())
173+
174+ def get_domain (self , domain : str ) -> AbstractResponse :
175+ """
176+ Returns the list of services and associated leaks for a given domain.
177+ """
178+ url = f"{ self .base_url } /domain/{ domain } "
179+ return self ._parse_host_result (self .__get (url , params = None ))
180+
181+ def search (
182+ self , query : str , scope : Scope = Scope .LEAK , page : int = 0
183+ ) -> AbstractResponse :
184+ """
185+ Simple search using a raw query string (same syntax as the website).
186+
187+ Example:
188+ >>> client.search("+plugin:GitConfigHttpPlugin", scope=Scope.LEAK)
189+ >>> client.search("+country:FR +port:22", scope=Scope.SERVICE)
190+ """
191+ if page < 0 :
192+ raise ValueError ("Page argument must be a positive integer" )
193+ url = f"{ self .base_url } /search"
194+ r = self .__get (
195+ url = url ,
196+ params = {"scope" : scope .value , "q" : query , "page" : page },
197+ )
198+ return self ._parse_events (r )
199+
200+ def bulk_export_stream (
201+ self , queries : list [Query ] | None = None
202+ ) -> Iterator [l9format .L9Aggregation ]:
203+ """
204+ Streaming version of bulk_export. Yields L9Aggregation objects one by one.
205+ More memory efficient for large result sets.
206+ """
207+ url = f"{ self .base_url } /bulk/search"
208+ params = {"q" : serialize_queries (queries )}
209+ r = requests .get (url , params = params , headers = self .headers , stream = True )
210+ if r .status_code != 200 :
211+ return
212+ for line in r .iter_lines ():
213+ json_event = json .loads (line )
214+ yield cast (
215+ l9format .L9Aggregation ,
216+ l9format .L9Aggregation .from_dict (json_event ),
217+ )
218+
219+ def get_api_status (self , force : bool = False ) -> AbstractResponse :
220+ """
221+ Check API status and subscription info via /api/user/info endpoint.
222+ Results are cached per client instance. Use force=True to refresh.
223+ """
224+ if self ._api_status is not None and not force :
225+ return self ._api_status
226+
227+ url = f"{ self .base_url } /api/user/info"
228+ r = self .__get (url , params = None )
229+ self ._api_status = r
230+ return r
231+
232+ def is_pro (self ) -> bool :
233+ """Check if the API key has Pro access. Result is cached."""
234+ r = self .get_api_status ()
235+ if r .is_success ():
236+ return bool (r .json ().get ("is_pro" , False ))
237+ return False
0 commit comments