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
|
File without changes
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Confirm or reject guesses about each endpoint by calling it (Stage 5).
|
|
2
|
+
|
|
3
|
+
For every endpoint with a replayable sample:
|
|
4
|
+
1. Replay it as captured. If that fails, the endpoint can't be called outside the
|
|
5
|
+
browser (signed URLs, one-time tokens...), and testing stops there.
|
|
6
|
+
2. Pagination: request the next page / offset / cursor and compare the records.
|
|
7
|
+
3. Required headers: drop all non-browser headers at once; only if that breaks the call,
|
|
8
|
+
drop them one at a time to find the ones that matter.
|
|
9
|
+
4. Secret-looking query parameters: drop each to see if it's required (API keys).
|
|
10
|
+
5. Name the auth style from what turned out to be required.
|
|
11
|
+
|
|
12
|
+
Every test is recorded as a hypothesis with its evidence (FR-24).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
22
|
+
|
|
23
|
+
from ghost_api.analysis import detectors
|
|
24
|
+
from ghost_api.analysis.models import Endpoint
|
|
25
|
+
from ghost_api.capture.filters import is_sensitive_name
|
|
26
|
+
from ghost_api.capture.models import CapturedRequest, RawRequest
|
|
27
|
+
from ghost_api.probe.replay import (
|
|
28
|
+
Replayer,
|
|
29
|
+
ReplayRefused,
|
|
30
|
+
ReplayResult,
|
|
31
|
+
is_replayable,
|
|
32
|
+
with_overrides,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
Outcome = Literal["confirmed", "rejected", "inconclusive"]
|
|
36
|
+
MAX_HEADER_TESTS = 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Hypothesis:
|
|
41
|
+
endpoint: str # Endpoint.key
|
|
42
|
+
text: str
|
|
43
|
+
result: Outcome
|
|
44
|
+
evidence: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ProbeReport:
|
|
49
|
+
endpoint: str # Endpoint.key
|
|
50
|
+
replayable: bool = False
|
|
51
|
+
pagination: dict[str, Any] | None = None
|
|
52
|
+
required_headers: list[str] = field(default_factory=list)
|
|
53
|
+
required_query: list[str] = field(default_factory=list)
|
|
54
|
+
auth_type: str | None = None
|
|
55
|
+
hypotheses: list[Hypothesis] = field(default_factory=list)
|
|
56
|
+
note: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Prober:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
replayer: Replayer,
|
|
63
|
+
on_sample: Callable[[CapturedRequest, RawRequest], object] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.replayer = replayer
|
|
66
|
+
self.on_sample = on_sample # new samples (e.g. page 2) enrich the schemas
|
|
67
|
+
|
|
68
|
+
def probe(self, endpoint: Endpoint, samples: list[RawRequest]) -> ProbeReport:
|
|
69
|
+
report = ProbeReport(endpoint.key)
|
|
70
|
+
sample = next((s for s in reversed(samples) if is_replayable(s)), None)
|
|
71
|
+
if sample is None:
|
|
72
|
+
report.note = "no sample that is safe to replay (only read-only requests are sent)"
|
|
73
|
+
return report
|
|
74
|
+
try:
|
|
75
|
+
baseline = self._baseline(endpoint, sample, report)
|
|
76
|
+
if baseline is None:
|
|
77
|
+
return report
|
|
78
|
+
self._pagination(endpoint, sample, baseline, report, samples)
|
|
79
|
+
self._headers(endpoint, sample, baseline, report)
|
|
80
|
+
self._secret_params(endpoint, sample, baseline, report)
|
|
81
|
+
except ReplayRefused as exc:
|
|
82
|
+
report.note = f"testing stopped: {exc}"
|
|
83
|
+
report.auth_type = detectors.auth_style(report.required_headers, report.required_query)
|
|
84
|
+
if report.auth_type == "bearer":
|
|
85
|
+
auth_value = next(
|
|
86
|
+
(v for k, v in sample.headers.items() if k.lower() == "authorization"), ""
|
|
87
|
+
)
|
|
88
|
+
if auth_value.lower().startswith("basic "):
|
|
89
|
+
report.auth_type = "basic"
|
|
90
|
+
return report
|
|
91
|
+
|
|
92
|
+
# --- tests ---
|
|
93
|
+
|
|
94
|
+
def _baseline(
|
|
95
|
+
self, ep: Endpoint, sample: RawRequest, report: ProbeReport
|
|
96
|
+
) -> ReplayResult | None:
|
|
97
|
+
result = self.replayer.send(sample)
|
|
98
|
+
ok = result.ok
|
|
99
|
+
report.replayable = ok
|
|
100
|
+
report.hypotheses.append(
|
|
101
|
+
Hypothesis(
|
|
102
|
+
ep.key,
|
|
103
|
+
"can be called directly, outside the browser",
|
|
104
|
+
"confirmed" if ok else "rejected",
|
|
105
|
+
_evidence(result),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return result if ok else None
|
|
109
|
+
|
|
110
|
+
def _pagination(
|
|
111
|
+
self,
|
|
112
|
+
ep: Endpoint,
|
|
113
|
+
sample: RawRequest,
|
|
114
|
+
baseline: ReplayResult,
|
|
115
|
+
report: ProbeReport,
|
|
116
|
+
samples: list[RawRequest] | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
query = dict(parse_qsl(urlsplit(sample.url).query, keep_blank_values=True))
|
|
119
|
+
body = _json_body(sample)
|
|
120
|
+
guess = detectors.guess_pagination(query, body, baseline.body)
|
|
121
|
+
if guess is None:
|
|
122
|
+
return
|
|
123
|
+
source = query if guess.location == "query" else (body or {})
|
|
124
|
+
current = source.get(guess.param)
|
|
125
|
+
text = f"paginates with {guess.style} parameter '{guess.param}' ({guess.evidence})"
|
|
126
|
+
|
|
127
|
+
next_value: str | int | None
|
|
128
|
+
if guess.style == "cursor":
|
|
129
|
+
next_value = detectors.next_cursor(baseline.body)
|
|
130
|
+
if next_value is None:
|
|
131
|
+
# The newest sample is often the last page (no next cursor): an earlier one
|
|
132
|
+
# that still had a next page shows the paging just as well.
|
|
133
|
+
earlier = self._page_with_cursor(sample, samples or [])
|
|
134
|
+
if earlier is not None:
|
|
135
|
+
sample, baseline = earlier
|
|
136
|
+
query = dict(parse_qsl(urlsplit(sample.url).query, keep_blank_values=True))
|
|
137
|
+
body = _json_body(sample)
|
|
138
|
+
source = query if guess.location == "query" else (body or {})
|
|
139
|
+
current = source.get(guess.param)
|
|
140
|
+
next_value = detectors.next_cursor(baseline.body)
|
|
141
|
+
if next_value is None:
|
|
142
|
+
report.hypotheses.append(
|
|
143
|
+
Hypothesis(ep.key, text, "inconclusive", "no next cursor in the response")
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
elif guess.style == "page":
|
|
147
|
+
next_value = _as_int(current, default=1) + 1
|
|
148
|
+
else: # offset
|
|
149
|
+
items = detectors.find_items(baseline.body) or []
|
|
150
|
+
step = _as_int(source.get(guess.size_param) if guess.size_param else None, len(items))
|
|
151
|
+
next_value = _as_int(current, default=0) + max(step, 1)
|
|
152
|
+
|
|
153
|
+
if guess.location == "query":
|
|
154
|
+
probe = with_overrides(sample, query={guess.param: str(next_value)})
|
|
155
|
+
else:
|
|
156
|
+
typed = next_value if isinstance(current, int) else str(next_value)
|
|
157
|
+
probe = with_overrides(sample, body={guess.param: typed})
|
|
158
|
+
result = self.replayer.send(probe)
|
|
159
|
+
outcome, evidence = _compare_pages(baseline, result)
|
|
160
|
+
report.hypotheses.append(Hypothesis(ep.key, text, outcome, evidence))
|
|
161
|
+
if outcome != "rejected":
|
|
162
|
+
report.pagination = {
|
|
163
|
+
"style": guess.style,
|
|
164
|
+
"param": guess.param,
|
|
165
|
+
"location": guess.location,
|
|
166
|
+
"size_param": guess.size_param,
|
|
167
|
+
"confirmed": outcome == "confirmed",
|
|
168
|
+
}
|
|
169
|
+
if result.ok and result.captured is not None and self.on_sample:
|
|
170
|
+
self.on_sample(result.captured, probe)
|
|
171
|
+
|
|
172
|
+
def _page_with_cursor(
|
|
173
|
+
self, newest: RawRequest, samples: list[RawRequest]
|
|
174
|
+
) -> tuple[RawRequest, ReplayResult] | None:
|
|
175
|
+
"""Replay at most two earlier samples to find a page that has a next cursor."""
|
|
176
|
+
tried = 0
|
|
177
|
+
for sample in reversed(samples):
|
|
178
|
+
if sample.url == newest.url or not is_replayable(sample) or tried >= 2:
|
|
179
|
+
continue
|
|
180
|
+
tried += 1
|
|
181
|
+
result = self.replayer.send(sample)
|
|
182
|
+
if result.ok and detectors.next_cursor(result.body) is not None:
|
|
183
|
+
return sample, result
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def _headers(
|
|
187
|
+
self, ep: Endpoint, sample: RawRequest, baseline: ReplayResult, report: ProbeReport
|
|
188
|
+
) -> None:
|
|
189
|
+
candidates = detectors.candidate_headers(sample.headers)[:MAX_HEADER_TESTS]
|
|
190
|
+
if not candidates:
|
|
191
|
+
return
|
|
192
|
+
stripped = self.replayer.send(with_overrides(sample, drop_headers=set(candidates)))
|
|
193
|
+
if _same(baseline, stripped):
|
|
194
|
+
report.hypotheses.append(
|
|
195
|
+
Hypothesis(
|
|
196
|
+
ep.key,
|
|
197
|
+
"needs none of its custom headers or cookies",
|
|
198
|
+
"confirmed",
|
|
199
|
+
f"still works without {', '.join(candidates)}",
|
|
200
|
+
) # fmt: skip
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
for header in candidates:
|
|
204
|
+
result = self.replayer.send(with_overrides(sample, drop_headers={header}))
|
|
205
|
+
required = not _same(baseline, result)
|
|
206
|
+
if required:
|
|
207
|
+
report.required_headers.append(header)
|
|
208
|
+
report.hypotheses.append(
|
|
209
|
+
Hypothesis(
|
|
210
|
+
ep.key,
|
|
211
|
+
f"header '{header}' is required",
|
|
212
|
+
"confirmed" if required else "rejected",
|
|
213
|
+
f"without it: {_against(baseline, result)}",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _secret_params(
|
|
218
|
+
self, ep: Endpoint, sample: RawRequest, baseline: ReplayResult, report: ProbeReport
|
|
219
|
+
) -> None:
|
|
220
|
+
query = dict(parse_qsl(urlsplit(sample.url).query, keep_blank_values=True))
|
|
221
|
+
for name in [k for k in query if is_sensitive_name(k)]:
|
|
222
|
+
result = self.replayer.send(with_overrides(sample, query={name: None}))
|
|
223
|
+
required = not _same(baseline, result)
|
|
224
|
+
if required:
|
|
225
|
+
report.required_query.append(name)
|
|
226
|
+
report.hypotheses.append(
|
|
227
|
+
Hypothesis(
|
|
228
|
+
ep.key,
|
|
229
|
+
f"query parameter '{name}' (a key or token) is required",
|
|
230
|
+
"confirmed" if required else "rejected",
|
|
231
|
+
f"without it: {_against(baseline, result)}",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --- helpers ---------------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _json_body(raw: RawRequest) -> dict[str, Any] | None:
|
|
240
|
+
if not raw.body:
|
|
241
|
+
return None
|
|
242
|
+
try:
|
|
243
|
+
data = json.loads(raw.body)
|
|
244
|
+
except ValueError:
|
|
245
|
+
return None
|
|
246
|
+
return data if isinstance(data, dict) else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _as_int(value: Any, default: int) -> int:
|
|
250
|
+
try:
|
|
251
|
+
return int(value)
|
|
252
|
+
except (TypeError, ValueError):
|
|
253
|
+
return default
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _same(baseline: ReplayResult, other: ReplayResult) -> bool:
|
|
257
|
+
return other.ok and detectors.shape(other.body) == detectors.shape(baseline.body)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _evidence(result: ReplayResult) -> str:
|
|
261
|
+
if result.error and result.status == 0:
|
|
262
|
+
return result.error
|
|
263
|
+
text = f"HTTP {result.status}"
|
|
264
|
+
if result.error:
|
|
265
|
+
text += f", {result.error}"
|
|
266
|
+
elif result.ok:
|
|
267
|
+
items = detectors.find_items(result.body)
|
|
268
|
+
if items is not None:
|
|
269
|
+
text += f", {len(items)} record{'' if len(items) == 1 else 's'}"
|
|
270
|
+
return text
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _against(baseline: ReplayResult, result: ReplayResult) -> str:
|
|
274
|
+
"""Evidence for "X is required": what came back without X. An HTTP 200 alone would read
|
|
275
|
+
as "not required", so a different kind of response says how it differs."""
|
|
276
|
+
text = _evidence(result)
|
|
277
|
+
if result.ok and not _same(baseline, result):
|
|
278
|
+
text += (
|
|
279
|
+
f", but a different response: {_describe(result.body)} "
|
|
280
|
+
f"instead of {_describe(baseline.body)}"
|
|
281
|
+
)
|
|
282
|
+
return text
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _describe(body: Any) -> str:
|
|
286
|
+
if isinstance(body, dict):
|
|
287
|
+
keys = list(body)
|
|
288
|
+
if not keys:
|
|
289
|
+
return "an empty object"
|
|
290
|
+
more = f" (+{len(keys) - 4} more)" if len(keys) > 4 else ""
|
|
291
|
+
return "fields " + ", ".join(keys[:4]) + more
|
|
292
|
+
if isinstance(body, list):
|
|
293
|
+
return f"a list of {len(body)}"
|
|
294
|
+
return f"a {type(body).__name__}"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _compare_pages(first: ReplayResult, second: ReplayResult) -> tuple[Outcome, str]:
|
|
298
|
+
if not second.ok:
|
|
299
|
+
return "inconclusive", f"next page failed: {_evidence(second)}"
|
|
300
|
+
if detectors.shape(first.body) != detectors.shape(second.body):
|
|
301
|
+
return "inconclusive", "next page returned a different kind of response"
|
|
302
|
+
a = detectors.item_fingerprints(detectors.find_items(first.body) or [])
|
|
303
|
+
b = detectors.item_fingerprints(detectors.find_items(second.body) or [])
|
|
304
|
+
if not a or not b:
|
|
305
|
+
return "inconclusive", f"pages had {len(a)} and {len(b)} records"
|
|
306
|
+
overlap = len(set(a) & set(b)) / max(len(set(b)), 1)
|
|
307
|
+
if overlap == 1.0:
|
|
308
|
+
return "rejected", "the next page returned the same records"
|
|
309
|
+
if overlap < 0.5:
|
|
310
|
+
return "confirmed", f"the next page returned {len(b)} different records"
|
|
311
|
+
return "inconclusive", f"{overlap:.0%} of records repeated on the next page"
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Call endpoints directly, outside the browser, to test hypotheses (FR-23).
|
|
2
|
+
|
|
3
|
+
Safety rules:
|
|
4
|
+
- Only read-only requests are replayed: GET/HEAD, and POSTs whose URL says they are
|
|
5
|
+
searches or queries (e.g. Algolia, GraphQL queries). Never PUT, PATCH or DELETE.
|
|
6
|
+
- Every request goes through the shared rate limiter (NFR-18).
|
|
7
|
+
- robots.txt is checked per host (NFR-17).
|
|
8
|
+
- Original headers (including cookies) are reused from memory and never stored.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from ghost_api.capture import filters, personal
|
|
24
|
+
from ghost_api.capture.models import CapturedRequest, RawRequest
|
|
25
|
+
from ghost_api.net import RateLimiter, RobotsPolicy, fetch_robots
|
|
26
|
+
from ghost_api.netguard import (
|
|
27
|
+
NetworkPolicy,
|
|
28
|
+
PolicyDenied,
|
|
29
|
+
ResponseTooLarge,
|
|
30
|
+
guarded_client,
|
|
31
|
+
read_limited,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Whole words in the URL (path segments split on punctuation and camelCase). A POST is
|
|
35
|
+
# read-only only if a READ word is present and no WRITE word is. Substring matching was a
|
|
36
|
+
# bug: "/api/lists/7/delete" and "/api/wishlist/add" passed because they contain "list".
|
|
37
|
+
READ_WORDS = {
|
|
38
|
+
"search", "query", "queries", "graphql", "filter", "filters", "find", "lookup",
|
|
39
|
+
"autocomplete", "suggest", "suggestions", "typeahead", "list", "browse", "count", "facets",
|
|
40
|
+
} # fmt: skip
|
|
41
|
+
WRITE_WORDS = {
|
|
42
|
+
"delete", "remove", "destroy", "erase", "add", "create", "insert", "update", "edit",
|
|
43
|
+
"modify", "save", "submit", "send", "cancel", "buy", "purchase", "pay", "checkout", "cart",
|
|
44
|
+
"order", "subscribe", "unsubscribe", "follow", "unfollow", "like", "unlike", "vote",
|
|
45
|
+
"upvote", "downvote", "logout", "signout", "login", "signin", "register", "signup",
|
|
46
|
+
"reset", "upload", "import", "publish", "archive", "restore", "move", "rename", "share",
|
|
47
|
+
"invite", "approve", "reject", "transfer", "withdraw", "deposit", "block", "report",
|
|
48
|
+
"flag", "hide", "mark", "set", "toggle", "enable", "disable", "apply", "claim", "redeem",
|
|
49
|
+
"book", "reserve", "rate", "review", "comment", "reply", "post",
|
|
50
|
+
} # fmt: skip
|
|
51
|
+
_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
52
|
+
# GraphQL mutations change data even though they go to the same URL as queries.
|
|
53
|
+
_MUTATION_RE = re.compile(r"^\s*mutation\b", re.I | re.M)
|
|
54
|
+
DROP_HEADERS = {"host", "content-length", "connection", "accept-encoding"}
|
|
55
|
+
TIMEOUT_S = 20.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ReplayRefused(Exception):
|
|
59
|
+
"""The request is not safe or not allowed to replay."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ReplayResult:
|
|
64
|
+
status: int
|
|
65
|
+
body: Any | None # parsed JSON, or None
|
|
66
|
+
error: str | None = None
|
|
67
|
+
captured: CapturedRequest | None = None # redacted record of this call
|
|
68
|
+
hidden: list[dict[str, Any]] = field(default_factory=list) # personal data hidden
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def ok(self) -> bool:
|
|
72
|
+
return self.error is None and 200 <= self.status < 300 and self.body is not None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def url_words(url: str) -> set[str]:
|
|
76
|
+
"""Lower-case words in a URL's path and query values: '/api/wishList/add?x=a' ->
|
|
77
|
+
{'api', 'wish', 'list', 'add', 'a'}."""
|
|
78
|
+
parts = urlsplit(url)
|
|
79
|
+
text = parts.path + " " + " ".join(v for _, v in parse_qsl(parts.query))
|
|
80
|
+
text = _CAMEL_RE.sub(" ", text)
|
|
81
|
+
return {w.lower() for w in re.split(r"[^A-Za-z0-9]+", text) if w and not w.isdigit()}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_replayable(raw: RawRequest) -> bool:
|
|
85
|
+
"""Only requests that read data. Anything with a write word in its URL is refused,
|
|
86
|
+
even a GET, because some sites change data with GET (`/logout`, `/item/7/delete`)."""
|
|
87
|
+
method = raw.method.upper()
|
|
88
|
+
words = url_words(raw.url)
|
|
89
|
+
if words & WRITE_WORDS:
|
|
90
|
+
return False
|
|
91
|
+
if method in ("GET", "HEAD"):
|
|
92
|
+
return True
|
|
93
|
+
if method == "POST" and words & READ_WORDS:
|
|
94
|
+
return not raw.body or _graphql_reads_only(raw.body)
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
GRAPHQL_READ_NAME = re.compile(
|
|
99
|
+
r"^(get|list|search|fetch|find|load|query|read|lookup|browse|view|show|count)", re.I
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _graphql_reads_only(body: str) -> bool:
|
|
104
|
+
"""True when every GraphQL operation in the body only reads. A batch (a list) is
|
|
105
|
+
checked item by item. A persisted query has no text to check: it counts as reading only
|
|
106
|
+
when its name says so (getUser, ListOrders...)."""
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(body)
|
|
109
|
+
except ValueError:
|
|
110
|
+
return not _MUTATION_RE.search(body)
|
|
111
|
+
operations = data if isinstance(data, list) else [data]
|
|
112
|
+
for op in operations:
|
|
113
|
+
if not isinstance(op, dict):
|
|
114
|
+
return False
|
|
115
|
+
query = str(op.get("query") or "")
|
|
116
|
+
if query:
|
|
117
|
+
if _MUTATION_RE.search(query):
|
|
118
|
+
return False
|
|
119
|
+
continue
|
|
120
|
+
name = str(op.get("operationName") or "")
|
|
121
|
+
persisted = "extensions" in op or name # only a hash and a name, no query text
|
|
122
|
+
if persisted and not GRAPHQL_READ_NAME.match(name):
|
|
123
|
+
return False
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def with_overrides(
|
|
128
|
+
raw: RawRequest,
|
|
129
|
+
query: dict[str, str | None] | None = None,
|
|
130
|
+
body: dict[str, Any] | None = None,
|
|
131
|
+
drop_headers: set[str] | None = None,
|
|
132
|
+
) -> RawRequest:
|
|
133
|
+
"""A copy of `raw` with query params / top-level JSON body keys changed, and headers
|
|
134
|
+
removed. A value of None removes a query parameter."""
|
|
135
|
+
url = raw.url
|
|
136
|
+
if query:
|
|
137
|
+
parts = urlsplit(url)
|
|
138
|
+
pairs = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
139
|
+
for k, v in query.items():
|
|
140
|
+
if v is None:
|
|
141
|
+
pairs.pop(k, None)
|
|
142
|
+
else:
|
|
143
|
+
pairs[k] = v
|
|
144
|
+
url = urlunsplit(parts._replace(query=urlencode(pairs)))
|
|
145
|
+
new_body = raw.body
|
|
146
|
+
if body and raw.body:
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(raw.body)
|
|
149
|
+
except ValueError:
|
|
150
|
+
data = None
|
|
151
|
+
if isinstance(data, dict):
|
|
152
|
+
data.update(body)
|
|
153
|
+
new_body = json.dumps(data)
|
|
154
|
+
drop = {h.lower() for h in (drop_headers or set())}
|
|
155
|
+
headers = {k: v for k, v in raw.headers.items() if k.lower() not in drop}
|
|
156
|
+
return RawRequest(raw.method, url, headers, new_body)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def with_path_params(raw: RawRequest, template: str, values: dict[str, str]) -> RawRequest:
|
|
160
|
+
"""Fill path parameters: template /indexes/{index}/query + {"index": "Comments"}."""
|
|
161
|
+
parts = urlsplit(raw.url)
|
|
162
|
+
segments = parts.path.split("/")
|
|
163
|
+
names = template.split("/")
|
|
164
|
+
if len(segments) != len(names):
|
|
165
|
+
return raw
|
|
166
|
+
for i, name in enumerate(names):
|
|
167
|
+
if name.startswith("{") and name.endswith("}") and name[1:-1] in values:
|
|
168
|
+
segments[i] = quote(values[name[1:-1]], safe="")
|
|
169
|
+
url = urlunsplit(parts._replace(path="/".join(segments)))
|
|
170
|
+
return RawRequest(raw.method, url, raw.headers, raw.body)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def body_keys(raw: RawRequest) -> set[str]:
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(raw.body or "")
|
|
176
|
+
except ValueError:
|
|
177
|
+
return set()
|
|
178
|
+
return set(data) if isinstance(data, dict) else set()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def typed_value(value: str) -> object:
|
|
182
|
+
"""'2' -> 2 and 'true' -> True for JSON bodies; anything else stays a string."""
|
|
183
|
+
try:
|
|
184
|
+
return json.loads(value)
|
|
185
|
+
except ValueError:
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def apply_params(
|
|
190
|
+
sample: RawRequest, path_template: str, path_names: set[str], params: dict[str, str]
|
|
191
|
+
) -> RawRequest:
|
|
192
|
+
"""Put each parameter where the endpoint expects it: the path, the JSON body or the
|
|
193
|
+
query string. Used by the agent's call_endpoint and the dashboard's "Try it"."""
|
|
194
|
+
in_body = body_keys(sample) - path_names
|
|
195
|
+
path = {k: v for k, v in params.items() if k in path_names}
|
|
196
|
+
body = {k: typed_value(v) for k, v in params.items() if k in in_body}
|
|
197
|
+
query: dict[str, str | None] = {
|
|
198
|
+
k: v for k, v in params.items() if k not in path_names and k not in in_body
|
|
199
|
+
}
|
|
200
|
+
request = with_path_params(sample, path_template, path)
|
|
201
|
+
return with_overrides(request, query=query or None, body=body or None)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Replayer:
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
target_host: str,
|
|
208
|
+
limiter: RateLimiter,
|
|
209
|
+
robots: RobotsPolicy | None = None,
|
|
210
|
+
client: httpx.Client | None = None,
|
|
211
|
+
robots_fetcher: Callable[[str], RobotsPolicy] = fetch_robots,
|
|
212
|
+
max_requests: int = 80,
|
|
213
|
+
policy: NetworkPolicy | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""`policy`: where requests may go, checked on every redirect hop (default: decided
|
|
216
|
+
from the target, so a public site can't send replays to a private address)."""
|
|
217
|
+
self.target_host = target_host
|
|
218
|
+
self.limiter = limiter
|
|
219
|
+
self._robots: dict[str, RobotsPolicy] = {}
|
|
220
|
+
if robots is not None:
|
|
221
|
+
self._robots[robots.host] = robots
|
|
222
|
+
self._fetch_robots = robots_fetcher
|
|
223
|
+
self.policy = policy or NetworkPolicy.for_target(f"https://{target_host}/")
|
|
224
|
+
self._client = client or guarded_client(
|
|
225
|
+
self.policy, timeout=TIMEOUT_S, follow_redirects=False
|
|
226
|
+
)
|
|
227
|
+
self.max_requests = max_requests
|
|
228
|
+
# Called for every request sent or refused: (method, url, status, decision). The
|
|
229
|
+
# audit log (audit.py) uses it.
|
|
230
|
+
self.on_sent: Callable[[str, str, int | None, str], None] | None = None
|
|
231
|
+
self.requests_made = 0
|
|
232
|
+
|
|
233
|
+
def close(self) -> None:
|
|
234
|
+
self._client.close()
|
|
235
|
+
|
|
236
|
+
def _note(self, raw: RawRequest, status: int | None, decision: str) -> None:
|
|
237
|
+
if self.on_sent is not None:
|
|
238
|
+
# The audit log must never stop a request from being reported.
|
|
239
|
+
with contextlib.suppress(OSError):
|
|
240
|
+
self.on_sent(raw.method, raw.url, status, decision)
|
|
241
|
+
|
|
242
|
+
def robots_allows(self, url: str) -> bool:
|
|
243
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
244
|
+
if host not in self._robots:
|
|
245
|
+
self._robots[host] = self._fetch_robots(url)
|
|
246
|
+
return self._robots[host].allows(url)
|
|
247
|
+
|
|
248
|
+
def send(self, raw: RawRequest, trusted_read: bool = False) -> ReplayResult:
|
|
249
|
+
"""Send a read-only request. `trusted_read`: the user checked this endpoint only
|
|
250
|
+
reads (e.g. a POST search, see `generate --allow`); otherwise the URL must say so."""
|
|
251
|
+
if not trusted_read and not is_replayable(raw):
|
|
252
|
+
self._note(raw, None, "refused: may change data")
|
|
253
|
+
raise ReplayRefused(f"{raw.method} requests that may change data are never replayed")
|
|
254
|
+
if self.requests_made >= self.max_requests:
|
|
255
|
+
self._note(raw, None, "refused: request budget used up")
|
|
256
|
+
raise ReplayRefused(f"probe budget of {self.max_requests} requests used up")
|
|
257
|
+
if not self.robots_allows(raw.url):
|
|
258
|
+
self._note(raw, None, "refused: robots.txt or project rules")
|
|
259
|
+
raise ReplayRefused("disallowed by robots.txt")
|
|
260
|
+
|
|
261
|
+
self.limiter.wait()
|
|
262
|
+
self.requests_made += 1
|
|
263
|
+
# Browsers report HTTP/2 pseudo-headers (":authority", ":path"...); they can't be
|
|
264
|
+
# sent as normal headers, and httpx derives them from the URL anyway.
|
|
265
|
+
headers = {
|
|
266
|
+
k: v
|
|
267
|
+
for k, v in raw.headers.items()
|
|
268
|
+
if k.lower() not in DROP_HEADERS and not k.startswith(":")
|
|
269
|
+
}
|
|
270
|
+
try: # the default client checks the destination of every hop (netguard.py)
|
|
271
|
+
with self._client.stream(
|
|
272
|
+
raw.method,
|
|
273
|
+
raw.url,
|
|
274
|
+
headers=headers,
|
|
275
|
+
content=raw.body.encode() if raw.body else None,
|
|
276
|
+
) as resp:
|
|
277
|
+
text = read_limited(resp).text
|
|
278
|
+
except PolicyDenied as exc:
|
|
279
|
+
self._note(raw, None, "refused: private network")
|
|
280
|
+
raise ReplayRefused(str(exc)) from exc
|
|
281
|
+
except ResponseTooLarge as exc:
|
|
282
|
+
self._note(raw, resp.status_code, "sent: reply too large, not read")
|
|
283
|
+
return ReplayResult(resp.status_code, None, str(exc))
|
|
284
|
+
except httpx.HTTPError as exc:
|
|
285
|
+
self._note(raw, 0, f"sent: network error ({type(exc).__name__})")
|
|
286
|
+
return ReplayResult(0, None, f"network error: {type(exc).__name__}")
|
|
287
|
+
self._note(raw, resp.status_code, "sent")
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
body = filters.parse_response_json(text)
|
|
291
|
+
except ValueError:
|
|
292
|
+
body = None
|
|
293
|
+
ctype = resp.headers.get("content-type")
|
|
294
|
+
stored_body, personal_data = (
|
|
295
|
+
personal.hide_body(filters.redact_body(filters.strip_xssi(text), ctype))
|
|
296
|
+
if body is not None
|
|
297
|
+
else (None, [])
|
|
298
|
+
)
|
|
299
|
+
captured = CapturedRequest(
|
|
300
|
+
method=raw.method,
|
|
301
|
+
url=filters.redact_url(raw.url),
|
|
302
|
+
status=resp.status_code,
|
|
303
|
+
resource_type="probe",
|
|
304
|
+
req_headers=filters.redact_headers(headers),
|
|
305
|
+
req_body=filters.redact_body(raw.body, headers.get("content-type")),
|
|
306
|
+
req_content_type=headers.get("content-type"),
|
|
307
|
+
resp_content_type=ctype,
|
|
308
|
+
resp_body=stored_body,
|
|
309
|
+
personal=personal_data,
|
|
310
|
+
third_party=filters.is_third_party(raw.url, self.target_host),
|
|
311
|
+
)
|
|
312
|
+
error = None if body is not None else "response is not JSON"
|
|
313
|
+
return ReplayResult(resp.status_code, body, error, captured)
|