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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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