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/__init__.py
ADDED
penpal/__main__.py
ADDED
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()
|