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/__init__.py
ADDED
exploitsynth/__main__.py
ADDED
exploitsynth/cli.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""ExploitSynth CLI — `exploitsynth scan ...` from your terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from . import __version__, config, output
|
|
15
|
+
from . import tunnel as tunnel_mod
|
|
16
|
+
from .client import ApiError, Client
|
|
17
|
+
from .discover import DiscoverError, discover_ports
|
|
18
|
+
from .live import follow_scans, poll_until_done, results_table
|
|
19
|
+
from .output import out, err
|
|
20
|
+
from .parsers import count_unidentified, parse_scan_file
|
|
21
|
+
from .ports import PortError, normalize_scope, parse_port_list
|
|
22
|
+
from .targets import TargetError, expand_targets, MAX_HOSTS_PER_SCAN
|
|
23
|
+
|
|
24
|
+
# Typer reflows the epilog into one paragraph, so keep it to links. Worked examples
|
|
25
|
+
# live in each command's docstring (e.g. `exploitsynth scan --help`).
|
|
26
|
+
EPILOG = "Docs: https://scan.exploitsynth.com/docs · Issues: https://github.com/exploitsynth/cli/issues"
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
help="ExploitSynth — AI port identification for ports your scanner couldn't fingerprint.",
|
|
30
|
+
epilog=EPILOG,
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
rich_markup_mode="rich",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# A short flag people will mistype as a global; documented in --help instead.
|
|
36
|
+
JSON_OPT = typer.Option(False, "--json", help="Emit machine-readable JSON on stdout.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _client() -> Client:
|
|
40
|
+
try:
|
|
41
|
+
return Client()
|
|
42
|
+
except ApiError as e:
|
|
43
|
+
output.fail(str(e))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ─────────────────────────── login ───────────────────────────
|
|
47
|
+
@app.command()
|
|
48
|
+
def login(
|
|
49
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", "-k", help="Your sk_ API key (prompted if omitted)."),
|
|
50
|
+
base_url: Optional[str] = typer.Option(None, "--url", help="Override API base URL (self-hosting)."),
|
|
51
|
+
):
|
|
52
|
+
"""Store an API key (in ~/.config/exploitsynth/config.json) and verify it."""
|
|
53
|
+
key = api_key or typer.prompt("API key", hide_input=True)
|
|
54
|
+
key = key.strip()
|
|
55
|
+
if not key:
|
|
56
|
+
output.fail("Empty API key.")
|
|
57
|
+
|
|
58
|
+
# Verify before saving — a quick authenticated call.
|
|
59
|
+
try:
|
|
60
|
+
Client(api_key=key, base_url=base_url).list_scans(limit=1)
|
|
61
|
+
except ApiError as e:
|
|
62
|
+
output.fail(f"Key rejected: {e}")
|
|
63
|
+
|
|
64
|
+
path = config.save_api_key(key, base_url)
|
|
65
|
+
out.print(Text(f"✓ Saved. Authenticated against {config.get_base_url()}", style="green"))
|
|
66
|
+
out.print(Text(f" {path}", style="dim"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ─────────────────────────── scan ────────────────────────────
|
|
70
|
+
def _targets_from_import(path: Path) -> List[Dict[str, Any]]:
|
|
71
|
+
try:
|
|
72
|
+
contents = path.read_text(errors="replace")
|
|
73
|
+
except OSError as e:
|
|
74
|
+
output.fail(f"Could not read {path}: {e}")
|
|
75
|
+
try:
|
|
76
|
+
result = parse_scan_file(contents)
|
|
77
|
+
except ValueError as e:
|
|
78
|
+
output.fail(str(e))
|
|
79
|
+
|
|
80
|
+
total_unident = count_unidentified(result.hosts)
|
|
81
|
+
if total_unident == 0:
|
|
82
|
+
output.fail(
|
|
83
|
+
f"{path.name}: every open port was already identified by {result.format} — "
|
|
84
|
+
"nothing for the agent to do."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
targets: List[Dict[str, Any]] = []
|
|
88
|
+
for host in result.hosts:
|
|
89
|
+
ports = [p.port for p in host.ports if not p.identified]
|
|
90
|
+
if ports:
|
|
91
|
+
targets.append({"ip": host.ip, "ports": ports})
|
|
92
|
+
if len(targets) > MAX_HOSTS_PER_SCAN:
|
|
93
|
+
output.fail(f"Import covers {len(targets)} hosts (max {MAX_HOSTS_PER_SCAN} per scan).")
|
|
94
|
+
|
|
95
|
+
output.note(
|
|
96
|
+
f"Imported {result.format}: {total_unident} unidentified port(s) "
|
|
97
|
+
f"across {len(targets)} host(s)."
|
|
98
|
+
)
|
|
99
|
+
return targets
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_target_source(target: Optional[str], targets_file: Optional[Path]) -> Optional[str]:
|
|
103
|
+
"""Resolve targets from a file (-iL), stdin ('-'), or the literal argument."""
|
|
104
|
+
if targets_file:
|
|
105
|
+
try:
|
|
106
|
+
return targets_file.read_text(errors="replace")
|
|
107
|
+
except OSError as e:
|
|
108
|
+
output.fail(f"Could not read {targets_file}: {e}")
|
|
109
|
+
if target == "-":
|
|
110
|
+
return sys.stdin.read()
|
|
111
|
+
return target
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _spec_cost(spec: str) -> int:
|
|
115
|
+
"""Credit cost of a discovery scope — mirrors specCost() in lib/credits.ts."""
|
|
116
|
+
if spec == "top-100":
|
|
117
|
+
return 100
|
|
118
|
+
if spec == "top-1000":
|
|
119
|
+
return 1000
|
|
120
|
+
if spec == "all":
|
|
121
|
+
return 65535
|
|
122
|
+
m = re.match(r"^(\d+)-(\d+)$", spec)
|
|
123
|
+
if m:
|
|
124
|
+
return int(m.group(2)) - int(m.group(1)) + 1
|
|
125
|
+
return 1000
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _estimate_cost(targets: List[Dict[str, Any]], port_spec: Optional[str]) -> int:
|
|
129
|
+
return sum(
|
|
130
|
+
len(t["ports"]) if t.get("ports") else _spec_cost(port_spec or "top-1000")
|
|
131
|
+
for t in targets
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
def scan(
|
|
137
|
+
target: Optional[str] = typer.Argument(
|
|
138
|
+
None, help="IP, CIDR (10.0.0.0/28), or list. Use '-' to read targets from stdin."
|
|
139
|
+
),
|
|
140
|
+
ports: Optional[str] = typer.Option(
|
|
141
|
+
None, "--ports", "-p", help="Known-open ports to identify directly, e.g. 22,80,9929."
|
|
142
|
+
),
|
|
143
|
+
scope: Optional[str] = typer.Option(
|
|
144
|
+
None, "--scope", "-s", help="Discovery scope: top100 | top1000 | all, or a range like 1-1000."
|
|
145
|
+
),
|
|
146
|
+
targets_file: Optional[Path] = typer.Option(
|
|
147
|
+
None, "-iL", "--targets-file", help="Read newline/comma-separated targets from a file."
|
|
148
|
+
),
|
|
149
|
+
nessus: Optional[Path] = typer.Option(None, "--nessus", help=".nessus export — import its unidentified ports."),
|
|
150
|
+
nmap: Optional[Path] = typer.Option(None, "--nmap", help="nmap -oX XML — import its unidentified ports."),
|
|
151
|
+
via: Optional[str] = typer.Option(
|
|
152
|
+
None, "--via", help="Egress vantage point. 'local' scans through this machine (private/firewalled targets)."
|
|
153
|
+
),
|
|
154
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project/engagement name (created if missing)."),
|
|
155
|
+
label: Optional[str] = typer.Option(None, "--label", "-l", help="Optional scan label."),
|
|
156
|
+
timeout: int = typer.Option(300, "--timeout", "-t", help="Per-port agent budget, seconds (30–600)."),
|
|
157
|
+
slow: bool = typer.Option(False, "--slow", help="Run one agent at a time (default: three)."),
|
|
158
|
+
follow: bool = typer.Option(True, "--follow/--no-follow", help="Stream live progress (default on)."),
|
|
159
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the credit-cost confirmation."),
|
|
160
|
+
json_output: bool = JSON_OPT,
|
|
161
|
+
):
|
|
162
|
+
"""Start a scan. Give a TARGET (or '-'/-iL for a list), or import with --nessus / --nmap.
|
|
163
|
+
|
|
164
|
+
Examples:
|
|
165
|
+
exploitsynth scan 203.0.113.9 --ports 22,80,9929
|
|
166
|
+
cat hosts.txt | exploitsynth scan - --scope top100
|
|
167
|
+
exploitsynth scan 10.129.45.12 --via local # private target, over a tunnel
|
|
168
|
+
"""
|
|
169
|
+
output.set_json(json_output)
|
|
170
|
+
if nessus and nmap:
|
|
171
|
+
output.fail("Use one of --nessus or --nmap, not both.")
|
|
172
|
+
|
|
173
|
+
# `--via local`: discovery runs on this machine and probes egress through a
|
|
174
|
+
# reverse tunnel out of here, so the cloud can reach private/firewalled targets.
|
|
175
|
+
via_local = False
|
|
176
|
+
if via is not None:
|
|
177
|
+
if via.strip().lower() != "local":
|
|
178
|
+
output.fail("Only `--via local` is supported today (named connectors coming soon).")
|
|
179
|
+
via_local = True
|
|
180
|
+
if not follow:
|
|
181
|
+
output.fail("`--via local` streams results live — it can't be used with --no-follow.")
|
|
182
|
+
if nessus or nmap:
|
|
183
|
+
output.fail("`--via local` scans a live network — it can't be combined with --nessus/--nmap.")
|
|
184
|
+
|
|
185
|
+
targets: List[Dict[str, Any]] = []
|
|
186
|
+
port_spec: Optional[str] = None
|
|
187
|
+
|
|
188
|
+
if nessus or nmap:
|
|
189
|
+
if target or ports or targets_file:
|
|
190
|
+
output.note("note: ignoring TARGET/--ports/-iL when importing a file.")
|
|
191
|
+
targets = _targets_from_import(nessus or nmap) # type: ignore[arg-type]
|
|
192
|
+
else:
|
|
193
|
+
if target and targets_file:
|
|
194
|
+
output.fail("Use a TARGET or -iL FILE, not both.")
|
|
195
|
+
source = _read_target_source(target, targets_file)
|
|
196
|
+
if not source or not source.strip():
|
|
197
|
+
output.fail("Provide a TARGET, '-' for stdin, -iL FILE, or import with --nessus / --nmap.")
|
|
198
|
+
try:
|
|
199
|
+
ips = expand_targets(source)
|
|
200
|
+
except TargetError as e:
|
|
201
|
+
output.fail(str(e))
|
|
202
|
+
if ports:
|
|
203
|
+
try:
|
|
204
|
+
port_list = parse_port_list(ports)
|
|
205
|
+
except PortError as e:
|
|
206
|
+
output.fail(str(e))
|
|
207
|
+
targets = [{"ip": ip, "ports": port_list} for ip in ips]
|
|
208
|
+
elif via_local:
|
|
209
|
+
# Discovery runs locally (we're on the target network); the cloud only
|
|
210
|
+
# identifies. Each host ends up with an explicit open-port list.
|
|
211
|
+
try:
|
|
212
|
+
spec = normalize_scope(scope or "top-1000")
|
|
213
|
+
except PortError as e:
|
|
214
|
+
output.fail(str(e))
|
|
215
|
+
output.note(f"Discovering open ports locally on {len(ips)} host(s)…", style="dim")
|
|
216
|
+
for ip in ips:
|
|
217
|
+
try:
|
|
218
|
+
found = discover_ports(ip, spec)
|
|
219
|
+
except DiscoverError as e:
|
|
220
|
+
output.fail(str(e))
|
|
221
|
+
shown = ", ".join(map(str, found[:12])) + (" …" if len(found) > 12 else "")
|
|
222
|
+
output.note(f" {ip}: {len(found)} open — {shown}")
|
|
223
|
+
targets.append({"ip": ip, "ports": found})
|
|
224
|
+
else:
|
|
225
|
+
# Discovery: nmap finds the open ports first, agent identifies them.
|
|
226
|
+
try:
|
|
227
|
+
port_spec = normalize_scope(scope or "top-1000")
|
|
228
|
+
except PortError as e:
|
|
229
|
+
output.fail(str(e))
|
|
230
|
+
targets = [{"ip": ip} for ip in ips]
|
|
231
|
+
|
|
232
|
+
client = _client()
|
|
233
|
+
|
|
234
|
+
# Cost preview + confirmation (skipped for --yes, --json, or non-interactive shells).
|
|
235
|
+
cost = _estimate_cost(targets, port_spec)
|
|
236
|
+
balance: Optional[int] = None
|
|
237
|
+
try:
|
|
238
|
+
balance = client.get_account()["credits"]
|
|
239
|
+
except ApiError:
|
|
240
|
+
pass
|
|
241
|
+
bal_txt = f" (balance: {balance})" if balance is not None else ""
|
|
242
|
+
output.note(f"This scan will use ~{cost} credits{bal_txt}.", style="yellow")
|
|
243
|
+
if not yes and output.is_tty() and not typer.confirm("Continue?", default=True):
|
|
244
|
+
output.note("Aborted.")
|
|
245
|
+
raise typer.Exit(0)
|
|
246
|
+
|
|
247
|
+
# `--via local`: open the reverse tunnel before submitting, and keep it up until
|
|
248
|
+
# the scan finishes. The tunnel only lives while this process runs.
|
|
249
|
+
tunnel: Optional[tunnel_mod.Tunnel] = None
|
|
250
|
+
if via_local:
|
|
251
|
+
output.note("Opening tunnel from this machine…", style="dim")
|
|
252
|
+
try:
|
|
253
|
+
tunnel = tunnel_mod.Tunnel(client).open()
|
|
254
|
+
except ApiError as e:
|
|
255
|
+
output.fail(str(e))
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
try:
|
|
259
|
+
scan_ids = client.create_scan(
|
|
260
|
+
targets,
|
|
261
|
+
port_spec=port_spec,
|
|
262
|
+
label=label,
|
|
263
|
+
max_workers=1 if slow else 3,
|
|
264
|
+
timeout_sec=timeout,
|
|
265
|
+
project=project,
|
|
266
|
+
tunnel_id=tunnel.id if tunnel else None,
|
|
267
|
+
)
|
|
268
|
+
except ApiError as e:
|
|
269
|
+
code = output.EXIT_NO_CREDITS if e.status == 402 else output.EXIT_ERROR
|
|
270
|
+
output.fail(str(e), code=code)
|
|
271
|
+
|
|
272
|
+
if not scan_ids:
|
|
273
|
+
output.fail("Server accepted the request but returned no scan id.")
|
|
274
|
+
|
|
275
|
+
output.note(f"Started {len(scan_ids)} scan(s) · project: {project or 'Default'}", style="bold green")
|
|
276
|
+
for sid in scan_ids:
|
|
277
|
+
output.note(f" {sid}")
|
|
278
|
+
|
|
279
|
+
if not follow:
|
|
280
|
+
if json_output:
|
|
281
|
+
output.emit_json({"scan_ids": scan_ids})
|
|
282
|
+
else:
|
|
283
|
+
output.note("Run `exploitsynth show <id>` to view results.")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
# Animate only for an interactive terminal; otherwise poll quietly and dump results.
|
|
287
|
+
if json_output or not output.is_tty():
|
|
288
|
+
states = poll_until_done(client, scan_ids, show_progress=not json_output)
|
|
289
|
+
if json_output:
|
|
290
|
+
output.emit_json([{"scan": s["scan"], "results": s["results"]} for s in states])
|
|
291
|
+
else:
|
|
292
|
+
for s in states:
|
|
293
|
+
if s["results"]:
|
|
294
|
+
out.print(results_table(s["results"]))
|
|
295
|
+
else:
|
|
296
|
+
out.print(Text("No services identified.", style="dim"))
|
|
297
|
+
else:
|
|
298
|
+
follow_scans(client, scan_ids)
|
|
299
|
+
finally:
|
|
300
|
+
if tunnel:
|
|
301
|
+
tunnel.close()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ─────────────────────────── scans (list) ─────────────────────
|
|
305
|
+
@app.command(name="scans")
|
|
306
|
+
def list_scans(
|
|
307
|
+
project: Optional[str] = typer.Option(None, "--project", help="Filter by project name."),
|
|
308
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Max rows."),
|
|
309
|
+
json_output: bool = JSON_OPT,
|
|
310
|
+
):
|
|
311
|
+
"""List recent scans.
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
exploitsynth scans --json | jq '.[] | {id, status}'
|
|
315
|
+
"""
|
|
316
|
+
output.set_json(json_output)
|
|
317
|
+
client = _client()
|
|
318
|
+
try:
|
|
319
|
+
rows = client.list_scans(project=project, limit=limit)
|
|
320
|
+
except ApiError as e:
|
|
321
|
+
output.fail(str(e))
|
|
322
|
+
|
|
323
|
+
if json_output:
|
|
324
|
+
output.emit_json(rows)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
if not rows:
|
|
328
|
+
output.note("No scans yet.")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
table = Table(box=None, pad_edge=False)
|
|
332
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
333
|
+
table.add_column("TARGET", style="cyan", no_wrap=True)
|
|
334
|
+
table.add_column("STATUS", no_wrap=True)
|
|
335
|
+
table.add_column("PORTS", justify="right")
|
|
336
|
+
table.add_column("LABEL", style="dim")
|
|
337
|
+
for r in rows:
|
|
338
|
+
status = r.get("status", "?")
|
|
339
|
+
style = {"done": "green", "error": "red", "canceled": "yellow"}.get(status, "yellow")
|
|
340
|
+
table.add_row(
|
|
341
|
+
str(r.get("id", ""))[:8],
|
|
342
|
+
r.get("ip", "?"),
|
|
343
|
+
Text(status, style=style),
|
|
344
|
+
str(len(r.get("ports") or [])),
|
|
345
|
+
r.get("label") or "",
|
|
346
|
+
)
|
|
347
|
+
out.print(table)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ─────────────────────────── show ────────────────────────────
|
|
351
|
+
@app.command()
|
|
352
|
+
def show(
|
|
353
|
+
scan_id: str = typer.Argument(..., help="Scan id (full UUID)."),
|
|
354
|
+
reasoning: bool = typer.Option(False, "--reasoning", "-r", help="Include the agent's reasoning per port."),
|
|
355
|
+
json_output: bool = JSON_OPT,
|
|
356
|
+
):
|
|
357
|
+
"""Show one scan's results."""
|
|
358
|
+
output.set_json(json_output)
|
|
359
|
+
client = _client()
|
|
360
|
+
try:
|
|
361
|
+
data = client.get_scan(scan_id)
|
|
362
|
+
except ApiError as e:
|
|
363
|
+
output.fail(str(e))
|
|
364
|
+
|
|
365
|
+
if json_output:
|
|
366
|
+
output.emit_json(data)
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
scan = data["scan"]
|
|
370
|
+
results = data["results"]
|
|
371
|
+
|
|
372
|
+
header = Text()
|
|
373
|
+
header.append(scan.get("ip", "?"), style="bold white")
|
|
374
|
+
header.append(f" {scan.get('status', '?')}", style="dim")
|
|
375
|
+
out.print(header)
|
|
376
|
+
|
|
377
|
+
if not results:
|
|
378
|
+
out.print(Text("No services identified.", style="dim"))
|
|
379
|
+
else:
|
|
380
|
+
out.print(results_table(results))
|
|
381
|
+
|
|
382
|
+
if reasoning:
|
|
383
|
+
for r in sorted(results, key=lambda x: x.get("port", 0)):
|
|
384
|
+
if r.get("extra_info"):
|
|
385
|
+
out.print(Text(f"\n:{r.get('port')} — {r.get('extra_info')}", style="dim"))
|
|
386
|
+
|
|
387
|
+
if scan.get("summary"):
|
|
388
|
+
out.print(Text("\nSummary", style="bold"))
|
|
389
|
+
out.print(Text(scan["summary"], style="dim"))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ─────────────────────────── credits ─────────────────────────
|
|
393
|
+
@app.command()
|
|
394
|
+
def credits(json_output: bool = JSON_OPT):
|
|
395
|
+
"""Show your credit balance."""
|
|
396
|
+
output.set_json(json_output)
|
|
397
|
+
client = _client()
|
|
398
|
+
try:
|
|
399
|
+
acct = client.get_account()
|
|
400
|
+
except ApiError as e:
|
|
401
|
+
output.fail(str(e))
|
|
402
|
+
|
|
403
|
+
if json_output:
|
|
404
|
+
output.emit_json(acct)
|
|
405
|
+
return
|
|
406
|
+
out.print(Text(f"{acct['credits']} credits", style="bold") + Text(f" · {acct['used']} used", style="dim"))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ─────────────────────────── cancel ──────────────────────────
|
|
410
|
+
@app.command()
|
|
411
|
+
def cancel(
|
|
412
|
+
scan_id: str = typer.Argument(..., help="Scan id (full UUID)."),
|
|
413
|
+
json_output: bool = JSON_OPT,
|
|
414
|
+
):
|
|
415
|
+
"""Cancel a queued or running scan."""
|
|
416
|
+
output.set_json(json_output)
|
|
417
|
+
client = _client()
|
|
418
|
+
try:
|
|
419
|
+
status = client.cancel_scan(scan_id)
|
|
420
|
+
except ApiError as e:
|
|
421
|
+
output.fail(str(e))
|
|
422
|
+
|
|
423
|
+
if json_output:
|
|
424
|
+
output.emit_json({"ok": True, "scan_id": scan_id, "status": status})
|
|
425
|
+
return
|
|
426
|
+
out.print(Text(f"✓ Scan {status}", style="green"))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@app.command()
|
|
430
|
+
def version():
|
|
431
|
+
"""Print the CLI version."""
|
|
432
|
+
out.print(f"exploitsynth {__version__}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
app()
|
exploitsynth/client.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Thin httpx wrapper over the ExploitSynth /api/v1 endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiError(Exception):
|
|
13
|
+
"""A non-2xx response or transport failure, with a human-readable message.
|
|
14
|
+
|
|
15
|
+
``status`` is the HTTP status code when there was a response (None on transport
|
|
16
|
+
failure), so callers can branch on it — e.g. 402 → out of credits.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
|
26
|
+
self.api_key = api_key or config.get_api_key()
|
|
27
|
+
self.base_url = (base_url or config.get_base_url()).rstrip("/")
|
|
28
|
+
if not self.api_key:
|
|
29
|
+
raise ApiError(
|
|
30
|
+
"No API key. Run `exploitsynth login`, or set EXPLOITSYNTH_API_KEY."
|
|
31
|
+
)
|
|
32
|
+
self._http = httpx.Client(
|
|
33
|
+
base_url=self.base_url,
|
|
34
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
35
|
+
timeout=30.0,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# -- lifecycle -------------------------------------------------------
|
|
39
|
+
def __enter__(self) -> "Client":
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *exc) -> None:
|
|
43
|
+
self.close()
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
self._http.close()
|
|
47
|
+
|
|
48
|
+
# -- internals -------------------------------------------------------
|
|
49
|
+
def _request(self, method: str, path: str, **kw) -> Dict[str, Any]:
|
|
50
|
+
try:
|
|
51
|
+
r = self._http.request(method, path, **kw)
|
|
52
|
+
except httpx.HTTPError as e:
|
|
53
|
+
raise ApiError(f"Could not reach {self.base_url}: {e}")
|
|
54
|
+
try:
|
|
55
|
+
data = r.json()
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise ApiError(f"{r.status_code}: unexpected non-JSON response.", status=r.status_code)
|
|
58
|
+
if r.status_code >= 400 or not data.get("ok", True):
|
|
59
|
+
raise ApiError(
|
|
60
|
+
data.get("error") or f"Request failed ({r.status_code}).",
|
|
61
|
+
status=r.status_code,
|
|
62
|
+
)
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
# -- endpoints -------------------------------------------------------
|
|
66
|
+
def create_scan(
|
|
67
|
+
self,
|
|
68
|
+
targets: List[Dict[str, Any]],
|
|
69
|
+
*,
|
|
70
|
+
port_spec: Optional[str] = None,
|
|
71
|
+
label: Optional[str] = None,
|
|
72
|
+
max_workers: Optional[int] = None,
|
|
73
|
+
timeout_sec: Optional[int] = None,
|
|
74
|
+
project: Optional[str] = None,
|
|
75
|
+
tunnel_id: Optional[str] = None,
|
|
76
|
+
) -> List[str]:
|
|
77
|
+
"""POST /api/v1/scan. Returns the created scan ids (one per host)."""
|
|
78
|
+
body: Dict[str, Any] = {"targets": targets}
|
|
79
|
+
if port_spec:
|
|
80
|
+
body["port_spec"] = port_spec
|
|
81
|
+
if label:
|
|
82
|
+
body["label"] = label
|
|
83
|
+
if max_workers is not None:
|
|
84
|
+
body["max_workers"] = max_workers
|
|
85
|
+
if timeout_sec is not None:
|
|
86
|
+
body["timeout_sec"] = timeout_sec
|
|
87
|
+
if project:
|
|
88
|
+
body["project"] = project
|
|
89
|
+
if tunnel_id:
|
|
90
|
+
body["tunnel_id"] = tunnel_id
|
|
91
|
+
data = self._request("POST", "/api/v1/scan", json=body)
|
|
92
|
+
return data.get("scan_ids") or ([data["scan_id"]] if data.get("scan_id") else [])
|
|
93
|
+
|
|
94
|
+
def get_scan(self, scan_id: str) -> Dict[str, Any]:
|
|
95
|
+
"""GET /api/v1/scan/:id → {scan, results}."""
|
|
96
|
+
data = self._request("GET", f"/api/v1/scan/{scan_id}")
|
|
97
|
+
return {"scan": data["scan"], "results": data.get("results", [])}
|
|
98
|
+
|
|
99
|
+
def list_scans(self, project: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
|
|
100
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
101
|
+
if project:
|
|
102
|
+
params["project"] = project
|
|
103
|
+
data = self._request("GET", "/api/v1/scan", params=params)
|
|
104
|
+
return data.get("scans", [])
|
|
105
|
+
|
|
106
|
+
def get_account(self) -> Dict[str, Any]:
|
|
107
|
+
"""GET /api/v1/account → {credits, used}."""
|
|
108
|
+
data = self._request("GET", "/api/v1/account")
|
|
109
|
+
return {"credits": data.get("credits", 0), "used": data.get("used", 0)}
|
|
110
|
+
|
|
111
|
+
def cancel_scan(self, scan_id: str) -> str:
|
|
112
|
+
"""DELETE /api/v1/scan/:id → the new status ('canceled')."""
|
|
113
|
+
data = self._request("DELETE", f"/api/v1/scan/{scan_id}")
|
|
114
|
+
return data.get("status", "canceled")
|
|
115
|
+
|
|
116
|
+
def open_tunnel(self) -> Dict[str, Any]:
|
|
117
|
+
"""POST /api/v1/tunnel → {tunnel_id, chisel_url, port, auth} for `--via local`."""
|
|
118
|
+
data = self._request("POST", "/api/v1/tunnel")
|
|
119
|
+
return {
|
|
120
|
+
"tunnel_id": data["tunnel_id"],
|
|
121
|
+
"chisel_url": data["chisel_url"],
|
|
122
|
+
"port": data["port"],
|
|
123
|
+
"auth": data.get("auth", ""),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def close_tunnel(self, tunnel_id: str) -> None:
|
|
127
|
+
"""DELETE /api/v1/tunnel/:id — free the tunnel slot."""
|
|
128
|
+
self._request("DELETE", f"/api/v1/tunnel/{tunnel_id}")
|
exploitsynth/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Config resolution: API key + base URL from env or ~/.config/exploitsynth/config.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://scan.exploitsynth.com"
|
|
11
|
+
|
|
12
|
+
ENV_KEY = "EXPLOITSYNTH_API_KEY"
|
|
13
|
+
ENV_URL = "EXPLOITSYNTH_API_URL"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _config_path() -> Path:
|
|
17
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
18
|
+
return Path(base) / "exploitsynth" / "config.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_file() -> dict:
|
|
22
|
+
path = _config_path()
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return {}
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(path.read_text())
|
|
27
|
+
except (json.JSONDecodeError, OSError):
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_api_key() -> Optional[str]:
|
|
32
|
+
"""Env var wins over the saved config file."""
|
|
33
|
+
return os.environ.get(ENV_KEY) or _read_file().get("api_key")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_base_url() -> str:
|
|
37
|
+
return os.environ.get(ENV_URL) or _read_file().get("base_url") or DEFAULT_BASE_URL
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_api_key(key: str, base_url: Optional[str] = None) -> Path:
|
|
41
|
+
path = _config_path()
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
data = _read_file()
|
|
44
|
+
data["api_key"] = key
|
|
45
|
+
if base_url:
|
|
46
|
+
data["base_url"] = base_url
|
|
47
|
+
path.write_text(json.dumps(data, indent=2))
|
|
48
|
+
# Key is a secret — lock the file down to the owner.
|
|
49
|
+
try:
|
|
50
|
+
os.chmod(path, 0o600)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
return path
|