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/api.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Ghost API from Python: the same things the command line does, as functions.
|
|
2
|
+
|
|
3
|
+
from ghost_api.api import discover, fetch, diff
|
|
4
|
+
|
|
5
|
+
result = discover("https://quotes.toscrape.com/scroll", "list quotes with their authors")
|
|
6
|
+
print(result.status, [e["path"] for e in result.endpoints], result.client_files)
|
|
7
|
+
|
|
8
|
+
rows = fetch("quotes.toscrape.com/scroll", "get_quotes", pages=3)
|
|
9
|
+
changes = diff("quotes.toscrape.com/scroll", live=True)
|
|
10
|
+
|
|
11
|
+
Every function respects the same rules as the CLI (robots.txt, rate limits, the network
|
|
12
|
+
boundary, personal data hidden) and uses the project's settings (ghost-api.toml) unless
|
|
13
|
+
`db` / `out` are given. Problems are exceptions: `NotAllowed`, `NoLLMKey`,
|
|
14
|
+
`NothingRemembered`, `GhostApiError`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ghost_api import drift, workflows
|
|
25
|
+
from ghost_api.config import Settings, default_db_path, load_env
|
|
26
|
+
from ghost_api.events import Event, EventBus
|
|
27
|
+
from ghost_api.generator import NothingToGenerate, generate_client, site_spec
|
|
28
|
+
from ghost_api.memory.store import Store
|
|
29
|
+
from ghost_api.pipeline import RunOptions
|
|
30
|
+
from ghost_api.runtime import OperationError, SiteRuntime
|
|
31
|
+
from ghost_api.site import SiteAmbiguous, resolve_site
|
|
32
|
+
from ghost_api.workflows import NoLLMKey, NotAllowed, NothingRemembered
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"GhostApiError",
|
|
36
|
+
"NoLLMKey",
|
|
37
|
+
"NotAllowed",
|
|
38
|
+
"NothingRemembered",
|
|
39
|
+
"RunResult",
|
|
40
|
+
"diff",
|
|
41
|
+
"discover",
|
|
42
|
+
"fetch",
|
|
43
|
+
"generate",
|
|
44
|
+
"operations",
|
|
45
|
+
"refresh",
|
|
46
|
+
"sites",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GhostApiError(Exception):
|
|
51
|
+
"""Something the caller can fix: an unknown site, nothing stored yet, a bad input."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RunResult:
|
|
56
|
+
status: str # goal-covered, finished, captured, bot-protection, interrupted, failed...
|
|
57
|
+
reason: str
|
|
58
|
+
site: str
|
|
59
|
+
run_id: int | None
|
|
60
|
+
endpoints: list[dict[str, Any]] # one summary per endpoint (path, status, params...)
|
|
61
|
+
client: dict[str, Any] | None # class name, directory, files, methods
|
|
62
|
+
report: str
|
|
63
|
+
report_html: str
|
|
64
|
+
openapi: str
|
|
65
|
+
network: dict[str, Any] = field(default_factory=dict) # the run's permissions summary
|
|
66
|
+
raw: dict[str, Any] = field(default_factory=dict) # everything, as `--json` prints it
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def ok(self) -> bool:
|
|
70
|
+
return self.status not in ("failed", "llm-unavailable", "bot-protection", "interrupted")
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def client_files(self) -> list[str]:
|
|
74
|
+
if not self.client:
|
|
75
|
+
return []
|
|
76
|
+
return [str(Path(self.client["directory"]) / f) for f in self.client.get("files", [])]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _result(data: dict[str, Any]) -> RunResult:
|
|
80
|
+
return RunResult(
|
|
81
|
+
status=str(data.get("status", "failed")),
|
|
82
|
+
reason=str(data.get("reason", "")),
|
|
83
|
+
site=str(data.get("site", "")),
|
|
84
|
+
run_id=data.get("run_id"),
|
|
85
|
+
endpoints=list(data.get("endpoints", [])),
|
|
86
|
+
client=data.get("client"),
|
|
87
|
+
report=str(data.get("report", "")),
|
|
88
|
+
report_html=str(data.get("report_html", "")),
|
|
89
|
+
openapi=str(data.get("openapi", "")),
|
|
90
|
+
network=dict(data.get("network", {})),
|
|
91
|
+
raw=data,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _bus(on_event: Callable[[Event], None] | None) -> tuple[EventBus, workflows.RunCollector]:
|
|
96
|
+
load_env()
|
|
97
|
+
bus = EventBus()
|
|
98
|
+
collector = workflows.RunCollector()
|
|
99
|
+
bus.subscribe(collector)
|
|
100
|
+
if on_event is not None:
|
|
101
|
+
bus.subscribe(on_event)
|
|
102
|
+
return bus, collector
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _db(db: Path | str | None) -> Path:
|
|
106
|
+
return Path(db) if db is not None else default_db_path()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _site(store: Store, name: str) -> str:
|
|
110
|
+
try:
|
|
111
|
+
return resolve_site(name, {r.domain for r in store.list_runs()})
|
|
112
|
+
except SiteAmbiguous as exc:
|
|
113
|
+
raise GhostApiError(str(exc)) from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def discover(
|
|
117
|
+
url: str,
|
|
118
|
+
goal: str,
|
|
119
|
+
*,
|
|
120
|
+
use_agent: bool = True,
|
|
121
|
+
lang: str = "python",
|
|
122
|
+
out: Path | str = "output",
|
|
123
|
+
db: Path | str | None = None,
|
|
124
|
+
headed: bool = False,
|
|
125
|
+
max_steps: int = 40,
|
|
126
|
+
max_llm_calls: int = 50,
|
|
127
|
+
max_minutes: float = 15.0,
|
|
128
|
+
rate_limit: float = 1.0,
|
|
129
|
+
verify: bool = True,
|
|
130
|
+
probe: bool = True,
|
|
131
|
+
on_event: Callable[[Event], None] | None = None,
|
|
132
|
+
) -> RunResult:
|
|
133
|
+
"""Explore a site (with the AI agent unless `use_agent=False`), test what it found,
|
|
134
|
+
and write a client and report. `on_event` receives every live event."""
|
|
135
|
+
settings = Settings(
|
|
136
|
+
url=url, goal=goal, lang=lang, headed=headed, max_steps=max_steps,
|
|
137
|
+
max_llm_calls=max_llm_calls, max_minutes=max_minutes, rate_limit=rate_limit,
|
|
138
|
+
db_path=_db(db),
|
|
139
|
+
) # fmt: skip
|
|
140
|
+
opts = RunOptions(lang=lang, headed=headed, max_steps=max_steps, max_minutes=max_minutes,
|
|
141
|
+
no_probe=not probe, no_verify=not verify, out=Path(out)) # fmt: skip
|
|
142
|
+
bus, collector = _bus(on_event)
|
|
143
|
+
workflows.run(settings, opts, bus, use_agent=use_agent)
|
|
144
|
+
return _result(collector.data)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def refresh(
|
|
148
|
+
site: str,
|
|
149
|
+
*,
|
|
150
|
+
use_llm: bool = True,
|
|
151
|
+
lang: str = "python",
|
|
152
|
+
out: Path | str = "output",
|
|
153
|
+
db: Path | str | None = None,
|
|
154
|
+
verify: bool = True,
|
|
155
|
+
on_event: Callable[[Event], None] | None = None,
|
|
156
|
+
) -> RunResult:
|
|
157
|
+
"""Re-check a known site from memory: replays first, the browser only if needed."""
|
|
158
|
+
path = _db(db)
|
|
159
|
+
with Store(path) as store:
|
|
160
|
+
name = _site(store, site)
|
|
161
|
+
opts = RunOptions(lang=lang, max_steps=10, no_verify=not verify, out=Path(out))
|
|
162
|
+
bus, collector = _bus(on_event)
|
|
163
|
+
workflows.refresh(path, name, opts, bus, use_llm=use_llm)
|
|
164
|
+
return _result(collector.data)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def generate(
|
|
168
|
+
site: str, *, lang: str = "python", out: Path | str = "output", db: Path | str | None = None
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""(Re)write a site's client from stored data (no browser, no LLM). Returns the client's
|
|
171
|
+
class name, files and version."""
|
|
172
|
+
with Store(_db(db)) as store:
|
|
173
|
+
name = _site(store, site)
|
|
174
|
+
try:
|
|
175
|
+
result = generate_client(store, name, Path(out), lang=lang)
|
|
176
|
+
except NothingToGenerate as exc:
|
|
177
|
+
raise GhostApiError(str(exc)) from exc
|
|
178
|
+
client = result.client
|
|
179
|
+
return {
|
|
180
|
+
"class_name": result.spec.class_name,
|
|
181
|
+
"directory": str(client.directory),
|
|
182
|
+
"files": [str(p) for p in (client.client_path, client.models_path, client.tests_path)],
|
|
183
|
+
"version": result.version.version if result.version else result.spec.version,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def operations(site: str, *, db: Path | str | None = None) -> list[dict[str, Any]]:
|
|
188
|
+
"""What can be fetched from a site: name, summary, endpoint, inputs (with examples)."""
|
|
189
|
+
with Store(_db(db)) as store:
|
|
190
|
+
name = _site(store, site)
|
|
191
|
+
try:
|
|
192
|
+
runtime = SiteRuntime(store, name)
|
|
193
|
+
except OperationError as exc:
|
|
194
|
+
raise GhostApiError(str(exc)) from exc
|
|
195
|
+
with runtime:
|
|
196
|
+
return [
|
|
197
|
+
{"name": op.name, "summary": op.summary, "endpoint": op.label,
|
|
198
|
+
"inputs": {i.name: i.example for i in op.inputs}, "pagination": op.pagination}
|
|
199
|
+
for op in runtime.operations()
|
|
200
|
+
] # fmt: skip
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def fetch(
|
|
204
|
+
site: str,
|
|
205
|
+
operation: str,
|
|
206
|
+
inputs: dict[str, str] | None = None,
|
|
207
|
+
*,
|
|
208
|
+
pages: int = 5,
|
|
209
|
+
max_rows: int = 1000,
|
|
210
|
+
db: Path | str | None = None,
|
|
211
|
+
) -> list[Any]:
|
|
212
|
+
"""Rows from one operation, following pages. Read-only, rate-limited, personal data
|
|
213
|
+
hidden (like `ghost-api fetch`)."""
|
|
214
|
+
with Store(_db(db)) as store:
|
|
215
|
+
name = _site(store, site)
|
|
216
|
+
try:
|
|
217
|
+
runtime = SiteRuntime(store, name)
|
|
218
|
+
except OperationError as exc:
|
|
219
|
+
raise GhostApiError(str(exc)) from exc
|
|
220
|
+
rows: list[Any] = []
|
|
221
|
+
with runtime:
|
|
222
|
+
try:
|
|
223
|
+
op = runtime.operation(operation)
|
|
224
|
+
for page in runtime.iterate(op, inputs or {}, pages, max_rows):
|
|
225
|
+
if not page.result.ok:
|
|
226
|
+
detail = page.result.error or f"HTTP {page.result.status}"
|
|
227
|
+
if not rows:
|
|
228
|
+
raise GhostApiError(f"{operation} failed: {detail}")
|
|
229
|
+
break
|
|
230
|
+
rows += page.records
|
|
231
|
+
except OperationError as exc:
|
|
232
|
+
raise GhostApiError(str(exc)) from exc
|
|
233
|
+
return rows
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def diff(site: str, *, live: bool = False, db: Path | str | None = None) -> dict[str, Any]:
|
|
237
|
+
"""What changed in a site's API since the last client version: version, previous and
|
|
238
|
+
a list of changes ({"severity", "where", "text"})."""
|
|
239
|
+
with Store(_db(db)) as store:
|
|
240
|
+
name = _site(store, site)
|
|
241
|
+
try:
|
|
242
|
+
spec = site_spec(store, name)
|
|
243
|
+
except NothingToGenerate as exc:
|
|
244
|
+
raise GhostApiError(str(exc)) from exc
|
|
245
|
+
info = drift.version_for(store, name, spec)
|
|
246
|
+
changes = list(info.changes)
|
|
247
|
+
if live:
|
|
248
|
+
changes += drift.live_check(store, name)
|
|
249
|
+
return {
|
|
250
|
+
"site": name,
|
|
251
|
+
"version": info.version,
|
|
252
|
+
"previous": info.previous,
|
|
253
|
+
"changes": drift.changes_json(changes),
|
|
254
|
+
"breaking": sum(c.severity == "breaking" for c in changes),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def sites(*, db: Path | str | None = None) -> list[str]:
|
|
259
|
+
"""Sites explored so far, newest first."""
|
|
260
|
+
path = _db(db)
|
|
261
|
+
if not path.exists():
|
|
262
|
+
return []
|
|
263
|
+
with Store(path) as store:
|
|
264
|
+
return list(dict.fromkeys(r.domain for r in store.list_runs()))
|
ghost_api/audit.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Audit log: every request Ghost API sent itself, and every request its rules blocked.
|
|
2
|
+
|
|
3
|
+
One JSON line per event, appended to `ghost_api_files/audit/<site>.jsonl` next to the
|
|
4
|
+
database, so a team can answer "what did this tool send to that site, and when?".
|
|
5
|
+
Only the method, host, path, the names of query parameters, the status and the decision
|
|
6
|
+
are kept: never query values, bodies, headers or cookies.
|
|
7
|
+
|
|
8
|
+
{"at": "2026-10-17T09:12:03+00:00", "site": "example.com", "run": 12, "kind": "direct",
|
|
9
|
+
"method": "GET", "host": "example.com", "path": "/api/items", "query": ["page"],
|
|
10
|
+
"status": 200, "decision": "sent"}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
21
|
+
|
|
22
|
+
from ghost_api.site import to_url_id
|
|
23
|
+
|
|
24
|
+
_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def audit_file(db_path: Path, site: str) -> Path:
|
|
28
|
+
return db_path.parent / f"{db_path.stem}_files" / "audit" / f"{to_url_id(site)}.jsonl"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuditLog:
|
|
32
|
+
def __init__(self, db_path: Path, site: str, run_id: int | None = None) -> None:
|
|
33
|
+
self.path = audit_file(db_path, site)
|
|
34
|
+
self.site = site
|
|
35
|
+
self.run_id = run_id
|
|
36
|
+
|
|
37
|
+
def record(self, kind: str, method: str, url: str, status: int | None, decision: str) -> None:
|
|
38
|
+
parts = urlsplit(url)
|
|
39
|
+
entry: dict[str, Any] = {
|
|
40
|
+
"at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
41
|
+
"site": self.site,
|
|
42
|
+
"run": self.run_id,
|
|
43
|
+
"kind": kind, # direct (endpoint tests, fetch, watch, serve) or blocked
|
|
44
|
+
"method": method.upper(),
|
|
45
|
+
"host": (parts.hostname or "").lower(),
|
|
46
|
+
"path": parts.path or "/",
|
|
47
|
+
"query": sorted({k for k, _ in parse_qsl(parts.query, keep_blank_values=True)}),
|
|
48
|
+
"status": status,
|
|
49
|
+
"decision": decision,
|
|
50
|
+
}
|
|
51
|
+
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
|
52
|
+
with _lock:
|
|
53
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
55
|
+
handle.write(line)
|
|
56
|
+
|
|
57
|
+
def blocked(self, message: str) -> None:
|
|
58
|
+
"""A rule stopped a request; the message names the host and path."""
|
|
59
|
+
entry = {
|
|
60
|
+
"at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
61
|
+
"site": self.site,
|
|
62
|
+
"run": self.run_id,
|
|
63
|
+
"kind": "blocked",
|
|
64
|
+
"decision": message[:300],
|
|
65
|
+
}
|
|
66
|
+
with _lock:
|
|
67
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
69
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read(db_path: Path, site: str, limit: int | None = None) -> list[dict[str, Any]]:
|
|
73
|
+
path = audit_file(db_path, site)
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return []
|
|
76
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
77
|
+
entries = [json.loads(line) for line in lines if line.strip()]
|
|
78
|
+
return entries[-limit:] if limit else entries
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Saved logins: the browser session (cookies and site storage) after you log in yourself.
|
|
2
|
+
|
|
3
|
+
`ghost-api login URL` opens a visible browser; you log in by hand (Ghost API never sees or
|
|
4
|
+
stores a password); the session is then saved per site. Later runs and refreshes open the
|
|
5
|
+
browser already logged in, and fetch / watch / serve add the session's cookies to their
|
|
6
|
+
direct calls. `ghost-api logout SITE` (or `forget`) deletes it.
|
|
7
|
+
|
|
8
|
+
At rest:
|
|
9
|
+
- Windows: encrypted with DPAPI for your Windows account, so another account, or a copy
|
|
10
|
+
of the file on another machine, can't read it.
|
|
11
|
+
- Elsewhere: a file only your user can read (mode 600), not encrypted. Use disk
|
|
12
|
+
encryption, or log out when you are done.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
from ghost_api.site import to_url_id
|
|
27
|
+
|
|
28
|
+
MAGIC_DPAPI = b"GHOSTAPI-DPAPI1\n"
|
|
29
|
+
MAGIC_PLAIN = b"GHOSTAPI-PLAIN1\n"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoginError(Exception):
|
|
33
|
+
"""A saved login can't be read (damaged, or made by another Windows account)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def logins_dir(db_path: Path) -> Path:
|
|
37
|
+
"""Next to the database, like screenshots: never in the output folder."""
|
|
38
|
+
return db_path.parent / f"{db_path.stem}_files" / "logins"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _file(db_path: Path, site: str) -> Path:
|
|
42
|
+
return logins_dir(db_path) / f"{to_url_id(site)}.session"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save(db_path: Path, site: str, state: dict[str, Any]) -> dict[str, Any]:
|
|
46
|
+
"""Save a Playwright storage state; returns what was saved (no values)."""
|
|
47
|
+
meta = {
|
|
48
|
+
"site": site,
|
|
49
|
+
"saved_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
50
|
+
"cookies": len(state.get("cookies", [])),
|
|
51
|
+
"domains": sorted({str(c.get("domain", "")).lstrip(".") for c in state.get("cookies", [])}),
|
|
52
|
+
"encrypted": sys.platform == "win32",
|
|
53
|
+
}
|
|
54
|
+
payload = json.dumps({"meta": meta, "state": state}).encode("utf-8")
|
|
55
|
+
path = _file(db_path, site)
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
data = MAGIC_DPAPI + _protect(payload) if meta["encrypted"] else MAGIC_PLAIN + payload
|
|
58
|
+
# Created with owner-only permissions before any secret is written.
|
|
59
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
60
|
+
with os.fdopen(fd, "wb") as handle:
|
|
61
|
+
handle.write(data)
|
|
62
|
+
return meta
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load(db_path: Path, site: str) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
|
66
|
+
"""(state, meta) for a site's saved login, or None when there is none."""
|
|
67
|
+
path = _file(db_path, site)
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return None
|
|
70
|
+
data = path.read_bytes()
|
|
71
|
+
try:
|
|
72
|
+
if data.startswith(MAGIC_DPAPI):
|
|
73
|
+
payload = _unprotect(data[len(MAGIC_DPAPI) :])
|
|
74
|
+
elif data.startswith(MAGIC_PLAIN):
|
|
75
|
+
payload = data[len(MAGIC_PLAIN) :]
|
|
76
|
+
else:
|
|
77
|
+
raise LoginError("unknown format")
|
|
78
|
+
saved = json.loads(payload)
|
|
79
|
+
return saved["state"], saved["meta"]
|
|
80
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
81
|
+
raise LoginError(
|
|
82
|
+
f"The saved login for {site} can't be read ({exc}). Run `ghost-api login` again."
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def delete(db_path: Path, site: str) -> bool:
|
|
87
|
+
path = _file(db_path, site)
|
|
88
|
+
if path.exists():
|
|
89
|
+
path.unlink()
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def saved_sites(db_path: Path) -> list[dict[str, Any]]:
|
|
95
|
+
out = []
|
|
96
|
+
for path in sorted(logins_dir(db_path).glob("*.session")):
|
|
97
|
+
try:
|
|
98
|
+
data = path.read_bytes()
|
|
99
|
+
if data.startswith(MAGIC_DPAPI):
|
|
100
|
+
data = MAGIC_PLAIN + _unprotect(data[len(MAGIC_DPAPI) :])
|
|
101
|
+
out.append(json.loads(data[len(MAGIC_PLAIN) :])["meta"])
|
|
102
|
+
except (OSError, ValueError, KeyError):
|
|
103
|
+
out.append({"site": path.stem, "saved_at": "?", "damaged": True})
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cookie_header(state: dict[str, Any], url: str, now: float | None = None) -> str:
|
|
108
|
+
"""The Cookie header a browser would send to `url` with this session."""
|
|
109
|
+
parts = urlsplit(url)
|
|
110
|
+
host, path = (parts.hostname or "").lower(), parts.path or "/"
|
|
111
|
+
now = time.time() if now is None else now
|
|
112
|
+
pairs = []
|
|
113
|
+
for cookie in state.get("cookies", []):
|
|
114
|
+
domain = str(cookie.get("domain", "")).lower()
|
|
115
|
+
bare = domain.lstrip(".")
|
|
116
|
+
if not (host == bare or host.endswith("." + bare)):
|
|
117
|
+
continue
|
|
118
|
+
if not path.startswith(str(cookie.get("path") or "/")):
|
|
119
|
+
continue
|
|
120
|
+
if cookie.get("secure") and parts.scheme != "https":
|
|
121
|
+
continue
|
|
122
|
+
expires = cookie.get("expires", -1)
|
|
123
|
+
if isinstance(expires, int | float) and 0 < expires < now:
|
|
124
|
+
continue
|
|
125
|
+
pairs.append(f"{cookie['name']}={cookie['value']}")
|
|
126
|
+
return "; ".join(pairs)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def age_days(meta: dict[str, Any]) -> float:
|
|
130
|
+
try:
|
|
131
|
+
saved = datetime.fromisoformat(str(meta["saved_at"]))
|
|
132
|
+
except (KeyError, ValueError):
|
|
133
|
+
return 0.0
|
|
134
|
+
return (datetime.now(UTC) - saved).total_seconds() / 86400
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- Windows DPAPI (crypt32), through ctypes: no extra dependency ---------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _protect(data: bytes) -> bytes:
|
|
141
|
+
return _dpapi(data, protect=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unprotect(data: bytes) -> bytes:
|
|
145
|
+
if sys.platform != "win32":
|
|
146
|
+
raise LoginError("this login was saved on Windows and can only be read there")
|
|
147
|
+
return _dpapi(data, protect=False)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _dpapi(data: bytes, protect: bool) -> bytes:
|
|
151
|
+
if sys.platform != "win32": # also tells the type checker windll exists below
|
|
152
|
+
raise LoginError("Windows data protection is only available on Windows")
|
|
153
|
+
import ctypes
|
|
154
|
+
from ctypes import wintypes
|
|
155
|
+
|
|
156
|
+
class Blob(ctypes.Structure):
|
|
157
|
+
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
|
|
158
|
+
|
|
159
|
+
buffer = ctypes.create_string_buffer(data, len(data))
|
|
160
|
+
blob_in = Blob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
|
|
161
|
+
blob_out = Blob()
|
|
162
|
+
crypt32 = ctypes.windll.crypt32
|
|
163
|
+
kernel32 = ctypes.windll.kernel32
|
|
164
|
+
call = crypt32.CryptProtectData if protect else crypt32.CryptUnprotectData
|
|
165
|
+
# CRYPTPROTECT_UI_FORBIDDEN = 0x1: never show a prompt.
|
|
166
|
+
ok = call(ctypes.byref(blob_in), None, None, None, None, 0x1, ctypes.byref(blob_out))
|
|
167
|
+
if not ok:
|
|
168
|
+
raise LoginError(f"Windows could not {'protect' if protect else 'unlock'} the login")
|
|
169
|
+
try:
|
|
170
|
+
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
|
|
171
|
+
finally:
|
|
172
|
+
kernel32.LocalFree(blob_out.pbData)
|