ghost-api-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/events.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Structured events emitted while a run happens.
|
|
2
|
+
|
|
3
|
+
The pipeline never prints. It emits these events, and each interface renders them:
|
|
4
|
+
the terminal (`ghost_api/ui/terminal.py`), the web dashboard (`ghost_api/web/`), and the
|
|
5
|
+
database (`run_events`), so any past run can be replayed in full.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from typing import Any, ClassVar, Literal
|
|
16
|
+
|
|
17
|
+
Phase = Literal["checks", "explore", "test", "generate", "verify", "learn", "done"]
|
|
18
|
+
PHASES: tuple[Phase, ...] = ("checks", "explore", "test", "generate", "verify", "learn", "done")
|
|
19
|
+
Level = Literal["info", "success", "warning", "error"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_log = logging.getLogger("ghost_api")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Event:
|
|
27
|
+
kind: ClassVar[str] = "event"
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
|
+
return {"type": self.kind, **asdict(self)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RunStarted(Event):
|
|
35
|
+
kind: ClassVar[str] = "run_started"
|
|
36
|
+
run_id: int
|
|
37
|
+
domain: str
|
|
38
|
+
start_url: str
|
|
39
|
+
goal: str
|
|
40
|
+
mode: Literal["run", "refresh"] = "run"
|
|
41
|
+
lang: str = "python"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PhaseChanged(Event):
|
|
46
|
+
kind: ClassVar[str] = "phase"
|
|
47
|
+
phase: Phase
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Notice(Event):
|
|
52
|
+
"""A message for the person: progress, warnings, errors (already plain-language)."""
|
|
53
|
+
|
|
54
|
+
kind: ClassVar[str] = "notice"
|
|
55
|
+
text: str
|
|
56
|
+
level: Level = "info"
|
|
57
|
+
detail: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RequestCaptured(Event):
|
|
62
|
+
kind: ClassVar[str] = "request"
|
|
63
|
+
method: str
|
|
64
|
+
url: str # redacted
|
|
65
|
+
status: int
|
|
66
|
+
third_party: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class AgentStep(Event):
|
|
71
|
+
kind: ClassVar[str] = "step"
|
|
72
|
+
step: int
|
|
73
|
+
action: str
|
|
74
|
+
args: dict[str, Any]
|
|
75
|
+
thought: str
|
|
76
|
+
result: str
|
|
77
|
+
new_endpoints: list[str] = field(default_factory=list)
|
|
78
|
+
requests: int = 0
|
|
79
|
+
screenshot: str | None = None # file name in the run's screenshot folder
|
|
80
|
+
box: dict[str, float] | None = None # element acted on: x, y, width, height (page px)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Screenshot(Event):
|
|
85
|
+
"""A browser screenshot outside agent steps (e.g. the first page load)."""
|
|
86
|
+
|
|
87
|
+
kind: ClassVar[str] = "screenshot"
|
|
88
|
+
name: str
|
|
89
|
+
url: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class EndpointsUpdated(Event):
|
|
94
|
+
"""The current endpoint map (summaries; full details come from the API/database)."""
|
|
95
|
+
|
|
96
|
+
kind: ClassVar[str] = "endpoints"
|
|
97
|
+
endpoints: list[dict[str, Any]]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class HypothesisTested(Event):
|
|
102
|
+
kind: ClassVar[str] = "hypothesis"
|
|
103
|
+
endpoint: str # display label
|
|
104
|
+
text: str
|
|
105
|
+
result: Literal["confirmed", "rejected", "inconclusive"]
|
|
106
|
+
evidence: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class ProbeFinished(Event):
|
|
111
|
+
kind: ClassVar[str] = "probe_done"
|
|
112
|
+
endpoints: int
|
|
113
|
+
direct_calls: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class VerifyRound(Event):
|
|
118
|
+
kind: ClassVar[str] = "verify_round"
|
|
119
|
+
number: int
|
|
120
|
+
passed: int
|
|
121
|
+
failed: int
|
|
122
|
+
skipped: int
|
|
123
|
+
failures: list[dict[str, str]] = field(default_factory=list) # name, message
|
|
124
|
+
repairs: list[dict[str, str]] = field(default_factory=list) # method, kind, action
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class ClientGenerated(Event):
|
|
129
|
+
kind: ClassVar[str] = "client"
|
|
130
|
+
class_name: str
|
|
131
|
+
lang: str
|
|
132
|
+
named_by_llm: bool
|
|
133
|
+
directory: str
|
|
134
|
+
methods: list[dict[str, Any]] # name, args, returns, iterator
|
|
135
|
+
credentials: list[dict[str, str]] = field(default_factory=list) # arg, env
|
|
136
|
+
skipped: list[str] = field(default_factory=list)
|
|
137
|
+
files: list[str] = field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class NetworkSummary(Event):
|
|
142
|
+
"""What a run sent where, and which rules applied (the report's permissions section)."""
|
|
143
|
+
|
|
144
|
+
kind: ClassVar[str] = "network"
|
|
145
|
+
target: str # the site's host
|
|
146
|
+
robots: str # how robots.txt was checked ("allowed by robots.txt", "check skipped"...)
|
|
147
|
+
rate_limit: float # requests per second, at most
|
|
148
|
+
local_target: bool # private addresses allowed (the target itself is local)
|
|
149
|
+
saved_login: bool # a saved login (ghost-api login) was used
|
|
150
|
+
api_calls_seen: int # API calls the page made (kept after filtering)
|
|
151
|
+
direct_calls: int # requests Ghost API sent itself (endpoint tests)
|
|
152
|
+
third_party_hosts: list[str] = field(default_factory=list) # API hosts besides the site
|
|
153
|
+
blocked: list[str] = field(default_factory=list) # requests the rules stopped
|
|
154
|
+
llm_calls: int = 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class UsageUpdated(Event):
|
|
159
|
+
kind: ClassVar[str] = "usage"
|
|
160
|
+
answered: int
|
|
161
|
+
tokens: int
|
|
162
|
+
cache_hits: int = 0
|
|
163
|
+
retries: int = 0
|
|
164
|
+
fallbacks: int = 0
|
|
165
|
+
by_provider: dict[str, int] = field(default_factory=dict)
|
|
166
|
+
problems: list[str] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class RunFinished(Event):
|
|
171
|
+
kind: ClassVar[str] = "run_finished"
|
|
172
|
+
status: str
|
|
173
|
+
reason: str
|
|
174
|
+
seconds: float
|
|
175
|
+
requests: int
|
|
176
|
+
endpoints: int
|
|
177
|
+
report: str = ""
|
|
178
|
+
openapi: str = ""
|
|
179
|
+
report_html: str = ""
|
|
180
|
+
lessons_learned: int = 0
|
|
181
|
+
filtered: dict[str, int] = field(default_factory=dict)
|
|
182
|
+
generation_error: str = ""
|
|
183
|
+
exposure: str = "" # "1 endpoint exposes personal data without a login: ..."
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
Subscriber = Callable[[Event], object] # return values are ignored
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class EventBus:
|
|
190
|
+
"""Delivers events to subscribers, in order. Thread-safe; a failing subscriber never
|
|
191
|
+
breaks the run (its error is kept in `errors`)."""
|
|
192
|
+
|
|
193
|
+
def __init__(self) -> None:
|
|
194
|
+
self._subscribers: list[Subscriber] = []
|
|
195
|
+
self._lock = threading.Lock()
|
|
196
|
+
self.errors: list[str] = []
|
|
197
|
+
self.started = time.monotonic()
|
|
198
|
+
|
|
199
|
+
def subscribe(self, callback: Subscriber) -> None:
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._subscribers.append(callback)
|
|
202
|
+
|
|
203
|
+
def emit(self, event: Event) -> None:
|
|
204
|
+
with self._lock:
|
|
205
|
+
subscribers = list(self._subscribers)
|
|
206
|
+
for callback in subscribers:
|
|
207
|
+
try:
|
|
208
|
+
callback(event)
|
|
209
|
+
except Exception as exc: # noqa: BLE001 - interfaces must not stop the run
|
|
210
|
+
message = f"{type(exc).__name__}: {exc}"
|
|
211
|
+
if not self.errors: # say it once, so a broken display can't flood the log
|
|
212
|
+
_log.warning("A display or listener failed (the run continues): %s", message)
|
|
213
|
+
self.errors.append(message)
|
ghost_api/exitcodes.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exit codes of the `ghost-api` command, for scripts and CI. Stable across releases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
OK = 0 # done (a run may still have found nothing: read the JSON or the report)
|
|
6
|
+
FAILED = 1 # the command failed: an error, failed checks, nothing stored to work on
|
|
7
|
+
USAGE = 2 # wrong command line or setup: bad option, missing LLM key, bad project file
|
|
8
|
+
NOT_ALLOWED = 3 # the site said no: robots.txt disallows it, or a bot-protection page
|
|
9
|
+
INTERRUPTED = 130 # stopped with Ctrl+C
|
|
10
|
+
|
|
11
|
+
_BY_STATUS = {
|
|
12
|
+
"failed": FAILED,
|
|
13
|
+
"llm-unavailable": FAILED,
|
|
14
|
+
"bot-protection": NOT_ALLOWED,
|
|
15
|
+
"interrupted": INTERRUPTED,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def for_run_status(status: str) -> int:
|
|
20
|
+
"""The exit code for a run or refresh that ended with `status`."""
|
|
21
|
+
return _BY_STATUS.get(status, OK)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Write report.md, report.html and openapi.yaml for a site (Stage 9, UI phase 5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ghost_api.export.html import ViewerMissing, build_report_html
|
|
10
|
+
from ghost_api.export.openapi import build_openapi, to_yaml
|
|
11
|
+
from ghost_api.export.report import build_report
|
|
12
|
+
from ghost_api.export.site_data import report_data
|
|
13
|
+
from ghost_api.generator.naming import NamingPlan, apply_plan
|
|
14
|
+
from ghost_api.generator.spec import ClientSpec
|
|
15
|
+
from ghost_api.memory.store import Store
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Exports",
|
|
19
|
+
"write_exports",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Exports:
|
|
25
|
+
report: Path
|
|
26
|
+
openapi: Path
|
|
27
|
+
html: Path | None # None if the report viewer is not built
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_exports(
|
|
31
|
+
store: Store,
|
|
32
|
+
domain: str,
|
|
33
|
+
run_id: int,
|
|
34
|
+
out_root: Path,
|
|
35
|
+
spec: ClientSpec | None = None,
|
|
36
|
+
) -> Exports:
|
|
37
|
+
"""Write the files into out_root/<domain>/ from what the database holds."""
|
|
38
|
+
run = store.get_run(run_id)
|
|
39
|
+
if run is None:
|
|
40
|
+
raise ValueError(f"run {run_id} not found")
|
|
41
|
+
endpoints = store.current_endpoints(domain)
|
|
42
|
+
plan = None
|
|
43
|
+
stored_plan = store.load_naming_plan(domain)
|
|
44
|
+
if stored_plan:
|
|
45
|
+
try:
|
|
46
|
+
plan = NamingPlan.model_validate_json(stored_plan)
|
|
47
|
+
except ValueError:
|
|
48
|
+
plan = None # fall back to URL-based names
|
|
49
|
+
_, names = apply_plan(plan, endpoints, domain)
|
|
50
|
+
|
|
51
|
+
folder = out_root / domain
|
|
52
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
openapi_path = folder / "openapi.yaml"
|
|
54
|
+
openapi_path.write_text(
|
|
55
|
+
to_yaml(build_openapi(domain, run.start_url, endpoints, names)), encoding="utf-8"
|
|
56
|
+
)
|
|
57
|
+
report_path = folder / "report.md"
|
|
58
|
+
markdown = build_report(store, domain, run_id, spec)
|
|
59
|
+
report_path.write_text(markdown, encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
generated_at = datetime.now(UTC).isoformat(timespec="seconds")
|
|
62
|
+
data = report_data(store, domain, run_id, folder, markdown, generated_at)
|
|
63
|
+
html_path: Path | None = None
|
|
64
|
+
try:
|
|
65
|
+
page = build_report_html(data)
|
|
66
|
+
except ViewerMissing:
|
|
67
|
+
pass # a source checkout without the built viewer: report.md is still there
|
|
68
|
+
else:
|
|
69
|
+
html_path = folder / "report.html"
|
|
70
|
+
html_path.write_text(page, encoding="utf-8")
|
|
71
|
+
return Exports(report_path, openapi_path, html_path)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""How much to trust each endpoint, from evidence only.
|
|
2
|
+
|
|
3
|
+
Points:
|
|
4
|
+
verified by live tests +2
|
|
5
|
+
failed after repairs -3
|
|
6
|
+
callable directly (replayable) +1
|
|
7
|
+
pagination confirmed +1
|
|
8
|
+
samples: 2-4 -> +1, 5+ -> +2
|
|
9
|
+
|
|
10
|
+
High is 4 or more, Medium 2-3, Low below 2.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from ghost_api.analysis.models import Endpoint
|
|
18
|
+
|
|
19
|
+
Level = Literal["High", "Medium", "Low"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def confidence_points(ep: Endpoint) -> int:
|
|
23
|
+
points = 0
|
|
24
|
+
if ep.status == "verified":
|
|
25
|
+
points += 2
|
|
26
|
+
elif ep.status == "failed":
|
|
27
|
+
points -= 3
|
|
28
|
+
if ep.replayable:
|
|
29
|
+
points += 1
|
|
30
|
+
if ep.pagination and ep.pagination.get("confirmed"):
|
|
31
|
+
points += 1
|
|
32
|
+
if ep.sample_count >= 5:
|
|
33
|
+
points += 2
|
|
34
|
+
elif ep.sample_count >= 2:
|
|
35
|
+
points += 1
|
|
36
|
+
return points
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def confidence(ep: Endpoint) -> Level:
|
|
40
|
+
points = confidence_points(ep)
|
|
41
|
+
if points >= 4:
|
|
42
|
+
return "High"
|
|
43
|
+
if points >= 2:
|
|
44
|
+
return "Medium"
|
|
45
|
+
return "Low"
|
ghost_api/export/html.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""report.html: one self-contained page to share a site's results (no server needed).
|
|
2
|
+
|
|
3
|
+
The page is the React report viewer built from web/ (`npm run build` writes
|
|
4
|
+
viewer/report.html with all scripts and styles inlined). This module puts the site's
|
|
5
|
+
data into it as JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
TEMPLATE = Path(__file__).parent / "viewer" / "report.html"
|
|
15
|
+
DATA_TAG = '<script id="ghost-report" type="application/json">__GHOST_REPORT__</script>'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ViewerMissing(RuntimeError):
|
|
19
|
+
"""The report viewer was not built (run `npm run build` in web/)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def embed_json(data: Any) -> str:
|
|
23
|
+
"""JSON that is safe inside a <script> element: no '</script>', '<!--' or '&' tricks."""
|
|
24
|
+
text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
25
|
+
return (
|
|
26
|
+
text.replace("&", "\\u0026")
|
|
27
|
+
.replace("<", "\\u003c")
|
|
28
|
+
.replace(">", "\\u003e")
|
|
29
|
+
.replace("
", "\\u2028")
|
|
30
|
+
.replace("
", "\\u2029")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_report_html(data: dict[str, Any], template: Path = TEMPLATE) -> str:
|
|
35
|
+
if not template.is_file():
|
|
36
|
+
raise ViewerMissing(f"report viewer not found at {template}")
|
|
37
|
+
page = template.read_text(encoding="utf-8")
|
|
38
|
+
if page.count(DATA_TAG) != 1:
|
|
39
|
+
raise ViewerMissing("report viewer has no data placeholder; rebuild it")
|
|
40
|
+
tag = DATA_TAG.replace("__GHOST_REPORT__", embed_json(data))
|
|
41
|
+
return page.replace(DATA_TAG, tag)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Export the endpoint map as an OpenAPI 3.1 document (FR-30).
|
|
2
|
+
|
|
3
|
+
3.1 is used because the inferred schemas are plain JSON Schema (for example
|
|
4
|
+
`"type": ["null", "string"]`), which 3.1 supports and 3.0 does not.
|
|
5
|
+
|
|
6
|
+
- Only read-only endpoints are exported, matching the generated client.
|
|
7
|
+
- Secrets never appear: secret parameters and headers become security schemes.
|
|
8
|
+
- Endpoints on other hosts (third-party APIs) get their own `servers` entry.
|
|
9
|
+
- Each operation carries `x-ghost-api` with status, confidence and sample count.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from ghost_api import __version__
|
|
22
|
+
from ghost_api.analysis.models import Endpoint
|
|
23
|
+
from ghost_api.capture.filters import is_sensitive_name
|
|
24
|
+
from ghost_api.capture.models import RawRequest
|
|
25
|
+
from ghost_api.export.confidence import confidence
|
|
26
|
+
from ghost_api.generator.naming import EndpointNames, pascal, snake
|
|
27
|
+
from ghost_api.probe.replay import is_replayable
|
|
28
|
+
|
|
29
|
+
OPENAPI_VERSION = "3.1.0"
|
|
30
|
+
JSON_TYPES = {"integer", "number", "boolean", "string"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_openapi(
|
|
34
|
+
domain: str,
|
|
35
|
+
start_url: str,
|
|
36
|
+
endpoints: list[Endpoint],
|
|
37
|
+
names: dict[str, EndpointNames],
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
parts = urlsplit(start_url)
|
|
40
|
+
main_host = (parts.hostname or "").lower()
|
|
41
|
+
doc: dict[str, Any] = {
|
|
42
|
+
"openapi": OPENAPI_VERSION,
|
|
43
|
+
"info": {
|
|
44
|
+
"title": f"{domain} (unofficial)",
|
|
45
|
+
"version": datetime.now(UTC).strftime("%Y.%m.%d"),
|
|
46
|
+
"description": (
|
|
47
|
+
f"The internal API of {domain}, discovered by Ghost API {__version__} from the "
|
|
48
|
+
"site's own traffic. Unofficial: it can change without notice, and the site's "
|
|
49
|
+
"terms of service apply."
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
"servers": [{"url": f"{parts.scheme}://{parts.netloc}"}],
|
|
53
|
+
"paths": {},
|
|
54
|
+
"components": {"schemas": {}, "securitySchemes": {}},
|
|
55
|
+
}
|
|
56
|
+
schemas: dict[str, Any] = doc["components"]["schemas"]
|
|
57
|
+
security_schemes: dict[str, Any] = doc["components"]["securitySchemes"]
|
|
58
|
+
|
|
59
|
+
for ep in endpoints:
|
|
60
|
+
if not _exportable(ep):
|
|
61
|
+
continue
|
|
62
|
+
n = names.get(ep.key)
|
|
63
|
+
operation: dict[str, Any] = {
|
|
64
|
+
"operationId": n.method_name if n else snake(f"{ep.method}_{ep.path_template}"),
|
|
65
|
+
"summary": n.summary if n else f"{ep.method} {ep.path_template}",
|
|
66
|
+
"parameters": [],
|
|
67
|
+
"responses": {},
|
|
68
|
+
"x-ghost-api": {
|
|
69
|
+
"status": ep.status,
|
|
70
|
+
"confidence": confidence(ep),
|
|
71
|
+
"samples": ep.sample_count,
|
|
72
|
+
"replayable": ep.replayable,
|
|
73
|
+
**({"pagination": ep.pagination} if ep.pagination else {}),
|
|
74
|
+
**({"note": ep.status_note} if ep.status_note else {}),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
if ep.host != main_host:
|
|
78
|
+
operation["servers"] = [{"url": f"https://{ep.host}"}]
|
|
79
|
+
|
|
80
|
+
security: dict[str, list[str]] = {}
|
|
81
|
+
for pp in ep.path_params:
|
|
82
|
+
operation["parameters"].append(
|
|
83
|
+
{
|
|
84
|
+
"name": pp.name,
|
|
85
|
+
"in": "path",
|
|
86
|
+
"required": True,
|
|
87
|
+
"schema": _param_schema(pp.type, pp.examples),
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
for qp in ep.query_params:
|
|
91
|
+
if qp.sensitive or is_sensitive_name(qp.name):
|
|
92
|
+
scheme = _scheme_name(qp.name)
|
|
93
|
+
security_schemes[scheme] = {"type": "apiKey", "in": "query", "name": qp.name}
|
|
94
|
+
security[scheme] = []
|
|
95
|
+
continue
|
|
96
|
+
schema = _param_schema(qp.type, qp.examples)
|
|
97
|
+
is_page = bool(ep.pagination) and ep.pagination.get("param") == qp.name # type: ignore[union-attr]
|
|
98
|
+
if qp.constant and qp.examples:
|
|
99
|
+
schema["default"] = _typed(qp.examples[0], qp.type)
|
|
100
|
+
elif is_page and qp.examples:
|
|
101
|
+
# Paging starts at the lowest value seen (page 1, offset 0...).
|
|
102
|
+
values = [_typed(e, qp.type) for e in qp.examples]
|
|
103
|
+
numbers = [v for v in values if isinstance(v, int | float)]
|
|
104
|
+
schema["default"] = min(numbers) if numbers else values[0]
|
|
105
|
+
operation["parameters"].append(
|
|
106
|
+
{
|
|
107
|
+
"name": qp.name,
|
|
108
|
+
"in": "query",
|
|
109
|
+
"required": qp.required and not qp.constant and not is_page,
|
|
110
|
+
"schema": schema,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
for header in ep.required_headers:
|
|
114
|
+
if header.lower() == "cookie":
|
|
115
|
+
security_schemes["sessionCookie"] = {
|
|
116
|
+
"type": "apiKey",
|
|
117
|
+
"in": "cookie",
|
|
118
|
+
"name": "session",
|
|
119
|
+
"description": "Session cookie(s) from a logged-in browser.",
|
|
120
|
+
}
|
|
121
|
+
security["sessionCookie"] = []
|
|
122
|
+
elif header.lower() == "authorization":
|
|
123
|
+
kind = "basic" if ep.auth_type == "basic" else "bearer"
|
|
124
|
+
security_schemes[f"{kind}Auth"] = {"type": "http", "scheme": kind}
|
|
125
|
+
security[f"{kind}Auth"] = []
|
|
126
|
+
elif is_sensitive_name(header):
|
|
127
|
+
scheme = _scheme_name(header)
|
|
128
|
+
security_schemes[scheme] = {"type": "apiKey", "in": "header", "name": header}
|
|
129
|
+
security[scheme] = []
|
|
130
|
+
else:
|
|
131
|
+
operation["parameters"].append(
|
|
132
|
+
{"name": header, "in": "header", "required": True, "schema": {"type": "string"}}
|
|
133
|
+
)
|
|
134
|
+
if security:
|
|
135
|
+
operation["security"] = [security]
|
|
136
|
+
if not operation["parameters"]:
|
|
137
|
+
del operation["parameters"]
|
|
138
|
+
|
|
139
|
+
if ep.request_schema is not None:
|
|
140
|
+
operation["requestBody"] = {
|
|
141
|
+
"required": True,
|
|
142
|
+
"content": {"application/json": {"schema": _clean(ep.request_schema)}},
|
|
143
|
+
}
|
|
144
|
+
model = n.response_model if n else pascal(f"{ep.method}_{ep.path_template}") + "Response"
|
|
145
|
+
if ep.response_schema is not None:
|
|
146
|
+
schemas[model] = _clean(ep.response_schema)
|
|
147
|
+
content = {"application/json": {"schema": {"$ref": f"#/components/schemas/{model}"}}}
|
|
148
|
+
operation["responses"]["200"] = {"description": "Success", "content": content}
|
|
149
|
+
else:
|
|
150
|
+
operation["responses"]["200"] = {"description": "Success (no JSON sample captured)"}
|
|
151
|
+
|
|
152
|
+
path_item = doc["paths"].setdefault(ep.path_template, {})
|
|
153
|
+
path_item[ep.method.lower()] = operation
|
|
154
|
+
|
|
155
|
+
if not schemas:
|
|
156
|
+
del doc["components"]["schemas"]
|
|
157
|
+
if not security_schemes:
|
|
158
|
+
del doc["components"]["securitySchemes"]
|
|
159
|
+
if not doc["components"]:
|
|
160
|
+
del doc["components"]
|
|
161
|
+
return doc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def to_yaml(doc: dict[str, Any]) -> str:
|
|
165
|
+
return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _exportable(ep: Endpoint) -> bool:
|
|
169
|
+
probe = RawRequest(ep.method, f"https://{ep.host}{ep.path_template}", {}, None)
|
|
170
|
+
return is_replayable(probe) and ep.replayable is not False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _param_schema(json_type: str, examples: list[str]) -> dict[str, Any]:
|
|
174
|
+
schema: dict[str, Any] = {"type": json_type if json_type in JSON_TYPES else "string"}
|
|
175
|
+
if examples:
|
|
176
|
+
schema["examples"] = [_typed(e, json_type) for e in examples[:3]]
|
|
177
|
+
return schema
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _typed(value: str, json_type: str) -> Any:
|
|
181
|
+
try:
|
|
182
|
+
if json_type == "integer":
|
|
183
|
+
return int(value)
|
|
184
|
+
if json_type == "number":
|
|
185
|
+
return float(value)
|
|
186
|
+
except ValueError:
|
|
187
|
+
return value
|
|
188
|
+
if json_type == "boolean":
|
|
189
|
+
return value.lower() == "true"
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _scheme_name(wire: str) -> str:
|
|
194
|
+
return pascal(wire)[:1].lower() + pascal(wire)[1:]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _clean(schema: dict[str, Any]) -> dict[str, Any]:
|
|
198
|
+
"""A copy without genson's '$schema' marker (not allowed inside OpenAPI)."""
|
|
199
|
+
out = copy.deepcopy(schema)
|
|
200
|
+
out.pop("$schema", None)
|
|
201
|
+
return out
|