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/__init__.py +3 -0
- penpal/__main__.py +5 -0
- penpal/advisor.py +481 -0
- penpal/api.py +332 -0
- penpal/cli.py +804 -0
- penpal/context.py +38 -0
- penpal/data/SOURCE_FACTS.json +36 -0
- penpal/data/SOURCE_SEEDS.json +169 -0
- penpal/doctor.py +141 -0
- penpal/ingest.py +232 -0
- penpal/mcp_server.py +170 -0
- penpal/models.py +258 -0
- penpal/module_cli.py +57 -0
- penpal/nmap_parser.py +58 -0
- penpal/playbooks.py +420 -0
- penpal/recommendations.py +101 -0
- penpal/runner.py +68 -0
- penpal/scan_profiles.py +87 -0
- penpal/scope.py +201 -0
- penpal/service_modules.py +401 -0
- penpal/sources.py +324 -0
- penpal/summary.py +51 -0
- penpal/workspace.py +349 -0
- penpal_enum-0.2.0rc2.data/data/share/penpal/playbooks/http-vhosts-hidden-apps.json +49 -0
- penpal_enum-0.2.0rc2.data/data/share/penpal/playbooks/ldap-kerberos-ad-context.json +52 -0
- penpal_enum-0.2.0rc2.data/data/share/penpal/playbooks/smb-shares-configs-credentials.json +45 -0
- penpal_enum-0.2.0rc2.data/data/share/penpal/playbooks/snmp-mail-remote.json +61 -0
- penpal_enum-0.2.0rc2.dist-info/METADATA +146 -0
- penpal_enum-0.2.0rc2.dist-info/RECORD +33 -0
- penpal_enum-0.2.0rc2.dist-info/WHEEL +5 -0
- penpal_enum-0.2.0rc2.dist-info/entry_points.txt +2 -0
- penpal_enum-0.2.0rc2.dist-info/licenses/LICENSE +21 -0
- penpal_enum-0.2.0rc2.dist-info/top_level.txt +1 -0
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"])
|