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.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. 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)