ulak 0.1.0__py3-none-any.whl

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.
ulak/__init__.py ADDED
@@ -0,0 +1,602 @@
1
+ """
2
+ ulak - a drop-in HTTP(S) proxy hook for arbitrary Python scripts.
3
+
4
+ The name comes from the Turkish word for a courier/relay rider: a messenger
5
+ who carries dispatches from one post to the next. This library does the same
6
+ for a script's network traffic: it quietly relays every HTTP/HTTPS request the
7
+ script makes through a proxy of your choice (Burp Suite, mitmproxy, ZAP, your
8
+ own listener), without touching the script's source code.
9
+
10
+ Why
11
+ ---
12
+ There is no single built-in switch that reroutes *all* outbound HTTP in a
13
+ Python process: every HTTP library opens its own socket and builds its own
14
+ connection. ``ulak`` therefore works on two layers at once:
15
+
16
+ 1. It sets the standard proxy environment variables, which many clients read.
17
+ 2. It monkey-patches the entry points of the well-known HTTP libraries, to
18
+ force the proxy even on clients that ignore the environment.
19
+
20
+ Usage
21
+ -----
22
+ Add two lines at the very top of the target script::
23
+
24
+ import ulak
25
+ ulak.proxy("127.0.0.1", 8080)
26
+
27
+ Every request made after that line flows through 127.0.0.1:8080.
28
+
29
+ ulak.proxy("127.0.0.1", 8080) # verify disabled (MITM default)
30
+ ulak.proxy("127.0.0.1", 8080, verify=True) # keep normal TLS verification
31
+ ulak.proxy("127.0.0.1", 8080, verify="burp-ca.pem")# trust the proxy's CA bundle
32
+ ulak.off() # revert everything
33
+
34
+ As a context manager::
35
+
36
+ with ulak.proxied("127.0.0.1", 8080):
37
+ run_the_thing()
38
+
39
+ Coverage
40
+ --------
41
+ Directly patched: requests, urllib (stdlib), urllib3, httpx, aiohttp,
42
+ httplib2, niquests, curl_cffi, tornado. Covered via environment variables:
43
+ pycurl / libcurl, botocore / boto3, google-api clients, and any child process
44
+ spawned with subprocess (e.g. curl, wget) since children inherit the
45
+ environment. See README for the full matrix and the known limits.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import logging
51
+ import os
52
+ import ssl
53
+ import sys
54
+ import warnings
55
+ from typing import Callable, Dict, List, Union
56
+
57
+ __all__ = ["proxy", "off", "proxied", "status", "is_active"]
58
+ __version__ = "0.1.0"
59
+
60
+ Verify = Union[bool, str]
61
+
62
+ logger = logging.getLogger("ulak")
63
+ logger.addHandler(logging.NullHandler())
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Internal state
67
+ # ---------------------------------------------------------------------------
68
+
69
+ _STATE: Dict[str, object] = {
70
+ "active": False,
71
+ "host": None,
72
+ "port": None,
73
+ "url": None,
74
+ "verify": None, # True / False / path to a CA bundle
75
+ "patched": [], # libraries successfully patched
76
+ "skipped": [], # libraries not installed, skipped
77
+ "unsupported": {}, # libraries present but not proxyable here (with reason)
78
+ "errors": {}, # libraries that raised while patching
79
+ }
80
+
81
+ # Originals, kept so off() can restore them exactly.
82
+ _ORIG: Dict[str, object] = {}
83
+ _SAVED_ENV: Dict[str, Union[str, None]] = {}
84
+
85
+ _VERBOSE = True
86
+
87
+
88
+ def _banner(msg: str) -> None:
89
+ logger.info(msg)
90
+ if _VERBOSE:
91
+ print(f"[ulak] {msg}", file=sys.stderr)
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Environment variables
96
+ # ---------------------------------------------------------------------------
97
+
98
+ _PROXY_ENV = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy",
99
+ "ALL_PROXY", "all_proxy")
100
+ _NO_PROXY_ENV = ("NO_PROXY", "no_proxy")
101
+
102
+
103
+ def _set_env(proxy_url: str) -> None:
104
+ if not _SAVED_ENV:
105
+ for key in _PROXY_ENV + _NO_PROXY_ENV:
106
+ _SAVED_ENV[key] = os.environ.get(key)
107
+ for key in _PROXY_ENV:
108
+ os.environ[key] = proxy_url
109
+ # A NO_PROXY set by the target script would exempt some hosts; clear it so
110
+ # that everything is routed through the proxy.
111
+ for key in _NO_PROXY_ENV:
112
+ os.environ.pop(key, None)
113
+
114
+
115
+ def _restore_env() -> None:
116
+ for key, value in _SAVED_ENV.items():
117
+ if value is None:
118
+ os.environ.pop(key, None)
119
+ else:
120
+ os.environ[key] = value
121
+ _SAVED_ENV.clear()
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # TLS helpers
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def _make_ssl_context(verify: Verify) -> ssl.SSLContext:
129
+ """Build an SSLContext matching the requested verification policy."""
130
+ if verify is False:
131
+ ctx = ssl.create_default_context()
132
+ ctx.check_hostname = False
133
+ ctx.verify_mode = ssl.CERT_NONE
134
+ return ctx
135
+ if isinstance(verify, str):
136
+ return ssl.create_default_context(cafile=verify)
137
+ return ssl.create_default_context()
138
+
139
+
140
+ def _silence_insecure_warnings() -> None:
141
+ if _STATE["verify"] is not False:
142
+ return
143
+ try:
144
+ import urllib3
145
+ urllib3.disable_warnings()
146
+ except Exception: # pragma: no cover - urllib3 may be absent
147
+ pass
148
+ try:
149
+ from urllib3.exceptions import InsecureRequestWarning
150
+ warnings.simplefilter("ignore", InsecureRequestWarning)
151
+ except Exception: # pragma: no cover
152
+ pass
153
+
154
+
155
+ # ===========================================================================
156
+ # Patchers. Each returns True when it did something, raises ImportError when
157
+ # its library is absent, and is otherwise wrapped so a failure cannot crash
158
+ # the host script.
159
+ # ===========================================================================
160
+
161
+ def _patch_env() -> bool:
162
+ _set_env(str(_STATE["url"]))
163
+ return True
164
+
165
+
166
+ def _patch_ssl() -> bool:
167
+ """Globally relax/redirect stdlib TLS verification when asked to."""
168
+ verify = _STATE["verify"]
169
+ if verify is True:
170
+ return False
171
+ _ORIG.setdefault("ssl._create_default_https_context",
172
+ ssl._create_default_https_context)
173
+ if verify is False:
174
+ ssl._create_default_https_context = ssl._create_unverified_context
175
+ else:
176
+ cafile = verify
177
+ ssl._create_default_https_context = (
178
+ lambda *a, **k: ssl.create_default_context(cafile=cafile))
179
+ return True
180
+
181
+
182
+ def _patch_urllib() -> bool:
183
+ import urllib.request as ur
184
+
185
+ proxy_url = str(_STATE["url"])
186
+ handlers: List[object] = [
187
+ ur.ProxyHandler({"http": proxy_url, "https": proxy_url})
188
+ ]
189
+ if _STATE["verify"] is not True:
190
+ handlers.append(ur.HTTPSHandler(context=_make_ssl_context(_STATE["verify"])))
191
+
192
+ _ORIG.setdefault("urllib.opener", ur._opener)
193
+ ur.install_opener(ur.build_opener(*handlers))
194
+ return True
195
+
196
+
197
+ def _patch_requests() -> bool:
198
+ import requests # noqa: F401
199
+ from requests.sessions import Session
200
+
201
+ _ORIG.setdefault("requests.Session.request", Session.request)
202
+ orig = _ORIG["requests.Session.request"]
203
+ proxies = {"http": _STATE["url"], "https": _STATE["url"]}
204
+ verify = _STATE["verify"]
205
+
206
+ def request(self, method, url, **kwargs):
207
+ kwargs["proxies"] = proxies
208
+ kwargs["verify"] = verify
209
+ return orig(self, method, url, **kwargs)
210
+
211
+ Session.request = request
212
+ _silence_insecure_warnings()
213
+ return True
214
+
215
+
216
+ def _patch_niquests() -> bool:
217
+ """niquests is a requests-compatible fork with its own module."""
218
+ import niquests
219
+ Session = niquests.Session
220
+
221
+ _ORIG.setdefault("niquests.Session.request", Session.request)
222
+ orig = _ORIG["niquests.Session.request"]
223
+ proxies = {"http": _STATE["url"], "https": _STATE["url"]}
224
+ verify = _STATE["verify"]
225
+
226
+ def request(self, method, url, **kwargs):
227
+ kwargs["proxies"] = proxies
228
+ kwargs["verify"] = verify
229
+ return orig(self, method, url, **kwargs)
230
+
231
+ Session.request = request
232
+ return True
233
+
234
+
235
+ def _patch_urllib3() -> bool:
236
+ """urllib3 ignores the environment and uses its own PoolManager, so we
237
+ swap PoolManager for a ProxyManager bound to the proxy. requests binds its
238
+ own PoolManager reference at import time, so this does not affect it."""
239
+ import urllib3
240
+
241
+ proxy_url = str(_STATE["url"])
242
+ verify = _STATE["verify"]
243
+
244
+ _ORIG.setdefault("urllib3.PoolManager", urllib3.PoolManager)
245
+ base_proxy_manager = urllib3.ProxyManager
246
+
247
+ extra: Dict[str, object] = {}
248
+ if verify is False:
249
+ extra["cert_reqs"] = "CERT_NONE"
250
+ elif isinstance(verify, str):
251
+ extra["ca_certs"] = verify
252
+
253
+ class _ProxiedPoolManager(base_proxy_manager): # type: ignore[valid-type,misc]
254
+ def __init__(self, num_pools=10, headers=None, **conn_kw):
255
+ merged = dict(extra)
256
+ merged.update(conn_kw) # caller-supplied values win
257
+ super().__init__(proxy_url, num_pools=num_pools,
258
+ headers=headers, **merged)
259
+
260
+ urllib3.PoolManager = _ProxiedPoolManager
261
+ _silence_insecure_warnings()
262
+ return True
263
+
264
+
265
+ def _patch_httpx() -> bool:
266
+ import inspect
267
+ import httpx
268
+
269
+ proxy_url = str(_STATE["url"])
270
+ verify = _STATE["verify"]
271
+ patched_any = False
272
+
273
+ for cls_name in ("Client", "AsyncClient"):
274
+ cls = getattr(httpx, cls_name, None)
275
+ if cls is None:
276
+ continue
277
+ key = f"httpx.{cls_name}.__init__"
278
+ _ORIG.setdefault(key, cls.__init__)
279
+ orig_init = _ORIG[key]
280
+ params = inspect.signature(orig_init).parameters
281
+
282
+ def make_init(orig_init, params):
283
+ def __init__(self, *args, **kwargs):
284
+ # httpx renamed the argument: "proxies" (<0.26) -> "proxy".
285
+ if "proxy" in params:
286
+ kwargs.setdefault("proxy", proxy_url)
287
+ elif "proxies" in params:
288
+ kwargs.setdefault("proxies", proxy_url)
289
+ if "verify" in params:
290
+ kwargs["verify"] = verify
291
+ return orig_init(self, *args, **kwargs)
292
+ return __init__
293
+
294
+ cls.__init__ = make_init(orig_init, params)
295
+ patched_any = True
296
+ return patched_any
297
+
298
+
299
+ def _patch_aiohttp() -> bool:
300
+ import aiohttp
301
+
302
+ proxy_url = str(_STATE["url"])
303
+ verify = _STATE["verify"]
304
+ cls = aiohttp.ClientSession
305
+
306
+ _ORIG.setdefault("aiohttp.ClientSession._request", cls._request)
307
+ orig = _ORIG["aiohttp.ClientSession._request"]
308
+
309
+ async def _request(self, method, str_or_url, **kwargs):
310
+ kwargs.setdefault("proxy", proxy_url)
311
+ if verify is False:
312
+ kwargs.setdefault("ssl", False)
313
+ elif isinstance(verify, str):
314
+ kwargs.setdefault("ssl", _make_ssl_context(verify))
315
+ return await orig(self, method, str_or_url, **kwargs)
316
+
317
+ cls._request = _request
318
+ return True
319
+
320
+
321
+ def _patch_httplib2() -> bool:
322
+ import httplib2
323
+
324
+ host, port = _STATE["host"], _STATE["port"]
325
+ verify = _STATE["verify"]
326
+
327
+ _ORIG.setdefault("httplib2.Http.__init__", httplib2.Http.__init__)
328
+ orig = _ORIG["httplib2.Http.__init__"]
329
+ proxy_info = httplib2.ProxyInfo(httplib2.socks.PROXY_TYPE_HTTP, host, int(port))
330
+
331
+ def __init__(self, *args, **kwargs):
332
+ kwargs["proxy_info"] = proxy_info
333
+ if verify is False:
334
+ kwargs["disable_ssl_certificate_validation"] = True
335
+ elif isinstance(verify, str):
336
+ kwargs["ca_certs"] = verify
337
+ orig(self, *args, **kwargs)
338
+
339
+ httplib2.Http.__init__ = __init__
340
+ return True
341
+
342
+
343
+ def _patch_curl_cffi() -> bool:
344
+ """curl_cffi exposes a requests-like API (sync Session and AsyncSession)."""
345
+ from curl_cffi import requests as cffi
346
+
347
+ proxies = {"http": _STATE["url"], "https": _STATE["url"]}
348
+ verify = _STATE["verify"]
349
+ patched_any = False
350
+
351
+ for cls_name in ("Session", "AsyncSession"):
352
+ cls = getattr(cffi, cls_name, None)
353
+ if cls is None or not hasattr(cls, "request"):
354
+ continue
355
+ key = f"curl_cffi.{cls_name}.request"
356
+ _ORIG.setdefault(key, cls.request)
357
+ orig = _ORIG[key]
358
+
359
+ def make_request(orig):
360
+ def request(self, method, url, **kwargs):
361
+ kwargs["proxies"] = proxies
362
+ kwargs["verify"] = verify
363
+ return orig(self, method, url, **kwargs)
364
+ return request
365
+
366
+ cls.request = make_request(orig)
367
+ patched_any = True
368
+ return patched_any
369
+
370
+
371
+ def _patch_tornado() -> bool:
372
+ """Route tornado through the proxy.
373
+
374
+ tornado's default (simple) client has no proxy support and raises if you
375
+ pass ``proxy_host``. Only its libcurl-backed client can proxy, so we switch
376
+ tornado to that client (requires pycurl) and inject the proxy fields into
377
+ every HTTPRequest. Without pycurl we cannot proxy tornado without breaking
378
+ it, so we raise NotImplementedError and leave tornado untouched.
379
+ """
380
+ try:
381
+ import pycurl # noqa: F401
382
+ except ImportError:
383
+ raise NotImplementedError(
384
+ "tornado can only be proxied via its libcurl client; install "
385
+ "pycurl to enable tornado interception")
386
+
387
+ import tornado.httpclient as thc
388
+
389
+ host, port = _STATE["host"], _STATE["port"]
390
+ verify = _STATE["verify"]
391
+
392
+ if "tornado.impl" not in _ORIG:
393
+ _ORIG["tornado.impl"] = getattr(thc.AsyncHTTPClient, "_impl_class", None)
394
+ thc.AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")
395
+
396
+ _ORIG.setdefault("tornado.HTTPRequest.__init__", thc.HTTPRequest.__init__)
397
+ orig = _ORIG["tornado.HTTPRequest.__init__"]
398
+
399
+ def __init__(self, *args, **kwargs):
400
+ kwargs.setdefault("proxy_host", host)
401
+ kwargs.setdefault("proxy_port", int(port))
402
+ if verify is False:
403
+ kwargs.setdefault("validate_cert", False)
404
+ elif isinstance(verify, str):
405
+ kwargs.setdefault("ca_certs", verify)
406
+ orig(self, *args, **kwargs)
407
+
408
+ thc.HTTPRequest.__init__ = __init__
409
+ return True
410
+
411
+
412
+ # (name, patcher, restorer-key-prefix). Order matters: env and ssl first.
413
+ _PATCHERS: List[tuple] = [
414
+ ("env", _patch_env),
415
+ ("ssl", _patch_ssl),
416
+ ("urllib", _patch_urllib),
417
+ ("requests", _patch_requests),
418
+ ("niquests", _patch_niquests),
419
+ ("urllib3", _patch_urllib3),
420
+ ("httpx", _patch_httpx),
421
+ ("aiohttp", _patch_aiohttp),
422
+ ("httplib2", _patch_httplib2),
423
+ ("curl_cffi", _patch_curl_cffi),
424
+ ("tornado", _patch_tornado),
425
+ ]
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # Restore logic (mirror of the patchers)
430
+ # ---------------------------------------------------------------------------
431
+
432
+ def _restore_all() -> None:
433
+ if "ssl._create_default_https_context" in _ORIG:
434
+ ssl._create_default_https_context = _ORIG.pop("ssl._create_default_https_context")
435
+
436
+ if "urllib.opener" in _ORIG:
437
+ import urllib.request as ur
438
+ saved = _ORIG.pop("urllib.opener")
439
+ if saved is None:
440
+ ur._opener = None
441
+ else:
442
+ ur.install_opener(saved)
443
+
444
+ if "requests.Session.request" in _ORIG:
445
+ from requests.sessions import Session
446
+ Session.request = _ORIG.pop("requests.Session.request")
447
+
448
+ if "niquests.Session.request" in _ORIG:
449
+ import niquests
450
+ niquests.Session.request = _ORIG.pop("niquests.Session.request")
451
+
452
+ if "urllib3.PoolManager" in _ORIG:
453
+ import urllib3
454
+ urllib3.PoolManager = _ORIG.pop("urllib3.PoolManager")
455
+
456
+ for cls_name in ("Client", "AsyncClient"):
457
+ key = f"httpx.{cls_name}.__init__"
458
+ if key in _ORIG:
459
+ import httpx
460
+ getattr(httpx, cls_name).__init__ = _ORIG.pop(key)
461
+
462
+ if "aiohttp.ClientSession._request" in _ORIG:
463
+ import aiohttp
464
+ aiohttp.ClientSession._request = _ORIG.pop("aiohttp.ClientSession._request")
465
+
466
+ if "httplib2.Http.__init__" in _ORIG:
467
+ import httplib2
468
+ httplib2.Http.__init__ = _ORIG.pop("httplib2.Http.__init__")
469
+
470
+ for cls_name in ("Session", "AsyncSession"):
471
+ key = f"curl_cffi.{cls_name}.request"
472
+ if key in _ORIG:
473
+ from curl_cffi import requests as cffi
474
+ getattr(cffi, cls_name).request = _ORIG.pop(key)
475
+
476
+ if "tornado.HTTPRequest.__init__" in _ORIG:
477
+ import tornado.httpclient as thc
478
+ thc.HTTPRequest.__init__ = _ORIG.pop("tornado.HTTPRequest.__init__")
479
+ if "tornado.impl" in _ORIG:
480
+ import tornado.httpclient as thc
481
+ prior = _ORIG.pop("tornado.impl")
482
+ thc.AsyncHTTPClient.configure(prior)
483
+
484
+ _restore_env()
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Public API
489
+ # ---------------------------------------------------------------------------
490
+
491
+ def proxy(host: str = "127.0.0.1",
492
+ port: int = 8080,
493
+ *,
494
+ verify: Verify = False,
495
+ scheme: str = "http",
496
+ verbose: bool = True) -> Dict[str, object]:
497
+ """Route all outbound HTTP/HTTPS traffic of this process through a proxy.
498
+
499
+ Parameters
500
+ ----------
501
+ host, port:
502
+ Proxy address. Defaults to 127.0.0.1:8080 (Burp Suite's default).
503
+ verify:
504
+ TLS verification policy.
505
+ ``False`` (default) disables verification, which is what an
506
+ intercepting proxy with its own CA normally requires. ``True`` keeps
507
+ normal verification. A string is treated as a path to a CA bundle to
508
+ trust (export your proxy's CA and pass it here).
509
+ scheme:
510
+ Proxy scheme, ``"http"`` by default (Burp/ZAP/mitmproxy are HTTP
511
+ proxies).
512
+ verbose:
513
+ Print a one-line activation banner to stderr.
514
+
515
+ Calling this again re-applies with the new settings (the previous hook is
516
+ reverted first). Returns a status dict describing what was patched.
517
+ """
518
+ global _VERBOSE
519
+ _VERBOSE = verbose
520
+
521
+ if _STATE["active"]:
522
+ off()
523
+
524
+ proxy_url = f"{scheme}://{host}:{port}"
525
+ _STATE.update({
526
+ "active": True,
527
+ "host": host,
528
+ "port": port,
529
+ "url": proxy_url,
530
+ "verify": verify,
531
+ "patched": [],
532
+ "skipped": [],
533
+ "unsupported": {},
534
+ "errors": {},
535
+ })
536
+
537
+ for name, fn in _PATCHERS:
538
+ try:
539
+ if fn():
540
+ _STATE["patched"].append(name) # type: ignore[union-attr]
541
+ except ImportError:
542
+ _STATE["skipped"].append(name) # type: ignore[union-attr]
543
+ except NotImplementedError as exc:
544
+ _STATE["unsupported"][name] = str(exc) # type: ignore[index]
545
+ except Exception as exc: # noqa: BLE001 - never crash the host script
546
+ _STATE["errors"][name] = repr(exc) # type: ignore[index]
547
+ logger.exception("ulak: patching %s failed", name)
548
+
549
+ _banner(f"routing HTTP/HTTPS through {proxy_url} (verify={verify!r})")
550
+ _banner("patched: " + ", ".join(_STATE["patched"])) # type: ignore[arg-type]
551
+ if _STATE["unsupported"]:
552
+ for lib, reason in _STATE["unsupported"].items(): # type: ignore[attr-defined]
553
+ _banner(f"note: {lib} not proxied ({reason})")
554
+ if _STATE["errors"]:
555
+ _banner(f"errors: {_STATE['errors']}")
556
+ if verify is False:
557
+ _banner("WARNING: TLS verification is DISABLED; use only with a proxy you trust.")
558
+ return status()
559
+
560
+
561
+ def off() -> None:
562
+ """Revert every patch and restore the original environment variables."""
563
+ if not _STATE["active"]:
564
+ return
565
+ try:
566
+ _restore_all()
567
+ finally:
568
+ _banner("proxy disabled; all hooks reverted.")
569
+ _STATE.update({
570
+ "active": False, "host": None, "port": None, "url": None,
571
+ "verify": None, "patched": [], "skipped": [],
572
+ "unsupported": {}, "errors": {},
573
+ })
574
+
575
+
576
+ def is_active() -> bool:
577
+ """Return whether a proxy hook is currently installed."""
578
+ return bool(_STATE["active"])
579
+
580
+
581
+ def status() -> Dict[str, object]:
582
+ """Return a copy of the current state."""
583
+ return dict(_STATE)
584
+
585
+
586
+ class proxied:
587
+ """Context manager: proxy on inside the block, reverted on exit.
588
+
589
+ >>> with proxied("127.0.0.1", 8080):
590
+ ... do_requests()
591
+ """
592
+
593
+ def __init__(self, host: str = "127.0.0.1", port: int = 8080, **kwargs):
594
+ self._args = (host, port)
595
+ self._kwargs = kwargs
596
+
597
+ def __enter__(self) -> Dict[str, object]:
598
+ return proxy(*self._args, **self._kwargs)
599
+
600
+ def __exit__(self, *exc) -> bool:
601
+ off()
602
+ return False
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: ulak
3
+ Version: 0.1.0
4
+ Summary: Drop-in HTTP(S) proxy hook: route all of a Python script's traffic through Burp/mitmproxy/ZAP with one import.
5
+ Author-email: Musa SANA <musa.sana@hotmail.com>
6
+ Maintainer-email: Musa SANA <musa.sana@hotmail.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/musana/ulak
9
+ Project-URL: Repository, https://github.com/musana/ulak
10
+ Project-URL: Issues, https://github.com/musana/ulak/issues
11
+ Project-URL: Changelog, https://github.com/musana/ulak/releases
12
+ Keywords: proxy,http,https,burp,mitmproxy,interception,security,monkeypatch,debugging
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Security
16
+ Classifier: Topic :: Internet :: Proxy Servers
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Operating System :: OS Independent
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: test
24
+ Requires-Dist: requests; extra == "test"
25
+ Requires-Dist: httpx; extra == "test"
26
+ Requires-Dist: aiohttp; extra == "test"
27
+ Requires-Dist: urllib3; extra == "test"
28
+ Requires-Dist: niquests; extra == "test"
29
+ Requires-Dist: httplib2; extra == "test"
30
+ Requires-Dist: curl_cffi; extra == "test"
31
+ Requires-Dist: pytest; extra == "test"
32
+ Dynamic: license-file
33
+
34
+ # ulak
35
+
36
+ Send every HTTP and HTTPS request a Python script makes through your proxy, with a single import, without changing the script.
37
+
38
+ ## The problem
39
+
40
+ When you run a script someone else wrote, you often want to see the HTTP traffic it sends. You can inspect it in Burp, mitmproxy, or ZAP, but getting there is painful. Either you set a system wide proxy, or you hunt down every request call in the code and add a proxy argument by hand. And Python has many HTTP libraries, so there is no single place to change.
41
+
42
+ ## The solution
43
+
44
+ `ulak` does it in one line. Add it to the top of the script and all outbound traffic goes through the proxy you pick. The name is the Turkish word for a courier who carries a message from one post to the next, which is what this does for the script's requests.
45
+
46
+ ```python
47
+ import ulak
48
+ ulak.proxy("127.0.0.1", 8080)
49
+ ```
50
+
51
+ Every request after that line is routed through `127.0.0.1:8080`.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install ulak
57
+ ```
58
+
59
+ There are no required dependencies. A library is only hooked if the script already uses it.
60
+
61
+ ## What it covers
62
+
63
+ Hooked directly, so they go through the proxy even when they ignore the environment:
64
+
65
+ - requests
66
+ - urllib (standard library)
67
+ - urllib3
68
+ - httpx
69
+ - aiohttp
70
+ - httplib2
71
+ - niquests
72
+ - curl_cffi
73
+ - tornado (needs pycurl; without it, tornado is left alone and reported as not proxied)
74
+
75
+ Covered through the proxy environment variables that `ulak` sets:
76
+
77
+ - pycurl and libcurl
78
+ - botocore and boto3, Google API clients
79
+ - any child process started with subprocess, such as curl or wget
80
+
81
+ ## How to use
82
+
83
+ ```python
84
+ import ulak
85
+
86
+ ulak.proxy("127.0.0.1", 8080) # verify off by default
87
+ ulak.proxy("127.0.0.1", 8080, verify=True) # keep normal TLS checks
88
+ ulak.proxy("127.0.0.1", 8080, verify="burp-ca.pem") # trust your proxy's CA
89
+ ulak.off() # undo everything
90
+ ```
91
+
92
+ Only for one block of code:
93
+
94
+ ```python
95
+ with ulak.proxied("127.0.0.1", 8080):
96
+ run_the_thing()
97
+ ```
98
+
99
+ Check the current state:
100
+
101
+ ```python
102
+ ulak.status() # what was patched, skipped, or failed
103
+ ulak.is_active() # True or False
104
+ ```
105
+
106
+ ## A note on TLS
107
+
108
+ A proxy like Burp breaks into TLS with its own certificate authority, which your client does not trust. That is why `verify` is off by default, so HTTPS requests do not fail. This turns off certificate checking, so use it only with a local proxy you control. The cleaner way is to export your proxy's CA certificate and pass `verify="path/to/ca.pem"`, which keeps verification on and trusts the proxy.
109
+
110
+ ## Limits
111
+
112
+ `ulak` works at the library level, so it cannot catch traffic that skips those libraries, such as raw socket clients or libraries that carry their own TLS stack in C. When you call `ulak.off()`, every change is reverted and the environment is restored to how it was.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,6 @@
1
+ ulak/__init__.py,sha256=2GyEMh8RApS7PejAj8EacHkf710ofImnOxPW6s8DS84,19987
2
+ ulak-0.1.0.dist-info/licenses/LICENSE,sha256=PJ2Xnx0k5wnZGRVZ_1y781_4G9Twhx2LJsXpIIMFJz0,1066
3
+ ulak-0.1.0.dist-info/METADATA,sha256=KVKjbQlrhYvk3OogVDsBzdTkQeEP180n6rSMZryJQVg,4325
4
+ ulak-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ ulak-0.1.0.dist-info/top_level.txt,sha256=ADvmE7FQ1JacMIaKettVjG-RgoIYxKTg8xua3R1QUPc,5
6
+ ulak-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Musa SANA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ulak