pwner 0.3.2__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.
- pwner/__init__.py +22 -0
- pwner/__main__.py +4 -0
- pwner/agent.py +566 -0
- pwner/app.py +366 -0
- pwner/cli.py +583 -0
- pwner/config.py +56 -0
- pwner/llm.py +607 -0
- pwner/prompts.py +206 -0
- pwner/repl.py +563 -0
- pwner/tools/__init__.py +181 -0
- pwner/tools/ad.py +679 -0
- pwner/tools/core.py +649 -0
- pwner/tools/net.py +226 -0
- pwner/tools/nmap.py +349 -0
- pwner/tools/pci.py +407 -0
- pwner/tools/report.py +330 -0
- pwner/tools/session.py +211 -0
- pwner/tools/target.py +124 -0
- pwner/tools/web.py +885 -0
- pwner/tui.py +171 -0
- pwner-0.3.2.dist-info/METADATA +377 -0
- pwner-0.3.2.dist-info/RECORD +26 -0
- pwner-0.3.2.dist-info/WHEEL +5 -0
- pwner-0.3.2.dist-info/entry_points.txt +2 -0
- pwner-0.3.2.dist-info/licenses/LICENSE +661 -0
- pwner-0.3.2.dist-info/top_level.txt +1 -0
pwner/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""pwner — an autonomous, agentic NETWORK penetration-testing tool.
|
|
2
|
+
|
|
3
|
+
Authorized-use only. Only run it against networks and hosts you have explicit
|
|
4
|
+
written permission to test.
|
|
5
|
+
|
|
6
|
+
Copyright (C) 2026 Berk D.
|
|
7
|
+
|
|
8
|
+
This program is free software: you can redistribute it and/or modify it under
|
|
9
|
+
the terms of the GNU Affero General Public License as published by the Free
|
|
10
|
+
Software Foundation, either version 3 of the License, or (at your option) any
|
|
11
|
+
later version.
|
|
12
|
+
|
|
13
|
+
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
14
|
+
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
|
15
|
+
PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
|
16
|
+
|
|
17
|
+
You should have received a copy of the GNU Affero General Public License along
|
|
18
|
+
with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__version__ = "0.3.2"
|
|
22
|
+
__license__ = "AGPL-3.0-or-later"
|
pwner/__main__.py
ADDED
pwner/agent.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Agentic orchestration + deterministic fallback for pwner."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from rich.markup import escape as _esc
|
|
9
|
+
|
|
10
|
+
from . import tools
|
|
11
|
+
from .llm import AIUnavailable, LLMClient, LLMError
|
|
12
|
+
from .prompts import SYSTEM
|
|
13
|
+
from .tools import ScanContext, ToolError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _short(args: dict) -> str:
|
|
17
|
+
s = json.dumps(args, default=str)
|
|
18
|
+
return s if len(s) <= 120 else s[:120] + "…"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _out(ctx: ScanContext, text: str = "") -> None:
|
|
22
|
+
"""Route output to the live TUI log (or plain print when not a TTY)."""
|
|
23
|
+
tui = getattr(ctx, "tui", None)
|
|
24
|
+
if tui is not None:
|
|
25
|
+
tui.log(text)
|
|
26
|
+
else:
|
|
27
|
+
print(text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _record(ctx: ScanContext, name: str, args: dict, result: dict) -> None:
|
|
31
|
+
"""Feed a tool result into the live network-topology panel."""
|
|
32
|
+
tui = getattr(ctx, "tui", None)
|
|
33
|
+
if tui is None:
|
|
34
|
+
return
|
|
35
|
+
host = str(args.get("host") or args.get("target") or args.get("domain") or "").strip()
|
|
36
|
+
if name == "ping_sweep":
|
|
37
|
+
for h in (result.get("live_hosts") or []):
|
|
38
|
+
tui.record_host(h)
|
|
39
|
+
elif name == "dns_enum":
|
|
40
|
+
for ip in (result.get("records") or {}).get("A", []):
|
|
41
|
+
tui.record_host(ip)
|
|
42
|
+
elif name in ("port_scan", "service_detect"):
|
|
43
|
+
if host:
|
|
44
|
+
tui.record_host(host)
|
|
45
|
+
for h in result.get("hosts", []):
|
|
46
|
+
for p in h.get("open_ports", []):
|
|
47
|
+
s = p.get("service", {})
|
|
48
|
+
ver = " ".join(x for x in (s.get("product", ""), s.get("version", "")) if x)
|
|
49
|
+
tui.record_port(host, p["port"], s.get("name", ""), ver)
|
|
50
|
+
elif name == "nuclei_scan":
|
|
51
|
+
for f in (result.get("findings") or []):
|
|
52
|
+
tui.note(f.get("ip") or host, f"[{f.get('severity', '?').upper()}] {f.get('name', '')}")
|
|
53
|
+
elif name == "report_finding":
|
|
54
|
+
tui.note(str(args.get("host") or host), f"[{args.get('severity', '?').upper()}] {args.get('title', '')}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _call_label(name: str, args: dict) -> str:
|
|
58
|
+
"""A plain-language description of what a tool call is about to do."""
|
|
59
|
+
h = str(args.get("host") or args.get("target") or args.get("domain") or "").strip()
|
|
60
|
+
ports = str(args.get("ports") or "").strip()
|
|
61
|
+
port = args.get("port")
|
|
62
|
+
return {
|
|
63
|
+
"ping_sweep": f"discovering live hosts on {h}",
|
|
64
|
+
"port_scan": f"port-scanning {h}" + (f" ({ports})" if ports else ""),
|
|
65
|
+
"service_detect": f"fingerprinting services on {h}" + (f":{ports}" if ports else ""),
|
|
66
|
+
"os_detect": f"detecting the OS of {h}",
|
|
67
|
+
"banner_grab": f"grabbing the banner from {h}:{port}",
|
|
68
|
+
"http_probe": f"GET {args.get('path') or '/'} on {h}:{port}",
|
|
69
|
+
"web_request": f"{str(args.get('method') or 'GET').upper()} {args.get('path') or '/'} on {h}:{port}",
|
|
70
|
+
"content_discovery": f"mapping paths on {h}:{args.get('port', 80)}",
|
|
71
|
+
"http_download": f"downloading {args.get('path') or '/'} from {h}:{args.get('port', 80)}",
|
|
72
|
+
"vuln_scan": f"running vulnerability scripts on {h}" + (f":{ports}" if ports else ""),
|
|
73
|
+
"nuclei_scan": f"running nuclei CVE/misconfig templates ({len(args.get('targets') or [])} target(s))",
|
|
74
|
+
"exploit_search": f"searching public exploits for {args.get('service','')} {args.get('version','')}".rstrip(),
|
|
75
|
+
"dns_enum": f"enumerating DNS records for {args.get('domain', h)}",
|
|
76
|
+
"ftp_anon": f"testing FTP login on {h}" + (f" as {args.get('user')}" if args.get("user") else " (anonymous)"),
|
|
77
|
+
"ftp_get": f"downloading {args.get('path','')} over FTP from {h}",
|
|
78
|
+
"smb_enum": f"enumerating SMB on {h}:{args.get('port', 445)}",
|
|
79
|
+
"ssl_info": f"inspecting TLS/certificate on {h}:{args.get('port', 443)}",
|
|
80
|
+
"tcp_send": f"sending a raw probe to {h}:{port}",
|
|
81
|
+
"snmp_check": f"checking SNMP on {h}",
|
|
82
|
+
"kerbrute_userenum": f"enumerating domain users on {args.get('domain', h)}",
|
|
83
|
+
"asrep_roast": f"AS-REP roasting {args.get('domain', h)}",
|
|
84
|
+
"kerberoast": f"kerberoasting {args.get('domain', h)} as {args.get('user','')}".rstrip(),
|
|
85
|
+
"ldap_enum": f"LDAP-enumerating {args.get('target') or args.get('dc_ip') or h}",
|
|
86
|
+
"rid_brute": f"RID-cycling {args.get('target') or h}",
|
|
87
|
+
"smb_shares": f"listing SMB shares on {args.get('target') or h}",
|
|
88
|
+
"smb_get": f"downloading {args.get('path','')} from the {args.get('share','C$')} share on {args.get('target') or h}",
|
|
89
|
+
"password_spray": f"spraying {args.get('proto','smb')} on {args.get('target') or h}",
|
|
90
|
+
"secretsdump": f"dumping secrets from {args.get('target') or h}",
|
|
91
|
+
"win_exec": f"executing on {args.get('target') or h} via {args.get('method','wmiexec')}",
|
|
92
|
+
"ssh_exec": f"executing on {args.get('target') or h} over SSH as {args.get('user','')}".rstrip(),
|
|
93
|
+
"bloodhound_collect": f"collecting BloodHound data for {args.get('domain', h)}",
|
|
94
|
+
"adcs_find": f"enumerating ADCS on {args.get('domain', h)}",
|
|
95
|
+
"dc_vuln_check": f"checking DC vulns on {args.get('target') or h}",
|
|
96
|
+
"hash_crack": f"cracking {len(args.get('hashes') or [])} hash(es) offline",
|
|
97
|
+
"cred_check": f"validating {args.get('user','')} across services on {args.get('target') or h}".rstrip(),
|
|
98
|
+
"ledger": ("reviewing the engagement ledger" if str(args.get('action') or 'show') == 'show'
|
|
99
|
+
else "updating the engagement ledger"),
|
|
100
|
+
"run_nmap": f"running a custom nmap scan on {h}",
|
|
101
|
+
"hydra_brute": f"brute-forcing {args.get('service','')} credentials on {h}:{port}",
|
|
102
|
+
"get_findings": "reviewing the findings so far",
|
|
103
|
+
}.get(name, name)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _result_summary(name: str, args: dict, result: dict) -> str:
|
|
107
|
+
"""One readable line summarizing a tool result (instead of dumping raw JSON)."""
|
|
108
|
+
if not isinstance(result, dict):
|
|
109
|
+
return str(result)[:160]
|
|
110
|
+
if result.get("error"):
|
|
111
|
+
return "error: " + str(result["error"]).splitlines()[0][:140]
|
|
112
|
+
if name == "ping_sweep":
|
|
113
|
+
hs = result.get("live_hosts") or []
|
|
114
|
+
return f"{result.get('count', len(hs))} live host(s)" + (f": {', '.join(hs[:10])}" if hs else "")
|
|
115
|
+
if name == "port_scan":
|
|
116
|
+
parts = []
|
|
117
|
+
for h in result.get("hosts", []):
|
|
118
|
+
ops = [p for p in h.get("open_ports", []) if p.get("state", "open") == "open"]
|
|
119
|
+
nums = ", ".join(str(p["port"]) for p in ops[:14])
|
|
120
|
+
parts.append(f"{h.get('address','')}: {len(ops)} open" + (f" → {nums}" if nums else ""))
|
|
121
|
+
return "; ".join(parts) or "no open ports"
|
|
122
|
+
if name in ("service_detect", "vuln_scan"):
|
|
123
|
+
lines = []
|
|
124
|
+
for h in result.get("hosts", []):
|
|
125
|
+
for p in h.get("open_ports", []):
|
|
126
|
+
s = p.get("service", {})
|
|
127
|
+
extra = " ".join(x for x in (s.get("product", ""), s.get("version", "")) if x)
|
|
128
|
+
tag = f"{h.get('address','')}:{p['port']} {s.get('name','?')} {extra}".rstrip()
|
|
129
|
+
nscr = len(p.get("scripts", []))
|
|
130
|
+
lines.append(tag + (f" ({nscr} script hit)" if name == "vuln_scan" and nscr else ""))
|
|
131
|
+
return "; ".join(lines[:10]) or "no services"
|
|
132
|
+
if name == "os_detect":
|
|
133
|
+
ms = [m for h in result.get("hosts", []) for m in (h.get("os_matches") or [])[:1]]
|
|
134
|
+
return "; ".join(f"{m.get('name')} ({m.get('accuracy')}%)" for m in ms) or "no OS match"
|
|
135
|
+
if name == "banner_grab":
|
|
136
|
+
return (result.get("banner") or result.get("note") or "").splitlines()[0][:140] or "(empty)"
|
|
137
|
+
if name in ("http_probe", "web_request"):
|
|
138
|
+
from urllib.parse import urlsplit
|
|
139
|
+
path = urlsplit(result.get("url", "")).path or args.get("path") or "/"
|
|
140
|
+
ex = " ".join((result.get("body_excerpt") or "").split())
|
|
141
|
+
forms = result.get("forms") or []
|
|
142
|
+
bits = [str(result.get("status", "")), path]
|
|
143
|
+
if result.get("title"):
|
|
144
|
+
bits.append(result["title"])
|
|
145
|
+
if forms:
|
|
146
|
+
bits.append(f"{len(forms)} form(s)")
|
|
147
|
+
head = " · ".join(b for b in bits if b)
|
|
148
|
+
return (head + (f" — {ex[:110]}" if ex else "")).strip(" ·")
|
|
149
|
+
if name == "http_download":
|
|
150
|
+
creds = result.get("credentials") or []
|
|
151
|
+
head = f"{result.get('status','')} · {result.get('size',0)}B · {result.get('content_type','')}".strip(" ·")
|
|
152
|
+
if creds:
|
|
153
|
+
head += f" · CREDS: {creds[0][:80]}" + (f" (+{len(creds)-1})" if len(creds) > 1 else "")
|
|
154
|
+
elif result.get("truncated"):
|
|
155
|
+
head += " · (capped)"
|
|
156
|
+
return head
|
|
157
|
+
if name == "nuclei_scan":
|
|
158
|
+
return f"{len(result.get('findings') or [])} finding(s)"
|
|
159
|
+
if name == "exploit_search":
|
|
160
|
+
return f"{len(result.get('results') or [])} public exploit(s) matched"
|
|
161
|
+
if name == "dns_enum":
|
|
162
|
+
recs = result.get("records") or {}
|
|
163
|
+
return ", ".join(f"{k}×{len(v)}" for k, v in recs.items() if v) or "no records"
|
|
164
|
+
if name == "ftp_anon":
|
|
165
|
+
if result.get("logged_in"):
|
|
166
|
+
n = len(result.get("listing") or [])
|
|
167
|
+
who = "anonymous" if result.get("anonymous") else result.get("user", "")
|
|
168
|
+
return f"login OK as {who}" + (f" · {n} entries" if n else "")
|
|
169
|
+
return "login refused"
|
|
170
|
+
if name in ("ftp_get", "smb_get"):
|
|
171
|
+
if result.get("ok"):
|
|
172
|
+
extra = " (truncated)" if result.get("truncated") else ""
|
|
173
|
+
creds = result.get("creds_found") or []
|
|
174
|
+
if creds:
|
|
175
|
+
extra += f" · {len(creds)} cred hit(s)"
|
|
176
|
+
return f"{result.get('bytes', 0)} bytes" + extra
|
|
177
|
+
return "error: " + str(result.get("error", ""))[:120]
|
|
178
|
+
if name in ("smb_enum", "ssl_info"):
|
|
179
|
+
bits = []
|
|
180
|
+
for h in result.get("hosts", []):
|
|
181
|
+
for s in h.get("host_scripts", []):
|
|
182
|
+
bits.append(s.get("id", ""))
|
|
183
|
+
for p in h.get("open_ports", []):
|
|
184
|
+
for s in p.get("scripts", []):
|
|
185
|
+
bits.append(s.get("id", ""))
|
|
186
|
+
bits = [b for b in dict.fromkeys(bits) if b]
|
|
187
|
+
return ", ".join(bits[:8]) or "no script output"
|
|
188
|
+
if name == "tcp_send":
|
|
189
|
+
resp = (result.get("response") or "").strip()
|
|
190
|
+
if resp:
|
|
191
|
+
return f"{result.get('sent_bytes', 0)}B sent → {resp.splitlines()[0][:140]}"
|
|
192
|
+
return result.get("note") or f"{result.get('sent_bytes', 0)}B sent, no response"
|
|
193
|
+
if name in ("asrep_roast", "kerberoast"):
|
|
194
|
+
n = result.get("count", len(result.get("hashes") or []))
|
|
195
|
+
return f"{n} crackable hash(es)" + (" — ready to crack" if n else "")
|
|
196
|
+
if name == "kerbrute_userenum":
|
|
197
|
+
v = result.get("valid_users") or []
|
|
198
|
+
return f"{len(v)} valid user(s)" + (f": {', '.join(v[:8])}" if v else "")
|
|
199
|
+
if name == "rid_brute":
|
|
200
|
+
return f"{result.get('count', 0)} principal(s) via RID cycling"
|
|
201
|
+
if name in ("ldap_enum", "adcs_find", "smb_shares"):
|
|
202
|
+
out = (result.get("output") or "").strip()
|
|
203
|
+
if name == "smb_shares" and result.get("pwned"):
|
|
204
|
+
return "Pwn3d! (admin) — " + " ".join(out.split())[:120]
|
|
205
|
+
return " ".join(out.split())[:160] or f"rc={result.get('rc','?')}"
|
|
206
|
+
if name == "password_spray":
|
|
207
|
+
v = result.get("valid") or []
|
|
208
|
+
return (f"VALID: {', '.join(v[:6])}" if v else "no valid creds") if not result.get("denied") else "denied"
|
|
209
|
+
if name == "secretsdump":
|
|
210
|
+
if result.get("denied"):
|
|
211
|
+
return "denied"
|
|
212
|
+
return f"{result.get('count', 0)} secret(s) dumped"
|
|
213
|
+
if name in ("win_exec", "ssh_exec"):
|
|
214
|
+
if result.get("denied"):
|
|
215
|
+
return "denied"
|
|
216
|
+
return " ".join((result.get("output") or "").split())[:160] or "(no output)"
|
|
217
|
+
if name == "bloodhound_collect":
|
|
218
|
+
return f"collected -> {result.get('zip') or result.get('output_dir') or 'see output'}"
|
|
219
|
+
if name == "dc_vuln_check":
|
|
220
|
+
v = result.get("vulnerable") or []
|
|
221
|
+
return (f"VULNERABLE: {', '.join(v)}" if v else "no listed vuln detected")
|
|
222
|
+
if name == "hash_crack":
|
|
223
|
+
return f"{result.get('count', 0)} cracked" if "count" in result else (result.get("output","")[:120] or "done")
|
|
224
|
+
if name == "cred_check":
|
|
225
|
+
vo = result.get("valid_on") or []
|
|
226
|
+
ao = result.get("admin_on") or []
|
|
227
|
+
if not vo:
|
|
228
|
+
return "no service accepted this credential"
|
|
229
|
+
return "valid on: " + ", ".join(vo) + (f" · ADMIN on: {', '.join(ao)}" if ao else "")
|
|
230
|
+
if name == "ledger":
|
|
231
|
+
lg = result.get("ledger") or {}
|
|
232
|
+
return " · ".join(f"{k}:{len(v)}" for k, v in lg.items() if isinstance(v, list)) or "empty"
|
|
233
|
+
if name == "get_findings":
|
|
234
|
+
return f"{len(result.get('findings') or [])} finding(s) so far"
|
|
235
|
+
s = json.dumps(result, default=str)
|
|
236
|
+
return s[:160] + ("…" if len(s) > 160 else "")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def _emit_thought(ctx: ScanContext, text: str) -> None:
|
|
240
|
+
"""The agent's own reasoning (what it says before acting) — the 'thinking' feed."""
|
|
241
|
+
text = text.strip()
|
|
242
|
+
if text:
|
|
243
|
+
_out(ctx, f"[italic grey62]{_esc(text)}[/italic grey62]")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def _emit_call(ctx: ScanContext, name: str, args: dict) -> None:
|
|
247
|
+
"""Announce an action in plain language BEFORE it runs (slow tools show intent)."""
|
|
248
|
+
if name in ("note", "report_finding", "request_consent", "get_findings", "ledger"):
|
|
249
|
+
return
|
|
250
|
+
tui = getattr(ctx, "tui", None)
|
|
251
|
+
if tui is not None: # feed the live status line / spinner
|
|
252
|
+
try:
|
|
253
|
+
tui.activity = _call_label(name, args)
|
|
254
|
+
except Exception: # noqa: BLE001
|
|
255
|
+
pass
|
|
256
|
+
_out(ctx, f"[cyan]▸ {_call_label(name, args)}[/cyan] [dim]…[/dim]")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _emit_step(ctx: ScanContext, name: str, args: dict, result: dict) -> None:
|
|
260
|
+
_record(ctx, name, args, result)
|
|
261
|
+
if name == "note":
|
|
262
|
+
_out(ctx, f"[italic grey62]{_esc(str(args.get('text', '')))}[/italic grey62]")
|
|
263
|
+
return
|
|
264
|
+
if name == "report_finding":
|
|
265
|
+
sev = str(args.get("severity", "?")).upper()
|
|
266
|
+
color = {"CRITICAL": "bright_red", "HIGH": "red", "MEDIUM": "orange3",
|
|
267
|
+
"LOW": "yellow", "INFO": "blue"}.get(sev, "white")
|
|
268
|
+
_out(ctx, f"[bold {color}][{sev}][/bold {color}] {args.get('title', '')} "
|
|
269
|
+
f"[cyan]@ {args.get('host', '')}[/cyan]")
|
|
270
|
+
return
|
|
271
|
+
if name == "request_consent":
|
|
272
|
+
dec = (result or {}).get("decision", "?")
|
|
273
|
+
_out(ctx, f"[yellow]consent: {args.get('action', '')} -> {dec}[/yellow]")
|
|
274
|
+
return
|
|
275
|
+
if name == "get_findings":
|
|
276
|
+
_out(ctx, f"[dim] ↳ {_result_summary(name, args, result)}[/dim]")
|
|
277
|
+
return
|
|
278
|
+
_out(ctx, f"[dim] ↳ {_result_summary(name, args, result)}[/dim]")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def run_agentic(ctx: ScanContext, cfg: tuple[str, str, str], max_steps: int | None = None,
|
|
282
|
+
instructions: str = "") -> tuple[list[dict], str]:
|
|
283
|
+
key, base, model = cfg
|
|
284
|
+
client = LLMClient(key, base, model)
|
|
285
|
+
dispatch = tools.build_dispatch(ctx)
|
|
286
|
+
ch = ctx.operator_channel
|
|
287
|
+
steer = ch.drain if ch is not None else None
|
|
288
|
+
_out(ctx, f"[bold]pwner agent[/bold] [dim]· model={model} · target={ctx.target}[/dim]\n")
|
|
289
|
+
|
|
290
|
+
async def on_step(n: str, a: dict, r: dict) -> None:
|
|
291
|
+
await _emit_step(ctx, n, a, r)
|
|
292
|
+
|
|
293
|
+
async def on_call(n: str, a: dict) -> None:
|
|
294
|
+
await _emit_call(ctx, n, a)
|
|
295
|
+
|
|
296
|
+
async def on_thought(t: str) -> None:
|
|
297
|
+
await _emit_thought(ctx, t)
|
|
298
|
+
|
|
299
|
+
# Opt-in extra deliberation for OpenAI/Gemini reasoning models (deepseek-reasoner thinks
|
|
300
|
+
# unconditionally and ignores it). Unset by default so non-reasoning models don't 400.
|
|
301
|
+
import os as _os
|
|
302
|
+
reasoning_effort = (_os.environ.get("PWNER_REASONING_EFFORT") or "").strip() or None
|
|
303
|
+
try:
|
|
304
|
+
summary = await client.run_tools(
|
|
305
|
+
SYSTEM, tools.brief(ctx, instructions), tools.tool_schemas(), dispatch,
|
|
306
|
+
max_steps=max_steps, reasoning_effort=reasoning_effort, on_step=on_step, on_call=on_call,
|
|
307
|
+
on_thought=on_thought, steer=steer)
|
|
308
|
+
except (AIUnavailable, LLMError) as e:
|
|
309
|
+
_out(ctx, f"[red][!] LLM error: {e}[/red]")
|
|
310
|
+
summary = "(LLM error — partial results below)"
|
|
311
|
+
return ctx.findings, summary
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _looks_like_ip(host: str) -> bool:
|
|
315
|
+
import ipaddress
|
|
316
|
+
try:
|
|
317
|
+
ipaddress.ip_address(host.strip())
|
|
318
|
+
return True
|
|
319
|
+
except ValueError:
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _open_ports(ports_res: dict) -> list[int]:
|
|
324
|
+
out = []
|
|
325
|
+
for h in ports_res.get("hosts", []):
|
|
326
|
+
for p in h.get("open_ports", []):
|
|
327
|
+
if p.get("state") == "open":
|
|
328
|
+
out.append(p["port"])
|
|
329
|
+
return sorted(set(out))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _print_services(ctx: ScanContext, svc: dict) -> None:
|
|
333
|
+
for h in svc.get("hosts", []):
|
|
334
|
+
for p in h.get("open_ports", []):
|
|
335
|
+
s = p.get("service", {})
|
|
336
|
+
name = s.get("name") or "?"
|
|
337
|
+
product = s.get("product") or ""
|
|
338
|
+
version = s.get("version") or ""
|
|
339
|
+
extra = " ".join(x for x in (product, version) if x)
|
|
340
|
+
_out(ctx, f" [green]{p['port']}/{p.get('protocol', 'tcp')}[/green] "
|
|
341
|
+
f"[bold]{name}[/bold] [dim]{extra}[/dim]".rstrip())
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _print_os(ctx: ScanContext, osd: dict) -> None:
|
|
345
|
+
for h in osd.get("hosts", []):
|
|
346
|
+
for m in h.get("os_matches", [])[:2]:
|
|
347
|
+
_out(ctx, f" [cyan]OS:[/cyan] {m.get('name')} [dim](accuracy {m.get('accuracy')}%)[/dim]")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _render_topology(ctx: ScanContext) -> None:
|
|
351
|
+
tui = getattr(ctx, "tui", None)
|
|
352
|
+
if tui is not None:
|
|
353
|
+
tui.render_topology()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
_NEGATIVE_MARKERS = (
|
|
357
|
+
"couldn't find", "could not find", "not vulnerable", "no vulnerability",
|
|
358
|
+
"not affected", "not found", "found no", "did not find", "does not appear",
|
|
359
|
+
"not exploitable", "not detected", "no xss", "no sql", "no lfi", "no rfi",
|
|
360
|
+
"no such", "unable to", "is not vulnerable", "isn't vulnerable", "no issue",
|
|
361
|
+
)
|
|
362
|
+
_INFORMATIONAL_SCRIPTS = {
|
|
363
|
+
"http-server-header", "http-title", "http-favicon", "http-date",
|
|
364
|
+
"http-robots.txt", "http-methods", "http-headers",
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _findings_from_vulns(host: str, vulns: dict) -> list[dict]:
|
|
369
|
+
findings = []
|
|
370
|
+
for h in vulns.get("hosts", []):
|
|
371
|
+
for p in h.get("open_ports", []):
|
|
372
|
+
s = p.get("service", {})
|
|
373
|
+
for sc in p.get("scripts", []):
|
|
374
|
+
out = (sc.get("output") or "").strip()
|
|
375
|
+
if not out:
|
|
376
|
+
continue
|
|
377
|
+
sid = sc.get("id", "")
|
|
378
|
+
if sid in _INFORMATIONAL_SCRIPTS:
|
|
379
|
+
continue
|
|
380
|
+
low = out.lower()
|
|
381
|
+
if any(m in low for m in _NEGATIVE_MARKERS):
|
|
382
|
+
continue
|
|
383
|
+
hard = any(k in low for k in ("vulnerable", "exploitable", "cve-"))
|
|
384
|
+
findings.append({
|
|
385
|
+
"host": host,
|
|
386
|
+
"port": p["port"],
|
|
387
|
+
"service": s.get("name", ""),
|
|
388
|
+
"title": f"{sid} on {s.get('name') or p['port']}",
|
|
389
|
+
"severity": "high" if hard else "medium",
|
|
390
|
+
"confidence": "probable",
|
|
391
|
+
"evidence": out[:500],
|
|
392
|
+
"remediation": "Review the flagged service and apply the vendor patch / hardening for the reported issue.",
|
|
393
|
+
})
|
|
394
|
+
return findings
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
async def run_deterministic(ctx: ScanContext, ports: str) -> tuple[list[dict], str]:
|
|
398
|
+
if "/" in (ctx.target or ""):
|
|
399
|
+
return await run_range(ctx, ports)
|
|
400
|
+
_out(ctx, f"[*] pwner deterministic (no AI) · target={ctx.target}\n")
|
|
401
|
+
target = ctx.target
|
|
402
|
+
hosts: list[str] = []
|
|
403
|
+
if "/" in target or _looks_like_ip(target):
|
|
404
|
+
hosts = [target]
|
|
405
|
+
else:
|
|
406
|
+
try:
|
|
407
|
+
dns = await tools.dns_enum(ctx, {"domain": target})
|
|
408
|
+
except ToolError as e:
|
|
409
|
+
_out(ctx, f" [!] {e}")
|
|
410
|
+
dns = {"records": {}}
|
|
411
|
+
_record(ctx, "dns_enum", {"domain": target}, dns)
|
|
412
|
+
ips = dns.get("records", {}).get("A", [])
|
|
413
|
+
if ips:
|
|
414
|
+
_out(ctx, f"[*] {target} resolves to {', '.join(ips)}")
|
|
415
|
+
hosts = ips
|
|
416
|
+
else:
|
|
417
|
+
_out(ctx, f"[!] {target} did not resolve — scanning hostname directly")
|
|
418
|
+
hosts = [target]
|
|
419
|
+
|
|
420
|
+
findings: list[dict] = []
|
|
421
|
+
for host in hosts:
|
|
422
|
+
_out(ctx, f"\n=== {host} ===")
|
|
423
|
+
try:
|
|
424
|
+
ports_res = await tools.port_scan(ctx, {"target": host, "ports": ports})
|
|
425
|
+
except ToolError as e:
|
|
426
|
+
_out(ctx, f" [!] {e}")
|
|
427
|
+
continue
|
|
428
|
+
_record(ctx, "port_scan", {"target": host}, ports_res)
|
|
429
|
+
open_ports = _open_ports(ports_res)
|
|
430
|
+
if not open_ports:
|
|
431
|
+
_out(ctx, " no open ports found")
|
|
432
|
+
continue
|
|
433
|
+
_out(ctx, f" open ports: {', '.join(map(str, open_ports))}")
|
|
434
|
+
try:
|
|
435
|
+
svc = await tools.service_detect(ctx, {"target": host, "ports": ",".join(map(str, open_ports))})
|
|
436
|
+
_record(ctx, "service_detect", {"target": host}, svc)
|
|
437
|
+
_print_services(ctx, svc)
|
|
438
|
+
except ToolError as e:
|
|
439
|
+
_out(ctx, f" [!] service detect: {e}")
|
|
440
|
+
_out(ctx, " running vuln detection…")
|
|
441
|
+
try:
|
|
442
|
+
vulns = await tools.vuln_scan(ctx, {"target": host, "ports": ",".join(map(str, open_ports))})
|
|
443
|
+
findings.extend(_findings_from_vulns(host, vulns))
|
|
444
|
+
except ToolError as e:
|
|
445
|
+
_out(ctx, f" [!] vuln scan: {e}")
|
|
446
|
+
try:
|
|
447
|
+
osd = await tools.os_detect(ctx, {"target": host})
|
|
448
|
+
_print_os(ctx, osd)
|
|
449
|
+
except ToolError as e:
|
|
450
|
+
_out(ctx, f" [!] os detect: {e}")
|
|
451
|
+
|
|
452
|
+
_render_topology(ctx)
|
|
453
|
+
summary = f"Scanned {len(hosts)} host(s); {len(findings)} finding(s) surfaced by detection scripts."
|
|
454
|
+
return findings, summary
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _nuclei_to_finding(nf: dict) -> dict:
|
|
458
|
+
host = nf.get("ip") or ""
|
|
459
|
+
if not host:
|
|
460
|
+
m = re.match(r"https?://([^/:]+)", nf.get("matched_at") or "")
|
|
461
|
+
host = m.group(1) if m else ""
|
|
462
|
+
return {
|
|
463
|
+
"host": host,
|
|
464
|
+
"port": None,
|
|
465
|
+
"service": "nuclei",
|
|
466
|
+
"title": nf.get("name") or nf.get("template_id", ""),
|
|
467
|
+
"severity": nf.get("severity", "info"),
|
|
468
|
+
"confidence": "confirmed",
|
|
469
|
+
"evidence": f"{nf.get('template_id', '')} matched at {nf.get('matched_at', '')}",
|
|
470
|
+
"remediation": (nf.get("description") or "")[:300],
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
async def run_range(ctx: ScanContext, ports: str) -> tuple[list[dict], str]:
|
|
475
|
+
"""Expand a CIDR -> discover live hosts -> deep-scan each in parallel ->
|
|
476
|
+
run nuclei over the discovered services -> aggregate findings."""
|
|
477
|
+
from .tools import (
|
|
478
|
+
ToolError,
|
|
479
|
+
expand_hosts,
|
|
480
|
+
has_nuclei,
|
|
481
|
+
nuclei_scan,
|
|
482
|
+
ping_sweep,
|
|
483
|
+
port_scan,
|
|
484
|
+
service_detect,
|
|
485
|
+
vuln_scan,
|
|
486
|
+
)
|
|
487
|
+
concurrency = max(1, ctx.concurrency)
|
|
488
|
+
_out(ctx, f"[*] pwner range scan · target={ctx.target}\n")
|
|
489
|
+
try:
|
|
490
|
+
hosts = expand_hosts(ctx.target, cap=ctx.range_cap)
|
|
491
|
+
except ToolError as e:
|
|
492
|
+
_out(ctx, f"[!] {e}")
|
|
493
|
+
return [], str(e)
|
|
494
|
+
if len(hosts) > 1:
|
|
495
|
+
_out(ctx, f"[*] {len(hosts)} address(es) in range — discovering live hosts…")
|
|
496
|
+
try:
|
|
497
|
+
sweep = await ping_sweep(ctx, {"target": ctx.target})
|
|
498
|
+
_record(ctx, "ping_sweep", {"target": ctx.target}, sweep)
|
|
499
|
+
live = sweep.get("live_hosts", [])
|
|
500
|
+
if live:
|
|
501
|
+
hosts = live
|
|
502
|
+
_out(ctx, f"[*] {len(live)} live host(s) found")
|
|
503
|
+
else:
|
|
504
|
+
_out(ctx, "[*] sweep found no live hosts; scanning the whole range anyway")
|
|
505
|
+
except ToolError as e:
|
|
506
|
+
_out(ctx, f" [!] sweep: {e}")
|
|
507
|
+
if not hosts:
|
|
508
|
+
return [], "no hosts in range"
|
|
509
|
+
|
|
510
|
+
findings: list[dict] = []
|
|
511
|
+
host_ports: dict[str, list[int]] = {}
|
|
512
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
513
|
+
|
|
514
|
+
async def scan_host(host: str) -> None:
|
|
515
|
+
async with sem:
|
|
516
|
+
_out(ctx, f"\n=== {host} ===")
|
|
517
|
+
try:
|
|
518
|
+
pr = await port_scan(ctx, {"target": host, "ports": ports})
|
|
519
|
+
except ToolError as e:
|
|
520
|
+
_out(ctx, f" [!] port scan: {e}")
|
|
521
|
+
return
|
|
522
|
+
_record(ctx, "port_scan", {"target": host}, pr)
|
|
523
|
+
open_ports = _open_ports(pr)
|
|
524
|
+
host_ports[host] = open_ports
|
|
525
|
+
if not open_ports:
|
|
526
|
+
_out(ctx, " no open ports")
|
|
527
|
+
return
|
|
528
|
+
_out(ctx, f" open ports: {', '.join(map(str, open_ports))}")
|
|
529
|
+
try:
|
|
530
|
+
svc = await service_detect(ctx, {"target": host, "ports": ",".join(map(str, open_ports))})
|
|
531
|
+
_record(ctx, "service_detect", {"target": host}, svc)
|
|
532
|
+
_print_services(ctx, svc)
|
|
533
|
+
except ToolError as e:
|
|
534
|
+
_out(ctx, f" [!] service detect: {e}")
|
|
535
|
+
try:
|
|
536
|
+
v = await vuln_scan(ctx, {"target": host, "ports": ",".join(map(str, open_ports))})
|
|
537
|
+
findings.extend(_findings_from_vulns(host, v))
|
|
538
|
+
except ToolError as e:
|
|
539
|
+
_out(ctx, f" [!] vuln scan: {e}")
|
|
540
|
+
|
|
541
|
+
await asyncio.gather(*(scan_host(h) for h in hosts))
|
|
542
|
+
|
|
543
|
+
if has_nuclei():
|
|
544
|
+
targets: list[str] = []
|
|
545
|
+
for host, ports in host_ports.items():
|
|
546
|
+
for p in ports:
|
|
547
|
+
targets.append(f"http://{host}:{p}")
|
|
548
|
+
if p == 443:
|
|
549
|
+
targets.append(f"https://{host}:{p}")
|
|
550
|
+
if targets:
|
|
551
|
+
_out(ctx, f"\n[*] nuclei over {len(targets)} target(s)…")
|
|
552
|
+
try:
|
|
553
|
+
n = await nuclei_scan(ctx, {"targets": targets, "severity": ctx.nuclei_severity})
|
|
554
|
+
_record(ctx, "nuclei_scan", {"target": ctx.target}, n)
|
|
555
|
+
nf = [_nuclei_to_finding(f) for f in n.get("findings", [])]
|
|
556
|
+
findings.extend(nf)
|
|
557
|
+
_out(ctx, f" nuclei: {len(nf)} finding(s)")
|
|
558
|
+
except ToolError as e:
|
|
559
|
+
_out(ctx, f" [!] nuclei: {e}")
|
|
560
|
+
else:
|
|
561
|
+
_out(ctx, "\n[*] nuclei not installed — skipping known-CVE template scan "
|
|
562
|
+
"(install with `brew install nuclei` for full coverage)")
|
|
563
|
+
|
|
564
|
+
_render_topology(ctx)
|
|
565
|
+
summary = f"Scanned {len(hosts)} host(s) across {ctx.target}; {len(findings)} finding(s)."
|
|
566
|
+
return findings, summary
|