exploitsynth 0.3.0__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.
- exploitsynth/__init__.py +3 -0
- exploitsynth/__main__.py +4 -0
- exploitsynth/cli.py +436 -0
- exploitsynth/client.py +128 -0
- exploitsynth/config.py +53 -0
- exploitsynth/discover.py +72 -0
- exploitsynth/live.py +179 -0
- exploitsynth/output.py +57 -0
- exploitsynth/parsers/__init__.py +59 -0
- exploitsynth/parsers/nessus.py +55 -0
- exploitsynth/parsers/nmap.py +55 -0
- exploitsynth/ports.py +63 -0
- exploitsynth/targets.py +55 -0
- exploitsynth/tunnel.py +158 -0
- exploitsynth-0.3.0.dist-info/METADATA +163 -0
- exploitsynth-0.3.0.dist-info/RECORD +18 -0
- exploitsynth-0.3.0.dist-info/WHEEL +4 -0
- exploitsynth-0.3.0.dist-info/entry_points.txt +2 -0
exploitsynth/discover.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Local nmap discovery for `--via local` scans.
|
|
2
|
+
|
|
3
|
+
When scanning through a tunnel, discovery runs on the *user's* machine (which is
|
|
4
|
+
on the target network) — the cloud only does identification. We use a connect scan
|
|
5
|
+
(-sT) so it never needs root.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiscoverError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _port_args(spec: Optional[str]) -> List[str]:
|
|
20
|
+
"""Translate a normalized port spec into nmap arguments (mirrors the worker)."""
|
|
21
|
+
if spec == "all":
|
|
22
|
+
return ["-p-"]
|
|
23
|
+
if spec == "top-100":
|
|
24
|
+
return ["--top-ports", "100"]
|
|
25
|
+
if not spec or spec == "top-1000":
|
|
26
|
+
return ["--top-ports", "1000"]
|
|
27
|
+
return ["-p", spec] # explicit list/range, e.g. "22,80,100-150"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_grepable(output: str) -> List[int]:
|
|
31
|
+
ports: set[int] = set()
|
|
32
|
+
for line in output.splitlines():
|
|
33
|
+
if "Ports:" not in line:
|
|
34
|
+
continue
|
|
35
|
+
_, _, rest = line.partition("Ports:")
|
|
36
|
+
for entry in rest.split(","):
|
|
37
|
+
parts = entry.strip().split("/")
|
|
38
|
+
if len(parts) >= 2 and parts[1] == "open":
|
|
39
|
+
try:
|
|
40
|
+
ports.add(int(parts[0]))
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
return sorted(ports)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def discover_ports(ip: str, spec: Optional[str]) -> List[int]:
|
|
47
|
+
"""Run a local nmap connect scan and return the open ports. Raises DiscoverError."""
|
|
48
|
+
if not shutil.which("nmap"):
|
|
49
|
+
raise DiscoverError(
|
|
50
|
+
"nmap is not installed — it's required for local discovery.\n"
|
|
51
|
+
" Install it (e.g. `sudo apt install nmap`), or pass --ports to skip discovery."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
timeout = 1200 if spec == "all" else 180
|
|
55
|
+
cmd = ["nmap", "-T4", "-sT", "-Pn", *_port_args(spec), "-oG", "-", ip]
|
|
56
|
+
try:
|
|
57
|
+
r = subprocess.run(cmd, capture_output=True, timeout=timeout)
|
|
58
|
+
except subprocess.TimeoutExpired:
|
|
59
|
+
raise DiscoverError(f"nmap timed out after {timeout}s scanning {ip}.")
|
|
60
|
+
except OSError as e:
|
|
61
|
+
raise DiscoverError(f"could not run nmap: {e}")
|
|
62
|
+
|
|
63
|
+
if r.returncode != 0:
|
|
64
|
+
raise DiscoverError(f"nmap failed: {r.stderr.decode(errors='replace').strip()}")
|
|
65
|
+
|
|
66
|
+
ports = _parse_grepable(r.stdout.decode(errors="replace"))
|
|
67
|
+
if not ports:
|
|
68
|
+
raise DiscoverError(
|
|
69
|
+
f"no open ports found on {ip}.\n"
|
|
70
|
+
" Confirm you can reach it from this machine (is your VPN connected?)."
|
|
71
|
+
)
|
|
72
|
+
return ports
|
exploitsynth/live.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Live terminal rendering of in-progress scans — the CLI twin of scan-live.tsx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from .client import ApiError, Client
|
|
16
|
+
from .output import out, err
|
|
17
|
+
|
|
18
|
+
POLL_SECONDS = 2.0
|
|
19
|
+
ACTIVE_STATES = {"queued", "nmap", "running"}
|
|
20
|
+
|
|
21
|
+
_CONF_STYLE = {"high": "green", "medium": "yellow", "low": "red"}
|
|
22
|
+
_STATUS_STYLE = {"done": "green", "error": "red", "canceled": "yellow"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _seconds_left(deadline: Optional[str]) -> Optional[int]:
|
|
26
|
+
if not deadline:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
dl = datetime.fromisoformat(deadline)
|
|
30
|
+
except ValueError:
|
|
31
|
+
return None
|
|
32
|
+
if dl.tzinfo is None:
|
|
33
|
+
dl = dl.replace(tzinfo=timezone.utc)
|
|
34
|
+
return max(0, int((dl - datetime.now(timezone.utc)).total_seconds()))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_active(status: str) -> bool:
|
|
38
|
+
return status in ACTIVE_STATES
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _service_text(service: Optional[str]) -> Text:
|
|
42
|
+
if not service:
|
|
43
|
+
return Text("—", style="dim")
|
|
44
|
+
low = service.strip().lower()
|
|
45
|
+
if low.startswith("unknown") or low == "unidentified":
|
|
46
|
+
return Text(service, style="yellow")
|
|
47
|
+
return Text(service, style="white")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def results_table(results: List[Dict[str, Any]]) -> Table:
|
|
51
|
+
table = Table(box=None, pad_edge=False, expand=False)
|
|
52
|
+
table.add_column("PORT", style="cyan", justify="right", no_wrap=True)
|
|
53
|
+
table.add_column("SERVICE", no_wrap=True)
|
|
54
|
+
table.add_column("VERSION")
|
|
55
|
+
table.add_column("CONF", no_wrap=True)
|
|
56
|
+
for r in sorted(results, key=lambda x: x.get("port", 0)):
|
|
57
|
+
conf = (r.get("confidence") or "").lower()
|
|
58
|
+
conf_text = Text(r.get("confidence") or "—", style=_CONF_STYLE.get(conf, "dim"))
|
|
59
|
+
table.add_row(
|
|
60
|
+
str(r.get("port", "?")),
|
|
61
|
+
_service_text(r.get("service")),
|
|
62
|
+
Text(r.get("version") or "—", style="white" if r.get("version") else "dim"),
|
|
63
|
+
conf_text,
|
|
64
|
+
)
|
|
65
|
+
return table
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _in_progress_lines(scan: Dict[str, Any]) -> List[Text]:
|
|
69
|
+
progress: Dict[str, Any] = scan.get("port_progress") or {}
|
|
70
|
+
lines: List[Text] = []
|
|
71
|
+
for port in sorted(progress, key=lambda p: int(p)):
|
|
72
|
+
p = progress[port]
|
|
73
|
+
if p.get("status") == "done":
|
|
74
|
+
continue
|
|
75
|
+
line = Text()
|
|
76
|
+
line.append(f":{port}".ljust(8), style="cyan")
|
|
77
|
+
if p.get("status") == "analyzing":
|
|
78
|
+
left = _seconds_left(p.get("deadline"))
|
|
79
|
+
line.append("identifying… ", style="white")
|
|
80
|
+
if left is not None:
|
|
81
|
+
line.append(f"{left}s left" if left > 0 else "wrapping up…", style="dim")
|
|
82
|
+
else:
|
|
83
|
+
line.append("queued", style="dim")
|
|
84
|
+
lines.append(line)
|
|
85
|
+
return lines
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _scan_panel(state: Dict[str, Any]) -> RenderableType:
|
|
89
|
+
scan = state["scan"]
|
|
90
|
+
results = state["results"]
|
|
91
|
+
status = scan.get("status", "?")
|
|
92
|
+
total = len(scan.get("ports") or [])
|
|
93
|
+
done = len({r.get("port") for r in results})
|
|
94
|
+
|
|
95
|
+
header = Text()
|
|
96
|
+
header.append(scan.get("ip", "?"), style="bold white")
|
|
97
|
+
header.append(" ")
|
|
98
|
+
if status == "done":
|
|
99
|
+
header.append("✓ done", style="bold green")
|
|
100
|
+
elif status == "error":
|
|
101
|
+
header.append(f"✗ {scan.get('error') or 'error'}", style="bold red")
|
|
102
|
+
elif status == "canceled":
|
|
103
|
+
header.append("✗ canceled", style="bold yellow")
|
|
104
|
+
else:
|
|
105
|
+
label = scan.get("stage_label") or status
|
|
106
|
+
prog = f"{done}/{total} ports" if total else label
|
|
107
|
+
header.append(f"● {prog}", style="bold yellow")
|
|
108
|
+
|
|
109
|
+
body: List[RenderableType] = [header]
|
|
110
|
+
if results:
|
|
111
|
+
body.append(results_table(results))
|
|
112
|
+
elif status == "done":
|
|
113
|
+
body.append(Text("No services identified.", style="dim"))
|
|
114
|
+
|
|
115
|
+
if _is_active(status):
|
|
116
|
+
ip_lines = _in_progress_lines(scan)
|
|
117
|
+
if ip_lines:
|
|
118
|
+
body.append(Text("in progress:", style="dim"))
|
|
119
|
+
body.extend(ip_lines)
|
|
120
|
+
|
|
121
|
+
if scan.get("summary"):
|
|
122
|
+
body.append(Text(scan["summary"], style="italic dim"))
|
|
123
|
+
|
|
124
|
+
return Panel(Group(*body), border_style="grey37", padding=(0, 1))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _render(states: List[Dict[str, Any]]) -> RenderableType:
|
|
128
|
+
return Group(*(_scan_panel(s) for s in states))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _init_states(scan_ids: List[str]) -> List[Dict[str, Any]]:
|
|
132
|
+
return [
|
|
133
|
+
{"id": sid, "scan": {"ip": sid[:8], "status": "queued", "ports": []}, "results": []}
|
|
134
|
+
for sid in scan_ids
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _poll_once(client: Client, states: List[Dict[str, Any]]) -> None:
|
|
139
|
+
for s in states:
|
|
140
|
+
if not _is_active(s["scan"].get("status", "")):
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
data = client.get_scan(s["id"])
|
|
144
|
+
s["scan"] = data["scan"]
|
|
145
|
+
s["results"] = data["results"]
|
|
146
|
+
except ApiError:
|
|
147
|
+
pass # transient — keep last good state, retry next tick
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _all_done(states: List[Dict[str, Any]]) -> bool:
|
|
151
|
+
return all(not _is_active(s["scan"].get("status", "")) for s in states)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def follow_scans(client: Client, scan_ids: List[str]) -> List[Dict[str, Any]]:
|
|
155
|
+
"""Interactive live view: render panels (to stderr) until all scans finish."""
|
|
156
|
+
states = _init_states(scan_ids)
|
|
157
|
+
with Live(_render(states), console=err, refresh_per_second=2, transient=False) as live:
|
|
158
|
+
while True:
|
|
159
|
+
_poll_once(client, states)
|
|
160
|
+
live.update(_render(states))
|
|
161
|
+
if _all_done(states):
|
|
162
|
+
break
|
|
163
|
+
time.sleep(POLL_SECONDS)
|
|
164
|
+
return states
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def poll_until_done(client: Client, scan_ids: List[str], show_progress: bool = True) -> List[Dict[str, Any]]:
|
|
168
|
+
"""Non-interactive poll: no animation, just dots on stderr. For pipes / --json."""
|
|
169
|
+
states = _init_states(scan_ids)
|
|
170
|
+
while True:
|
|
171
|
+
_poll_once(client, states)
|
|
172
|
+
if show_progress:
|
|
173
|
+
err.print(".", end="")
|
|
174
|
+
if _all_done(states):
|
|
175
|
+
break
|
|
176
|
+
time.sleep(POLL_SECONDS)
|
|
177
|
+
if show_progress:
|
|
178
|
+
err.print("")
|
|
179
|
+
return states
|
exploitsynth/output.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Output plumbing: primary data to stdout, progress/notes to stderr, optional JSON.
|
|
2
|
+
|
|
3
|
+
Rich's Console already honors ``NO_COLOR`` and drops styling when the stream isn't a
|
|
4
|
+
TTY, so piping `exploitsynth ... | jq` (or into a file) stays clean automatically.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, NoReturn
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
# stdout = the thing you'd pipe (tables / JSON). stderr = progress + status notes.
|
|
17
|
+
out = Console()
|
|
18
|
+
err = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
# Exit codes scripts can branch on.
|
|
21
|
+
EXIT_ERROR = 1
|
|
22
|
+
EXIT_NO_CREDITS = 3
|
|
23
|
+
|
|
24
|
+
_json = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_json(enabled: bool) -> None:
|
|
28
|
+
global _json
|
|
29
|
+
_json = enabled
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def json_enabled() -> bool:
|
|
33
|
+
return _json
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_tty() -> bool:
|
|
37
|
+
"""True only when both we and the user's terminal are interactive."""
|
|
38
|
+
return out.is_terminal and sys.stdin.isatty()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def note(msg: str, style: str = "dim") -> None:
|
|
42
|
+
"""A human status line — to stderr, and suppressed entirely in JSON mode."""
|
|
43
|
+
if not _json:
|
|
44
|
+
err.print(msg, style=style)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def emit_json(obj: Any) -> None:
|
|
48
|
+
# Plain print (not Rich) so nothing wraps or injects markup into machine output.
|
|
49
|
+
print(json.dumps(obj, indent=2, default=str))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fail(msg: str, code: int = EXIT_ERROR) -> NoReturn:
|
|
53
|
+
if _json:
|
|
54
|
+
print(json.dumps({"ok": False, "error": msg}))
|
|
55
|
+
else:
|
|
56
|
+
err.print(f"error: {msg}", style="bold red")
|
|
57
|
+
raise typer.Exit(code=code)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Import-file parsing: nmap XML (-oX) and .nessus exports → unidentified open ports.
|
|
2
|
+
|
|
3
|
+
Port of websites/scanner_agent/lib/parsers/. The key signal we extract is which
|
|
4
|
+
open ports the scanner did NOT identify — those are what the agent works on.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
# Service names that mean "the scanner didn't really identify this".
|
|
13
|
+
UNIDENTIFIED_NAMES = {"", "unknown", "tcpwrapped", "?", "general", "unrecognized"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ImportedPort:
|
|
18
|
+
port: int
|
|
19
|
+
protocol: str
|
|
20
|
+
service: Optional[str]
|
|
21
|
+
version: Optional[str]
|
|
22
|
+
identified: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ImportedHost:
|
|
27
|
+
ip: str
|
|
28
|
+
ports: List[ImportedPort]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ParseResult:
|
|
33
|
+
format: str # "nmap" | "nessus"
|
|
34
|
+
hosts: List[ImportedHost]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_identified_name(name: Optional[str], has_version: bool) -> bool:
|
|
38
|
+
if has_version:
|
|
39
|
+
return True
|
|
40
|
+
if not name:
|
|
41
|
+
return False
|
|
42
|
+
return name.strip().lower() not in UNIDENTIFIED_NAMES
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def count_unidentified(hosts: List[ImportedHost]) -> int:
|
|
46
|
+
return sum(1 for h in hosts for p in h.ports if not p.identified)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_scan_file(contents: str) -> ParseResult:
|
|
50
|
+
"""Detect format from the file head and parse accordingly."""
|
|
51
|
+
from .nmap import parse_nmap_xml
|
|
52
|
+
from .nessus import parse_nessus
|
|
53
|
+
|
|
54
|
+
head = contents[:2000]
|
|
55
|
+
if "<NessusClientData" in head:
|
|
56
|
+
return ParseResult(format="nessus", hosts=parse_nessus(contents))
|
|
57
|
+
if "<nmaprun" in head:
|
|
58
|
+
return ParseResult(format="nmap", hosts=parse_nmap_xml(contents))
|
|
59
|
+
raise ValueError("Unrecognised file. Provide nmap XML (-oX) or a .nessus export.")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Parse a .nessus export (NessusClientData_v2). Port of lib/parsers/nessus.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
from xml.etree.ElementTree import Element # type only
|
|
7
|
+
|
|
8
|
+
import defusedxml.ElementTree as ET # XXE / billion-laughs safe
|
|
9
|
+
from defusedxml.common import DefusedXmlException
|
|
10
|
+
|
|
11
|
+
from . import ImportedHost, ImportedPort, is_identified_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_nessus(xml: str) -> List[ImportedHost]:
|
|
15
|
+
try:
|
|
16
|
+
root = ET.fromstring(xml)
|
|
17
|
+
except (ET.ParseError, DefusedXmlException) as e:
|
|
18
|
+
raise ValueError(f"Could not parse .nessus file: {e}")
|
|
19
|
+
|
|
20
|
+
hosts: List[ImportedHost] = []
|
|
21
|
+
for report_host in root.iter("ReportHost"):
|
|
22
|
+
ip = _host_ip(report_host)
|
|
23
|
+
if not ip:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
# Merge items per (protocol, port): prefer an identified name.
|
|
27
|
+
by_port: Dict[str, ImportedPort] = {}
|
|
28
|
+
for item in report_host.findall("ReportItem"):
|
|
29
|
+
try:
|
|
30
|
+
port = int(item.get("port", ""))
|
|
31
|
+
except ValueError:
|
|
32
|
+
continue
|
|
33
|
+
if port == 0: # host-level finding
|
|
34
|
+
continue
|
|
35
|
+
protocol = item.get("protocol", "tcp")
|
|
36
|
+
name = (item.get("svc_name") or "").strip() or None
|
|
37
|
+
|
|
38
|
+
key = f"{protocol}/{port}"
|
|
39
|
+
identified = is_identified_name(name, False)
|
|
40
|
+
existing = by_port.get(key)
|
|
41
|
+
if existing is None or (not existing.identified and identified):
|
|
42
|
+
by_port[key] = ImportedPort(port, protocol, name, None, identified)
|
|
43
|
+
|
|
44
|
+
if by_port:
|
|
45
|
+
ports = sorted(by_port.values(), key=lambda p: p.port)
|
|
46
|
+
hosts.append(ImportedHost(ip, ports))
|
|
47
|
+
|
|
48
|
+
return hosts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _host_ip(report_host: Element) -> Optional[str]:
|
|
52
|
+
for tag in report_host.findall("./HostProperties/tag"):
|
|
53
|
+
if tag.get("name") == "host-ip" and tag.text:
|
|
54
|
+
return tag.text.strip()
|
|
55
|
+
return report_host.get("name")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Parse nmap XML (-oX). Port of lib/parsers/nmap.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
import defusedxml.ElementTree as ET # XXE / billion-laughs safe
|
|
8
|
+
from defusedxml.common import DefusedXmlException
|
|
9
|
+
|
|
10
|
+
from . import ImportedHost, ImportedPort, is_identified_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_nmap_xml(xml: str) -> List[ImportedHost]:
|
|
14
|
+
try:
|
|
15
|
+
root = ET.fromstring(xml)
|
|
16
|
+
except (ET.ParseError, DefusedXmlException) as e:
|
|
17
|
+
raise ValueError(f"Could not parse nmap XML: {e}")
|
|
18
|
+
|
|
19
|
+
hosts: List[ImportedHost] = []
|
|
20
|
+
for host in root.iter("host"):
|
|
21
|
+
addrs = host.findall("address")
|
|
22
|
+
ip_node = next((a for a in addrs if a.get("addrtype") == "ipv4"), addrs[0] if addrs else None)
|
|
23
|
+
ip = ip_node.get("addr") if ip_node is not None else None
|
|
24
|
+
if not ip:
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
ports: List[ImportedPort] = []
|
|
28
|
+
for port in host.findall("./ports/port"):
|
|
29
|
+
state = port.find("state")
|
|
30
|
+
if state is None or state.get("state") != "open":
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
portid = int(port.get("portid", ""))
|
|
34
|
+
except ValueError:
|
|
35
|
+
continue
|
|
36
|
+
protocol = port.get("protocol", "tcp")
|
|
37
|
+
|
|
38
|
+
svc = port.find("service")
|
|
39
|
+
name = svc.get("name") if svc is not None else None
|
|
40
|
+
product = svc.get("product") if svc is not None else None
|
|
41
|
+
version = svc.get("version") if svc is not None else None
|
|
42
|
+
method = svc.get("method") if svc is not None else None # "probed" | "table"
|
|
43
|
+
|
|
44
|
+
version_str = " ".join(x for x in (product, version) if x) or None
|
|
45
|
+
# Identified only if probed (not a port-table guess) with a real name.
|
|
46
|
+
probed = method == "probed"
|
|
47
|
+
identified = probed and is_identified_name(name, bool(version_str))
|
|
48
|
+
|
|
49
|
+
ports.append(ImportedPort(portid, protocol, name, version_str, identified))
|
|
50
|
+
|
|
51
|
+
if ports:
|
|
52
|
+
ports.sort(key=lambda p: p.port)
|
|
53
|
+
hosts.append(ImportedHost(ip, ports))
|
|
54
|
+
|
|
55
|
+
return hosts
|
exploitsynth/ports.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Port-spec validation. Port of lib/ports.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
# Friendly aliases people type → canonical spec the API expects.
|
|
9
|
+
SCOPE_ALIASES = {
|
|
10
|
+
"top100": "top-100",
|
|
11
|
+
"top-100": "top-100",
|
|
12
|
+
"top1000": "top-1000",
|
|
13
|
+
"top-1000": "top-1000",
|
|
14
|
+
"all": "all",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_PORT_RE = re.compile(r"^(\d{1,5})(?:-(\d{1,5}))?$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PortError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_scope(s: str) -> str:
|
|
25
|
+
"""Map an alias (top1000) to the canonical spec (top-1000), or validate a port list."""
|
|
26
|
+
key = s.strip().lower()
|
|
27
|
+
if key in SCOPE_ALIASES:
|
|
28
|
+
return SCOPE_ALIASES[key]
|
|
29
|
+
return validate_port_list(s) # treat as a raw port list / range
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_port_list(text: str) -> str:
|
|
33
|
+
"""Validate "22,80,443,100-150"; return the normalised spec or raise PortError."""
|
|
34
|
+
tokens = [t.strip() for t in text.split(",") if t.strip()]
|
|
35
|
+
if not tokens:
|
|
36
|
+
raise PortError("Enter at least one port or range.")
|
|
37
|
+
out: List[str] = []
|
|
38
|
+
for t in tokens:
|
|
39
|
+
m = _PORT_RE.match(t)
|
|
40
|
+
if not m:
|
|
41
|
+
raise PortError(f"Invalid port: {t}")
|
|
42
|
+
a = int(m.group(1))
|
|
43
|
+
b = int(m.group(2)) if m.group(2) is not None else a
|
|
44
|
+
if not (1 <= a <= 65535) or not (1 <= b <= 65535):
|
|
45
|
+
raise PortError(f"Out of range (1-65535): {t}")
|
|
46
|
+
if b < a:
|
|
47
|
+
raise PortError(f"Reversed range: {t}")
|
|
48
|
+
out.append(f"{a}-{b}" if m.group(2) is not None else f"{a}")
|
|
49
|
+
return ",".join(out)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_port_list(text: str) -> List[int]:
|
|
53
|
+
"""Expand a validated port list/range into individual ints."""
|
|
54
|
+
spec = validate_port_list(text)
|
|
55
|
+
ports: List[int] = []
|
|
56
|
+
for t in spec.split(","):
|
|
57
|
+
if "-" in t:
|
|
58
|
+
a, b = (int(x) for x in t.split("-"))
|
|
59
|
+
ports.extend(range(a, b + 1))
|
|
60
|
+
else:
|
|
61
|
+
ports.append(int(t))
|
|
62
|
+
# Dedupe, preserve order.
|
|
63
|
+
return list(dict.fromkeys(ports))
|
exploitsynth/targets.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Expand a free-form target string into host IPs. Port of lib/targets.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
MAX_HOSTS_PER_SCAN = 16
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TargetError(ValueError):
|
|
13
|
+
"""Raised when a target string can't be parsed or expands too wide."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def expand_targets(text: str) -> List[str]:
|
|
17
|
+
"""
|
|
18
|
+
Accept single IPs, CIDR ranges (10.0.0.0/24), and comma/space/newline lists.
|
|
19
|
+
Drops network + broadcast for ranges larger than /31, mirroring the web app.
|
|
20
|
+
Deduped, order-preserving. Raises TargetError on bad input or > MAX_HOSTS.
|
|
21
|
+
"""
|
|
22
|
+
tokens = [t for t in re.split(r"[\s,]+", text.strip()) if t]
|
|
23
|
+
if not tokens:
|
|
24
|
+
raise TargetError("Enter at least one target.")
|
|
25
|
+
|
|
26
|
+
seen: dict[str, None] = {} # ordered set
|
|
27
|
+
|
|
28
|
+
def add(ip: str) -> None:
|
|
29
|
+
if ip not in seen:
|
|
30
|
+
seen[ip] = None
|
|
31
|
+
if len(seen) > MAX_HOSTS_PER_SCAN:
|
|
32
|
+
raise TargetError(f"Too many hosts (max {MAX_HOSTS_PER_SCAN} per scan).")
|
|
33
|
+
|
|
34
|
+
for token in tokens:
|
|
35
|
+
if "/" in token:
|
|
36
|
+
try:
|
|
37
|
+
net = ipaddress.ip_network(token, strict=False)
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise TargetError(f"Invalid CIDR: {token}")
|
|
40
|
+
if net.version != 4:
|
|
41
|
+
raise TargetError(f"Only IPv4 is supported: {token}")
|
|
42
|
+
# /31 and /32 have no network/broadcast to drop.
|
|
43
|
+
hosts = net.hosts() if net.prefixlen < 31 else net
|
|
44
|
+
for ip in hosts:
|
|
45
|
+
add(str(ip))
|
|
46
|
+
else:
|
|
47
|
+
try:
|
|
48
|
+
ip = ipaddress.ip_address(token)
|
|
49
|
+
except ValueError:
|
|
50
|
+
raise TargetError(f"Invalid IP: {token}")
|
|
51
|
+
if ip.version != 4:
|
|
52
|
+
raise TargetError(f"Only IPv4 is supported: {token}")
|
|
53
|
+
add(token)
|
|
54
|
+
|
|
55
|
+
return list(seen.keys())
|