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,188 @@
|
|
|
1
|
+
"""Find personal and sensitive data in API replies, and hide it before anything is kept.
|
|
2
|
+
|
|
3
|
+
Found on turftown.in: a public venue-details call returned owners' PAN and GST numbers,
|
|
4
|
+
bank UPI IDs and staff emails to anyone. Ghost API now spots such values when a reply is
|
|
5
|
+
captured, replaces them with a marker like "[hidden email: a***@g***.com]" (strings) or
|
|
6
|
+
0 (numbers, so the inferred type stays the same), and records what it hid. The report
|
|
7
|
+
then shows which endpoints expose what, without ever storing the values themselves.
|
|
8
|
+
|
|
9
|
+
Detection uses the value's shape and the field's name together, to keep false alarms
|
|
10
|
+
low: a 10-digit number is only a phone number in a field called phone/mobile/contact.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
Severity = Literal["high", "medium"]
|
|
21
|
+
|
|
22
|
+
_EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$")
|
|
23
|
+
_UPI_RE = re.compile(r"^[A-Za-z0-9._-]{2,}@[A-Za-z][A-Za-z0-9]{1,}$") # name@okaxis (no dot)
|
|
24
|
+
_PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
|
|
25
|
+
_GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
|
|
26
|
+
_PHONE_RE = re.compile(r"^\+?[0-9][0-9 ()-]{7,16}[0-9]$")
|
|
27
|
+
_INTL_PHONE_RE = re.compile(r"^\+[1-9][0-9 ()-]{7,16}[0-9]$")
|
|
28
|
+
_AADHAAR_RE = re.compile(r"^[2-9][0-9]{3}[ -]?[0-9]{4}[ -]?[0-9]{4}$")
|
|
29
|
+
_DIGITS_RE = re.compile(r"^[0-9]{9,19}$")
|
|
30
|
+
_MARKER_RE = re.compile(r"^\[hidden ([a-z_]+): (.*)\]$")
|
|
31
|
+
|
|
32
|
+
_PHONE_NAMES = ("phone", "mobile", "contact", "whatsapp", "msisdn", "tel")
|
|
33
|
+
_ACCOUNT_NAMES = ("account_number", "accountnumber", "account_no", "acc_no", "bank_account")
|
|
34
|
+
_CARD_NAMES = ("card", "cc_number", "ccnumber")
|
|
35
|
+
_AADHAAR_NAMES = ("aadhaar", "aadhar", "uid_number")
|
|
36
|
+
_UPI_NAMES = ("upi", "vpa")
|
|
37
|
+
# Common UPI handle providers (the part after "@"), for values in fields not named upi/vpa.
|
|
38
|
+
_UPI_PROVIDERS = {
|
|
39
|
+
"okaxis", "okhdfcbank", "okicici", "oksbi", "ybl", "ibl", "axl", "paytm", "upi", "apl",
|
|
40
|
+
"yapl", "rapl", "airtel", "freecharge", "jio", "kotak", "hdfcbank", "icici", "sbi",
|
|
41
|
+
"axisbank", "pthdfc", "ptsbi", "ptyes", "ptaxis", "waaxis", "wahdfcbank", "wasbi",
|
|
42
|
+
"waicici", "abfspay", "ikwik", "idfcbank", "indus", "barodampay", "fbl", "federal",
|
|
43
|
+
} # fmt: skip
|
|
44
|
+
_DOB_NAMES = ("dob", "date_of_birth", "dateofbirth", "birth_date", "birthdate", "birthday")
|
|
45
|
+
# A person's name, when the field says so (names can't be told from their value alone).
|
|
46
|
+
_HOLDER_NAMES = (
|
|
47
|
+
"accountname", "account_name", "accountholder", "account_holder", "holder_name",
|
|
48
|
+
"holdername", "beneficiary", "owner_name", "ownername", "card_holder", "cardholder",
|
|
49
|
+
) # fmt: skip
|
|
50
|
+
|
|
51
|
+
SEVERITY: dict[str, Severity] = {
|
|
52
|
+
"pan": "high", "gstin": "high", "aadhaar": "high", "bank_account": "high",
|
|
53
|
+
"card": "high", "upi": "high", "email": "medium", "phone": "medium",
|
|
54
|
+
"date_of_birth": "medium", "account_holder": "medium",
|
|
55
|
+
} # fmt: skip
|
|
56
|
+
LABEL = {
|
|
57
|
+
"pan": "PAN number", "gstin": "GST number", "aadhaar": "Aadhaar number",
|
|
58
|
+
"bank_account": "bank account number", "card": "card number", "upi": "UPI ID",
|
|
59
|
+
"email": "email address", "phone": "phone number", "date_of_birth": "date of birth",
|
|
60
|
+
"account_holder": "account holder's name",
|
|
61
|
+
} # fmt: skip
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Finding:
|
|
66
|
+
path: str # field path, list positions as []: "data.rating[].email"
|
|
67
|
+
kind: str
|
|
68
|
+
severity: Severity
|
|
69
|
+
example: str # masked: "a***@g***.com"
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, str]:
|
|
72
|
+
return asdict(self)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def classify(key: str, value: Any) -> str | None:
|
|
76
|
+
"""The kind of personal data `value` (in field `key`) is, or None."""
|
|
77
|
+
if isinstance(value, bool) or value is None:
|
|
78
|
+
return None
|
|
79
|
+
name = key.lower()
|
|
80
|
+
text = str(value).strip()
|
|
81
|
+
if not text or _MARKER_RE.match(text):
|
|
82
|
+
return None
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
if _EMAIL_RE.match(text):
|
|
85
|
+
return "email"
|
|
86
|
+
if _PAN_RE.match(text):
|
|
87
|
+
return "pan"
|
|
88
|
+
if _GSTIN_RE.match(text):
|
|
89
|
+
return "gstin"
|
|
90
|
+
if _UPI_RE.match(text) and (
|
|
91
|
+
any(n in name for n in _UPI_NAMES) or text.rpartition("@")[2].lower() in _UPI_PROVIDERS
|
|
92
|
+
):
|
|
93
|
+
return "upi"
|
|
94
|
+
if _INTL_PHONE_RE.match(text) and _digit_count(text) >= 10:
|
|
95
|
+
return "phone"
|
|
96
|
+
if (
|
|
97
|
+
isinstance(value, str)
|
|
98
|
+
and any(n in name.replace("-", "_") for n in _HOLDER_NAMES)
|
|
99
|
+
and re.search(r"[A-Za-z]{2}", text)
|
|
100
|
+
):
|
|
101
|
+
return "account_holder"
|
|
102
|
+
if any(n in name for n in _DOB_NAMES) and isinstance(value, str) and re.search(r"\d", text):
|
|
103
|
+
return "date_of_birth"
|
|
104
|
+
if any(n in name for n in _AADHAAR_NAMES) and _AADHAAR_RE.match(text):
|
|
105
|
+
return "aadhaar"
|
|
106
|
+
if any(n in name for n in _ACCOUNT_NAMES) and _DIGITS_RE.match(text):
|
|
107
|
+
return "bank_account"
|
|
108
|
+
if any(n in name for n in _CARD_NAMES) and _DIGITS_RE.match(text) and _luhn(text):
|
|
109
|
+
return "card"
|
|
110
|
+
if (
|
|
111
|
+
any(n in name for n in _PHONE_NAMES)
|
|
112
|
+
and _PHONE_RE.match(text)
|
|
113
|
+
and 10 <= _digit_count(text) <= 15
|
|
114
|
+
):
|
|
115
|
+
return "phone"
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def mask(text: str, kind: str) -> str:
|
|
120
|
+
"""Enough to recognise the kind, nothing to recover the value: 'a***@g***.com'."""
|
|
121
|
+
if kind in ("email", "upi") and "@" in text:
|
|
122
|
+
user, _, domain = text.partition("@")
|
|
123
|
+
head, dot, tld = domain.rpartition(".")
|
|
124
|
+
domain_masked = f"{head[:1]}***{dot}{tld}" if dot else f"{domain[:1]}***"
|
|
125
|
+
return f"{user[:1]}***@{domain_masked}"
|
|
126
|
+
keep = 2 if len(text) > 6 else 1
|
|
127
|
+
return text[:keep] + "*" * max(3, len(text) - keep - 1) + text[-1:]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def hide(data: Any, path: str = "") -> tuple[Any, list[Finding]]:
|
|
131
|
+
"""A copy of `data` with personal values replaced, and what was replaced."""
|
|
132
|
+
findings: list[Finding] = []
|
|
133
|
+
|
|
134
|
+
def walk(value: Any, key: str, where: str) -> Any:
|
|
135
|
+
if isinstance(value, dict):
|
|
136
|
+
return {
|
|
137
|
+
k: walk(v, str(k), f"{where}.{k}" if where else str(k)) for k, v in value.items()
|
|
138
|
+
}
|
|
139
|
+
if isinstance(value, list):
|
|
140
|
+
return [walk(v, key, f"{where}[]") for v in value]
|
|
141
|
+
kind = classify(key, value)
|
|
142
|
+
if kind is None:
|
|
143
|
+
return value
|
|
144
|
+
example = mask(str(value).strip(), kind)
|
|
145
|
+
findings.append(Finding(where or key or "value", kind, SEVERITY[kind], example))
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
return f"[hidden {kind}: {example}]"
|
|
148
|
+
return 0 # numbers stay numbers, so the inferred schema keeps its type
|
|
149
|
+
|
|
150
|
+
return walk(data, "", path), findings
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def hide_body(body: str | None) -> tuple[str | None, list[dict[str, str]]]:
|
|
154
|
+
"""For a JSON body (text): the body with personal values hidden, and what was hidden
|
|
155
|
+
(one entry per field path). Non-JSON bodies are returned as they are."""
|
|
156
|
+
if not body:
|
|
157
|
+
return body, []
|
|
158
|
+
try:
|
|
159
|
+
data = json.loads(body)
|
|
160
|
+
except ValueError:
|
|
161
|
+
return body, []
|
|
162
|
+
hidden, findings = hide(data)
|
|
163
|
+
if not findings:
|
|
164
|
+
return body, []
|
|
165
|
+
return json.dumps(hidden, ensure_ascii=False), summarize(findings)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def summarize(findings: list[Finding]) -> list[dict[str, str]]:
|
|
169
|
+
"""One entry per (path, kind), with how many values were hidden."""
|
|
170
|
+
seen: dict[tuple[str, str], dict[str, Any]] = {}
|
|
171
|
+
for f in findings:
|
|
172
|
+
entry = seen.setdefault((f.path, f.kind), {**f.to_dict(), "count": 0})
|
|
173
|
+
entry["count"] += 1
|
|
174
|
+
return list(seen.values())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _digit_count(text: str) -> int:
|
|
178
|
+
return sum(c.isdigit() for c in text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _luhn(digits: str) -> bool:
|
|
182
|
+
total = 0
|
|
183
|
+
for i, ch in enumerate(reversed(digits)):
|
|
184
|
+
n = int(ch)
|
|
185
|
+
if i % 2:
|
|
186
|
+
n = n * 2 - 9 if n > 4 else n * 2
|
|
187
|
+
total += n
|
|
188
|
+
return total % 10 == 0
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Record API traffic from a Playwright page (FR-13, FR-14).
|
|
2
|
+
|
|
3
|
+
The 'response' event handler only queues responses; bodies are read later in `flush()`,
|
|
4
|
+
which the browser driver calls after every action. Reading bodies outside the event
|
|
5
|
+
handler keeps the sync Playwright API safe from re-entrancy problems.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
15
|
+
from playwright.sync_api import Page, Response
|
|
16
|
+
|
|
17
|
+
from ghost_api.capture import filters, personal
|
|
18
|
+
from ghost_api.capture.models import CapturedRequest, RawRequest
|
|
19
|
+
|
|
20
|
+
MAX_BODY_CHARS = 500_000
|
|
21
|
+
MAX_CAPTURE_BYTES = 5_000_000 # one reply bigger than this is not read (response-too-large)
|
|
22
|
+
RUN_CAPTURE_BUDGET = 200_000_000 # bodies read in one run; beyond it nothing more is read
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TrafficRecorder:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
target_host: str,
|
|
29
|
+
on_capture: Callable[[CapturedRequest], None] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.target_host = target_host
|
|
32
|
+
self.on_capture = on_capture
|
|
33
|
+
self.captured: list[CapturedRequest] = []
|
|
34
|
+
# raw[i] is the unredacted original of captured[i] (memory only, for replaying).
|
|
35
|
+
self.raw: list[RawRequest | None] = []
|
|
36
|
+
self.dropped: Counter[str] = Counter()
|
|
37
|
+
self._pending: list[Response] = []
|
|
38
|
+
self.bytes_read = 0
|
|
39
|
+
|
|
40
|
+
def attach(self, page: Page) -> None:
|
|
41
|
+
page.on("response", self._on_response)
|
|
42
|
+
|
|
43
|
+
def _on_response(self, response: Response) -> None:
|
|
44
|
+
request = response.request
|
|
45
|
+
reason = filters.classify(request.resource_type, request.url, self.target_host)
|
|
46
|
+
if reason:
|
|
47
|
+
self.dropped[reason] += 1
|
|
48
|
+
return
|
|
49
|
+
if 300 <= response.status < 400:
|
|
50
|
+
self.dropped["redirect"] += 1
|
|
51
|
+
return
|
|
52
|
+
self._pending.append(response)
|
|
53
|
+
|
|
54
|
+
def flush(self) -> list[CapturedRequest]:
|
|
55
|
+
"""Read bodies of queued responses, keep JSON ones, redact, and store them."""
|
|
56
|
+
new: list[CapturedRequest] = []
|
|
57
|
+
pending, self._pending = self._pending, []
|
|
58
|
+
for response in pending:
|
|
59
|
+
try:
|
|
60
|
+
built = self._build(response)
|
|
61
|
+
except Exception: # noqa: BLE001 - one odd response must not stop the run
|
|
62
|
+
self.dropped["unreadable"] += 1
|
|
63
|
+
continue
|
|
64
|
+
if built is None:
|
|
65
|
+
continue
|
|
66
|
+
new.append(self.add(*built))
|
|
67
|
+
return new
|
|
68
|
+
|
|
69
|
+
def add(self, item: CapturedRequest, raw: RawRequest | None = None) -> CapturedRequest:
|
|
70
|
+
"""Record a request (from the browser, or a replay made by the prober)."""
|
|
71
|
+
self.captured.append(item)
|
|
72
|
+
self.raw.append(raw)
|
|
73
|
+
if self.on_capture:
|
|
74
|
+
self.on_capture(item)
|
|
75
|
+
return item
|
|
76
|
+
|
|
77
|
+
def _build(self, response: Response) -> tuple[CapturedRequest, RawRequest] | None:
|
|
78
|
+
request = response.request
|
|
79
|
+
try:
|
|
80
|
+
resp_headers = response.all_headers()
|
|
81
|
+
req_headers = request.all_headers()
|
|
82
|
+
declared = resp_headers.get("content-length", "")
|
|
83
|
+
if declared.isdigit() and int(declared) > MAX_CAPTURE_BYTES:
|
|
84
|
+
self.dropped["response-too-large"] += 1 # never read into memory
|
|
85
|
+
return None
|
|
86
|
+
if self.bytes_read > RUN_CAPTURE_BUDGET:
|
|
87
|
+
self.dropped["capture-budget-used"] += 1
|
|
88
|
+
return None
|
|
89
|
+
body_bytes = response.body()
|
|
90
|
+
post_bytes = request.post_data_buffer
|
|
91
|
+
except PlaywrightError:
|
|
92
|
+
# Body is gone (page navigated away, request aborted, ...).
|
|
93
|
+
self.dropped["no-body"] += 1
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
self.bytes_read += len(body_bytes)
|
|
97
|
+
built = build_capture(
|
|
98
|
+
request.method, request.url, request.resource_type, response.status,
|
|
99
|
+
req_headers, post_bytes, resp_headers, body_bytes, self.target_host,
|
|
100
|
+
) # fmt: skip
|
|
101
|
+
if isinstance(built, str):
|
|
102
|
+
self.dropped[built] += 1
|
|
103
|
+
return None
|
|
104
|
+
return built
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_capture(
|
|
108
|
+
method: str,
|
|
109
|
+
url: str,
|
|
110
|
+
resource_type: str,
|
|
111
|
+
status: int,
|
|
112
|
+
req_headers: dict[str, str],
|
|
113
|
+
post_bytes: bytes | None,
|
|
114
|
+
resp_headers: dict[str, str],
|
|
115
|
+
body_bytes: bytes,
|
|
116
|
+
target_host: str,
|
|
117
|
+
) -> tuple[CapturedRequest, RawRequest] | str:
|
|
118
|
+
"""One request and reply, filtered and made safe to store: secrets redacted, personal
|
|
119
|
+
data hidden. Returns why it was dropped (a short reason) when it is not a JSON API call.
|
|
120
|
+
Used for the browser's traffic and for imported HAR files alike."""
|
|
121
|
+
req_headers = {k.lower(): v for k, v in req_headers.items()}
|
|
122
|
+
resp_headers = {k.lower(): v for k, v in resp_headers.items()}
|
|
123
|
+
if len(body_bytes) > MAX_CAPTURE_BYTES:
|
|
124
|
+
# The body is not decoded, parsed or stored, so a partial reply never becomes a schema.
|
|
125
|
+
return "response-too-large"
|
|
126
|
+
# Binary bodies (protobuf, images sent over fetch...) are not JSON APIs.
|
|
127
|
+
body = _utf8(body_bytes)
|
|
128
|
+
post_data = _utf8(post_bytes) if post_bytes else None
|
|
129
|
+
if body is None or (post_bytes and post_data is None):
|
|
130
|
+
return "binary"
|
|
131
|
+
resp_ctype = resp_headers.get("content-type")
|
|
132
|
+
if not filters.is_json_content_type(resp_ctype) and not _looks_like_json(body):
|
|
133
|
+
return "non-json"
|
|
134
|
+
body = filters.strip_xssi(body)
|
|
135
|
+
if body.strip() and not _looks_like_json(body):
|
|
136
|
+
# Says JSON but isn't (e.g. Google's length-prefixed batchexecute envelope).
|
|
137
|
+
return "invalid-json"
|
|
138
|
+
|
|
139
|
+
# Redact the full body first: truncated JSON can't be parsed for key-based redaction.
|
|
140
|
+
safe_body = filters.redact_body(body, resp_ctype) or ""
|
|
141
|
+
hidden_body, personal_data = personal.hide_body(safe_body)
|
|
142
|
+
safe_body = hidden_body or ""
|
|
143
|
+
truncated = len(safe_body) > MAX_BODY_CHARS
|
|
144
|
+
if truncated:
|
|
145
|
+
safe_body = safe_body[:MAX_BODY_CHARS]
|
|
146
|
+
|
|
147
|
+
req_ctype = req_headers.get("content-type")
|
|
148
|
+
raw = RawRequest(method, url, dict(req_headers), post_data)
|
|
149
|
+
captured = CapturedRequest(
|
|
150
|
+
method=method,
|
|
151
|
+
url=filters.redact_url(url),
|
|
152
|
+
status=status,
|
|
153
|
+
resource_type=resource_type,
|
|
154
|
+
req_headers=filters.redact_headers(req_headers),
|
|
155
|
+
req_body=filters.redact_body(post_data, req_ctype),
|
|
156
|
+
req_content_type=req_ctype,
|
|
157
|
+
resp_content_type=resp_ctype,
|
|
158
|
+
resp_body=safe_body,
|
|
159
|
+
truncated=truncated,
|
|
160
|
+
third_party=filters.is_third_party(url, target_host),
|
|
161
|
+
personal=personal_data,
|
|
162
|
+
)
|
|
163
|
+
return captured, raw
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _utf8(data: bytes) -> str | None:
|
|
167
|
+
try:
|
|
168
|
+
return data.decode("utf-8")
|
|
169
|
+
except UnicodeDecodeError:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _looks_like_json(body: str) -> bool:
|
|
174
|
+
stripped = body.lstrip()
|
|
175
|
+
if stripped[:1] not in ("{", "["):
|
|
176
|
+
return False
|
|
177
|
+
try:
|
|
178
|
+
json.loads(stripped)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The `ghost-api` command line.
|
|
2
|
+
|
|
3
|
+
_common.py the app, global options (--json, --plain, project defaults) and helpers
|
|
4
|
+
explore.py run, import, refresh, login, logout
|
|
5
|
+
client.py generate, verify, report, diff, mock
|
|
6
|
+
data.py fetch, watch, serve, mcp, ui
|
|
7
|
+
manage.py init, doctor, inspect, forget
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# isort: off
|
|
11
|
+
# Importing the command modules registers their commands, in this order in --help.
|
|
12
|
+
from ghost_api.cli import explore # noqa: F401
|
|
13
|
+
from ghost_api.cli import client # noqa: F401
|
|
14
|
+
from ghost_api.cli import data # noqa: F401
|
|
15
|
+
from ghost_api.cli import manage # noqa: F401
|
|
16
|
+
|
|
17
|
+
# isort: on
|
|
18
|
+
from ghost_api.cli._common import (
|
|
19
|
+
GLOBAL_FLAGS,
|
|
20
|
+
_invoke,
|
|
21
|
+
_json_first,
|
|
22
|
+
_options,
|
|
23
|
+
_RunResult,
|
|
24
|
+
app,
|
|
25
|
+
console,
|
|
26
|
+
entry,
|
|
27
|
+
json_out,
|
|
28
|
+
safe_output,
|
|
29
|
+
)
|
|
30
|
+
from ghost_api.cli.data import schedule_command
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"GLOBAL_FLAGS",
|
|
34
|
+
"_RunResult",
|
|
35
|
+
"_invoke",
|
|
36
|
+
"_json_first",
|
|
37
|
+
"_options",
|
|
38
|
+
"app",
|
|
39
|
+
"console",
|
|
40
|
+
"entry",
|
|
41
|
+
"json_out",
|
|
42
|
+
"safe_output",
|
|
43
|
+
"schedule_command",
|
|
44
|
+
]
|
ghost_api/cli/_common.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Shared parts of the command line: the app, global options and helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from ghost_api import __version__, exitcodes, workflows
|
|
15
|
+
from ghost_api.config import (
|
|
16
|
+
check_language,
|
|
17
|
+
default_db_path,
|
|
18
|
+
load_env,
|
|
19
|
+
state_dir,
|
|
20
|
+
)
|
|
21
|
+
from ghost_api.llm.providers import default_providers
|
|
22
|
+
from ghost_api.memory.store import Store, StoreError
|
|
23
|
+
from ghost_api.project import ProjectError, default_map, find_project
|
|
24
|
+
from ghost_api.site import SiteAmbiguous, resolve_site
|
|
25
|
+
from ghost_api.ui import keys
|
|
26
|
+
from ghost_api.ui.terminal import (
|
|
27
|
+
TerminalRenderer,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
help="Ghost API: turn any website into a typed, tested API client.",
|
|
32
|
+
add_completion=True,
|
|
33
|
+
)
|
|
34
|
+
console = Console()
|
|
35
|
+
_options = {"plain": False, "json": False}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _version(value: bool) -> None:
|
|
39
|
+
if value:
|
|
40
|
+
console.print(f"ghost-api {__version__}")
|
|
41
|
+
raise typer.Exit()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback(invoke_without_command=True)
|
|
45
|
+
def main(
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
version: Annotated[
|
|
48
|
+
bool | None,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--version", callback=_version, is_eager=True, expose_value=False, help="Show version."
|
|
51
|
+
),
|
|
52
|
+
] = None,
|
|
53
|
+
plain: Annotated[
|
|
54
|
+
bool, typer.Option("--plain", help="Simple line-by-line output (for logs and CI).")
|
|
55
|
+
] = False,
|
|
56
|
+
json_output: Annotated[
|
|
57
|
+
bool,
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--json",
|
|
60
|
+
help="Print the result as JSON on stdout (progress goes to stderr). "
|
|
61
|
+
"Works anywhere on the command line.",
|
|
62
|
+
),
|
|
63
|
+
] = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
load_env()
|
|
66
|
+
for stream in (sys.stdout, sys.stderr):
|
|
67
|
+
safe_output(stream)
|
|
68
|
+
_options["plain"] = plain or json_output
|
|
69
|
+
_options["json"] = json_output
|
|
70
|
+
if json_output: # stdout is only for the JSON result; everything else goes to stderr
|
|
71
|
+
saved = console._file # usually None: Rich then follows sys.stdout as it changes
|
|
72
|
+
console.file = sys.stderr
|
|
73
|
+
ctx.call_on_close(lambda: setattr(console, "_file", saved))
|
|
74
|
+
try:
|
|
75
|
+
project = find_project()
|
|
76
|
+
except ProjectError as exc:
|
|
77
|
+
console.print(f"[red]{exc}[/]")
|
|
78
|
+
raise typer.Exit(code=exitcodes.USAGE) from exc
|
|
79
|
+
if project is not None:
|
|
80
|
+
ctx.default_map = default_map(project, ctx.command)
|
|
81
|
+
if ctx.invoked_subcommand is None:
|
|
82
|
+
if not sys.stdin.isatty():
|
|
83
|
+
console.print(ctx.get_help())
|
|
84
|
+
raise typer.Exit()
|
|
85
|
+
_interactive()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
GLOBAL_FLAGS = ("--json", "--plain")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _json_first(args: list[str]) -> list[str]:
|
|
92
|
+
"""`--json` and `--plain` may be typed anywhere (`ghost-api inspect --json`); they are
|
|
93
|
+
global options, so they are moved in front of the command."""
|
|
94
|
+
flags = [f for f in GLOBAL_FLAGS if f in args]
|
|
95
|
+
return [*flags, *(a for a in args if a not in GLOBAL_FLAGS)] if flags else args
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def json_out(data: object) -> None:
|
|
99
|
+
"""The command's result, as JSON on stdout (progress and messages are on stderr)."""
|
|
100
|
+
sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False, default=str) + "\n")
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def entry() -> None:
|
|
105
|
+
"""The `ghost-api` command: the app, with a plain message for a damaged database."""
|
|
106
|
+
try:
|
|
107
|
+
app(args=_json_first(sys.argv[1:]), prog_name="ghost-api")
|
|
108
|
+
except StoreError as exc:
|
|
109
|
+
console.print(f"[red]{exc}[/]")
|
|
110
|
+
raise SystemExit(1) from None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def safe_output(stream: object) -> None:
|
|
114
|
+
"""Never crash on a character the output can't show (e.g. '◆' when output is
|
|
115
|
+
redirected to a file on Windows, which uses cp1252): print a '?' instead."""
|
|
116
|
+
if stream is None: # pythonw / no console: nothing to protect
|
|
117
|
+
return
|
|
118
|
+
encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
|
|
119
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
120
|
+
if encoding != "utf8" and reconfigure is not None:
|
|
121
|
+
reconfigure(errors="replace")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _invoke(args: list[str]) -> None:
|
|
125
|
+
"""Run `ghost-api ARGS...` inside this process (the interactive session's commands)."""
|
|
126
|
+
command = typer.main.get_command(app)
|
|
127
|
+
try:
|
|
128
|
+
command.main(args=_json_first(args), prog_name="ghost-api", standalone_mode=False)
|
|
129
|
+
except typer.Abort:
|
|
130
|
+
console.print("[yellow]Stopped.[/]")
|
|
131
|
+
except typer.Exit:
|
|
132
|
+
pass
|
|
133
|
+
except StoreError as exc:
|
|
134
|
+
console.print(f"[red]{exc}[/]")
|
|
135
|
+
except typer.TyperException as exc: # usage errors: missing argument, unknown option...
|
|
136
|
+
format_message = getattr(exc, "format_message", None)
|
|
137
|
+
message = format_message() if callable(format_message) else str(exc)
|
|
138
|
+
console.print(f"[red]{message}[/] [dim](see /help)[/]")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _interactive() -> None:
|
|
142
|
+
"""`ghost-api` on its own: a session that stays open until /exit or Ctrl+C twice."""
|
|
143
|
+
from ghost_api.ui.repl import Repl, run_session
|
|
144
|
+
|
|
145
|
+
def ask(question: str) -> str:
|
|
146
|
+
with keys.input_paused():
|
|
147
|
+
return console.input(f"[bold]{question}[/]")
|
|
148
|
+
|
|
149
|
+
def has_llm() -> bool:
|
|
150
|
+
load_env() # a key added to .env while the session is open counts at once
|
|
151
|
+
return any(p.api_key for p in default_providers())
|
|
152
|
+
|
|
153
|
+
repl = Repl(console, _invoke, ask)
|
|
154
|
+
run_session(console, repl, has_llm, history_file=state_dir() / "history.txt")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _RunResult(workflows.RunCollector):
|
|
158
|
+
"""A run's outcome for `--json`, and its exit code."""
|
|
159
|
+
|
|
160
|
+
def finish(self, db: Path) -> None:
|
|
161
|
+
"""Print the JSON (with --json) and exit with the code for the run's status."""
|
|
162
|
+
status = str(self.data["status"])
|
|
163
|
+
if _options["json"]:
|
|
164
|
+
json_out(self.data | {"database": str(db), "exit_code": _run_exit(status)})
|
|
165
|
+
if _run_exit(status):
|
|
166
|
+
raise typer.Exit(code=_run_exit(status))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _run_exit(status: str) -> int:
|
|
170
|
+
return exitcodes.for_run_status(status)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _renderer() -> TerminalRenderer:
|
|
174
|
+
return TerminalRenderer(console, plain=True if _options["plain"] else None)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _normalize_url(url: str) -> str:
|
|
178
|
+
if "://" not in url:
|
|
179
|
+
url = "https://" + url
|
|
180
|
+
parts = urlsplit(url)
|
|
181
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
182
|
+
raise typer.BadParameter(f"Not a valid http(s) URL: {url}")
|
|
183
|
+
return url
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _check_lang(lang: str) -> str:
|
|
187
|
+
try:
|
|
188
|
+
return check_language(lang)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _not_allowed(url: str, reason: str = "disallowed by robots.txt") -> None:
|
|
194
|
+
if _options["json"]:
|
|
195
|
+
json_out({"status": "not-allowed", "url": url, "reason": reason,
|
|
196
|
+
"exit_code": exitcodes.NOT_ALLOWED}) # fmt: skip
|
|
197
|
+
elif not reason.startswith("robots.txt"): # robots.txt was already reported
|
|
198
|
+
console.print(f"[yellow]{reason}[/]")
|
|
199
|
+
raise typer.Exit(code=exitcodes.NOT_ALLOWED)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _need_db(db: Path | None) -> Path:
|
|
203
|
+
path = db or default_db_path()
|
|
204
|
+
if not path.exists():
|
|
205
|
+
console.print(f"No database at {path}. Run `ghost-api run` first.")
|
|
206
|
+
raise typer.Exit(code=1)
|
|
207
|
+
return path
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
SITE_HELP = "Site explored before, e.g. quotes.toscrape.com or www.google.com/maps"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _site(store: Store, name: str) -> str:
|
|
214
|
+
"""The stored site for what was typed: a site, a URL, or just the domain."""
|
|
215
|
+
try:
|
|
216
|
+
return resolve_site(name, {r.domain for r in store.list_runs()})
|
|
217
|
+
except SiteAmbiguous as exc:
|
|
218
|
+
console.print(str(exc))
|
|
219
|
+
raise typer.Exit(code=1) from exc
|