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.
@@ -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))
@@ -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())