ghost-api-cli 0.2.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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/net.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Polite networking for requests the tool sends itself: rate limiting and robots.txt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
9
|
+
from urllib.robotparser import RobotFileParser
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ghost_api.config import TOOL_NAME, tool_user_agent
|
|
14
|
+
from ghost_api.netguard import (
|
|
15
|
+
NetworkPolicy,
|
|
16
|
+
PolicyDenied,
|
|
17
|
+
ResponseTooLarge,
|
|
18
|
+
guarded_client,
|
|
19
|
+
read_limited,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
MAX_ROBOTS_BYTES = 512 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateLimiter:
|
|
26
|
+
"""Blocks so that calls to `wait()` happen at most `rate` times per second (NFR-18)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, rate: float) -> None:
|
|
29
|
+
if rate <= 0:
|
|
30
|
+
raise ValueError("rate must be greater than 0")
|
|
31
|
+
self.interval = 1.0 / rate
|
|
32
|
+
self._last = float("-inf")
|
|
33
|
+
|
|
34
|
+
def slow_down_to(self, min_interval: float) -> None:
|
|
35
|
+
self.interval = max(self.interval, min_interval)
|
|
36
|
+
|
|
37
|
+
def wait(self) -> None:
|
|
38
|
+
now = time.monotonic()
|
|
39
|
+
delay = self._last + self.interval - now
|
|
40
|
+
if delay > 0:
|
|
41
|
+
time.sleep(delay)
|
|
42
|
+
self._last = time.monotonic()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class RobotsVerdict:
|
|
47
|
+
allowed: bool
|
|
48
|
+
reason: str
|
|
49
|
+
crawl_delay: float | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def robots_url(url: str) -> str:
|
|
53
|
+
parts = urlsplit(url)
|
|
54
|
+
return urlunsplit((parts.scheme, parts.netloc, "/robots.txt", "", ""))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RobotsPolicy:
|
|
58
|
+
"""robots.txt rules for one host, reusable for every URL the agent visits (NFR-17)."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
host: str,
|
|
63
|
+
parser: RobotFileParser | None,
|
|
64
|
+
default_allowed: bool,
|
|
65
|
+
reason: str,
|
|
66
|
+
agent: str = TOOL_NAME,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.host = host
|
|
69
|
+
self._parser = parser
|
|
70
|
+
self._default = default_allowed
|
|
71
|
+
self.reason = reason
|
|
72
|
+
self.agent = agent
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def allow_all(cls, host: str, reason: str = "robots.txt check skipped") -> RobotsPolicy:
|
|
76
|
+
return cls(host, None, True, reason)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_text(cls, host: str, robots_txt: str, agent: str = TOOL_NAME) -> RobotsPolicy:
|
|
80
|
+
parser = RobotFileParser()
|
|
81
|
+
parser.parse(robots_txt.splitlines())
|
|
82
|
+
return cls(host, parser, True, "robots.txt rules", agent)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def crawl_delay(self) -> float | None:
|
|
86
|
+
if self._parser is None:
|
|
87
|
+
return None
|
|
88
|
+
delay = self._parser.crawl_delay(self.agent)
|
|
89
|
+
return float(delay) if delay is not None else None
|
|
90
|
+
|
|
91
|
+
def allows(self, url: str) -> bool:
|
|
92
|
+
"""Rules apply to the host they came from; other hosts are not checked here."""
|
|
93
|
+
if (urlsplit(url).hostname or "").lower() != self.host:
|
|
94
|
+
return True
|
|
95
|
+
if self._parser is None:
|
|
96
|
+
return self._default
|
|
97
|
+
return self._parser.can_fetch(self.agent, url)
|
|
98
|
+
|
|
99
|
+
def verdict(self, url: str) -> RobotsVerdict:
|
|
100
|
+
allowed = self.allows(url)
|
|
101
|
+
if self._parser is None:
|
|
102
|
+
reason = self.reason
|
|
103
|
+
else:
|
|
104
|
+
reason = "allowed by robots.txt" if allowed else "disallowed by robots.txt"
|
|
105
|
+
return RobotsVerdict(allowed, reason, self.crawl_delay)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MultiHostRobotsPolicy(RobotsPolicy):
|
|
109
|
+
"""Lazily fetches and caches robots.txt per host, so subdomains are checked too (Issue 3).
|
|
110
|
+
|
|
111
|
+
The agent may navigate from ``www.example.com`` to ``api.example.com``. A plain
|
|
112
|
+
``RobotsPolicy`` only knows the rules for one host and returns ``True`` for any other.
|
|
113
|
+
This wrapper fetches and caches each host's robots.txt on first encounter.
|
|
114
|
+
|
|
115
|
+
It exposes the same ``allows(url)`` interface as ``RobotsPolicy`` so it's a drop-in
|
|
116
|
+
replacement wherever the orchestrator or pipeline calls ``robots.allows(url)``.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
seed: RobotsPolicy,
|
|
122
|
+
limiter: RateLimiter | None = None,
|
|
123
|
+
fetcher: Callable[[str, RateLimiter | None], RobotsPolicy] | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__(seed.host, None, True, seed.reason, seed.agent)
|
|
126
|
+
self._cache: dict[str, RobotsPolicy] = {seed.host: seed}
|
|
127
|
+
self._limiter = limiter
|
|
128
|
+
self._fetch = fetcher or fetch_robots
|
|
129
|
+
|
|
130
|
+
def _for(self, url: str) -> RobotsPolicy:
|
|
131
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
132
|
+
if host not in self._cache:
|
|
133
|
+
self._cache[host] = self._fetch(url, self._limiter)
|
|
134
|
+
return self._cache[host]
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def crawl_delay(self) -> float | None:
|
|
138
|
+
return self._cache[self.host].crawl_delay
|
|
139
|
+
|
|
140
|
+
def allows(self, url: str) -> bool:
|
|
141
|
+
"""Check the correct host's robots.txt, fetching it lazily if needed."""
|
|
142
|
+
return self._for(url).allows(url)
|
|
143
|
+
|
|
144
|
+
def verdict(self, url: str) -> RobotsVerdict:
|
|
145
|
+
return self._for(url).verdict(url)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class DenyPaths(RobotsPolicy):
|
|
149
|
+
"""robots.txt plus the project's own rules (ghost-api.toml deny_paths) for one host:
|
|
150
|
+
a denied path is treated exactly like a robots.txt Disallow, everywhere."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, inner: RobotsPolicy, deny: tuple[str, ...]) -> None:
|
|
153
|
+
super().__init__(inner.host, None, True, inner.reason, inner.agent)
|
|
154
|
+
self.inner = inner
|
|
155
|
+
self.deny = deny
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def crawl_delay(self) -> float | None:
|
|
159
|
+
return self.inner.crawl_delay
|
|
160
|
+
|
|
161
|
+
def _denied(self, url: str) -> bool:
|
|
162
|
+
parts = urlsplit(url)
|
|
163
|
+
if (parts.hostname or "").lower() != self.host:
|
|
164
|
+
return False
|
|
165
|
+
path = parts.path or "/"
|
|
166
|
+
return any(path == p or path.startswith(p.rstrip("/") + "/") for p in self.deny)
|
|
167
|
+
|
|
168
|
+
def allows(self, url: str) -> bool:
|
|
169
|
+
return not self._denied(url) and self.inner.allows(url)
|
|
170
|
+
|
|
171
|
+
def verdict(self, url: str) -> RobotsVerdict:
|
|
172
|
+
if self._denied(url):
|
|
173
|
+
return RobotsVerdict(False, "denied by the project rules (ghost-api.toml)")
|
|
174
|
+
return self.inner.verdict(url)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def evaluate_robots(robots_txt: str, url: str, agent: str = TOOL_NAME) -> RobotsVerdict:
|
|
178
|
+
"""Apply robots.txt rules to a URL. Pure function, so it's easy to test."""
|
|
179
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
180
|
+
return RobotsPolicy.from_text(host, robots_txt, agent).verdict(url)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def fetch_robots(
|
|
184
|
+
url: str,
|
|
185
|
+
limiter: RateLimiter | None = None,
|
|
186
|
+
policy: NetworkPolicy | None = None,
|
|
187
|
+
transport: httpx.BaseTransport | None = None,
|
|
188
|
+
) -> RobotsPolicy:
|
|
189
|
+
"""Fetch robots.txt, following RFC 9309 for missing or unreachable files."""
|
|
190
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
191
|
+
if limiter:
|
|
192
|
+
limiter.wait()
|
|
193
|
+
# Redirects are followed (RFC 9309), but each hop is checked: a public site's
|
|
194
|
+
# robots.txt must not lead Ghost API to a private address.
|
|
195
|
+
client = guarded_client(
|
|
196
|
+
policy or NetworkPolicy.for_target(url),
|
|
197
|
+
follow_redirects=True,
|
|
198
|
+
timeout=10,
|
|
199
|
+
transport=transport,
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
with (
|
|
203
|
+
client,
|
|
204
|
+
client.stream(
|
|
205
|
+
"GET", robots_url(url), headers={"User-Agent": tool_user_agent()}
|
|
206
|
+
) as resp,
|
|
207
|
+
):
|
|
208
|
+
text = read_limited(resp, MAX_ROBOTS_BYTES, MAX_ROBOTS_BYTES).text
|
|
209
|
+
except PolicyDenied as exc:
|
|
210
|
+
return RobotsPolicy(host, None, False, f"robots.txt {exc}")
|
|
211
|
+
except ResponseTooLarge:
|
|
212
|
+
# RFC 9309: at least 500 KiB must be parsed; a bigger file is treated as unreadable.
|
|
213
|
+
return RobotsPolicy(host, None, False, "robots.txt is too large to read")
|
|
214
|
+
except httpx.HTTPError as exc:
|
|
215
|
+
# Unreachable robots.txt: assume everything is disallowed.
|
|
216
|
+
return RobotsPolicy(host, None, False, f"robots.txt unreachable ({type(exc).__name__})")
|
|
217
|
+
if resp.status_code in (401, 403):
|
|
218
|
+
# The site refused to show its rules (Wikipedia does this to clients that don't
|
|
219
|
+
# identify themselves). Not knowing the rules is not permission.
|
|
220
|
+
return RobotsPolicy(
|
|
221
|
+
host,
|
|
222
|
+
None,
|
|
223
|
+
False,
|
|
224
|
+
f"robots.txt is forbidden (HTTP {resp.status_code}), so the site's rules can't be "
|
|
225
|
+
"read. Set GHOST_API_CONTACT in .env so the site can identify you",
|
|
226
|
+
)
|
|
227
|
+
if 400 <= resp.status_code < 500:
|
|
228
|
+
return RobotsPolicy(host, None, True, f"no robots.txt (HTTP {resp.status_code})")
|
|
229
|
+
if resp.status_code >= 500:
|
|
230
|
+
return RobotsPolicy(host, None, False, f"robots.txt server error (HTTP {resp.status_code})")
|
|
231
|
+
return RobotsPolicy.from_text(host, text)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_robots(url: str, limiter: RateLimiter | None = None) -> RobotsVerdict:
|
|
235
|
+
return fetch_robots(url, limiter).verdict(url)
|
ghost_api/netguard.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Where Ghost API may connect: never to this computer or the local network by accident.
|
|
2
|
+
|
|
3
|
+
A public website can redirect, or make its page call, an address such as 127.0.0.1,
|
|
4
|
+
192.168.1.1 or the cloud metadata address 169.254.169.254. Ghost API runs on the user's
|
|
5
|
+
machine, so following that would let any website reach the user's private network (SSRF).
|
|
6
|
+
|
|
7
|
+
`NetworkPolicy` decides per destination. Private addresses are blocked unless the user's
|
|
8
|
+
own target is local (the demo shop, tests, a site on the LAN): then the run is in *local
|
|
9
|
+
target mode* and private addresses are allowed, because the user asked for them.
|
|
10
|
+
|
|
11
|
+
The check resolves the host name and looks at every address it resolves to. The browser's
|
|
12
|
+
traffic goes through `browser/egress.py`, which connects to the exact address that was
|
|
13
|
+
checked, so a host name can't be switched to a private address between check and use.
|
|
14
|
+
Direct requests (httpx) are checked on every redirect hop; httpx resolves the name again
|
|
15
|
+
itself, a small window that is documented in SECURITY.md.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ipaddress
|
|
21
|
+
import socket
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
import httpcore
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
31
|
+
Resolver = Callable[[str, int], list[str]]
|
|
32
|
+
|
|
33
|
+
MAX_RESPONSE_BYTES = 5_000_000 # a reply bigger than this (after decompression) is not read
|
|
34
|
+
MAX_COMPRESSED_BYTES = 2_000_000 # bytes on the wire for one reply
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PolicyDenied(Exception):
|
|
38
|
+
"""A destination Ghost API refuses to connect to."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ResponseTooLarge(Exception):
|
|
42
|
+
"""A reply was bigger than the limit; reading stopped (response-too-large)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def system_resolver(host: str, port: int) -> list[str]:
|
|
46
|
+
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
47
|
+
return list(dict.fromkeys(str(info[4][0]) for info in infos))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def address_problem(ip: IPAddress) -> str | None:
|
|
51
|
+
"""Why an address is not on the public internet, or None when it is."""
|
|
52
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
53
|
+
ip = ip.ipv4_mapped
|
|
54
|
+
if ip.is_loopback:
|
|
55
|
+
return "this computer (loopback)"
|
|
56
|
+
if ip.is_link_local:
|
|
57
|
+
return "a link-local address (e.g. cloud metadata)"
|
|
58
|
+
if ip.is_unspecified:
|
|
59
|
+
return "an unspecified address"
|
|
60
|
+
if ip.is_multicast:
|
|
61
|
+
return "a multicast address"
|
|
62
|
+
if ip.is_private or not ip.is_global:
|
|
63
|
+
return "a private-network address"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _literal_ip(host: str) -> IPAddress | None:
|
|
68
|
+
try:
|
|
69
|
+
return ipaddress.ip_address(host.strip("[]"))
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class NetworkPolicy:
|
|
76
|
+
"""`allow_private`: local target mode. `blocked`: tests replace the address rule."""
|
|
77
|
+
|
|
78
|
+
allow_private: bool = False
|
|
79
|
+
resolver: Resolver = system_resolver
|
|
80
|
+
blocked: Callable[[IPAddress, int], str | None] = field(
|
|
81
|
+
default=lambda ip, _port: address_problem(ip)
|
|
82
|
+
)
|
|
83
|
+
_cache: dict[tuple[str, int], list[str]] = field(default_factory=dict, repr=False)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def for_target(cls, url: str, resolver: Resolver = system_resolver) -> NetworkPolicy:
|
|
87
|
+
"""Local target mode when the user's own target is on this machine or the LAN."""
|
|
88
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
89
|
+
local = host == "localhost" or host.endswith(".localhost")
|
|
90
|
+
if not local:
|
|
91
|
+
try:
|
|
92
|
+
addresses = [_literal_ip(host)] if _literal_ip(host) else [
|
|
93
|
+
ipaddress.ip_address(a) for a in resolver(host, 443)
|
|
94
|
+
] # fmt: skip
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
addresses = []
|
|
97
|
+
local = any(a is not None and address_problem(a) for a in addresses)
|
|
98
|
+
return cls(allow_private=local, resolver=resolver)
|
|
99
|
+
|
|
100
|
+
def resolve(self, host: str, port: int) -> list[str]:
|
|
101
|
+
"""The addresses `host` may be reached at; raises PolicyDenied if any is blocked.
|
|
102
|
+
|
|
103
|
+
All addresses must pass: a name that resolves to one public and one private
|
|
104
|
+
address could be steered to the private one."""
|
|
105
|
+
host = host.strip("[]").lower()
|
|
106
|
+
literal = _literal_ip(host)
|
|
107
|
+
if literal is not None:
|
|
108
|
+
addresses = [str(literal)]
|
|
109
|
+
else:
|
|
110
|
+
key = (host, port)
|
|
111
|
+
if key not in self._cache:
|
|
112
|
+
try:
|
|
113
|
+
self._cache[key] = self.resolver(host, port)
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
raise PolicyDenied(f"{host} could not be resolved ({exc})") from exc
|
|
116
|
+
addresses = self._cache[key]
|
|
117
|
+
if not addresses:
|
|
118
|
+
raise PolicyDenied(f"{host} has no address")
|
|
119
|
+
if not self.allow_private:
|
|
120
|
+
for address in addresses:
|
|
121
|
+
problem = self.blocked(ipaddress.ip_address(address), port)
|
|
122
|
+
if problem:
|
|
123
|
+
raise PolicyDenied(
|
|
124
|
+
f"blocked {host}: it points to {problem}. Ghost API does not let a "
|
|
125
|
+
"website reach your computer or local network"
|
|
126
|
+
)
|
|
127
|
+
return addresses
|
|
128
|
+
|
|
129
|
+
def check_url(self, url: str) -> None:
|
|
130
|
+
parts = urlsplit(url)
|
|
131
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
132
|
+
raise PolicyDenied(f"blocked {url[:80]}: only http(s) addresses are allowed")
|
|
133
|
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
|
134
|
+
self.resolve(parts.hostname, port)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PinnedBackend(httpcore.SyncBackend):
|
|
138
|
+
"""Connects to the address the policy checked, not to a second DNS answer, so a host
|
|
139
|
+
name can't be switched to a private address between check and connection (DNS
|
|
140
|
+
rebinding). TLS still uses the host name, so certificates are checked as usual."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, policy: NetworkPolicy) -> None:
|
|
143
|
+
self.policy = policy
|
|
144
|
+
|
|
145
|
+
def connect_tcp(
|
|
146
|
+
self,
|
|
147
|
+
host: str,
|
|
148
|
+
port: int,
|
|
149
|
+
timeout: float | None = None,
|
|
150
|
+
local_address: str | None = None,
|
|
151
|
+
socket_options: Any = None,
|
|
152
|
+
) -> httpcore.NetworkStream:
|
|
153
|
+
address = self.policy.resolve(host, port)[0] # raises PolicyDenied when blocked
|
|
154
|
+
return super().connect_tcp(address, port, timeout, local_address, socket_options)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def guarded_client(policy: NetworkPolicy, **kwargs: Any) -> httpx.Client:
|
|
158
|
+
"""An httpx client that checks every request it sends, redirect hops included, and
|
|
159
|
+
connects only to the addresses it checked."""
|
|
160
|
+
|
|
161
|
+
def check(request: httpx.Request) -> None:
|
|
162
|
+
policy.check_url(str(request.url))
|
|
163
|
+
|
|
164
|
+
if "transport" not in kwargs or kwargs["transport"] is None:
|
|
165
|
+
kwargs.pop("transport", None)
|
|
166
|
+
transport = httpx.HTTPTransport()
|
|
167
|
+
pool = getattr(transport, "_pool", None)
|
|
168
|
+
if pool is not None and hasattr(pool, "_network_backend"):
|
|
169
|
+
pool._network_backend = PinnedBackend(policy)
|
|
170
|
+
kwargs["transport"] = transport
|
|
171
|
+
return httpx.Client(event_hooks={"request": [check]}, **kwargs)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class LimitedBody:
|
|
176
|
+
content: bytes
|
|
177
|
+
encoding: str | None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def text(self) -> str:
|
|
181
|
+
return self.content.decode(self.encoding or "utf-8", errors="replace")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def read_limited(
|
|
185
|
+
response: httpx.Response,
|
|
186
|
+
max_bytes: int = MAX_RESPONSE_BYTES,
|
|
187
|
+
max_compressed: int = MAX_COMPRESSED_BYTES,
|
|
188
|
+
) -> LimitedBody:
|
|
189
|
+
"""Read a streamed reply, stopping as soon as it is too big (on the wire or unpacked),
|
|
190
|
+
so a huge or highly compressed reply never fills memory."""
|
|
191
|
+
declared = response.headers.get("content-length", "")
|
|
192
|
+
if declared.isdigit() and int(declared) > max_compressed:
|
|
193
|
+
raise ResponseTooLarge(f"response-too-large: {int(declared):,} bytes declared")
|
|
194
|
+
chunks: list[bytes] = []
|
|
195
|
+
size = 0
|
|
196
|
+
for chunk in response.iter_bytes():
|
|
197
|
+
size += len(chunk)
|
|
198
|
+
if size > max_bytes:
|
|
199
|
+
raise ResponseTooLarge(f"response-too-large: over {max_bytes:,} bytes unpacked")
|
|
200
|
+
if response.num_bytes_downloaded > max_compressed:
|
|
201
|
+
raise ResponseTooLarge(f"response-too-large: over {max_compressed:,} bytes")
|
|
202
|
+
chunks.append(chunk)
|
|
203
|
+
return LimitedBody(b"".join(chunks), response.encoding)
|