penpal-enum 0.2.0rc2__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.
penpal/api.py ADDED
@@ -0,0 +1,332 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import logging
6
+ import sys
7
+ from http import HTTPStatus
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from urllib.parse import parse_qs, unquote, urlparse
11
+
12
+ from .advisor import build_suggestions
13
+ from .context import build_context
14
+ from .ingest import extract_evidence
15
+ from .models import ParameterResolutionError, normalize_environment_variable_name
16
+ from .nmap_parser import NmapParseError, parse_nmap_xml
17
+ from .scope import ScopeViolationError
18
+ from .summary import render_summary
19
+ from .workspace import TargetExistsError, TargetNotFoundError, Workspace, WorkspaceError
20
+
21
+
22
+ LOGGER = logging.getLogger(__name__)
23
+ MAX_REQUEST_BODY_BYTES = 1024 * 1024
24
+
25
+
26
+ class ApiRequestError(ValueError):
27
+ def __init__(self, message: str, status: HTTPStatus = HTTPStatus.BAD_REQUEST):
28
+ super().__init__(message)
29
+ self.status = status
30
+
31
+
32
+ def serve(
33
+ workspace: Workspace,
34
+ host: str = "127.0.0.1",
35
+ port: int = 8765,
36
+ *,
37
+ allow_remote: bool = False,
38
+ ) -> None:
39
+ remote = not _is_loopback_host(host)
40
+ if remote and not allow_remote:
41
+ raise ValueError(
42
+ f"refusing to bind the unauthenticated API to non-loopback host {host!r}; "
43
+ "use --allow-remote only on an isolated, trusted network"
44
+ )
45
+ if remote:
46
+ print(
47
+ "WARNING: PenPal's API has no authentication or TLS; remote clients can request unmasked workspace data.",
48
+ file=sys.stderr,
49
+ )
50
+
51
+ handler = make_handler(workspace)
52
+ server = ThreadingHTTPServer((host, port), handler)
53
+ display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
54
+ print(f"PenPal API listening on http://{display_host}:{port}")
55
+ try:
56
+ server.serve_forever()
57
+ finally:
58
+ server.server_close()
59
+
60
+
61
+ def make_handler(workspace: Workspace) -> type[BaseHTTPRequestHandler]:
62
+ class PenPalHandler(BaseHTTPRequestHandler):
63
+ server_version = "PenPal/0.1"
64
+
65
+ def version_string(self) -> str:
66
+ return self.server_version
67
+
68
+ def do_GET(self) -> None:
69
+ try:
70
+ path = _parts(self.path)
71
+ if path == ["api", "health"]:
72
+ self._json({"ok": True})
73
+ elif path == ["api", "targets"]:
74
+ self._json({"targets": [target.to_dict() for target in workspace.list_targets()]})
75
+ elif len(path) == 3 and path[:2] == ["api", "targets"]:
76
+ target = workspace.require_target(path[2])
77
+ self._json({"target": target.to_dict()})
78
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "services":
79
+ services = workspace.load_services(path[2])
80
+ self._json({"services": [service.to_dict() for service in services]})
81
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "evidence":
82
+ reveal = _query_bool(self.path, "reveal_secrets")
83
+ evidence = workspace.load_evidence(path[2])
84
+ self._json({"evidence": [item.to_dict(reveal=reveal) for item in evidence]})
85
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "parameters":
86
+ workspace.require_target(path[2])
87
+ reveal = _query_bool(self.path, "reveal_secrets")
88
+ parameters = workspace.load_parameters(path[2])
89
+ self._json({"parameters": [item.to_dict(reveal=reveal) for item in parameters.values()]})
90
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "suggestions":
91
+ target = workspace.require_target(path[2])
92
+ suggestions = build_suggestions(
93
+ workspace.load_services(path[2]),
94
+ workspace.load_evidence(path[2]),
95
+ target_host=target.host,
96
+ target_name=target.name,
97
+ parameters=workspace.load_parameters(path[2]),
98
+ reveal_secrets=_query_bool(self.path, "reveal_secrets"),
99
+ )
100
+ self._json({"suggestions": [suggestion.to_dict() for suggestion in suggestions]})
101
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "context":
102
+ self._json(
103
+ build_context(workspace, path[2], reveal_secrets=_query_bool(self.path, "reveal_secrets"))
104
+ )
105
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "summary":
106
+ target = workspace.require_target(path[2])
107
+ services = workspace.load_services(path[2])
108
+ self._json({"summary": render_summary(target, services)})
109
+ else:
110
+ self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
111
+ except ParameterResolutionError as exc:
112
+ self._json({"error": str(exc)}, HTTPStatus.CONFLICT)
113
+ except ScopeViolationError as exc:
114
+ self._json({"error": str(exc)}, HTTPStatus.FORBIDDEN)
115
+ except TargetNotFoundError as exc:
116
+ self._json({"error": str(exc)}, HTTPStatus.NOT_FOUND)
117
+ except WorkspaceError:
118
+ self._internal_error()
119
+ except Exception:
120
+ self._internal_error()
121
+
122
+ def do_POST(self) -> None:
123
+ try:
124
+ path = _parts(self.path)
125
+ body = self._read_body()
126
+ if path == ["api", "targets"]:
127
+ host = _required_text(body, "host")
128
+ name = None if body.get("name") is None else _required_text(body, "name")
129
+ target = workspace.create_target(
130
+ host=host,
131
+ name=name,
132
+ force=_optional_bool(body, "force"),
133
+ )
134
+ self._json({"target": target.to_dict()}, HTTPStatus.CREATED)
135
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "parse-nmap":
136
+ workspace.require_target(path[2])
137
+ xml_path = Path(_required_text(body, "path"))
138
+ try:
139
+ parsed = parse_nmap_xml(xml_path)
140
+ except (NmapParseError, OSError) as exc:
141
+ raise ApiRequestError(f"unable to parse Nmap XML: {exc}") from exc
142
+ services = workspace.merge_services(path[2], parsed)
143
+ self._json({"services": [service.to_dict() for service in services]})
144
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "ingest":
145
+ target = workspace.require_target(path[2])
146
+ if "path" in body:
147
+ raise ApiRequestError("body.path is not supported; send tool output in body.text")
148
+ text = _optional_text(body, "text", default="", allow_empty=True)
149
+ reveal_secrets = _optional_bool(body, "reveal_secrets")
150
+ existing_ids = {item.id for item in workspace.load_evidence(path[2])}
151
+ result = extract_evidence(
152
+ text,
153
+ source=_optional_text(body, "source", default="paste"),
154
+ service_key=_optional_text(body, "service", default="", allow_empty=True),
155
+ existing_ids=existing_ids,
156
+ )
157
+ saved = workspace.append_evidence(path[2], result.evidence)
158
+ suggestions = build_suggestions(
159
+ workspace.load_services(path[2]),
160
+ saved,
161
+ target_host=target.host,
162
+ target_name=target.name,
163
+ parameters=workspace.load_parameters(path[2]),
164
+ reveal_secrets=reveal_secrets,
165
+ )
166
+ self._json(
167
+ {
168
+ "added": [item.to_dict(reveal=reveal_secrets) for item in result.evidence],
169
+ "ignored_duplicates": result.ignored_duplicates,
170
+ "suggestions": [suggestion.to_dict() for suggestion in suggestions],
171
+ }
172
+ )
173
+ elif len(path) == 4 and path[:2] == ["api", "targets"] and path[3] == "parameters":
174
+ workspace.require_target(path[2])
175
+ key = _required_text(body, "key")
176
+ reveal_secrets = _optional_bool(body, "reveal_secrets")
177
+ if "env_var" in body:
178
+ if "value" in body:
179
+ raise ApiRequestError("parameter body must contain value or env_var, not both")
180
+ try:
181
+ env_var = normalize_environment_variable_name(_required_text(body, "env_var"))
182
+ except ValueError as exc:
183
+ raise ApiRequestError(str(exc)) from exc
184
+ parameter = workspace.set_environment_parameter(
185
+ path[2],
186
+ key,
187
+ env_var,
188
+ )
189
+ else:
190
+ parameter = workspace.set_parameter(
191
+ path[2],
192
+ key,
193
+ _required_text(body, "value", allow_empty=True),
194
+ sensitive=_optional_bool(body, "sensitive") or _looks_sensitive(key),
195
+ source=_optional_text(body, "source", default="manual"),
196
+ )
197
+ self._json({"parameter": parameter.to_dict(reveal=reveal_secrets)})
198
+ else:
199
+ self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
200
+ except ParameterResolutionError as exc:
201
+ self._json({"error": str(exc)}, HTTPStatus.CONFLICT)
202
+ except ScopeViolationError as exc:
203
+ self._json({"error": str(exc)}, HTTPStatus.FORBIDDEN)
204
+ except TargetExistsError as exc:
205
+ self._json({"error": str(exc)}, HTTPStatus.CONFLICT)
206
+ except TargetNotFoundError as exc:
207
+ self._json({"error": str(exc)}, HTTPStatus.NOT_FOUND)
208
+ except ApiRequestError as exc:
209
+ self._json({"error": str(exc)}, exc.status)
210
+ except Exception:
211
+ self._internal_error()
212
+
213
+ def log_message(self, fmt: str, *args: object) -> None:
214
+ return
215
+
216
+ def _read_body(self) -> dict[str, object]:
217
+ if self.headers.get("Transfer-Encoding"):
218
+ self.close_connection = True
219
+ raise ApiRequestError("transfer-encoded request bodies are not supported")
220
+
221
+ raw_length = self.headers.get("Content-Length")
222
+ if raw_length is None:
223
+ return {}
224
+ if not raw_length.isdigit():
225
+ self.close_connection = True
226
+ raise ApiRequestError("Content-Length must be a non-negative integer")
227
+
228
+ try:
229
+ length = int(raw_length)
230
+ except ValueError as exc:
231
+ self.close_connection = True
232
+ raise ApiRequestError("Content-Length must be a non-negative integer") from exc
233
+ if length > MAX_REQUEST_BODY_BYTES:
234
+ self.close_connection = True
235
+ raise ApiRequestError(
236
+ f"request body exceeds the {MAX_REQUEST_BODY_BYTES}-byte limit",
237
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
238
+ )
239
+ if length == 0:
240
+ return {}
241
+
242
+ raw = self.rfile.read(length)
243
+ if len(raw) != length:
244
+ self.close_connection = True
245
+ raise ApiRequestError("request body ended before Content-Length bytes were received")
246
+ try:
247
+ decoded = raw.decode("utf-8")
248
+ except UnicodeDecodeError as exc:
249
+ raise ApiRequestError("request body must be UTF-8") from exc
250
+ try:
251
+ body = json.loads(decoded)
252
+ except (json.JSONDecodeError, RecursionError) as exc:
253
+ raise ApiRequestError("request body must contain valid JSON") from exc
254
+ if not isinstance(body, dict):
255
+ raise ApiRequestError("expected JSON object")
256
+ return body
257
+
258
+ def _json(self, payload: dict[str, object], status: HTTPStatus = HTTPStatus.OK) -> None:
259
+ encoded = json.dumps(payload, indent=2).encode("utf-8")
260
+ self.send_response(status.value)
261
+ self.send_header("Content-Type", "application/json; charset=utf-8")
262
+ self.send_header("Content-Length", str(len(encoded)))
263
+ self.send_header("Cache-Control", "no-store")
264
+ self.send_header("X-Content-Type-Options", "nosniff")
265
+ if self.close_connection:
266
+ self.send_header("Connection", "close")
267
+ self.end_headers()
268
+ self.wfile.write(encoded)
269
+
270
+ def _internal_error(self) -> None:
271
+ LOGGER.exception("Unhandled PenPal API error")
272
+ self._json({"error": "internal server error"}, HTTPStatus.INTERNAL_SERVER_ERROR)
273
+
274
+ return PenPalHandler
275
+
276
+
277
+ def _parts(raw_path: str) -> list[str]:
278
+ parsed = urlparse(raw_path)
279
+ return [unquote(part) for part in parsed.path.strip("/").split("/") if part]
280
+
281
+
282
+ def _query_bool(raw_path: str, key: str) -> bool:
283
+ values = parse_qs(urlparse(raw_path).query).get(key, [])
284
+ return any(value.lower() in {"1", "true", "yes", "on"} for value in values)
285
+
286
+
287
+ def _is_loopback_host(host: str) -> bool:
288
+ candidate = host.strip()
289
+ if candidate.lower() == "localhost":
290
+ return True
291
+ try:
292
+ return ipaddress.ip_address(candidate).is_loopback
293
+ except ValueError:
294
+ return False
295
+
296
+
297
+ def _required_text(body: dict[str, object], key: str, *, allow_empty: bool = False) -> str:
298
+ if key not in body:
299
+ raise ApiRequestError(f"missing field: {key}")
300
+ return _text_value(body[key], key, allow_empty=allow_empty)
301
+
302
+
303
+ def _optional_text(
304
+ body: dict[str, object],
305
+ key: str,
306
+ *,
307
+ default: str,
308
+ allow_empty: bool = False,
309
+ ) -> str:
310
+ if key not in body:
311
+ return default
312
+ return _text_value(body[key], key, allow_empty=allow_empty)
313
+
314
+
315
+ def _text_value(value: object, key: str, *, allow_empty: bool) -> str:
316
+ if not isinstance(value, str):
317
+ raise ApiRequestError(f"field {key} must be a string")
318
+ if not allow_empty and not value.strip():
319
+ raise ApiRequestError(f"field {key} must not be empty")
320
+ return value
321
+
322
+
323
+ def _optional_bool(body: dict[str, object], key: str) -> bool:
324
+ value = body.get(key, False)
325
+ if not isinstance(value, bool):
326
+ raise ApiRequestError(f"field {key} must be a boolean")
327
+ return value
328
+
329
+
330
+ def _looks_sensitive(name: str) -> bool:
331
+ lowered = name.lower()
332
+ return any(token in lowered for token in ["pass", "password", "secret", "token", "key", "hash"])