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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""A compact, LLM-friendly view of the current page (FR-8, NFR-6).
|
|
2
|
+
|
|
3
|
+
JavaScript in the page lists visible interactive elements and tags each with a
|
|
4
|
+
`data-ghost-id` attribute so the agent can refer to it by number. Python then removes
|
|
5
|
+
elements the agent must never use (FR-10, FR-11, NFR-21) and caps the list size.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from ghost_api.capture.filters import is_same_site
|
|
16
|
+
from ghost_api.net import RobotsPolicy
|
|
17
|
+
|
|
18
|
+
MAX_ELEMENTS = 60
|
|
19
|
+
MAX_LABEL = 60
|
|
20
|
+
MAX_TEXT = 400
|
|
21
|
+
|
|
22
|
+
# Labels or URLs of actions that change server state, spend money or end the session.
|
|
23
|
+
DESTRUCTIVE_RE = re.compile(
|
|
24
|
+
r"\b(delete|remove|destroy|erase|buy|purchase|checkout|check out|pay|payment|place order|"
|
|
25
|
+
r"order now|add to (cart|bag|basket)|subscribe|unsubscribe|log ?out|sign ?out|logout|"
|
|
26
|
+
r"signout|deactivate|close account|cancel (my )?(account|subscription|order)|donate|"
|
|
27
|
+
r"report|block|unfollow|follow|upvote|downvote|vote|flag|hide|reply|comment|send|share|"
|
|
28
|
+
r"invite|transfer|withdraw|confirm)\b",
|
|
29
|
+
re.I,
|
|
30
|
+
)
|
|
31
|
+
# Input types the agent must not fill in.
|
|
32
|
+
FORBIDDEN_INPUT_TYPES = {"password", "file", "hidden"}
|
|
33
|
+
|
|
34
|
+
SNAPSHOT_JS = r"""
|
|
35
|
+
({ MAX_LABEL, MAX_TEXT }) => {
|
|
36
|
+
document.querySelectorAll('[data-ghost-id]').forEach(e => e.removeAttribute('data-ghost-id'));
|
|
37
|
+
const selector = [
|
|
38
|
+
'a[href]', 'button', 'input:not([type=hidden])', 'select', 'textarea', 'summary',
|
|
39
|
+
'[role=button]', '[role=link]', '[role=tab]', '[role=menuitem]', '[role=option]',
|
|
40
|
+
'[role=checkbox]', '[role=switch]', '[role=searchbox]', '[role=combobox]', '[onclick]',
|
|
41
|
+
'[contenteditable=true]'
|
|
42
|
+
].join(',');
|
|
43
|
+
const clean = s => (s || '').replace(/\s+/g, ' ').trim();
|
|
44
|
+
const visible = el => {
|
|
45
|
+
const r = el.getBoundingClientRect();
|
|
46
|
+
if (r.width < 2 || r.height < 2) return false;
|
|
47
|
+
const st = getComputedStyle(el);
|
|
48
|
+
return st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) > 0.05;
|
|
49
|
+
};
|
|
50
|
+
const labelOf = el => {
|
|
51
|
+
const parts = [
|
|
52
|
+
el.getAttribute('aria-label'), el.innerText, el.getAttribute('placeholder'),
|
|
53
|
+
el.getAttribute('title'), el.getAttribute('alt'), el.getAttribute('name'),
|
|
54
|
+
el.tagName === 'INPUT' && ['submit', 'button'].includes(el.type) ? el.value : '',
|
|
55
|
+
el.querySelector && el.querySelector('img[alt]') ? el.querySelector('img[alt]').alt : ''
|
|
56
|
+
];
|
|
57
|
+
for (const p of parts) { const c = clean(p); if (c) return c.slice(0, MAX_LABEL); }
|
|
58
|
+
return '';
|
|
59
|
+
};
|
|
60
|
+
const out = [];
|
|
61
|
+
let n = 0;
|
|
62
|
+
for (const el of document.querySelectorAll(selector)) {
|
|
63
|
+
if (!visible(el) || el.disabled) continue;
|
|
64
|
+
// Open links in this tab so the recorder sees their traffic.
|
|
65
|
+
if (el.tagName === 'A' && el.target) el.target = '_self';
|
|
66
|
+
const r = el.getBoundingClientRect();
|
|
67
|
+
n += 1;
|
|
68
|
+
el.setAttribute('data-ghost-id', String(n));
|
|
69
|
+
out.push({
|
|
70
|
+
id: n,
|
|
71
|
+
tag: el.tagName.toLowerCase(),
|
|
72
|
+
role: el.getAttribute('role') || '',
|
|
73
|
+
type: (el.getAttribute('type') || '').toLowerCase(),
|
|
74
|
+
label: labelOf(el),
|
|
75
|
+
href: el.tagName === 'A' ? el.href : '',
|
|
76
|
+
download: el.hasAttribute('download'),
|
|
77
|
+
inViewport: r.bottom > 0 && r.top < window.innerHeight,
|
|
78
|
+
top: r.top + window.scrollY
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const doc = document.documentElement;
|
|
82
|
+
const maxScroll = doc.scrollHeight - window.innerHeight;
|
|
83
|
+
const scroll = maxScroll <= 5 ? 'no-scroll'
|
|
84
|
+
: window.scrollY <= 5 ? 'top'
|
|
85
|
+
: window.scrollY >= maxScroll - 5 ? 'bottom' : 'middle';
|
|
86
|
+
const main = document.querySelector('main, [role=main], #content, article') || document.body;
|
|
87
|
+
return {
|
|
88
|
+
url: location.href,
|
|
89
|
+
title: document.title,
|
|
90
|
+
scroll,
|
|
91
|
+
text: clean(main ? main.innerText : '').slice(0, MAX_TEXT),
|
|
92
|
+
elements: out
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
"""
|
|
96
|
+
SNAPSHOT_ARGS = {"MAX_LABEL": MAX_LABEL, "MAX_TEXT": MAX_TEXT}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Element:
|
|
101
|
+
id: int
|
|
102
|
+
kind: str # link, button, input:text, select, ...
|
|
103
|
+
label: str
|
|
104
|
+
href: str = ""
|
|
105
|
+
in_viewport: bool = False
|
|
106
|
+
|
|
107
|
+
def describe(self) -> str:
|
|
108
|
+
text = (
|
|
109
|
+
f'[{self.id}] {self.kind} "{self.label}"' if self.label else f"[{self.id}] {self.kind}"
|
|
110
|
+
)
|
|
111
|
+
if self.href:
|
|
112
|
+
text += f" -> {_short_href(self.href)}"
|
|
113
|
+
return text
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class PageSnapshot:
|
|
118
|
+
url: str
|
|
119
|
+
title: str
|
|
120
|
+
scroll: str
|
|
121
|
+
text: str
|
|
122
|
+
elements: list[Element] = field(default_factory=list)
|
|
123
|
+
blocked: int = 0 # elements removed for safety
|
|
124
|
+
has_password_field: bool = False # likely a login form
|
|
125
|
+
hidden: int = 0 # elements left out to keep the prompt small
|
|
126
|
+
|
|
127
|
+
def element(self, element_id: int) -> Element | None:
|
|
128
|
+
return next((e for e in self.elements if e.id == element_id), None)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def kind_of(raw: dict[str, Any]) -> str:
|
|
132
|
+
tag = str(raw.get("tag") or "")
|
|
133
|
+
role = str(raw.get("role") or "")
|
|
134
|
+
typ = str(raw.get("type") or "")
|
|
135
|
+
if role:
|
|
136
|
+
return role
|
|
137
|
+
if tag == "a":
|
|
138
|
+
return "link"
|
|
139
|
+
if tag == "input":
|
|
140
|
+
return f"input:{typ or 'text'}"
|
|
141
|
+
if tag in ("textarea", "select", "summary", "button"):
|
|
142
|
+
return tag
|
|
143
|
+
return "clickable"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def block_reason(
|
|
147
|
+
raw: dict[str, Any], target_host: str, robots: RobotsPolicy | None = None
|
|
148
|
+
) -> str | None:
|
|
149
|
+
"""Why the agent must not use this element, or None if it's allowed."""
|
|
150
|
+
label = str(raw.get("label") or "")
|
|
151
|
+
href = str(raw.get("href") or "")
|
|
152
|
+
kind = kind_of(raw)
|
|
153
|
+
if raw.get("download"):
|
|
154
|
+
return "download"
|
|
155
|
+
if kind.startswith("input:") and kind.split(":", 1)[1] in FORBIDDEN_INPUT_TYPES:
|
|
156
|
+
return "sensitive input"
|
|
157
|
+
if DESTRUCTIVE_RE.search(label) or (href and DESTRUCTIVE_RE.search(urlsplit(href).path)):
|
|
158
|
+
return "destructive"
|
|
159
|
+
if href:
|
|
160
|
+
scheme = urlsplit(href).scheme
|
|
161
|
+
if scheme in ("mailto", "tel", "sms", "data", "blob"):
|
|
162
|
+
return f"{scheme} link"
|
|
163
|
+
if scheme in ("http", "https"):
|
|
164
|
+
if not is_same_site(href, target_host):
|
|
165
|
+
return "off-site"
|
|
166
|
+
if robots is not None and not robots.allows(href):
|
|
167
|
+
return "robots.txt"
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_snapshot(
|
|
172
|
+
raw: dict[str, Any], target_host: str, robots: RobotsPolicy | None = None
|
|
173
|
+
) -> PageSnapshot:
|
|
174
|
+
"""Turn the page script's output into a safe, size-capped snapshot."""
|
|
175
|
+
kept: list[Element] = []
|
|
176
|
+
seen: set[tuple[str, str, str]] = set()
|
|
177
|
+
blocked = 0
|
|
178
|
+
password = False
|
|
179
|
+
for item in raw.get("elements", []):
|
|
180
|
+
password = password or kind_of(item) == "input:password"
|
|
181
|
+
if block_reason(item, target_host, robots):
|
|
182
|
+
blocked += 1
|
|
183
|
+
continue
|
|
184
|
+
el = Element(
|
|
185
|
+
id=int(item["id"]),
|
|
186
|
+
kind=kind_of(item),
|
|
187
|
+
label=str(item.get("label") or ""),
|
|
188
|
+
href=str(item.get("href") or ""),
|
|
189
|
+
in_viewport=bool(item.get("inViewport")),
|
|
190
|
+
)
|
|
191
|
+
key = (el.kind, el.label, el.href)
|
|
192
|
+
if key in seen: # e.g. twenty identical "Read more" links
|
|
193
|
+
continue
|
|
194
|
+
seen.add(key)
|
|
195
|
+
kept.append(el)
|
|
196
|
+
# Elements on screen first; the rest in page order.
|
|
197
|
+
kept.sort(key=lambda e: not e.in_viewport)
|
|
198
|
+
shown = kept[:MAX_ELEMENTS]
|
|
199
|
+
return PageSnapshot(
|
|
200
|
+
url=str(raw.get("url", "")),
|
|
201
|
+
title=str(raw.get("title", ""))[:120],
|
|
202
|
+
scroll=str(raw.get("scroll", "")),
|
|
203
|
+
text=str(raw.get("text", "")),
|
|
204
|
+
elements=shown,
|
|
205
|
+
blocked=blocked,
|
|
206
|
+
hidden=len(kept) - len(shown),
|
|
207
|
+
has_password_field=password,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _short_href(href: str) -> str:
|
|
212
|
+
parts = urlsplit(href)
|
|
213
|
+
if parts.scheme == "javascript":
|
|
214
|
+
return "(script)"
|
|
215
|
+
path = parts.path or "/"
|
|
216
|
+
text = (
|
|
217
|
+
path
|
|
218
|
+
+ (f"?{parts.query}" if parts.query else "")
|
|
219
|
+
+ (f"#{parts.fragment}" if parts.fragment else "")
|
|
220
|
+
)
|
|
221
|
+
return text if len(text) <= 70 else text[:67] + "..."
|
|
File without changes
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Decide which captured requests are API traffic, and strip secrets out of them.
|
|
2
|
+
|
|
3
|
+
Everything here is plain, deterministic code. Redaction runs before anything is stored,
|
|
4
|
+
logged or sent to an LLM (FR-16, NFR-13).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
13
|
+
|
|
14
|
+
REDACTED = "[REDACTED]"
|
|
15
|
+
|
|
16
|
+
# Resource types Playwright reports for script-initiated requests.
|
|
17
|
+
API_RESOURCE_TYPES = {"xhr", "fetch"}
|
|
18
|
+
|
|
19
|
+
# Third-party analytics / tracking / monitoring hosts. Matched as a domain suffix.
|
|
20
|
+
NOISE_DOMAINS = (
|
|
21
|
+
"google-analytics.com",
|
|
22
|
+
"analytics.google.com",
|
|
23
|
+
"googletagmanager.com",
|
|
24
|
+
"doubleclick.net",
|
|
25
|
+
"googlesyndication.com",
|
|
26
|
+
"googleadservices.com",
|
|
27
|
+
"facebook.net",
|
|
28
|
+
"facebook.com",
|
|
29
|
+
"connect.facebook.net",
|
|
30
|
+
"segment.io",
|
|
31
|
+
"segment.com",
|
|
32
|
+
"mixpanel.com",
|
|
33
|
+
"amplitude.com",
|
|
34
|
+
"hotjar.com",
|
|
35
|
+
"hotjar.io",
|
|
36
|
+
"clarity.ms",
|
|
37
|
+
"fullstory.com",
|
|
38
|
+
"sentry.io",
|
|
39
|
+
"nr-data.net",
|
|
40
|
+
"newrelic.com",
|
|
41
|
+
"datadoghq.com",
|
|
42
|
+
"browser-intake-datadoghq.com",
|
|
43
|
+
"intercom.io",
|
|
44
|
+
"launchdarkly.com",
|
|
45
|
+
"optimizely.com",
|
|
46
|
+
"bing.com",
|
|
47
|
+
"linkedin.com",
|
|
48
|
+
"tiktok.com",
|
|
49
|
+
"twitter.com",
|
|
50
|
+
"ads-twitter.com",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Third-party infrastructure that returns JSON but is never the site's data API:
|
|
54
|
+
# CDNs, captchas, consent banners, payment widgets, video players, push services.
|
|
55
|
+
INFRA_DOMAINS = (
|
|
56
|
+
"gstatic.com", "fonts.googleapis.com", "recaptcha.net", "hcaptcha.com",
|
|
57
|
+
"challenges.cloudflare.com",
|
|
58
|
+
"cloudflareinsights.com", "jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com",
|
|
59
|
+
"stripe.com", "stripe.network", "paypal.com", "braintreegateway.com", "youtube.com",
|
|
60
|
+
"ytimg.com", "vimeo.com", "onesignal.com", "pusher.com", "cookielaw.org", "onetrust.com",
|
|
61
|
+
"cookiebot.com", "usercentrics.eu", "trustarc.com", "quantcast.com", "gravatar.com",
|
|
62
|
+
"wp.com", "zendesk.com", "hubspot.com", "hs-scripts.com", "typekit.net", "fontawesome.com",
|
|
63
|
+
) # fmt: skip
|
|
64
|
+
|
|
65
|
+
# Subdomains that carry tracking or monitoring, even on the target's own domain.
|
|
66
|
+
NOISE_SUBDOMAINS = {
|
|
67
|
+
"telemetry", "analytics", "metrics", "stats", "tracking", "track", "log", "logs", "rum",
|
|
68
|
+
"beacon", "pixel", "collector", "sentry",
|
|
69
|
+
} # fmt: skip
|
|
70
|
+
|
|
71
|
+
# Second-level public suffixes, so "shop.example.co.uk" maps to "example.co.uk".
|
|
72
|
+
_TWO_PART_SUFFIXES = {
|
|
73
|
+
"co.uk", "org.uk", "ac.uk", "gov.uk", "com.au", "net.au", "org.au", "co.in", "net.in",
|
|
74
|
+
"org.in", "co.jp", "ne.jp", "com.br", "com.cn", "com.mx", "co.nz", "co.za", "com.sg",
|
|
75
|
+
} # fmt: skip
|
|
76
|
+
|
|
77
|
+
_SENSITIVE_WORDS = {
|
|
78
|
+
"token", "secret", "password", "passwd", "pwd", "apikey", "csrf", "xsrf", "session",
|
|
79
|
+
"sessionid", "sessid", "sid", "auth", "authorization", "signature", "sig", "cookie",
|
|
80
|
+
"jwt", "otp", "bearer", "credential", "credentials",
|
|
81
|
+
} # fmt: skip
|
|
82
|
+
# Run-together names like PHPSESSID, csrftoken, authtoken.
|
|
83
|
+
_SENSITIVE_SUFFIXES = ("sessid", "sessionid", "token", "secret", "password", "apikey")
|
|
84
|
+
# "key" alone is often a legitimate ID field, so only these combinations count.
|
|
85
|
+
_SENSITIVE_KEY_PREFIXES = {"api", "access", "private", "secret", "client", "auth", "signing"}
|
|
86
|
+
|
|
87
|
+
_IP_RE = re.compile(r"\d{1,3}(\.\d{1,3}){3}")
|
|
88
|
+
|
|
89
|
+
# Beacon-style endpoints that sites host on their own domain.
|
|
90
|
+
_FIRST_PARTY_NOISE_RE = re.compile(r"/(collect|beacon|telemetry|pixel|__track|rum)(/|$)", re.I)
|
|
91
|
+
# Health checks and liveness probes (first- or third-party): never a data API.
|
|
92
|
+
_HEALTH_RE = re.compile(
|
|
93
|
+
r"/(isalive|is_alive|health|healthz|healthcheck|ping|heartbeat|livez|readyz)/?$", re.I
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}")
|
|
97
|
+
_BEARER_RE = re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")
|
|
98
|
+
_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def site_of(host: str) -> str:
|
|
102
|
+
"""Approximate the registrable domain: 'api.shop.example.com' -> 'example.com'."""
|
|
103
|
+
host = host.lower().rstrip(".").split(":")[0]
|
|
104
|
+
if _IP_RE.fullmatch(host):
|
|
105
|
+
return host
|
|
106
|
+
parts = host.split(".")
|
|
107
|
+
if len(parts) <= 2:
|
|
108
|
+
return host
|
|
109
|
+
last_two = ".".join(parts[-2:])
|
|
110
|
+
if last_two in _TWO_PART_SUFFIXES:
|
|
111
|
+
return ".".join(parts[-3:])
|
|
112
|
+
return last_two
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_same_site(url: str, target_host: str) -> bool:
|
|
116
|
+
"""True if the URL is on the target's domain or one of its subdomains (FR-10)."""
|
|
117
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
118
|
+
return bool(host) and site_of(host) == site_of(target_host)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_noise_domain(url: str) -> bool:
|
|
122
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
123
|
+
if host.split(".", 1)[0] in NOISE_SUBDOMAINS:
|
|
124
|
+
return True
|
|
125
|
+
return any(host == d or host.endswith("." + d) for d in NOISE_DOMAINS)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_infra_domain(url: str) -> bool:
|
|
129
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
130
|
+
return any(host == d or host.endswith("." + d) for d in INFRA_DOMAINS)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_third_party(url: str, target_host: str) -> bool:
|
|
134
|
+
return not is_same_site(url, target_host)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_json_content_type(content_type: str | None) -> bool:
|
|
138
|
+
return bool(content_type) and "json" in content_type.lower() # type: ignore[union-attr]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Some sites put a guard before their JSON so another site's <script> tag can't read it
|
|
142
|
+
# (Google: ")]}'"; older sites: "while(1);" or "for(;;);"). Browsers' own code strips it.
|
|
143
|
+
XSSI_PREFIXES = (")]}'", "while(1);", "for(;;);")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def strip_xssi(text: str) -> str:
|
|
147
|
+
"""Remove an anti-hijacking prefix from a JSON response, if there is one."""
|
|
148
|
+
stripped = text.lstrip()
|
|
149
|
+
for prefix in XSSI_PREFIXES:
|
|
150
|
+
if stripped.startswith(prefix):
|
|
151
|
+
return stripped[len(prefix) :].lstrip(",").lstrip()
|
|
152
|
+
return text
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_response_json(text: str) -> Any:
|
|
156
|
+
"""Parse a JSON response body, allowing an anti-hijacking prefix. Raises ValueError."""
|
|
157
|
+
return json.loads(strip_xssi(text))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def classify(resource_type: str, url: str, target_host: str) -> str | None:
|
|
161
|
+
"""Return None if this request should be kept, otherwise the reason it was dropped.
|
|
162
|
+
|
|
163
|
+
Requests to other domains are kept (as third-party APIs, e.g. a site's search running
|
|
164
|
+
on Algolia) unless they are analytics or known infrastructure.
|
|
165
|
+
"""
|
|
166
|
+
if resource_type not in API_RESOURCE_TYPES:
|
|
167
|
+
return f"resource:{resource_type}"
|
|
168
|
+
if is_noise_domain(url):
|
|
169
|
+
return "analytics"
|
|
170
|
+
if is_third_party(url, target_host) and is_infra_domain(url):
|
|
171
|
+
return "infrastructure"
|
|
172
|
+
if _HEALTH_RE.search(urlsplit(url).path):
|
|
173
|
+
return "health-check"
|
|
174
|
+
if _FIRST_PARTY_NOISE_RE.search(urlsplit(url).path):
|
|
175
|
+
return "analytics"
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- Redaction -----------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _name_tokens(name: str) -> list[str]:
|
|
183
|
+
name = _CAMEL_RE.sub("_", name)
|
|
184
|
+
return [t for t in re.split(r"[^a-z0-9]+", name.lower()) if t]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_sensitive_name(name: str) -> bool:
|
|
188
|
+
"""True for header / parameter / field names that usually hold secrets.
|
|
189
|
+
|
|
190
|
+
Splits on separators and camelCase so 'accessToken' and 'X-Auth-Token' match,
|
|
191
|
+
but 'author' and 'key' do not.
|
|
192
|
+
"""
|
|
193
|
+
tokens = _name_tokens(name)
|
|
194
|
+
joined = "".join(tokens)
|
|
195
|
+
if joined in _SENSITIVE_WORDS:
|
|
196
|
+
return True
|
|
197
|
+
for i, tok in enumerate(tokens):
|
|
198
|
+
if tok in _SENSITIVE_WORDS or tok.endswith(_SENSITIVE_SUFFIXES):
|
|
199
|
+
return True
|
|
200
|
+
if tok == "key" and i > 0 and tokens[i - 1] in _SENSITIVE_KEY_PREFIXES:
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def redact_text(text: str) -> str:
|
|
206
|
+
"""Mask secret-looking values (JWTs, bearer/basic credentials) inside free text."""
|
|
207
|
+
text = _JWT_RE.sub(REDACTED, text)
|
|
208
|
+
return _BEARER_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", text)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def redact_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
212
|
+
return {k: (REDACTED if is_sensitive_name(k) else redact_text(v)) for k, v in headers.items()}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def redact_url(url: str) -> str:
|
|
216
|
+
parts = urlsplit(url)
|
|
217
|
+
if not parts.query:
|
|
218
|
+
return url
|
|
219
|
+
pairs = parse_qsl(parts.query, keep_blank_values=True)
|
|
220
|
+
safe = [(k, REDACTED if is_sensitive_name(k) else redact_text(v)) for k, v in pairs]
|
|
221
|
+
return urlunsplit(parts._replace(query=urlencode(safe, safe="[]{}")))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def redact_json(value: Any) -> Any:
|
|
225
|
+
if isinstance(value, dict):
|
|
226
|
+
return {
|
|
227
|
+
k: (
|
|
228
|
+
REDACTED
|
|
229
|
+
if is_sensitive_name(k) and not isinstance(v, dict | list)
|
|
230
|
+
else redact_json(v)
|
|
231
|
+
)
|
|
232
|
+
for k, v in value.items()
|
|
233
|
+
}
|
|
234
|
+
if isinstance(value, list):
|
|
235
|
+
return [redact_json(v) for v in value]
|
|
236
|
+
if isinstance(value, str):
|
|
237
|
+
return redact_text(value)
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def redact_body(body: str | None, content_type: str | None) -> str | None:
|
|
242
|
+
"""Redact a request or response body, keeping its format."""
|
|
243
|
+
if not body:
|
|
244
|
+
return body
|
|
245
|
+
ctype = (content_type or "").lower()
|
|
246
|
+
stripped = body.lstrip()
|
|
247
|
+
if "json" in ctype or stripped[:1] in ("{", "["):
|
|
248
|
+
try:
|
|
249
|
+
return json.dumps(redact_json(json.loads(body)), ensure_ascii=False)
|
|
250
|
+
except ValueError:
|
|
251
|
+
pass
|
|
252
|
+
if "x-www-form-urlencoded" in ctype:
|
|
253
|
+
pairs = parse_qsl(body, keep_blank_values=True)
|
|
254
|
+
return urlencode([(k, REDACTED if is_sensitive_name(k) else v) for k, v in pairs])
|
|
255
|
+
return redact_text(body)
|
ghost_api/capture/har.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Read a HAR file (the browser's "Save all as HAR") into captured requests.
|
|
2
|
+
|
|
3
|
+
For sites Ghost API can't explore by itself: behind a login, a bot check you passed by
|
|
4
|
+
hand, or a flow too long for the agent. You browse; your browser records; Ghost API maps
|
|
5
|
+
the API from the recording, with no browser and no LLM of its own.
|
|
6
|
+
|
|
7
|
+
Every entry goes through the same filters as live capture (`recorder.build_capture`):
|
|
8
|
+
only JSON API calls are kept, cookies, tokens and keys are redacted, and personal data is
|
|
9
|
+
hidden before anything is stored. The unredacted originals stay in memory only, for
|
|
10
|
+
testing endpoints with direct calls. A HAR file itself holds your cookies and tokens in
|
|
11
|
+
plain text: delete it when you are done.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import binascii
|
|
18
|
+
import json
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
from ghost_api.capture import filters
|
|
27
|
+
from ghost_api.capture.models import CapturedRequest, RawRequest
|
|
28
|
+
from ghost_api.capture.recorder import build_capture
|
|
29
|
+
|
|
30
|
+
MAX_HAR_BYTES = 500_000_000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HarError(Exception):
|
|
34
|
+
"""The file is not a HAR file Ghost API can read."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class HarImport:
|
|
39
|
+
start_url: str
|
|
40
|
+
target_host: str
|
|
41
|
+
captured: list[tuple[CapturedRequest, RawRequest]] = field(default_factory=list)
|
|
42
|
+
dropped: Counter[str] = field(default_factory=Counter)
|
|
43
|
+
entries: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_har(path: Path, start_url: str | None = None) -> HarImport:
|
|
47
|
+
if path.stat().st_size > MAX_HAR_BYTES:
|
|
48
|
+
raise HarError(f"{path.name} is larger than {MAX_HAR_BYTES // 1_000_000} MB")
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
51
|
+
entries = data["log"]["entries"]
|
|
52
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
53
|
+
raise HarError(f"{path.name} is not a HAR file ({exc})") from exc
|
|
54
|
+
if not isinstance(entries, list):
|
|
55
|
+
raise HarError(f"{path.name} has no entries")
|
|
56
|
+
start = start_url or _start_url(entries)
|
|
57
|
+
if not start:
|
|
58
|
+
raise HarError(f"{path.name} has no http(s) requests")
|
|
59
|
+
result = HarImport(start, (urlsplit(start).hostname or "").lower(), entries=len(entries))
|
|
60
|
+
for entry in entries:
|
|
61
|
+
try:
|
|
62
|
+
item = _entry(entry, result.target_host)
|
|
63
|
+
except (KeyError, TypeError, ValueError):
|
|
64
|
+
result.dropped["unreadable"] += 1
|
|
65
|
+
continue
|
|
66
|
+
if isinstance(item, str):
|
|
67
|
+
result.dropped[item] += 1
|
|
68
|
+
else:
|
|
69
|
+
result.captured.append(item)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _start_url(entries: list[Any]) -> str:
|
|
74
|
+
"""The first page the browser loaded, or the origin of the first request."""
|
|
75
|
+
for entry in entries:
|
|
76
|
+
url = str(entry.get("request", {}).get("url", ""))
|
|
77
|
+
if entry.get("_resourceType") == "document" and url.startswith("http"):
|
|
78
|
+
return url
|
|
79
|
+
for entry in entries:
|
|
80
|
+
url = str(entry.get("request", {}).get("url", ""))
|
|
81
|
+
if url.startswith("http"):
|
|
82
|
+
parts = urlsplit(url)
|
|
83
|
+
return f"{parts.scheme}://{parts.netloc}/"
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _headers(items: list[dict[str, Any]]) -> dict[str, str]:
|
|
88
|
+
# HTTP/2 pseudo-headers (":authority") are not real headers.
|
|
89
|
+
return {str(h["name"]).lower(): str(h["value"]) for h in items if not h["name"].startswith(":")}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resource_type(entry: dict[str, Any], resp_headers: dict[str, str]) -> str:
|
|
93
|
+
"""Chrome and Edge write `_resourceType`; Firefox doesn't, so JSON replies count as fetch."""
|
|
94
|
+
kind = entry.get("_resourceType")
|
|
95
|
+
if isinstance(kind, str) and kind:
|
|
96
|
+
return kind.lower()
|
|
97
|
+
if filters.is_json_content_type(resp_headers.get("content-type")):
|
|
98
|
+
return "fetch"
|
|
99
|
+
return "other"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _entry(entry: dict[str, Any], target_host: str) -> tuple[CapturedRequest, RawRequest] | str:
|
|
103
|
+
request, response = entry["request"], entry["response"]
|
|
104
|
+
url = str(request["url"])
|
|
105
|
+
if not url.startswith(("http://", "https://")):
|
|
106
|
+
return "not-http"
|
|
107
|
+
req_headers = _headers(request.get("headers", []))
|
|
108
|
+
resp_headers = _headers(response.get("headers", []))
|
|
109
|
+
kind = _resource_type(entry, resp_headers)
|
|
110
|
+
reason = filters.classify(kind, url, target_host)
|
|
111
|
+
if reason:
|
|
112
|
+
return reason
|
|
113
|
+
status = int(response.get("status") or 0)
|
|
114
|
+
if 300 <= status < 400:
|
|
115
|
+
return "redirect"
|
|
116
|
+
if status == 0:
|
|
117
|
+
return "no-reply" # blocked, cancelled or failed in the browser
|
|
118
|
+
content = response.get("content", {})
|
|
119
|
+
text = content.get("text")
|
|
120
|
+
if text is None:
|
|
121
|
+
return "no-body" # saved without content ("Save all as HAR (sanitized)")
|
|
122
|
+
body = _decode(str(text), content.get("encoding"))
|
|
123
|
+
if body is None:
|
|
124
|
+
return "binary"
|
|
125
|
+
post = request.get("postData", {}).get("text")
|
|
126
|
+
post_bytes = str(post).encode("utf-8") if post is not None else None
|
|
127
|
+
if "content-type" not in resp_headers and content.get("mimeType"):
|
|
128
|
+
resp_headers["content-type"] = str(content["mimeType"])
|
|
129
|
+
return build_capture(
|
|
130
|
+
str(request["method"]).upper(), url, kind, status, req_headers, post_bytes,
|
|
131
|
+
resp_headers, body, target_host,
|
|
132
|
+
) # fmt: skip
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _decode(text: str, encoding: Any) -> bytes | None:
|
|
136
|
+
if encoding == "base64":
|
|
137
|
+
try:
|
|
138
|
+
return base64.b64decode(text, validate=True)
|
|
139
|
+
except (binascii.Error, ValueError):
|
|
140
|
+
return None
|
|
141
|
+
return text.encode("utf-8")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def summary(result: HarImport) -> Iterator[str]:
|
|
145
|
+
yield f"{result.entries} entries, {len(result.captured)} API calls kept"
|
|
146
|
+
if result.dropped:
|
|
147
|
+
top = ", ".join(f"{k}: {v}" for k, v in result.dropped.most_common(6))
|
|
148
|
+
yield f"left out: {top}"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Data captured from the browser's network traffic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CapturedRequest:
|
|
12
|
+
"""One API request/response pair. All fields are already redacted."""
|
|
13
|
+
|
|
14
|
+
method: str
|
|
15
|
+
url: str
|
|
16
|
+
status: int
|
|
17
|
+
resource_type: str
|
|
18
|
+
req_headers: dict[str, str]
|
|
19
|
+
req_body: str | None
|
|
20
|
+
req_content_type: str | None
|
|
21
|
+
resp_content_type: str | None
|
|
22
|
+
resp_body: str | None
|
|
23
|
+
truncated: bool = False
|
|
24
|
+
third_party: bool = False # served from a different domain than the target site
|
|
25
|
+
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
26
|
+
# Personal data hidden from resp_body before storing (capture/personal.py):
|
|
27
|
+
# [{"path", "kind", "severity", "example" (masked), "count"}].
|
|
28
|
+
personal: list[dict[str, Any]] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RawRequest:
|
|
33
|
+
"""The original, UNREDACTED request, kept in memory only so endpoints can be replayed.
|
|
34
|
+
|
|
35
|
+
Never stored, logged or sent to an LLM (NFR-13). repr() hides headers and body.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
method: str
|
|
39
|
+
url: str = field(repr=False)
|
|
40
|
+
headers: dict[str, str] = field(repr=False)
|
|
41
|
+
body: str | None = field(default=None, repr=False)
|