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 ADDED
@@ -0,0 +1,3 @@
1
+ """PenPal: backend-first enumeration assistant for authorized pentesters."""
2
+
3
+ __version__ = "0.2.0rc2"
penpal/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
penpal/advisor.py ADDED
@@ -0,0 +1,481 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import re
5
+ from typing import Any
6
+
7
+ from .models import Evidence, Parameter, Service
8
+
9
+
10
+ MAIL_PORTS = {110, 143, 993, 995}
11
+ REMOTE_ACCESS_PORTS = {22, 3389, 5985, 5986, 445}
12
+ WEB_PORTS = {80, 443, 8080, 8000, 8008, 8443}
13
+
14
+
15
+ @dataclass
16
+ class Suggestion:
17
+ id: str
18
+ title: str
19
+ reason: str
20
+ confidence: str
21
+ value: str
22
+ risk: str
23
+ supporting_facts: list[str]
24
+ next_actions: list[str]
25
+ command_examples: list[str] = field(default_factory=list)
26
+ metadata: dict[str, Any] = field(default_factory=dict)
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ return {
30
+ "id": self.id,
31
+ "title": self.title,
32
+ "reason": self.reason,
33
+ "confidence": self.confidence,
34
+ "value": self.value,
35
+ "risk": self.risk,
36
+ "supporting_facts": list(self.supporting_facts),
37
+ "next_actions": list(self.next_actions),
38
+ "command_examples": list(self.command_examples),
39
+ "metadata": dict(self.metadata),
40
+ }
41
+
42
+
43
+ def build_suggestions(
44
+ services: list[Service],
45
+ evidence: list[Evidence],
46
+ target_host: str = "<target>",
47
+ target_name: str = "<target_name>",
48
+ parameters: dict[str, Parameter] | None = None,
49
+ reveal_secrets: bool = False,
50
+ playbooks: list[dict[str, Any]] | None = None,
51
+ ) -> list[Suggestion]:
52
+ parameters = parameters or {}
53
+ target_host = _parameter_value(parameters, "target_host", target_host, reveal_secrets)
54
+ target_name = _parameter_value(parameters, "target_name", target_name, reveal_secrets)
55
+ suggestions: list[Suggestion] = []
56
+ service_ports = {service.port for service in services if service.state == "open"}
57
+ service_names = {service.name.lower() for service in services if service.state == "open"}
58
+ evidence_by_type: dict[str, list[Evidence]] = {}
59
+ for item in evidence:
60
+ evidence_by_type.setdefault(item.type, []).append(item)
61
+
62
+ has_snmp = 161 in service_ports or "snmp" in service_names
63
+ has_mail = bool(service_ports & MAIL_PORTS) or bool(service_names & {"imap", "pop3", "imaps", "pop3s"})
64
+ has_remote = bool(service_ports & REMOTE_ACCESS_PORTS) or bool(
65
+ service_names & {"ssh", "rdp", "ms-wbt-server", "winrm", "microsoft-ds"}
66
+ )
67
+ usernames = evidence_by_type.get("username", [])
68
+ credential_candidates = evidence_by_type.get("credential_candidate", [])
69
+ hostnames = evidence_by_type.get("hostname", []) + evidence_by_type.get("domain", [])
70
+ web_paths = evidence_by_type.get("web_path", [])
71
+ interesting_files = evidence_by_type.get("interesting_file", [])
72
+
73
+ if has_snmp and has_mail and has_remote:
74
+ suggestions.append(
75
+ Suggestion(
76
+ id="path_snmp_mail_remote",
77
+ title="Investigate SNMP to mail to remote access path",
78
+ reason="SNMP, mail, and remote access services are present, which can form a useful enumeration chain if SNMP reveals identities or configuration.",
79
+ confidence="medium",
80
+ value="high",
81
+ risk="normal",
82
+ supporting_facts=_facts_for_ports(services, {161} | MAIL_PORTS | REMOTE_ACCESS_PORTS),
83
+ next_actions=[
84
+ "Run SNMP community checks if not already done.",
85
+ "If a community is valid, run snmpwalk and ingest the output.",
86
+ "Extract usernames, hostnames, processes, and credential-looking strings.",
87
+ "Use only known valid credentials for mail or remote access checks.",
88
+ ],
89
+ command_examples=[
90
+ f"snmpwalk -v2c -c <community> {target_host}",
91
+ f"snmpwalk -v2c -c <community> {target_host} 1.3.6.1.2.1.25.4.2.1.2",
92
+ f"snmpwalk -v2c -c <community> {target_host} 1.3.6.1.2.1.25.6.3.1.2",
93
+ f"python -m penpal ingest {target_name} --file .\\snmpwalk.txt --source snmpwalk --service udp/161",
94
+ ],
95
+ )
96
+ )
97
+
98
+ if usernames and has_mail:
99
+ suggestions.append(
100
+ Suggestion(
101
+ id="usernames_to_mail",
102
+ title="Use discovered usernames to guide mail enumeration",
103
+ reason="Usernames were found and mail services are exposed. They can help build a focused username list for authorized mail checks.",
104
+ confidence="medium",
105
+ value="medium",
106
+ risk="normal",
107
+ supporting_facts=[_evidence_fact(item, reveal_secrets) for item in usernames[:6]]
108
+ + _facts_for_ports(services, MAIL_PORTS),
109
+ next_actions=[
110
+ "Review username source context before using them.",
111
+ "Build a usernames list in loot/ or evidence exports.",
112
+ "Test only credentials you already know or are authorized to validate.",
113
+ ],
114
+ command_examples=_mail_command_examples(services, target_host)
115
+ + [
116
+ f"python -m penpal evidence {target_name}",
117
+ f"python -m penpal ingest {target_name} --file .\\mail-check.txt --source mail --service <tcp/port>",
118
+ ],
119
+ )
120
+ )
121
+
122
+ if credential_candidates and has_remote:
123
+ suggestions.append(
124
+ Suggestion(
125
+ id="credentials_to_remote",
126
+ title="Review credential candidates for remote access follow-up",
127
+ reason="Credential-looking strings were found and remote access services are exposed.",
128
+ confidence="medium",
129
+ value="high",
130
+ risk="normal",
131
+ supporting_facts=[_evidence_fact(item, reveal_secrets) for item in credential_candidates[:5]]
132
+ + _facts_for_ports(services, REMOTE_ACCESS_PORTS),
133
+ next_actions=[
134
+ "Manually validate whether the candidate is a real credential.",
135
+ "Record confirmed credentials as evidence with their source.",
136
+ "Use known valid credentials against RDP, WinRM, SMB, or SSH when allowed.",
137
+ ],
138
+ command_examples=_remote_access_command_examples(services, target_host),
139
+ )
140
+ )
141
+
142
+ if hostnames and _has_web(services):
143
+ domain = _parameter_value(
144
+ parameters, "domain", _first_value(evidence_by_type.get("domain", []), "<domain>"), reveal_secrets
145
+ )
146
+ suggestions.append(
147
+ Suggestion(
148
+ id="hostnames_to_vhosts",
149
+ title="Feed discovered hostnames into web virtual host checks",
150
+ reason="Hostnames or domains were found while HTTP services are exposed.",
151
+ confidence="medium",
152
+ value="medium",
153
+ risk="normal",
154
+ supporting_facts=[_evidence_fact(item, reveal_secrets) for item in hostnames[:6]]
155
+ + _facts_for_ports(services, WEB_PORTS),
156
+ next_actions=[
157
+ "Add discovered names to your local hosts mapping when appropriate.",
158
+ "Run vhost discovery using the known domain.",
159
+ "Ingest web discovery output so hidden apps become evidence.",
160
+ ],
161
+ command_examples=[
162
+ f'ffuf -u http://{target_host}/ -H "Host: FUZZ.{domain}" -w <wordlist> -mc all -fs <baseline_size>',
163
+ f"feroxbuster -u http://{domain}/ -w <wordlist> -o .\\penpal-workspace\\targets\\{target_name}\\web\\ferox-{domain}.txt",
164
+ f"python -m penpal ingest {target_name} --file .\\vhosts.txt --source ffuf --service tcp/80",
165
+ ],
166
+ )
167
+ )
168
+
169
+ if web_paths:
170
+ suggestions.append(
171
+ Suggestion(
172
+ id="review_web_paths",
173
+ title="Review discovered web paths",
174
+ reason="Web paths were extracted from pasted output and may include hidden panels, backups, or application routes.",
175
+ confidence="medium",
176
+ value="medium",
177
+ risk="passive",
178
+ supporting_facts=[_evidence_fact(item, reveal_secrets) for item in web_paths[:8]],
179
+ next_actions=[
180
+ "Open interesting paths manually and capture notes or screenshots.",
181
+ "Prioritize admin, backup, upload, config, and version-revealing paths.",
182
+ "Ingest any page titles, errors, or exposed files you find.",
183
+ ],
184
+ command_examples=_web_path_command_examples(services, web_paths, target_host)
185
+ + [
186
+ f"python -m penpal ingest {target_name} --file .\\web-notes.txt --source manual-web --service <tcp/port>",
187
+ ],
188
+ )
189
+ )
190
+
191
+ if interesting_files:
192
+ suggestions.append(
193
+ Suggestion(
194
+ id="review_interesting_files",
195
+ title="Review interesting files for new facts",
196
+ reason="File names or paths suggest configs, backups, keys, or other high-signal artifacts.",
197
+ confidence="medium",
198
+ value="high",
199
+ risk="passive",
200
+ supporting_facts=[_evidence_fact(item, reveal_secrets) for item in interesting_files[:8]],
201
+ next_actions=[
202
+ "Move relevant downloaded files into loot/.",
203
+ "Extract usernames, hostnames, service names, and credential-looking strings.",
204
+ "Feed any confirmed facts back into the evidence store.",
205
+ ],
206
+ command_examples=[
207
+ 'grep -RniE "pass|user|cred|key|token|secret" loot/',
208
+ "Get-ChildItem .\\loot -Recurse -File | Select-String -Pattern 'pass|user|cred|key|token|secret'",
209
+ f"python -m penpal ingest {target_name} --file .\\loot-review.txt --source loot-review",
210
+ ],
211
+ )
212
+ )
213
+
214
+ suggestions.extend(_playbook_suggestions(playbooks or [], services, evidence, reveal_secrets))
215
+ defaults = {"target_host": target_host, "target_name": target_name}
216
+ return _render_suggestion_parameters(dedupe_suggestions(suggestions), parameters, reveal_secrets, defaults)
217
+
218
+
219
+ def _playbook_suggestions(
220
+ playbooks: list[dict[str, Any]],
221
+ services: list[Service],
222
+ evidence: list[Evidence],
223
+ reveal_secrets: bool,
224
+ ) -> list[Suggestion]:
225
+ suggestions: list[Suggestion] = []
226
+ for playbook in playbooks:
227
+ matched_signals = _matched_playbook_signals(playbook, services, evidence, reveal_secrets)
228
+ if matched_signals is None:
229
+ continue
230
+ supporting_facts = [fact for match in matched_signals for fact in match["facts"]]
231
+ actions = playbook.get("actions", [])
232
+ suggestions.append(
233
+ Suggestion(
234
+ id=f"playbook_{_suggestion_id(str(playbook['id']))}",
235
+ title=str(playbook["title"]),
236
+ reason=str(playbook["description"]),
237
+ confidence="medium",
238
+ value="high",
239
+ risk=_highest_action_risk(actions),
240
+ supporting_facts=supporting_facts,
241
+ next_actions=[str(action["description"]) for action in actions],
242
+ command_examples=[str(command) for action in actions for command in action.get("commands", [])],
243
+ metadata={
244
+ "source": "playbook",
245
+ "playbook_id": playbook["id"],
246
+ "playbook_schema": playbook["schema"],
247
+ "playbook_tags": list(playbook.get("tags", [])),
248
+ "matched_signals": matched_signals,
249
+ },
250
+ )
251
+ )
252
+ return suggestions
253
+
254
+
255
+ def _matched_playbook_signals(
256
+ playbook: dict[str, Any],
257
+ services: list[Service],
258
+ evidence: list[Evidence],
259
+ reveal_secrets: bool,
260
+ ) -> list[dict[str, Any]] | None:
261
+ matches: list[dict[str, Any]] = []
262
+ for index, signal in enumerate(playbook.get("signals", [])):
263
+ matched = _match_playbook_signal(signal, services, evidence, reveal_secrets)
264
+ if not matched:
265
+ return None
266
+ matches.append(
267
+ {
268
+ "index": index,
269
+ "type": signal.get("type", ""),
270
+ "criteria": dict(signal),
271
+ "facts": matched,
272
+ }
273
+ )
274
+ return matches
275
+
276
+
277
+ def _match_playbook_signal(
278
+ signal: dict[str, Any],
279
+ services: list[Service],
280
+ evidence: list[Evidence],
281
+ reveal_secrets: bool,
282
+ ) -> list[str]:
283
+ signal_type = signal.get("type")
284
+ if signal_type in {"service", "service_any"}:
285
+ return _match_service_signal(signal, services)
286
+ if signal_type == "evidence":
287
+ evidence_type = signal.get("evidence_type")
288
+ return [_evidence_fact(item, reveal_secrets) for item in evidence if item.type == evidence_type][:8]
289
+ return []
290
+
291
+
292
+ def _match_service_signal(signal: dict[str, Any], services: list[Service]) -> list[str]:
293
+ ports = {signal["port"]} if "port" in signal else set(signal.get("ports", []))
294
+ names = (
295
+ {str(signal["name"]).lower()} if "name" in signal else {str(name).lower() for name in signal.get("names", [])}
296
+ )
297
+ protocol = str(signal.get("protocol", "")).lower()
298
+ facts: list[str] = []
299
+ for service in services:
300
+ if service.state != "open":
301
+ continue
302
+ if protocol and service.protocol.lower() != protocol:
303
+ continue
304
+ if ports and service.port not in ports:
305
+ continue
306
+ if names and service.name.lower() not in names:
307
+ continue
308
+ facts.append(f"{service.protocol}/{service.port} {service.name or 'unknown'}")
309
+ return facts[:8]
310
+
311
+
312
+ RISK_RANK = {"passive": 0, "normal": 1, "aggressive": 2, "approval_required": 3}
313
+
314
+
315
+ def _highest_action_risk(actions: list[dict[str, Any]]) -> str:
316
+ return max((str(action["risk"]) for action in actions), key=lambda risk: RISK_RANK.get(risk, 0), default="normal")
317
+
318
+
319
+ def _suggestion_id(value: str) -> str:
320
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._-") or "playbook"
321
+
322
+
323
+ def _has_web(services: list[Service]) -> bool:
324
+ return any(service.port in WEB_PORTS or "http" in service.name.lower() for service in services)
325
+
326
+
327
+ def _mail_command_examples(services: list[Service], target_host: str) -> list[str]:
328
+ commands: list[str] = []
329
+ ports = {service.port for service in services if service.state == "open"}
330
+ if 143 in ports:
331
+ commands.extend(
332
+ [
333
+ f"nc -nv {target_host} 143",
334
+ f'curl --url "imap://{target_host}:143/INBOX" --user "<known_user>:<known_password>" --verbose',
335
+ ]
336
+ )
337
+ if 993 in ports:
338
+ commands.extend(
339
+ [
340
+ f"openssl s_client -connect {target_host}:993 -crlf",
341
+ f'curl --url "imaps://{target_host}:993/INBOX" --user "<known_user>:<known_password>" --verbose --insecure',
342
+ ]
343
+ )
344
+ if 110 in ports:
345
+ commands.extend(
346
+ [
347
+ f"nc -nv {target_host} 110",
348
+ f'curl --url "pop3://{target_host}:110" --user "<known_user>:<known_password>" --verbose',
349
+ ]
350
+ )
351
+ if 995 in ports:
352
+ commands.extend(
353
+ [
354
+ f"openssl s_client -connect {target_host}:995 -crlf",
355
+ f'curl --url "pop3s://{target_host}:995" --user "<known_user>:<known_password>" --verbose --insecure',
356
+ ]
357
+ )
358
+ return commands or [f'curl --url "imap://{target_host}:143/INBOX" --user "<known_user>:<known_password>" --verbose']
359
+
360
+
361
+ def _remote_access_command_examples(services: list[Service], target_host: str) -> list[str]:
362
+ commands: list[str] = []
363
+ ports = {service.port for service in services if service.state == "open"}
364
+ if 22 in ports:
365
+ commands.append(f"ssh <known_user>@{target_host}")
366
+ if 445 in ports:
367
+ commands.extend(
368
+ [
369
+ f"smbclient -L //{target_host} -U <known_user>",
370
+ f"smbclient //{target_host}/<share> -U <known_user>",
371
+ ]
372
+ )
373
+ if 3389 in ports:
374
+ commands.append(f"xfreerdp /v:{target_host} /u:<known_user> /p:<known_password> /cert:ignore")
375
+ if 5985 in ports:
376
+ commands.append(f"evil-winrm -i {target_host} -u <known_user> -p '<known_password>'")
377
+ if 5986 in ports:
378
+ commands.append(f"evil-winrm -S -i {target_host} -u <known_user> -p '<known_password>'")
379
+ return commands or [f"ssh <known_user>@{target_host}"]
380
+
381
+
382
+ def _web_path_command_examples(services: list[Service], paths: list[Evidence], target_host: str) -> list[str]:
383
+ web_service = next(
384
+ (service for service in services if service.port in WEB_PORTS or "http" in service.name.lower()), None
385
+ )
386
+ port = web_service.port if web_service else 80
387
+ scheme = "https" if port in {443, 8443} or (web_service and web_service.tunnel == "ssl") else "http"
388
+ path = _first_value(paths, "/<path>")
389
+ port_part = "" if (scheme == "http" and port == 80) or (scheme == "https" and port == 443) else f":{port}"
390
+ return [
391
+ f"curl -i {scheme}://{target_host}{port_part}{path}",
392
+ f"feroxbuster -u {scheme}://{target_host}{port_part}/ -w <wordlist> -o .\\ferox-{port}.txt",
393
+ ]
394
+
395
+
396
+ def _first_value(items: list[Evidence], fallback: str) -> str:
397
+ return items[0].value if items else fallback
398
+
399
+
400
+ def _facts_for_ports(services: list[Service], ports: set[int]) -> list[str]:
401
+ facts: list[str] = []
402
+ for service in services:
403
+ if service.state == "open" and service.port in ports:
404
+ facts.append(f"{service.protocol}/{service.port} {service.name or 'unknown'}")
405
+ return facts
406
+
407
+
408
+ def _evidence_fact(item: Evidence, reveal_secrets: bool = False) -> str:
409
+ value = item.value if reveal_secrets or not item.sensitive else "<sensitive>"
410
+ if item.service_key:
411
+ return f"{item.type}: {value} ({item.service_key})"
412
+ return f"{item.type}: {value}"
413
+
414
+
415
+ def dedupe_suggestions(suggestions: list[Suggestion]) -> list[Suggestion]:
416
+ seen: set[str] = set()
417
+ result: list[Suggestion] = []
418
+ for suggestion in suggestions:
419
+ if suggestion.id not in seen:
420
+ seen.add(suggestion.id)
421
+ result.append(suggestion)
422
+ return result
423
+
424
+
425
+ PLACEHOLDER_RE = re.compile(r"<([A-Za-z0-9_.-]+)>")
426
+ PARAMETER_ALIASES: dict[str, tuple[str, ...]] = {
427
+ "community": ("snmp_community",),
428
+ "known_user": ("username", "user"),
429
+ "known_password": ("password", "pass"),
430
+ "domain": ("dns_domain",),
431
+ "wordlist": ("web_wordlist", "dir_wordlist"),
432
+ }
433
+
434
+
435
+ def _render_suggestion_parameters(
436
+ suggestions: list[Suggestion],
437
+ parameters: dict[str, Parameter],
438
+ reveal_secrets: bool,
439
+ defaults: dict[str, str] | None = None,
440
+ ) -> list[Suggestion]:
441
+ defaults = defaults or {}
442
+ for suggestion in suggestions:
443
+ suggestion.command_examples = [
444
+ _render_command(command, parameters, reveal_secrets, defaults) for command in suggestion.command_examples
445
+ ]
446
+ return suggestions
447
+
448
+
449
+ def _render_command(
450
+ command: str,
451
+ parameters: dict[str, Parameter],
452
+ reveal_secrets: bool,
453
+ defaults: dict[str, str] | None = None,
454
+ ) -> str:
455
+ defaults = defaults or {}
456
+
457
+ def replace(match: re.Match[str]) -> str:
458
+ name = match.group(1)
459
+ value = _parameter_value(parameters, name, defaults.get(name, match.group(0)), reveal_secrets)
460
+ return value
461
+
462
+ return PLACEHOLDER_RE.sub(replace, command)
463
+
464
+
465
+ def _parameter_value(
466
+ parameters: dict[str, Parameter],
467
+ name: str,
468
+ fallback: str,
469
+ reveal_secrets: bool,
470
+ ) -> str:
471
+ parameter = parameters.get(name)
472
+ if parameter is None:
473
+ for alias in PARAMETER_ALIASES.get(name, ()):
474
+ parameter = parameters.get(alias)
475
+ if parameter is not None:
476
+ break
477
+ if parameter is None:
478
+ return fallback
479
+ if parameter.sensitive and not reveal_secrets:
480
+ return f"<{name}>"
481
+ return parameter.require_value()