klyrek-http 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ .qodo/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .env
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+ *.log
17
+ .idea/
18
+ .vscode/
19
+ !.vscode/extensions.json
20
+ klyrek_output/
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: klyrek-http
3
+ Version: 0.1.0
4
+ Summary: HTTP client abstraction and request management for the Klyrek ecosystem
5
+ Author: Klyrek Contributors
6
+ License: MIT
7
+ Keywords: appsec,http,pentesting,reconnaissance,security
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Information Technology
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Security
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: klyrek-core
16
+ Provides-Extra: dev
17
+ Requires-Dist: mypy>=1.10; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.6; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # klyrek-http
23
+
24
+ The HTTP layer shared by every Klyrek package that talks to a target: `klyrek-crawler`,
25
+ `klyrek-api`, `klyrek-tech`, `klyrek-auth`, `klyrek-js`, `klyrek-assets`, and `klyrek-headers` all
26
+ issue requests through `KlyrekHTTPClient` rather than calling `httpx`/`requests` directly.
27
+
28
+ Every request goes through two checks before it leaves the process:
29
+
30
+ 1. **Scope enforcement** — the URL's host must be declared in the scan's
31
+ [`AuthorizationScope`](../klyrek-core/README.md#scope-enforcement), or the request raises
32
+ `ScopeViolationError` instead of firing.
33
+ 2. **Rate limiting** — a per-host minimum interval (`KlyrekConfig.rate_limit_per_host`) keeps a
34
+ scan from hammering a target faster than was authorized.
35
+
36
+ ```python
37
+ from klyrek_core.config import KlyrekConfig
38
+ from klyrek_core.scope import AuthorizationScope
39
+ from klyrek_http.client import KlyrekHTTPClient
40
+
41
+ scope = AuthorizationScope(authorized_hosts=["target.com", "*.target.com"])
42
+
43
+ with KlyrekHTTPClient(scope, config=KlyrekConfig(rate_limit_per_host=5)) as client:
44
+ response = client.get("https://target.com/")
45
+ ```
@@ -0,0 +1,24 @@
1
+ # klyrek-http
2
+
3
+ The HTTP layer shared by every Klyrek package that talks to a target: `klyrek-crawler`,
4
+ `klyrek-api`, `klyrek-tech`, `klyrek-auth`, `klyrek-js`, `klyrek-assets`, and `klyrek-headers` all
5
+ issue requests through `KlyrekHTTPClient` rather than calling `httpx`/`requests` directly.
6
+
7
+ Every request goes through two checks before it leaves the process:
8
+
9
+ 1. **Scope enforcement** — the URL's host must be declared in the scan's
10
+ [`AuthorizationScope`](../klyrek-core/README.md#scope-enforcement), or the request raises
11
+ `ScopeViolationError` instead of firing.
12
+ 2. **Rate limiting** — a per-host minimum interval (`KlyrekConfig.rate_limit_per_host`) keeps a
13
+ scan from hammering a target faster than was authorized.
14
+
15
+ ```python
16
+ from klyrek_core.config import KlyrekConfig
17
+ from klyrek_core.scope import AuthorizationScope
18
+ from klyrek_http.client import KlyrekHTTPClient
19
+
20
+ scope = AuthorizationScope(authorized_hosts=["target.com", "*.target.com"])
21
+
22
+ with KlyrekHTTPClient(scope, config=KlyrekConfig(rate_limit_per_host=5)) as client:
23
+ response = client.get("https://target.com/")
24
+ ```
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "klyrek-http"
7
+ version = "0.1.0"
8
+ description = "HTTP client abstraction and request management for the Klyrek ecosystem"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Klyrek Contributors" }]
13
+ keywords = ["security", "reconnaissance", "appsec", "pentesting", "http"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Information Technology",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Security",
20
+ ]
21
+ dependencies = [
22
+ "klyrek-core",
23
+ "httpx>=0.27",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.10"]
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/klyrek_http"]
@@ -0,0 +1,8 @@
1
+ """klyrek-http: scope-checked, rate-limited HTTP client for the Klyrek ecosystem."""
2
+
3
+ from klyrek_http.client import KlyrekHTTPClient
4
+ from klyrek_http.rate_limit import RateLimiter
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["KlyrekHTTPClient", "RateLimiter", "__version__"]
@@ -0,0 +1,86 @@
1
+ """A scope-checked, rate-limited HTTP client shared across the Klyrek ecosystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from klyrek_core.config import KlyrekConfig
11
+ from klyrek_core.logging import get_logger
12
+ from klyrek_core.scope import AuthorizationScope
13
+ from klyrek_http.rate_limit import RateLimiter
14
+
15
+ logger = get_logger("http.client")
16
+
17
+
18
+ class KlyrekHTTPClient:
19
+ """Every Klyrek package that talks to a target should issue requests through this client.
20
+
21
+ It enforces the scan's :class:`AuthorizationScope` and per-host rate limit on every
22
+ request, so no individual package has to remember to do so itself.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ scope: AuthorizationScope,
28
+ config: KlyrekConfig | None = None,
29
+ transport: httpx.BaseTransport | None = None,
30
+ ) -> None:
31
+ self.scope = scope
32
+ self.config = config or KlyrekConfig()
33
+ min_interval = (
34
+ 1.0 / self.config.rate_limit_per_host if self.config.rate_limit_per_host > 0 else 0.0
35
+ )
36
+ self.rate_limiter = RateLimiter(min_interval=min_interval)
37
+ self._client = httpx.Client(
38
+ headers={"User-Agent": self.config.user_agent},
39
+ timeout=self.config.timeout_seconds,
40
+ verify=self.config.verify_tls,
41
+ follow_redirects=True,
42
+ transport=transport,
43
+ event_hooks={"request": [self._enforce_scope_and_rate_limit]},
44
+ )
45
+
46
+ def _enforce_scope_and_rate_limit(self, request: httpx.Request) -> None:
47
+ # Registered as an httpx "request" hook rather than checked once up front, because
48
+ # follow_redirects=True makes httpx dispatch redirect hops as their own requests
49
+ # internally. A check only in request() would see the original URL and miss a
50
+ # redirect to an out-of-scope host.
51
+ url = str(request.url)
52
+ self.scope.check(url)
53
+ self.rate_limiter.wait(request.url.host)
54
+
55
+ def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
56
+ logger.debug("%s %s", method, url)
57
+ return self._client.request(method, url, **kwargs)
58
+
59
+ def get(self, url: str, **kwargs: Any) -> httpx.Response:
60
+ return self.request("GET", url, **kwargs)
61
+
62
+ def post(self, url: str, **kwargs: Any) -> httpx.Response:
63
+ return self.request("POST", url, **kwargs)
64
+
65
+ def put(self, url: str, **kwargs: Any) -> httpx.Response:
66
+ return self.request("PUT", url, **kwargs)
67
+
68
+ def head(self, url: str, **kwargs: Any) -> httpx.Response:
69
+ return self.request("HEAD", url, **kwargs)
70
+
71
+ def delete(self, url: str, **kwargs: Any) -> httpx.Response:
72
+ return self.request("DELETE", url, **kwargs)
73
+
74
+ def close(self) -> None:
75
+ self._client.close()
76
+
77
+ def __enter__(self) -> KlyrekHTTPClient:
78
+ return self
79
+
80
+ def __exit__(
81
+ self,
82
+ exc_type: type[BaseException] | None,
83
+ exc: BaseException | None,
84
+ tb: TracebackType | None,
85
+ ) -> None:
86
+ self.close()
File without changes
@@ -0,0 +1,37 @@
1
+ """Per-host rate limiting so a scan never exceeds its authorized request rate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class RateLimiter:
12
+ """Enforces a minimum interval between requests to the same host.
13
+
14
+ Hosts are tracked independently: waiting for ``a.com`` never blocks a
15
+ concurrent request to ``b.com``. ``clock``/``sleep`` are injectable so
16
+ tests can run instantly against a fake clock.
17
+ """
18
+
19
+ min_interval: float = 0.0
20
+ clock: Callable[[], float] = time.monotonic
21
+ sleep: Callable[[float], None] = time.sleep
22
+ _last_request_at: dict[str, float] = field(default_factory=dict, repr=False)
23
+
24
+ def wait(self, host: str) -> None:
25
+ """Block, if needed, until ``min_interval`` has passed since the last request to host."""
26
+ if self.min_interval <= 0:
27
+ self._last_request_at[host] = self.clock()
28
+ return
29
+
30
+ now = self.clock()
31
+ last = self._last_request_at.get(host)
32
+ if last is not None:
33
+ remaining = self.min_interval - (now - last)
34
+ if remaining > 0:
35
+ self.sleep(remaining)
36
+ now = self.clock()
37
+ self._last_request_at[host] = now
@@ -0,0 +1,108 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from klyrek_core.config import KlyrekConfig
5
+ from klyrek_core.exceptions import ScopeViolationError
6
+ from klyrek_core.scope import AuthorizationScope
7
+ from klyrek_http.client import KlyrekHTTPClient
8
+
9
+
10
+ def _echo_transport() -> httpx.MockTransport:
11
+ def handler(request: httpx.Request) -> httpx.Response:
12
+ return httpx.Response(
13
+ 200,
14
+ json={"path": request.url.path, "ua": request.headers.get("user-agent")},
15
+ )
16
+
17
+ return httpx.MockTransport(handler)
18
+
19
+
20
+ def test_request_within_scope_succeeds():
21
+ scope = AuthorizationScope(authorized_hosts=["target.com"])
22
+ config = KlyrekConfig(rate_limit_per_host=0)
23
+ with KlyrekHTTPClient(scope, config=config, transport=_echo_transport()) as client:
24
+ response = client.get("https://target.com/login")
25
+ assert response.status_code == 200
26
+ assert response.json()["path"] == "/login"
27
+
28
+
29
+ def test_request_out_of_scope_raises():
30
+ scope = AuthorizationScope(authorized_hosts=["target.com"])
31
+ with KlyrekHTTPClient(scope, transport=_echo_transport()) as client, pytest.raises(
32
+ ScopeViolationError
33
+ ):
34
+ client.get("https://evil.com")
35
+
36
+
37
+ def test_user_agent_applied_from_config():
38
+ scope = AuthorizationScope(authorized_hosts=["target.com"])
39
+ config = KlyrekConfig(user_agent="Klyrek-Test/1.0", rate_limit_per_host=0)
40
+ with KlyrekHTTPClient(scope, config=config, transport=_echo_transport()) as client:
41
+ response = client.get("https://target.com/")
42
+ assert response.json()["ua"] == "Klyrek-Test/1.0"
43
+
44
+
45
+ def test_verbs_dispatch_correct_http_method():
46
+ scope = AuthorizationScope(authorized_hosts=["target.com"])
47
+ seen_methods: list[str] = []
48
+
49
+ def handler(request: httpx.Request) -> httpx.Response:
50
+ seen_methods.append(request.method)
51
+ return httpx.Response(200)
52
+
53
+ config = KlyrekConfig(rate_limit_per_host=0)
54
+ with KlyrekHTTPClient(scope, config=config, transport=httpx.MockTransport(handler)) as client:
55
+ client.get("https://target.com/")
56
+ client.post("https://target.com/")
57
+ client.put("https://target.com/")
58
+ client.head("https://target.com/")
59
+ client.delete("https://target.com/")
60
+
61
+ assert seen_methods == ["GET", "POST", "PUT", "HEAD", "DELETE"]
62
+
63
+
64
+ def test_scope_violation_short_circuits_before_request():
65
+ scope = AuthorizationScope(authorized_hosts=["target.com"])
66
+ called = False
67
+
68
+ def handler(request: httpx.Request) -> httpx.Response:
69
+ nonlocal called
70
+ called = True
71
+ return httpx.Response(200)
72
+
73
+ with KlyrekHTTPClient(scope, transport=httpx.MockTransport(handler)) as client, pytest.raises(
74
+ ScopeViolationError
75
+ ):
76
+ client.get("https://evil.com")
77
+
78
+ assert called is False
79
+
80
+
81
+ def test_redirect_to_out_of_scope_host_is_blocked():
82
+ scope = AuthorizationScope(authorized_hosts=["target.com"])
83
+ config = KlyrekConfig(rate_limit_per_host=0)
84
+
85
+ def handler(request: httpx.Request) -> httpx.Response:
86
+ if request.url.host == "target.com":
87
+ return httpx.Response(302, headers={"Location": "https://evil.com/steal"})
88
+ return httpx.Response(200) # pragma: no cover - should never be reached
89
+
90
+ with KlyrekHTTPClient(
91
+ scope, config=config, transport=httpx.MockTransport(handler)
92
+ ) as client, pytest.raises(ScopeViolationError):
93
+ client.get("https://target.com/login")
94
+
95
+
96
+ def test_redirect_within_scope_is_followed():
97
+ scope = AuthorizationScope(authorized_hosts=["target.com"])
98
+ config = KlyrekConfig(rate_limit_per_host=0)
99
+
100
+ def handler(request: httpx.Request) -> httpx.Response:
101
+ if request.url.path == "/old":
102
+ return httpx.Response(302, headers={"Location": "https://target.com/new"})
103
+ return httpx.Response(200, json={"path": request.url.path})
104
+
105
+ with KlyrekHTTPClient(scope, config=config, transport=httpx.MockTransport(handler)) as client:
106
+ response = client.get("https://target.com/old")
107
+
108
+ assert response.json()["path"] == "/new"
@@ -0,0 +1,67 @@
1
+ import pytest
2
+
3
+ from klyrek_http.rate_limit import RateLimiter
4
+
5
+
6
+ class FakeClock:
7
+ """A controllable clock where sleep() advances time instead of blocking."""
8
+
9
+ def __init__(self, start: float = 0.0) -> None:
10
+ self.t = start
11
+ self.sleep_calls: list[float] = []
12
+
13
+ def now(self) -> float:
14
+ return self.t
15
+
16
+ def tick(self, seconds: float) -> None:
17
+ self.t += seconds
18
+
19
+ def advance(self, seconds: float) -> None:
20
+ self.sleep_calls.append(seconds)
21
+ self.t += seconds
22
+
23
+
24
+ def _limiter(min_interval: float, clock: FakeClock) -> RateLimiter:
25
+ return RateLimiter(min_interval=min_interval, clock=clock.now, sleep=clock.advance)
26
+
27
+
28
+ def test_no_wait_on_first_request():
29
+ clock = FakeClock()
30
+ limiter = _limiter(1.0, clock)
31
+ limiter.wait("target.com")
32
+ assert clock.sleep_calls == []
33
+
34
+
35
+ def test_waits_remaining_interval():
36
+ clock = FakeClock()
37
+ limiter = _limiter(1.0, clock)
38
+ limiter.wait("target.com")
39
+ clock.tick(0.4)
40
+ limiter.wait("target.com")
41
+ assert clock.sleep_calls == [pytest.approx(0.6)]
42
+
43
+
44
+ def test_no_wait_when_interval_already_elapsed():
45
+ clock = FakeClock()
46
+ limiter = _limiter(1.0, clock)
47
+ limiter.wait("target.com")
48
+ clock.tick(1.5)
49
+ limiter.wait("target.com")
50
+ assert clock.sleep_calls == []
51
+
52
+
53
+ def test_hosts_tracked_independently():
54
+ clock = FakeClock()
55
+ limiter = _limiter(1.0, clock)
56
+ limiter.wait("a.com")
57
+ clock.tick(0.1)
58
+ limiter.wait("b.com")
59
+ assert clock.sleep_calls == []
60
+
61
+
62
+ def test_zero_interval_never_waits():
63
+ clock = FakeClock()
64
+ limiter = _limiter(0.0, clock)
65
+ limiter.wait("target.com")
66
+ limiter.wait("target.com")
67
+ assert clock.sleep_calls == []