diff --git a/config/mcp_config.yaml.example b/config/mcp_config.yaml.example index 03bbc869e71f4..b95051f1303be 100644 --- a/config/mcp_config.yaml.example +++ b/config/mcp_config.yaml.example @@ -21,6 +21,13 @@ mcp: # max_threads: 5 # Maximum thread pool size (default: 5) # workers: 0 # Number of worker processes (default: 0, single-process mode) + # Dangerous actions gate (default: false) + # When false, destructive tools (msf_module_execute, msf_module_check, + # msf_session_stop, msf_session_write) return an error. Override at runtime + # with the --enable-dangerous-actions CLI flag or by setting the + # MSF_MCP_DANGEROUS_ACTIONS=true environment variable. + dangerous_actions: false + # Rate limiting (optional - defaults shown) rate_limit: enabled: true diff --git a/config/mcp_config_jsonrpc.yaml.example b/config/mcp_config_jsonrpc.yaml.example index 8c55186be0323..33fcc8574e9ef 100644 --- a/config/mcp_config_jsonrpc.yaml.example +++ b/config/mcp_config_jsonrpc.yaml.example @@ -19,6 +19,13 @@ mcp: # max_threads: 5 # Maximum thread pool size (default: 5) # workers: 0 # Number of worker processes (default: 0, single-process mode) + # Dangerous actions gate (default: false) + # When false, destructive tools (msf_module_execute, msf_module_check, + # msf_session_stop, msf_session_write) return an error. Override at runtime + # with the --enable-dangerous-actions CLI flag or by setting the + # MSF_MCP_DANGEROUS_ACTIONS=true environment variable. + dangerous_actions: false + # Rate limiting (optional - defaults shown) rate_limit: enabled: true diff --git a/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md b/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md index 8837be746ceb7..bb627a2d1fe09 100644 --- a/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md +++ b/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md @@ -1,6 +1,9 @@ -The Metasploit MCP Server (`msfmcpd`) provides AI applications with secure, structured access to Metasploit Framework data through the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP). It acts as a middleware layer between AI clients (such as Claude, Cursor, or custom agents) and Metasploit, exposing 8 standardized tools for querying reconnaissance data and searching modules. +The Metasploit MCP Server (`msfmcpd`) provides AI applications with secure, structured access to Metasploit Framework data and execution capabilities through the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP). It acts as a middleware layer between AI clients (such as Claude, Cursor, or custom agents) and Metasploit, exposing 16 standardized tools for searching modules, querying reconnaissance data, executing modules, and interacting with sessions. -This initial implementation is **read-only**. Only tools that query data (modules, hosts, services, vulnerabilities, etc.) are available. Tools for module execution, session interaction, and database modifications will be added in a future iteration. +Tools are split into two classes: + +- **Read-only tools** (always enabled): query modules, hosts, services, vulnerabilities, notes, credentials, loot, jobs, and sessions. +- **Dangerous tools** (gated, disabled by default): run module execution and check methods, stop sessions, and write to interactive sessions. These tools must be explicitly enabled by the operator via the `--enable-dangerous-actions` CLI flag, the `MSF_MCP_DANGEROUS_ACTIONS` environment variable, or the `mcp.dangerous_actions` configuration key. See [Dangerous Actions Mode](#dangerous-actions-mode) below. ## Architecture @@ -9,7 +12,7 @@ flowchart TD ai_app["AI Application
(Claude, Cursor, etc.)"] subgraph msfmcp_server["MsfMcp Server"] - mcp_layer["MCP Layer (8 Tools)
Input Validation / Rate Limiting / Response Transformation"] + mcp_layer["MCP Layer (16 Tools)
Input Validation / Rate Limiting / Response Transformation
Dangerous Actions Gate"] rpc_manager["RPC Manager
Auto-detect / Auto-start / Lifecycle Management"] api_client["Metasploit API Client
MessagePack RPC (port 55553) / JSON-RPC (port 8081)
Session Management"] @@ -95,6 +98,8 @@ Options: --password PASS MSF API password (for MessagePack auth) --no-auto-start-rpc Disable automatic RPC server startup --mcp-transport TRANSPORT MCP server transport type ('stdio' or 'http') + --enable-dangerous-actions Enable dangerous tools (module execute/check, + session stop/write). Disabled by default. -h, --help Show this help message -v, --version Show version information ``` @@ -118,6 +123,7 @@ All configuration settings can be overridden by environment variables: | `MSF_MCP_HOST` | MCP server host (for HTTP transport) | | `MSF_MCP_PORT` | MCP server port (for HTTP transport) | | `MSF_MCP_AUTH_TOKEN` | MCP server Bearer token for authentication (for HTTP transport) | +| `MSF_MCP_DANGEROUS_ACTIONS` | Enable dangerous tools (`true`/`1`/`yes`/`on` to enable, anything else to disable) | Example using environment variables: @@ -225,9 +231,26 @@ The same can be achieved through the environment variable, e.g. `MSF_MCP_AUTH_TO ## MCP Tools -The server exposes 8 tools to AI applications via the MCP protocol. +The server exposes 16 tools to AI applications via the MCP protocol. Tools marked **dangerous** are gated by [Dangerous Actions Mode](#dangerous-actions-mode) and return a tool error unless the operator has explicitly enabled it. + +### Response envelope + +Every successful tool response is wrapped in the same envelope: + +```jsonc +{ + "metadata": { "query_time": /* plus tool-specific keys */ }, + "data": +} +``` + +`metadata.query_time` is the wall-clock time (in seconds) the MCP server spent handling the call. Individual tools may add fields to `metadata` (for example, database tools include `workspace`, `total_items`, `returned_items`, `limit`, and `offset`; `msf_session_list` includes `total_sessions`). The shapes documented per tool below describe `data` only. -### msf_search_modules +Errors surface as MCP tool errors (`isError: true` responses with a `text` content). Validation errors, rate-limit rejections, authentication failures, and Metasploit API errors all follow that path. + +### Read-only Tools + +#### msf_search_modules Search for Metasploit modules by keywords, CVE IDs, or module names. @@ -235,16 +258,16 @@ Search for Metasploit modules by keywords, CVE IDs, or module names. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_module_info +#### msf_module_info Get detailed information about a specific Metasploit module. -- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `encoder`, `nop`) +- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `encoder`, `evasion`, `nop`) - `name` (string, required): Module path (e.g., `windows/smb/ms17_010_eternalblue`) Returns complete module details including options, targets, references, and authors. -### msf_host_info +#### msf_host_info Query discovered hosts from the Metasploit database. @@ -254,7 +277,7 @@ Query discovered hosts from the Metasploit database. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_service_info +#### msf_service_info Query discovered services on hosts. @@ -267,7 +290,7 @@ Query discovered services on hosts. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_vulnerability_info +#### msf_vulnerability_info Query discovered vulnerabilities. @@ -279,7 +302,7 @@ Query discovered vulnerabilities. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_note_info +#### msf_note_info Query notes stored in the database. @@ -291,7 +314,7 @@ Query notes stored in the database. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_credential_info +#### msf_credential_info Query discovered credentials. @@ -299,7 +322,7 @@ Query discovered credentials. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_loot_info +#### msf_loot_info Query collected loot (files, data dumps). @@ -307,6 +330,108 @@ Query collected loot (files, data dumps). - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) +#### msf_module_results + +Retrieve the result of a previously executed module run by its UUID. + +- `uuid` (string, required): 24-character alphanumeric run UUID returned by `msf_module_execute` or `msf_module_check` + +`data` is one of: + +- `{ "status": "ready" }` — job is queued but has not started yet +- `{ "status": "running" }` — job is still executing +- `{ "status": "completed", "result": ... }` — job finished; payload depends on module type (a check returns a `CheckCode` payload, a scanner returns per-host results, etc.) +- `{ "status": "errored", "error": "..." }` — job finished with an error + +#### msf_running_stats + +Return a snapshot of running and waiting module runs known to the framework. + +Takes no parameters. `data` is an object with: + +- `waiting`: list of run UUIDs queued but not yet started +- `running`: list of run UUIDs currently executing +- `results`: list of run UUIDs whose results are available via `msf_module_results` + +#### msf_session_list + +List active Metasploit sessions. + +Takes no parameters. `data` is a hash keyed by session id (serialised as a string when transported over JSON), where each value describes the session type (e.g. `meterpreter`, `shell`), tunnel endpoints, the target host, and the originating module. `metadata.total_sessions` reports the number of entries in `data`. + +#### msf_session_read + +Non-destructively read pending output from an interactive session (meterpreter, database, or SMB). + +- `session_id` (integer, required): Session id from `msf_session_list` + +Returns `data: { "data": "..." }`. The MCP server does not buffer; clients are responsible for reassembling reads. Returns a tool error if the session id is not active. + +### Dangerous Tools + +These four tools can change the state of the framework, remote targets, or running sessions. They are gated by [Dangerous Actions Mode](#dangerous-actions-mode) and return a tool error unless the operator has explicitly enabled it. + +#### msf_module_execute + +Run a Metasploit module as a background job. + +- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `evasion`) +- `name` (string, required): Module path (e.g., `multi/handler`) +- `options` (object, required): Datastore options as a flat JSON object. Keys are Metasploit option names; namespaced (`HTTP::compression`) and hyphenated (`BEARER-TOKEN`) names are supported. Values must be scalars (string, integer, number, boolean, or null). No nested objects or arrays. + +Returns `data: { "job_id": N, "uuid": "<24 chars>" }`. Poll `msf_module_results` with the returned UUID to retrieve the outcome. + +This tool does not implement check-before-exploit. If a module supports `Msf::Exploit::Remote::AutoCheck`, the framework runs the check automatically; otherwise, run `msf_module_check` first. + +#### msf_module_check + +Run the `check` method of an exploit or auxiliary module to determine whether a target is vulnerable, without exploiting it. + +- `type` (string, required): Module type (`exploit` or `auxiliary`) +- `name` (string, required): Module path +- `options` (object, required): Datastore options (same shape as `msf_module_execute`) + +Returns `data: { "job_id": N, "uuid": "<24 chars>" }`. Poll `msf_module_results` for the `CheckCode` (e.g. `Vulnerable`, `Safe`, `Detected`, `Appears`, `Unknown`). + +When the target module does not implement a `check` method, the tool returns a **successful** response with `data: { "status": "unsupported", "message": "Module does not implement a check method" }` rather than a tool error, so clients can distinguish "not supported" from a transport or runtime failure. + +#### msf_session_stop + +Terminate an active session. + +- `session_id` (integer, required): Session id from `msf_session_list` + +Returns `data: { "result": "success" }`. Returns a tool error if the session id is not active. + +#### msf_session_write + +Send data to an interactive session (meterpreter, database, or SMB). Use `msf_session_read` afterwards to retrieve any output produced. + +- `session_id` (integer, required): Session id from `msf_session_list` +- `data` (string, required): Bytes to write to the session, capped at 10,000 characters per call + +Returns `data: { "result": "" }`. + +## Dangerous Actions Mode + +The four tools listed under [Dangerous Tools](#dangerous-tools) are disabled by default and must be explicitly enabled. When the gate is closed, calls to any of those tools return an MCP tool error and the underlying RPC call is never issued. + +### Enabling + +Dangerous mode can be enabled through any of three mechanisms, in order of precedence (highest first): + +1. **CLI flag**: pass `--enable-dangerous-actions` to `msfmcpd`. +2. **Environment variable**: set `MSF_MCP_DANGEROUS_ACTIONS=true` (also accepts `1`, `yes`, `on`). +3. **Configuration file**: set `mcp.dangerous_actions: true` in `mcp_config.yaml`. + +When the gate is open, `msfmcpd` prints a warning banner on startup: + +``` +WARNING: dangerous actions mode is ENABLED. Destructive tools (module +execute/check, session stop/write) are now callable from connected MCP +clients. +``` + ## Integration with AI Applications Add the MCP server to your AI application configuration. The exact format depends on the client. @@ -352,9 +477,13 @@ If you use RVM to manage Ruby versions, specify the full path to RVM so the corr ## Security Considerations +### Dangerous Actions Gate + +Tools that execute modules or interact with sessions are disabled by default. They must be explicitly enabled per session via CLI flag, environment variable, or configuration key — see [Dangerous Actions Mode](#dangerous-actions-mode). When the gate is closed, the RPC client is never invoked for a gated call. + ### Input Validation -All tool parameters are validated against strict JSON schemas. IP addresses are validated using Ruby's `IPAddr` class with CIDR support, workspace names are restricted to alphanumeric characters plus underscore/hyphen, port ranges are validated (1-65535), and search queries are limited to 500 characters. +All tool parameters are validated against strict JSON schemas. IP addresses are validated using Ruby's `IPAddr` class with CIDR support, workspace names are restricted to alphanumeric characters plus underscore/hyphen, port ranges are validated (1-65535), and search queries are limited to 500 characters. Module datastore option keys must match Metasploit's option-name shape (identifier segments joined by `::` or `-`, up to 128 characters); option payloads are capped at 100 keys, 8 KB per string value, and 64 KB aggregate. Session data writes are capped at 64 KB per call. ### Credential Management diff --git a/lib/msf/base/simple/auxiliary.rb b/lib/msf/base/simple/auxiliary.rb index 7cd7ddd7b087d..2b08392f046c1 100644 --- a/lib/msf/base/simple/auxiliary.rb +++ b/lib/msf/base/simple/auxiliary.rb @@ -70,6 +70,8 @@ def self.run_simple(omod, opts = {}, job_listener: Msf::Simple::NoopJobListener. end run_uuid = Rex::Text.rand_text_alphanumeric(24) + mod.run_uuid = run_uuid + omod.run_uuid = run_uuid job_listener.waiting run_uuid ctx = [mod, run_uuid, job_listener] run_as_job = opts['RunAsJob'].nil? ? mod.passive? : opts['RunAsJob'] @@ -128,6 +130,7 @@ def self.check_simple(mod, opts, job_listener: Msf::Simple::NoopJobListener.inst mod.validate run_uuid = Rex::Text.rand_text_alphanumeric(24) + mod.run_uuid = run_uuid job_listener.waiting run_uuid ctx = [mod, run_uuid, job_listener] diff --git a/lib/msf/base/simple/evasion.rb b/lib/msf/base/simple/evasion.rb index 9bbd3fc74f29f..693256446e52b 100644 --- a/lib/msf/base/simple/evasion.rb +++ b/lib/msf/base/simple/evasion.rb @@ -7,7 +7,7 @@ module Evasion include Module - def self.run_simple(oevasion, opts, &block) + def self.run_simple(oevasion, opts, job_listener: Msf::Simple::NoopJobListener.instance, &block) evasion = oevasion.replicant # Trap and print errors here (makes them UI-independent) begin @@ -28,7 +28,7 @@ def self.run_simple(oevasion, opts, &block) evasion.options.validate(evasion.datastore) # Start it up - driver = EvasionDriver.new(evasion.framework) + driver = EvasionDriver.new(evasion.framework, job_listener: job_listener) # Initialize the driver instance driver.evasion = evasion @@ -85,9 +85,11 @@ def self.run_simple(oevasion, opts, &block) # Save the job identifier this evasion is running as evasion.job_id = driver.job_id + evasion.run_uuid = driver.run_uuid # Propagate this back to the caller for console mgmt oevasion.job_id = evasion.job_id + oevasion.run_uuid = evasion.run_uuid rescue ::Interrupt evasion.error = $! raise $! @@ -103,8 +105,8 @@ def self.run_simple(oevasion, opts, &block) nil end - def run_simple(opts = {}, &block) - Msf::Simple::Evasion.run_simple(self, opts, &block) + def run_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) + Msf::Simple::Evasion.run_simple(self, opts, job_listener: job_listener, &block) end end diff --git a/lib/msf/base/simple/exploit.rb b/lib/msf/base/simple/exploit.rb index 479239a20a510..7a9058d09455d 100644 --- a/lib/msf/base/simple/exploit.rb +++ b/lib/msf/base/simple/exploit.rb @@ -82,8 +82,10 @@ def self.exploit_simple(oexploit, opts, &block) # Verify the options exploit.validate + job_listener = opts.fetch('JobListener', Msf::Simple::NoopJobListener.instance) + # Start it up - driver = Msf::ExploitDriver.new(exploit.framework) + driver = Msf::ExploitDriver.new(exploit.framework, job_listener: job_listener) # Keep the handler of driver running if exploiting multiple targets. driver.keep_handler = true if opts['multi'] @@ -146,9 +148,11 @@ def self.exploit_simple(oexploit, opts, &block) # Save the job identifier this exploit is running as exploit.job_id = driver.job_id + exploit.run_uuid = driver.run_uuid # Propagate this back to the caller for console mgmt oexploit.job_id = exploit.job_id + oexploit.run_uuid = exploit.run_uuid rescue ::Interrupt exploit.error = $! raise $! diff --git a/lib/msf/base/simple/post.rb b/lib/msf/base/simple/post.rb index 6e329c79aca44..b643ff5e2b1dd 100644 --- a/lib/msf/base/simple/post.rb +++ b/lib/msf/base/simple/post.rb @@ -37,7 +37,7 @@ module Post # Whether or not the module should be run in the context of a background # job. # - def self.run_simple(omod, opts = {}, &block) + def self.run_simple(omod, opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) # Clone the module to prevent changes to the original instance mod = omod.replicant @@ -64,11 +64,16 @@ def self.run_simple(omod, opts = {}, &block) mod.init_ui(nil, nil) end + run_uuid = Rex::Text.rand_text_alphanumeric(24) + mod.run_uuid = run_uuid + omod.run_uuid = run_uuid + job_listener.waiting run_uuid + ctx = [mod, run_uuid, job_listener] + # # Disable this until we can test background stuff a little better # if(mod.passive? or opts['RunAsJob']) - ctx = [ mod.replicant ] mod.job_id = mod.framework.jobs.start_bg_job( "Post: #{mod.refname}", ctx, @@ -78,7 +83,6 @@ def self.run_simple(omod, opts = {}, &block) # Propagate this back to the caller for console mgmt omod.job_id = mod.job_id else - ctx = [ mod ] self.job_run_proc(ctx) self.job_cleanup_proc(ctx) end @@ -87,8 +91,8 @@ def self.run_simple(omod, opts = {}, &block) # # Calls the class method. # - def run_simple(opts = {}, &block) - Msf::Simple::Post.run_simple(self, opts, &block) + def run_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) + Msf::Simple::Post.run_simple(self, opts, job_listener: job_listener, &block) end protected @@ -99,8 +103,9 @@ def run_simple(opts = {}, &block) # XXX: Mostly Copy/pasted from simple/auxiliary.rb # def self.job_run_proc(ctx) - mod = ctx[0] + mod, run_uuid, job_listener = ctx begin + job_listener.start run_uuid mod.setup mod.framework.events.on_module_run(mod) # Grab the session object since we need to fire an event for not @@ -109,33 +114,41 @@ def self.job_run_proc(ctx) s = mod.framework.sessions.get(mod.datastore["SESSION"]) if s mod.framework.events.on_session_module_run(s, mod) - mod.run + result = mod.run + job_listener.completed(run_uuid, result, mod) + return result else mod.print_error("Session not found") + job_listener.failed(run_uuid, 'Session not found', mod) mod.cleanup return end rescue Msf::Post::Complete + job_listener.completed(run_uuid, nil, mod) mod.cleanup return rescue Msf::Post::Failed => e mod.error = e mod.print_error("Post aborted due to failure: #{e.message}") + job_listener.failed(run_uuid, e, mod) mod.cleanup return rescue ::Timeout::Error => e mod.error = e mod.print_error("Post triggered a timeout exception") + job_listener.failed(run_uuid, e, mod) mod.cleanup return rescue ::Interrupt => e mod.error = e mod.print_error("Post interrupted by the console user") + job_listener.failed(run_uuid, e, mod) mod.cleanup return rescue ::Msf::OptionValidateError => e mod.error = e ::Msf::Ui::Formatter::OptionValidateError.print_error(mod, e) + job_listener.failed(run_uuid, e, mod) rescue ::Exception => e mod.error = e mod.print_error("Post failed: #{e.class} #{e}") @@ -148,6 +161,7 @@ def self.job_run_proc(ctx) end elog('Post failed', error: e) + job_listener.failed(run_uuid, e, mod) mod.cleanup return diff --git a/lib/msf/core/evasion_driver.rb b/lib/msf/core/evasion_driver.rb index 87d8dfefee6df..bad5bb85acecf 100644 --- a/lib/msf/core/evasion_driver.rb +++ b/lib/msf/core/evasion_driver.rb @@ -7,13 +7,14 @@ class EvasionDriver # # Initializes the evasion driver using the supplied framework instance. # - def initialize(framework) + def initialize(framework, job_listener: Msf::Simple::NoopJobListener.instance) self.payload = nil self.evasion = nil self.use_job = false self.job_id = nil self.force_wait_for_session = false self.semaphore = Mutex.new + self.job_listener = job_listener end def target_idx=(target_idx) @@ -80,15 +81,20 @@ def run # evasion module instance evasion.generate_payload(payload) + self.run_uuid = Rex::Text.rand_text_alphanumeric(24) + evasion.run_uuid = self.run_uuid + self.job_listener.waiting self.run_uuid + # No need to copy since we aren't creating a job. We wait until # they're finished running to do anything else with them, so # nothing should be able to modify their datastore or other # settings until after they're done. - ctx = [ evasion, payload ] + ctx = [ evasion, payload, self.run_uuid, self.job_listener ] job_run_proc(ctx) job_cleanup_proc(ctx) + nil end attr_accessor :evasion # :nodoc: @@ -99,24 +105,40 @@ def run # job. # attr_accessor :job_id + # + # The run UUID generated for the most recent #run invocation. Used by the + # job listener and reported back to RPC clients via the module attribute of + # the same name. + # + attr_accessor :run_uuid attr_accessor :force_wait_for_session # :nodoc: attr_accessor :session # :nodoc: # To synchronize threads cleaning up the evasion attr_accessor :semaphore + attr_accessor :job_listener + protected # # Job run proc, sets up the eevasion and kicks it off. # def job_run_proc(ctx) - evasion, payload = ctx - evasion.setup - evasion.framework.events.on_module_run(evasion) - - # Launch the evasion module - evasion.run + evasion, payload, run_uuid, job_listener = ctx + job_listener ||= self.job_listener + begin + job_listener.start run_uuid + evasion.setup + evasion.framework.events.on_module_run(evasion) + + # Launch the evasion module + result = evasion.run + job_listener.completed(run_uuid, result, evasion) + rescue ::Exception => e + job_listener.failed(run_uuid, e, evasion) + raise + end end # diff --git a/lib/msf/core/exploit_driver.rb b/lib/msf/core/exploit_driver.rb index f0e51b8bb5d24..9742d669e1905 100644 --- a/lib/msf/core/exploit_driver.rb +++ b/lib/msf/core/exploit_driver.rb @@ -15,7 +15,7 @@ class ExploitDriver # # Initializes the exploit driver using the supplied framework instance. # - def initialize(framework) + def initialize(framework, job_listener: Msf::Simple::NoopJobListener.instance) self.payload = nil self.exploit = nil self.use_job = false @@ -23,6 +23,7 @@ def initialize(framework) self.force_wait_for_session = false self.keep_handler = false self.semaphore = Mutex.new + self.job_listener = job_listener end # @@ -138,6 +139,9 @@ def run # run exploit.job_id = nil + self.run_uuid = Rex::Text.rand_text_alphanumeric(24) + self.job_listener.waiting self.run_uuid + # If we are being instructed to run as a job then let's create that job # like a good person. if (use_job or exploit.passive?) @@ -153,7 +157,8 @@ def run # Generate the encoded version of the supplied payload for the # newly copied exploit module instance e.generate_payload(p) - ctx = [ e, p ] + e.run_uuid = self.run_uuid + ctx = [ e, p, self.run_uuid, self.job_listener ] e.job_id = e.framework.jobs.start_bg_job( "Exploit: #{e.refname}", @@ -171,7 +176,8 @@ def run # they're finished running to do anything else with them, so # nothing should be able to modify their datastore or other # settings until after they're done. - ctx = [ exploit, payload ] + exploit.run_uuid = self.run_uuid + ctx = [ exploit, payload, self.run_uuid, self.job_listener ] begin job_run_proc(ctx) @@ -185,7 +191,7 @@ def run end end - return session + self.session end attr_accessor :exploit # :nodoc: @@ -196,6 +202,12 @@ def run # job. # attr_accessor :job_id + # + # The run UUID generated for the most recent #run invocation. Used by the + # job listener and reported back to RPC clients via the module attribute of + # the same name. + # + attr_accessor :run_uuid attr_accessor :force_wait_for_session # :nodoc: attr_accessor :session # :nodoc: attr_accessor :keep_handler # :nodoc: @@ -203,6 +215,8 @@ def run # To synchronize threads cleaning up the exploit and the handler attr_accessor :semaphore + attr_accessor :job_listener + protected # @@ -210,11 +224,12 @@ def run # def job_run_proc(ctx) begin - exploit, payload = ctx + exploit, payload, run_uuid, job_listener = ctx # Default session wait time.. delay = payload.wfs_delay + exploit.wfs_delay delay = nil if exploit.passive? + job_listener.start run_uuid # Set the exploit up the bomb exploit.setup @@ -222,6 +237,7 @@ def job_run_proc(ctx) # Launch the exploit exploit.exploit + job_listener.completed(run_uuid, 'Waiting for session...', exploit) rescue ::Exception => e if [::RuntimeError, ::Interrupt].include?(e.class) @@ -229,7 +245,15 @@ def job_run_proc(ctx) delay = 0.01 end + iout = StringIO.new + rex_out = Rex::Ui::Text::Output::Stdio.new(io: iout) + rex_out.disable_color + old_user_output = exploit.user_output + exploit.user_output = rex_out fail_reason = exploit.handle_exception(e) + iout.close_write + exploit.user_output = old_user_output + job_listener.failed(run_uuid, iout.string.strip, exploit) end # Start bind handlers after exploit completion @@ -243,6 +267,10 @@ def job_run_proc(ctx) (exploit.passive? == false and exploit.handler_enabled?) begin self.session = payload.wait_for_session(delay) + # Update the result to reflect the session creation + if self.session + job_listener.completed(run_uuid, "#{session.desc} session #{session.name} opened (#{session.tunnel_to_s}) at #{Time.now}", exploit) + end rescue ::Interrupt, Timeout::ExitException # Don't let interrupt pass upward # We also want report a failure when the Timeout expires diff --git a/lib/msf/core/mcp.rb b/lib/msf/core/mcp.rb index d301892addf7f..8ef13b0460c90 100644 --- a/lib/msf/core/mcp.rb +++ b/lib/msf/core/mcp.rb @@ -65,12 +65,20 @@ module MCP require_relative 'mcp/tools/tool_helper' require_relative 'mcp/tools/search_modules' require_relative 'mcp/tools/module_info' +require_relative 'mcp/tools/module_execute' +require_relative 'mcp/tools/module_check' +require_relative 'mcp/tools/module_results' +require_relative 'mcp/tools/running_stats' require_relative 'mcp/tools/host_info' require_relative 'mcp/tools/service_info' require_relative 'mcp/tools/vulnerability_info' require_relative 'mcp/tools/note_info' require_relative 'mcp/tools/credential_info' require_relative 'mcp/tools/loot_info' +require_relative 'mcp/tools/session_list' +require_relative 'mcp/tools/session_stop' +require_relative 'mcp/tools/session_read' +require_relative 'mcp/tools/session_write' require_relative 'mcp/server' # Application Layer diff --git a/lib/msf/core/mcp/application.rb b/lib/msf/core/mcp/application.rb index 97545819f6f84..540e518243995 100644 --- a/lib/msf/core/mcp/application.rb +++ b/lib/msf/core/mcp/application.rb @@ -116,6 +116,10 @@ def parse_arguments @options[:mcp_transport] = transport end + opts.on('--enable-dangerous-actions', 'Enable destructive MCP tools (module execution, session control)') do + @options[:enable_dangerous_actions_cli] = true + end + opts.on('-h', '--help', 'Show this help message') do @output.puts opts exit 0 @@ -201,6 +205,9 @@ def load_configuration if @options[:mcp_transport] @config[:mcp][:transport] = @options[:mcp_transport] end + if @options[:enable_dangerous_actions_cli] + @config[:mcp][:dangerous_actions] = true + end end # Validate the loaded configuration @@ -266,9 +273,14 @@ def authenticate_metasploit # @return [void] def initialize_mcp_server @output.puts "Initializing MCP server..." + dangerous_actions = @config&.dig(:mcp, :dangerous_actions) == true + if dangerous_actions + @output.puts "WARNING: dangerous actions mode is ENABLED. Destructive tools (module execution, session control) are accessible." + end @mcp_server = Msf::MCP::Server.new( msf_client: @msf_client, - rate_limiter: @rate_limiter + rate_limiter: @rate_limiter, + dangerous_actions: dangerous_actions ) end diff --git a/lib/msf/core/mcp/config/loader.rb b/lib/msf/core/mcp/config/loader.rb index a501f3e5c61ca..25e2b4cc4ec65 100644 --- a/lib/msf/core/mcp/config/loader.rb +++ b/lib/msf/core/mcp/config/loader.rb @@ -68,6 +68,7 @@ def self.apply_defaults(config) end config[:mcp][:transport] ||= 'stdio' + config[:mcp][:dangerous_actions] = config[:mcp].fetch(:dangerous_actions, false) if config[:mcp][:transport] == 'http' config[:mcp][:host] ||= Defaults::MCP_HOST @@ -135,6 +136,11 @@ def self.apply_env_overrides(config) config[:mcp][:min_threads] = ENV['MSF_MCP_MIN_THREADS'].to_i if ENV['MSF_MCP_MIN_THREADS'] config[:mcp][:max_threads] = ENV['MSF_MCP_MAX_THREADS'].to_i if ENV['MSF_MCP_MAX_THREADS'] config[:mcp][:workers] = ENV['MSF_MCP_WORKERS'].to_i if ENV['MSF_MCP_WORKERS'] + + # Dangerous actions gate override + if ENV['MSF_MCP_DANGEROUS_ACTIONS'] && !ENV['MSF_MCP_DANGEROUS_ACTIONS'].empty? + config[:mcp][:dangerous_actions] = parse_boolean(ENV['MSF_MCP_DANGEROUS_ACTIONS']) + end end # Parse a string value into a boolean diff --git a/lib/msf/core/mcp/config/validator.rb b/lib/msf/core/mcp/config/validator.rb index fb613417d17dc..edebd4650fe7d 100644 --- a/lib/msf/core/mcp/config/validator.rb +++ b/lib/msf/core/mcp/config/validator.rb @@ -101,6 +101,10 @@ def validate!(config) errors[:'mcp.min_threads'] = "must be less than or equal to mcp.max_threads" end end + + if config[:mcp].key?(:dangerous_actions) && ![true, false].include?(config[:mcp][:dangerous_actions]) + errors[:'mcp.dangerous_actions'] = "must be boolean (true or false)" + end end # Validate conditional requirements based on API type diff --git a/lib/msf/core/mcp/logging/sinks/sanitizing.rb b/lib/msf/core/mcp/logging/sinks/sanitizing.rb index 666ce5f802104..465c7a4b8e174 100644 --- a/lib/msf/core/mcp/logging/sinks/sanitizing.rb +++ b/lib/msf/core/mcp/logging/sinks/sanitizing.rb @@ -55,7 +55,7 @@ def sanitize(data) data.each_with_object({}) do |(k, v), result| result[k] = if k.to_s.match?(SENSITIVE_KEYS) v.is_a?(Hash) || v.is_a?(Array) ? sanitize(v) : REDACTED - elsif k.to_sym == :exception && v.is_a?(Exception) + elsif k.to_s == 'exception' && v.is_a?(Exception) ex_msg = { class: v.class.name, message: sanitize(v.message) } if get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL bt = v.backtrace&.first(5) || [] diff --git a/lib/msf/core/mcp/metasploit/client.rb b/lib/msf/core/mcp/metasploit/client.rb index 2d7c91ca2e3c9..f85ccc293bf03 100644 --- a/lib/msf/core/mcp/metasploit/client.rb +++ b/lib/msf/core/mcp/metasploit/client.rb @@ -9,7 +9,10 @@ module Metasploit class Client extend Forwardable - def_delegators :@client, :authenticate, :search_modules, :module_info, :db_hosts, :db_services, :db_vulns, :db_notes, :db_creds, :db_loot, :shutdown + def_delegators :@client, :authenticate, :search_modules, :module_info, + :db_hosts, :db_services, :db_vulns, :db_notes, :db_creds, :db_loot, + :module_execute, :module_check, :module_results, :running_stats, + :session_list, :session_stop, :session_read, :session_write, :shutdown ## # Initialize Metasploit client with explicit parameters diff --git a/lib/msf/core/mcp/metasploit/jsonrpc_client.rb b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb index ac626859d9224..64ae4acde01d8 100644 --- a/lib/msf/core/mcp/metasploit/jsonrpc_client.rb +++ b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb @@ -129,6 +129,65 @@ def db_loot(options = {}) call_api('db.loots', [options]) end + # Execute a Metasploit module + # @param type [String] Module type ('exploit', 'auxiliary', 'post', 'payload', 'evasion') + # @param name [String] Module name (e.g. 'multi/handler') + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_execute(type, name, options = {}) + call_api('module.execute', [type, name, options]) + end + + # Run the check method of a Metasploit module + # @param type [String] Module type ('exploit' or 'auxiliary') + # @param name [String] Module name + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_check(type, name, options = {}) + call_api('module.check', [type, name, options]) + end + + # Get module execution results by UUID + # @param uuid [String] Module run UUID returned by module_execute / module_check + # @return [Hash] Result hash with 'status' key (and 'result' / 'error' as applicable) + def module_results(uuid) + call_api('module.results', [uuid]) + end + + # Get currently running module stats (waiting / running / results) + # @return [Hash] Hash with 'waiting', 'running', 'results' arrays of UUIDs + def running_stats + call_api('module.running_stats', []) + end + + # List active sessions + # @return [Hash] Hash keyed by session ID with session info hashes as values + def session_list + call_api('session.list', []) + end + + # Stop (kill) an active session + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'result' key set to 'success' + def session_stop(session_id) + call_api('session.stop', [session_id]) + end + + # Read buffered output from an interactive session (meterpreter, DB, SMB) + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'data' key containing the buffered output + def session_read(session_id) + call_api('session.interactive_read', [session_id]) + end + + # Send input to an interactive session + # @param session_id [Integer] Session ID + # @param data [String] Data to send to the session + # @return [Hash] Response with 'result' key + def session_write(session_id, data) + call_api('session.interactive_write', [session_id, data]) + end + # Shutdown client def shutdown @http&.finish if @http&.started? diff --git a/lib/msf/core/mcp/metasploit/messagepack_client.rb b/lib/msf/core/mcp/metasploit/messagepack_client.rb index ef96c99b1fe49..b81aa6da350f9 100644 --- a/lib/msf/core/mcp/metasploit/messagepack_client.rb +++ b/lib/msf/core/mcp/metasploit/messagepack_client.rb @@ -165,6 +165,65 @@ def db_loot(options = {}) call_api('db.loots', [options]) end + # Execute a Metasploit module + # @param type [String] Module type ('exploit', 'auxiliary', 'post', 'payload', 'evasion') + # @param name [String] Module name (e.g. 'multi/handler') + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_execute(type, name, options = {}) + call_api('module.execute', [type, name, options]) + end + + # Run the check method of a Metasploit module + # @param type [String] Module type ('exploit' or 'auxiliary') + # @param name [String] Module name + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_check(type, name, options = {}) + call_api('module.check', [type, name, options]) + end + + # Get module execution results by UUID + # @param uuid [String] Module run UUID returned by module_execute / module_check + # @return [Hash] Result hash with 'status' key (and 'result' / 'error' as applicable) + def module_results(uuid) + call_api('module.results', [uuid]) + end + + # Get currently running module stats (waiting / running / results) + # @return [Hash] Hash with 'waiting', 'running', 'results' arrays of UUIDs + def running_stats + call_api('module.running_stats', []) + end + + # List active sessions + # @return [Hash] Hash keyed by session ID with session info hashes as values + def session_list + call_api('session.list', []) + end + + # Stop (kill) an active session + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'result' key set to 'success' + def session_stop(session_id) + call_api('session.stop', [session_id]) + end + + # Read buffered output from an interactive session (meterpreter, DB, SMB) + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'data' key containing the buffered output + def session_read(session_id) + call_api('session.interactive_read', [session_id]) + end + + # Send input to an interactive session + # @param session_id [Integer] Session ID + # @param data [String] Data to send to the session + # @return [Hash] Response with 'result' key + def session_write(session_id, data) + call_api('session.interactive_write', [session_id, data]) + end + # Shutdown client and cleanup def shutdown @token = nil diff --git a/lib/msf/core/mcp/security/input_validator.rb b/lib/msf/core/mcp/security/input_validator.rb index 7b047f52b2f9b..5d46dbb64b09c 100644 --- a/lib/msf/core/mcp/security/input_validator.rb +++ b/lib/msf/core/mcp/security/input_validator.rb @@ -192,6 +192,128 @@ def self.validate_only_up!(only_up) def self.validate_protocol!(protocol) validate_parameter!('Protocol', protocol.to_s.downcase, %w[tcp udp], allow_nil: true) end + + # Module datastore option limits. + # + # Bounds an oversized options hash before it reaches the Metasploit + # datastore. Real modules register on the order of 10-60 options across + # all inherited mixins, so 100 is a comfortable ceiling. This is a + # defence-in-depth cap against DoS-style input abuse from an MCP client, + # not a transport limit (MCP itself does not restrict payload size). + MODULE_OPTIONS_MAX_KEYS = 100 + # Matches the option name conventions used across the Framework: standard + # SCREAMING_SNAKE_CASE (RHOSTS, LPORT), CamelCase advanced options + # (SSLVersion, BasicAuth), namespaced mixin options separated by `::` + # (HTTP::compression, CMDSTAGER::FLAVOR, EXE::Custom), multi-level + # namespaces (HTML::javascript::escape), and hyphenated header-style + # names (BEARER-TOKEN). Each segment must start with a letter or + # underscore. Leading/trailing separators and double separators + # (`:::`, `--`) are rejected. + MODULE_OPTIONS_KEY_REGEX = /\A[A-Za-z_][A-Za-z0-9_]*(?:(?:::|-)[A-Za-z_][A-Za-z0-9_]*)*\z/ + MODULE_OPTIONS_KEY_MAX_LENGTH = 128 + MODULE_OPTIONS_VALUE_MAX_BYTES = 8 * 1024 + MODULE_OPTIONS_TOTAL_MAX_BYTES = 64 * 1024 + + # Allowed scalar types for datastore values. The Metasploit datastore is flat, + # so no nested Hashes / Arrays are permitted. + MODULE_OPTION_SCALAR_TYPES = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze + + # UUID format produced by Metasploit's RPC layer via Rex::Text.rand_text_alphanumeric(24). + # The generator emits exactly 24 characters drawn from a mixed-case alphanumeric pool. + MODULE_RUN_UUID_REGEX = /\A[A-Za-z0-9]{24}\z/ + + SESSION_ID_RANGE = 1..65535 + SESSION_DATA_MAX_BYTES = 64 * 1024 + + # Validate module datastore options hash. + # + # Enforces structural limits before any RPC call so a misbehaving client + # cannot push an unbounded payload through to the framework. + # + # Keys may be Strings or Symbols (the MCP transport deep-symbolizes JSON + # input). Either form is validated against MODULE_OPTIONS_KEY_REGEX via + # `to_s`; the tool layer is responsible for normalizing to String keys + # before forwarding to the Metasploit datastore. + # + # @param options [Hash] Module datastore options + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_module_options!(options) + raise ValidationError, 'Module options must be a Hash' unless options.is_a?(Hash) + + if options.size > MODULE_OPTIONS_MAX_KEYS + raise ValidationError, "Module options has too many keys (max #{MODULE_OPTIONS_MAX_KEYS})" + end + + total_bytes = 0 + options.each do |key, value| + unless key.is_a?(String) || key.is_a?(Symbol) + raise ValidationError, "Invalid module option key: #{key.inspect}" + end + + key_str = key.to_s + if key_str.length > MODULE_OPTIONS_KEY_MAX_LENGTH + raise ValidationError, "Module option key exceeds #{MODULE_OPTIONS_KEY_MAX_LENGTH} chars: #{key.inspect}" + end + unless key_str.match?(MODULE_OPTIONS_KEY_REGEX) + raise ValidationError, "Invalid module option key: #{key.inspect}" + end + + unless MODULE_OPTION_SCALAR_TYPES.any? { |klass| value.is_a?(klass) } + raise ValidationError, "Invalid module option value for #{key}: must be a scalar (String, Integer, Float, Boolean, or nil)" + end + + if value.is_a?(String) && value.bytesize > MODULE_OPTIONS_VALUE_MAX_BYTES + raise ValidationError, "Module option #{key} string value exceeds #{MODULE_OPTIONS_VALUE_MAX_BYTES} bytes" + end + + total_bytes += key.to_s.bytesize + total_bytes += value.bytesize if value.is_a?(String) + if total_bytes > MODULE_OPTIONS_TOTAL_MAX_BYTES + raise ValidationError, "Module options total payload exceeds #{MODULE_OPTIONS_TOTAL_MAX_BYTES} bytes" + end + end + + true + end + + # Validate a module run UUID returned by module.execute / module.check. + # + # @param uuid [String] UUID string + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_uuid!(uuid) + raise ValidationError, 'UUID must be a String' unless uuid.is_a?(String) + + validate_parameter!('UUID', uuid, MODULE_RUN_UUID_REGEX, max_size: 24) + end + + # Validate a session identifier. + # + # @param session_id [Integer] Session ID + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_session_id!(session_id) + raise ValidationError, 'Session ID must be an Integer' unless session_id.is_a?(Integer) + + validate_parameter!('Session ID', session_id, SESSION_ID_RANGE) + end + + # Validate session input data for session.interactive_write. + # + # @param data [String] Data to send to the session + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_session_data!(data) + raise ValidationError, 'Session data must be a String' unless data.is_a?(String) + raise ValidationError, 'Session data cannot be empty' if data.empty? + + if data.bytesize > SESSION_DATA_MAX_BYTES + raise ValidationError, "Session data exceeds #{SESSION_DATA_MAX_BYTES} bytes" + end + + true + end end end end diff --git a/lib/msf/core/mcp/server.rb b/lib/msf/core/mcp/server.rb index 864a3c45c9d9d..c2a72959a8aee 100644 --- a/lib/msf/core/mcp/server.rb +++ b/lib/msf/core/mcp/server.rb @@ -24,15 +24,17 @@ class Server # # @param msf_client [Metasploit::Client] Configured and authenticated Metasploit client # @param rate_limiter [Security::RateLimiter] Configured rate limiter + # @param dangerous_actions [Boolean] Whether dangerous (destructive) tools are permitted # - def initialize(msf_client:, rate_limiter:) + def initialize(msf_client:, rate_limiter:, dangerous_actions: false) @msf_client = msf_client # Create server context (passed to all tool calls) - # Tools only need msf_client and rate_limiter + # Tools only need msf_client, rate_limiter, and the dangerous_actions gate. @server_context = { msf_client: @msf_client, - rate_limiter: rate_limiter + rate_limiter: rate_limiter, + dangerous_actions: dangerous_actions == true } # Create MCP configuration with request lifecycle callbacks @@ -47,12 +49,20 @@ def initialize(msf_client:, rate_limiter:) tools: [ Tools::SearchModules, Tools::ModuleInfo, + Tools::ModuleExecute, + Tools::ModuleCheck, + Tools::ModuleResults, + Tools::RunningStats, Tools::HostInfo, Tools::ServiceInfo, Tools::VulnerabilityInfo, Tools::NoteInfo, Tools::CredentialInfo, - Tools::LootInfo + Tools::LootInfo, + Tools::SessionList, + Tools::SessionStop, + Tools::SessionRead, + Tools::SessionWrite ], server_context: @server_context, configuration: mcp_config diff --git a/lib/msf/core/mcp/tools/credential_info.rb b/lib/msf/core/mcp/tools/credential_info.rb index 9d003bab7ec64..b85d149858ade 100644 --- a/lib/msf/core/mcp/tools/credential_info.rb +++ b/lib/msf/core/mcp/tools/credential_info.rb @@ -33,8 +33,7 @@ class CredentialInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( diff --git a/lib/msf/core/mcp/tools/loot_info.rb b/lib/msf/core/mcp/tools/loot_info.rb index 62a56e29058f0..dadc784788b08 100644 --- a/lib/msf/core/mcp/tools/loot_info.rb +++ b/lib/msf/core/mcp/tools/loot_info.rb @@ -34,8 +34,7 @@ class LootInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( diff --git a/lib/msf/core/mcp/tools/module_check.rb b/lib/msf/core/mcp/tools/module_check.rb new file mode 100644 index 0000000000000..0d876524609f1 --- /dev/null +++ b/lib/msf/core/mcp/tools/module_check.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Run a Module's Check Method + # + # Invokes the Metasploit Framework `module.check` RPC endpoint. Returns the + # job_id and run UUID for polling via {ModuleResults}. When the module does + # not implement a check method, returns a structured `status: unsupported` + # response instead of an error. + # + class ModuleCheck < ::MCP::Tool + # Message fragment emitted by Msf::Exploit::CheckCode::Unsupported when + # a target module does not implement #check. + UNSUPPORTED_MESSAGE_PATTERN = /module does not support check/i + + tool_name 'msf_module_check' + description 'Run the check method of a Metasploit exploit or auxiliary module. '\ + 'Returns a job_id and run UUID; use msf_module_results to retrieve the CheckCode result.' + + input_schema( + properties: { + type: { + type: 'string', + description: 'Module type', + enum: %w[exploit auxiliary] + }, + name: { + type: 'string', + description: 'Module path/name (e.g., windows/smb/ms17_010_eternalblue)', + minLength: 1, + maxLength: 500 + }, + options: { + type: 'object', + description: 'Module datastore options as a JSON object. Keys are Metasploit option ' \ + 'names (e.g. RHOSTS, RPORT). Namespaced mixin options that use the `::` ' \ + 'separator are also accepted (e.g. HTTP::compression, SMB::ChunkSize), ' \ + 'as are hyphenated identifiers (e.g. BEARER-TOKEN). ' \ + 'Values must be scalars (string, integer, float, boolean, or null). ' \ + 'Example: {"RHOSTS": "192.0.2.10", "RPORT": 445}. ' \ + 'No nested objects or arrays.', + additionalProperties: { type: %w[string integer number boolean null] } + } + }, + required: [:type, :name, :options] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + job_id: { type: 'integer' }, + uuid: { type: 'string' }, + status: { type: 'string' }, + message: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute module check + # + # @param type [String] Module type ('exploit' or 'auxiliary') + # @param name [String] Module path/name + # @param options [Hash] Datastore options forwarded to module.check + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with job_id and uuid, + # or { status: 'unsupported' } when the module has no check method + # + def call(type:, name:, options:, server_context:) + start_time = Time.now + + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('module_check') + + Msf::MCP::Security::InputValidator.validate_parameter!('Module type', type, %w[exploit auxiliary]) + Msf::MCP::Security::InputValidator.validate_module_name!(name) + Msf::MCP::Security::InputValidator.validate_module_options!(options) + + # MCP deep-symbolizes JSON input; the Metasploit datastore is keyed by Strings. + stringified_options = options.transform_keys(&:to_s) + + raw_result = msf_client.module_check(type, name, stringified_options) + + data = { + job_id: raw_result['job_id'], + uuid: raw_result['uuid'] + } + + metadata = { query_time: (Time.now - start_time).round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + if e.message.to_s.match?(UNSUPPORTED_MESSAGE_PATTERN) + metadata = { query_time: (Time.now - start_time).round(3) } + data = { status: 'unsupported', message: 'Module does not implement a check method' } + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + else + tool_error_response("Metasploit API error: #{e.message}") + end + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/module_execute.rb b/lib/msf/core/mcp/tools/module_execute.rb new file mode 100644 index 0000000000000..b19b3f3e6c637 --- /dev/null +++ b/lib/msf/core/mcp/tools/module_execute.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Execute a Metasploit Module + # + # Invokes the Metasploit Framework `module.execute` RPC endpoint to start + # an exploit, auxiliary, post, payload, or evasion module. Returns the + # job_id and run UUID, which can later be polled via {ModuleResults}. + # + class ModuleExecute < ::MCP::Tool + tool_name 'msf_module_execute' + description 'Execute a Metasploit module (exploit, auxiliary, post, payload, or evasion). '\ + 'Returns a job_id and run UUID; use msf_module_results to retrieve the outcome.' + + input_schema( + properties: { + type: { + type: 'string', + description: 'Module type', + enum: %w[exploit auxiliary post payload evasion] + }, + name: { + type: 'string', + description: 'Module path/name (e.g., windows/smb/ms17_010_eternalblue)', + minLength: 1, + maxLength: 500 + }, + options: { + type: 'object', + description: 'Module datastore options as a JSON object. Keys are Metasploit option ' \ + 'names (e.g. RHOSTS, RPORT, LHOST, LPORT, PAYLOAD, TARGET, ACTION). ' \ + 'Namespaced mixin options that use the `::` separator are also accepted ' \ + '(e.g. HTTP::compression, CMDSTAGER::FLAVOR, EXE::Custom), as are ' \ + 'hyphenated identifiers (e.g. BEARER-TOKEN). ' \ + 'Values must be scalars (string, integer, float, boolean, or null). ' \ + 'Example: {"RHOSTS": "192.0.2.10", "RPORT": 4444, "PAYLOAD": "windows/meterpreter/reverse_tcp"}. ' \ + 'No nested objects or arrays.', + additionalProperties: { type: %w[string integer number boolean null] } + } + }, + required: [:type, :name, :options] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + job_id: { type: 'integer' }, + uuid: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute module run + # + # @param type [String] Module type (exploit/auxiliary/post/payload/evasion) + # @param name [String] Module path/name + # @param options [Hash] Datastore options forwarded unchanged to module.execute + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with job_id and uuid + # + def call(type:, name:, options:, server_context:) + start_time = Time.now + + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('module_execute') + + Msf::MCP::Security::InputValidator.validate_module_type!(type) + Msf::MCP::Security::InputValidator.validate_module_name!(name) + Msf::MCP::Security::InputValidator.validate_module_options!(options) + + # MCP deep-symbolizes JSON input; the Metasploit datastore is keyed by Strings. + stringified_options = options.transform_keys(&:to_s) + + raw_result = msf_client.module_execute(type, name, stringified_options) + + data = { + job_id: raw_result['job_id'], + uuid: raw_result['uuid'] + } + + metadata = { query_time: (Time.now - start_time).round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/module_results.rb b/lib/msf/core/mcp/tools/module_results.rb new file mode 100644 index 0000000000000..8a827fe89b424 --- /dev/null +++ b/lib/msf/core/mcp/tools/module_results.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Retrieve Module Run Results + # + # Polls the `module.results` RPC endpoint with a UUID returned by + # {ModuleExecute} or {ModuleCheck} and returns the current status. + # + class ModuleResults < ::MCP::Tool + tool_name 'msf_module_results' + description 'Retrieve the result of a previously started module run by UUID. '\ + 'Returns the run status (ready, running, completed, or errored) and any associated result data.' + + input_schema( + properties: { + uuid: { + type: 'string', + description: 'Module run UUID returned by msf_module_execute or msf_module_check', + minLength: 1, + maxLength: 50 + } + }, + required: [:uuid] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + status: { type: 'string', enum: %w[ready running completed errored] }, + result: {}, + error: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute results lookup + # + # @param uuid [String] Module run UUID + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with run status + # + def call(uuid:, server_context:) + start_time = Time.now + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('module_results') + + Msf::MCP::Security::InputValidator.validate_uuid!(uuid) + + raw_result = msf_client.module_results(uuid) + + data = raw_result.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } + + metadata = { query_time: (Time.now - start_time).round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/note_info.rb b/lib/msf/core/mcp/tools/note_info.rb index 776288e6dedbc..2a66a279b80da 100644 --- a/lib/msf/core/mcp/tools/note_info.rb +++ b/lib/msf/core/mcp/tools/note_info.rb @@ -51,8 +51,7 @@ class NoteInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( diff --git a/lib/msf/core/mcp/tools/running_stats.rb b/lib/msf/core/mcp/tools/running_stats.rb new file mode 100644 index 0000000000000..21237141bdd66 --- /dev/null +++ b/lib/msf/core/mcp/tools/running_stats.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: List Running Module Stats + # + # Wraps the `module.running_stats` RPC endpoint and returns the UUIDs of + # module runs currently in each lifecycle state (waiting, running, results). + # + class RunningStats < ::MCP::Tool + tool_name 'msf_running_stats' + description 'List the UUIDs of currently waiting, running, and completed module runs. '\ + 'Use msf_module_results with one of these UUIDs to retrieve details.' + + input_schema(properties: {}) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + waiting: { type: 'array', items: { type: 'string' } }, + running: { type: 'array', items: { type: 'string' } }, + results: { type: 'array', items: { type: 'string' } } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute running stats lookup + # + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with running stats + # + def call(server_context:) + start_time = Time.now + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('running_stats') + + raw_result = msf_client.running_stats + + data = { + waiting: raw_result['waiting'] || [], + running: raw_result['running'] || [], + results: raw_result['results'] || [] + } + + metadata = { query_time: (Time.now - start_time).round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/service_info.rb b/lib/msf/core/mcp/tools/service_info.rb index 053e017de4e58..d7746ae67e475 100644 --- a/lib/msf/core/mcp/tools/service_info.rb +++ b/lib/msf/core/mcp/tools/service_info.rb @@ -56,8 +56,7 @@ class ServiceInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( diff --git a/lib/msf/core/mcp/tools/session_list.rb b/lib/msf/core/mcp/tools/session_list.rb new file mode 100644 index 0000000000000..aa722f822bf54 --- /dev/null +++ b/lib/msf/core/mcp/tools/session_list.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: List Active Sessions + # + # Wraps the `session.list` RPC endpoint and returns a hash of active + # framework sessions keyed by session ID. + # + class SessionList < ::MCP::Tool + tool_name 'msf_session_list' + description 'List all active Metasploit sessions. '\ + 'Returns session details including type, transport endpoints, target, and platform.' + + input_schema(properties: {}) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' }, + total_sessions: { type: 'integer' } + } + }, + data: { type: 'object' } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute session listing + # + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with sessions hash + # + def call(server_context:) + start_time = Time.now + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_list') + + raw_result = msf_client.session_list || {} + + metadata = { + query_time: (Time.now - start_time).round(3), + total_sessions: raw_result.size + } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: raw_result) }], + structured_content: { metadata: metadata, data: raw_result } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/session_read.rb b/lib/msf/core/mcp/tools/session_read.rb new file mode 100644 index 0000000000000..05eb7a3471e93 --- /dev/null +++ b/lib/msf/core/mcp/tools/session_read.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Read Output from an Interactive Session + # + # Wraps the `session.interactive_read` RPC endpoint. Works for meterpreter, + # DB, SMB, LDAP, and shell/powershell session types. + # + class SessionRead < ::MCP::Tool + tool_name 'msf_session_read' + description 'Read buffered output from an interactive Metasploit session '\ + '(meterpreter, database, SMB, LDAP, shell, powershell).' + + input_schema( + properties: { + session_id: { + type: 'integer', + description: 'Session ID to read from', + minimum: 1 + } + }, + required: [:session_id] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + data: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Read from an interactive session + # + # @param session_id [Integer] Session ID + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with session output + # + def call(session_id:, server_context:) + start_time = Time.now + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_read') + + Msf::MCP::Security::InputValidator.validate_session_id!(session_id) + + raw_result = msf_client.session_read(session_id) + + data = { data: raw_result['data'].to_s } + metadata = { query_time: (Time.now - start_time).round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/session_stop.rb b/lib/msf/core/mcp/tools/session_stop.rb new file mode 100644 index 0000000000000..fdc2258b3216a --- /dev/null +++ b/lib/msf/core/mcp/tools/session_stop.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Stop an Active Session + # + # Wraps the `session.stop` RPC endpoint to terminate a running Metasploit + # session. + # + class SessionStop < ::MCP::Tool + tool_name 'msf_session_stop' + description 'Stop (kill) an active Metasploit session by its session ID.' + + input_schema( + properties: { + session_id: { + type: 'integer', + description: 'Session ID to stop', + minimum: 1 + } + }, + required: [:session_id] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + result: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Stop a session + # + # @param session_id [Integer] Session ID + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with stop result + # + def call(session_id:, server_context:) + start_time = Time.now + + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_stop') + + Msf::MCP::Security::InputValidator.validate_session_id!(session_id) + + raw_result = msf_client.session_stop(session_id) + + data = { result: raw_result['result'] } + metadata = { query_time: (Time.now - start_time).round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/session_write.rb b/lib/msf/core/mcp/tools/session_write.rb new file mode 100644 index 0000000000000..99d130bc04752 --- /dev/null +++ b/lib/msf/core/mcp/tools/session_write.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Send Input to an Interactive Session + # + # Wraps the `session.interactive_write` RPC endpoint. The provided data is + # forwarded to the session for processing (meterpreter, database, SMB, LDAP, + # shell, powershell). + # + class SessionWrite < ::MCP::Tool + tool_name 'msf_session_write' + description 'Send input data to an interactive Metasploit session '\ + '(meterpreter, database, SMB, LDAP, shell, powershell). Use msf_session_read to retrieve output.' + + input_schema( + properties: { + session_id: { + type: 'integer', + description: 'Session ID to write to', + minimum: 1 + }, + data: { + type: 'string', + description: 'Input data to send to the session', + minLength: 1, + maxLength: 10_000 + } + }, + required: [:session_id, :data] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + result: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Write to an interactive session + # + # @param session_id [Integer] Session ID + # @param data [String] Data to write to the session + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with write result + # + def call(session_id:, data:, server_context:) + start_time = Time.now + + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_write') + + Msf::MCP::Security::InputValidator.validate_session_id!(session_id) + Msf::MCP::Security::InputValidator.validate_session_data!(data) + + raw_result = msf_client.session_write(session_id, data) + + response_data = { result: raw_result['result'] } + metadata = { query_time: (Time.now - start_time).round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: response_data) }], + structured_content: { metadata: metadata, data: response_data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/tool_helper.rb b/lib/msf/core/mcp/tools/tool_helper.rb index 56cc84b788120..d3ddc43c02186 100644 --- a/lib/msf/core/mcp/tools/tool_helper.rb +++ b/lib/msf/core/mcp/tools/tool_helper.rb @@ -2,6 +2,12 @@ module Msf::MCP module Tools + ## + # Raised when a tool annotated as dangerous is invoked but the server is + # not running with dangerous actions mode enabled. + # + class DangerousModeDisabledError < ::Msf::MCP::Error; end + ## # Shared helper methods for MCP tools. # @@ -10,6 +16,10 @@ module Tools # of raising exceptions that the MCP server would wrap as internal errors. # module ToolHelper + DANGEROUS_MODE_DISABLED_MESSAGE = 'This tool requires dangerous actions mode to be enabled. ' \ + 'Enable it with: --enable-dangerous-actions flag, MSF_MCP_DANGEROUS_ACTIONS=true environment ' \ + 'variable, or mcp.dangerous_actions: true in config file.' + ## # Build a standard MCP error response. # @@ -22,6 +32,20 @@ def tool_error_response(message) error: true ) end + + ## + # Guard a dangerous tool invocation by checking the dangerous_actions + # flag in the server context. + # + # @param server_context [Hash] Server context with :dangerous_actions key + # @raise [DangerousModeDisabledError] If dangerous mode is not enabled + # @return [void] + # + def dangerous_mode_required!(server_context) + return if server_context[:dangerous_actions] == true + + raise DangerousModeDisabledError, DANGEROUS_MODE_DISABLED_MESSAGE + end end end end diff --git a/lib/msf/core/mcp/tools/vulnerability_info.rb b/lib/msf/core/mcp/tools/vulnerability_info.rb index 8a548139366fa..211b788525a8c 100644 --- a/lib/msf/core/mcp/tools/vulnerability_info.rb +++ b/lib/msf/core/mcp/tools/vulnerability_info.rb @@ -54,8 +54,7 @@ class VulnerabilityInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( diff --git a/lib/msf/core/module.rb b/lib/msf/core/module.rb index 3eb8b3d4115b8..a47bbc8525873 100644 --- a/lib/msf/core/module.rb +++ b/lib/msf/core/module.rb @@ -423,6 +423,14 @@ def default_cred? # attr_accessor :job_id + # + # The run UUID produced by the most recent invocation of this module via + # one of the Simple wrappers (exploit_simple / run_simple). Mirrors the + # value reported to the job listener and tracked by + # Msf::RPC::RpcJobStatusTracker. + # + attr_accessor :run_uuid + # # The last exception to occur using this module # diff --git a/lib/msf/core/rpc/v10/rpc_module.rb b/lib/msf/core/rpc/v10/rpc_module.rb index 3aa73bb34ee2a..3d3f230272965 100644 --- a/lib/msf/core/rpc/v10/rpc_module.rb +++ b/lib/msf/core/rpc/v10/rpc_module.rb @@ -556,7 +556,7 @@ def rpc_results(uuid) elsif self.job_status_tracker.waiting? uuid {"status" => "ready"} else - error(404, "Results not found for module instance #{uuid}") + error(500, "Results not found for module instance #{uuid}") end end @@ -751,27 +751,28 @@ def _run_exploit(mod, opts) opts['PAYLOAD'] = Msf::Payload.choose_payload(mod) end - s = Msf::Simple::Exploit.exploit_simple(mod, { + Msf::Simple::Exploit.exploit_simple(mod, { 'Payload' => opts['PAYLOAD'], 'Target' => opts['TARGET'], 'RunAsJob' => true, - 'Options' => opts + 'Options' => opts, + 'JobListener' => self.job_status_tracker }) { "job_id" => mod.job_id, - "uuid" => mod.uuid + "uuid" => mod.run_uuid || mod.uuid } end def _run_auxiliary(mod, opts) - uuid, job = Msf::Simple::Auxiliary.run_simple(mod,{ + Msf::Simple::Auxiliary.run_simple(mod,{ 'Action' => opts['ACTION'], 'RunAsJob' => true, 'Options' => opts }, job_listener: self.job_status_tracker) { - "job_id" => job, - "uuid" => uuid + "job_id" => mod.job_id, + "uuid" => mod.run_uuid || mod.uuid } end @@ -802,10 +803,10 @@ def _run_post(mod, opts) Msf::Simple::Post.run_simple(mod, { 'RunAsJob' => true, 'Options' => opts - }) + }, job_listener: self.job_status_tracker) { "job_id" => mod.job_id, - "uuid" => mod.uuid + "uuid" => mod.run_uuid || mod.uuid } end @@ -815,11 +816,11 @@ def _run_evasion(mod, opts) 'Target' => opts['TARGET'], 'RunAsJob' => true, 'Options' => opts - }) + }, job_listener: self.job_status_tracker) { 'job_id' => mod.job_id, - 'uuid' => mod.uuid + 'uuid' => mod.run_uuid || mod.uuid } end diff --git a/lib/msf/core/rpc/v10/rpc_session.rb b/lib/msf/core/rpc/v10/rpc_session.rb index 54e8d1f6bfdea..c1093e93327f1 100644 --- a/lib/msf/core/rpc/v10/rpc_session.rb +++ b/lib/msf/core/rpc/v10/rpc_session.rb @@ -181,7 +181,7 @@ def rpc_meterpreter_read(sid) rpc_interactive_read(sid) end - # Reads the output from an interactive session (meterpreter, DB sessions, SMB) + # Reads the output from an interactive session (meterpreter, DB sessions, SMB, shell, powershell) # # @note Multiple concurrent callers writing and reading the same Meterperter session can lead to # a conflict, where one caller gets the others output and vice versa. Concurrent access to a @@ -190,6 +190,7 @@ def rpc_meterpreter_read(sid) # @raise [Msf::RPC::Exception] An error that could be one of these: # * 500 Unknown Session ID. # * 500 Session doesn't support interactive operations. + # * 500 Session is disconnected. # @return [Hash] It contains the following key: # * 'data' [String] Data read. # @example Here's how you would use this from the client: @@ -197,6 +198,14 @@ def rpc_meterpreter_read(sid) def rpc_interactive_read(sid) session = _valid_interactive_session(sid) + if SHELL_SESSION_TYPES.include?(session.type) + begin + return { 'data' => session.shell_read.to_s } + rescue ::Exception => e + error(500, "Session Disconnected: #{e.class} #{e}") + end + end + unless session.user_output.respond_to?(:dump_buffer) session.init_ui(Rex::Ui::Text::Input::Buffer.new, Rex::Ui::Text::Output::Buffer.new) end @@ -302,7 +311,7 @@ def rpc_meterpreter_write(sid, data) rpc_interactive_write(sid, data) end - # Sends an input to an interactive prompt (meterpreter, DB sessions, SMB) + # Sends an input to an interactive prompt (meterpreter, DB sessions, SMB, shell, powershell) # You may want to use #rpc_interactive_read to retrieve the output. # @note Multiple concurrent callers writing and reading the same Meterperter session can lead to # a conflict, where one caller gets the others output and vice versa. Concurrent access to @@ -312,6 +321,7 @@ def rpc_meterpreter_write(sid, data) # @raise [Msf::RPC::Exception] An error that could be one of these: # * 500 Unknown Session ID. # * 500 Session doesn't support interactive operations. + # * 500 Session is disconnected. # @return [Hash] A hash indicating the action was successful or not. It contains the following key: # * 'result' [String] Either 'success' or 'failure'. # @example Here's how you would use this from the client: @@ -319,6 +329,16 @@ def rpc_meterpreter_write(sid, data) def rpc_interactive_write(sid, data) session = _valid_interactive_session(sid) + if SHELL_SESSION_TYPES.include?(session.type) + begin + payload = data.end_with?("\n") ? data : "#{data}\n" + session.shell_write(payload) + return { 'result' => 'success' } + rescue ::Exception => e + error(500, "Session Disconnected: #{e.class} #{e}") + end + end + unless session.user_output.respond_to? :dump_buffer session.init_ui(Rex::Ui::Text::Input::Buffer.new, Rex::Ui::Text::Output::Buffer.new) end @@ -532,8 +552,12 @@ def rpc_compatible_modules(sid) mysql smb ldap + shell + powershell ].freeze + SHELL_SESSION_TYPES = %w[shell powershell].freeze + def _find_module(_mtype, mname) mod = framework.modules.create(mname) error(500, 'Invalid Module') if mod.nil? diff --git a/lib/msf/core/rpc/v10/service.rb b/lib/msf/core/rpc/v10/service.rb index 4c4ebe5471bb8..8348abacffa23 100644 --- a/lib/msf/core/rpc/v10/service.rb +++ b/lib/msf/core/rpc/v10/service.rb @@ -84,8 +84,8 @@ def on_request_uri(cli, req) res.body = process_exception(e).to_msgpack res.code = e.code res.message = e.http_msg - rescue ::StandardError => e - elog('Unknown Exception', error: e) + rescue ::StandardError, ::ScriptError => e + elog('Unhandled Exception', error: e) res.body = process_exception(e).to_msgpack res.code = 500 res.message = 'Internal Server Error' diff --git a/spec/lib/msf/base/simple/auxiliary_job_tracking_spec.rb b/spec/lib/msf/base/simple/auxiliary_job_tracking_spec.rb new file mode 100644 index 0000000000000..f4ce9d98af6e5 --- /dev/null +++ b/spec/lib/msf/base/simple/auxiliary_job_tracking_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/auxiliary' +require 'msf/base/simple/noop_job_listener' + +RSpec.describe Msf::Simple::Auxiliary, '#job_run_proc job tracking' do + let(:run_uuid) { 'aux-run-uuid' } + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil, on_session_module_run: nil) } + let(:framework) { double('Framework', events: events) } + let(:mod) do + double( + 'AuxiliaryModule', + framework: framework, + respond_to?: true, + :check_code= => nil, + :last_vuln_attempt= => nil, + setup: nil, + cleanup: nil, + report_failure: nil, + :error= => nil, + :fail_reason= => nil, + :fail_detail= => nil, + print_error: nil, + print_status: nil, + fail_reason: Msf::Module::Failure::None, + fail_detail: nil + ) + end + let(:ctx) { [mod, run_uuid, job_listener] } + + context 'when the block completes successfully' do + it 'reports start then completed with the block result' do + result = 'aux-result' + described_class.job_run_proc(ctx) { |_m| result } + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:completed).with(run_uuid, result, mod) + expect(job_listener).not_to have_received(:failed) + end + end + + context 'when the block raises a generic exception' do + it 'reports failed with the exception and propagates' do + err = RuntimeError.new('boom') + described_class.job_run_proc(ctx) { |_m| raise err } + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:failed).with(run_uuid, err, mod) + expect(job_listener).not_to have_received(:completed) + end + end + + context 'when the block raises Msf::Auxiliary::Complete' do + it 'is reported via failed (mirrors existing aux behavior) then is swallowed' do + described_class.job_run_proc(ctx) { |_m| raise Msf::Auxiliary::Complete } + expect(job_listener).to have_received(:failed).with(run_uuid, kind_of(Msf::Auxiliary::Complete), mod) + end + end +end diff --git a/spec/lib/msf/base/simple/evasion_job_tracking_spec.rb b/spec/lib/msf/base/simple/evasion_job_tracking_spec.rb new file mode 100644 index 0000000000000..63b7ea747b86e --- /dev/null +++ b/spec/lib/msf/base/simple/evasion_job_tracking_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/evasion' +require 'msf/base/simple/noop_job_listener' +require 'msf/core/evasion_driver' + +RSpec.describe Msf::EvasionDriver, '#job_run_proc job tracking' do + let(:run_uuid) { 'evasion-run-uuid' } + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil) } + let(:framework) { double('Framework', events: events) } + let(:evasion_mod) { double('EvasionModule', setup: nil, framework: framework, run: 'evasion-output', cleanup: nil) } + let(:payload) { double('Payload') } + let(:driver) { described_class.new(framework) } + + before do + driver.job_listener = job_listener if driver.respond_to?(:job_listener=) + driver.evasion = evasion_mod + driver.payload = payload + end + + context 'when the module runs successfully' do + it 'reports start then completed' do + driver.send(:job_run_proc, [evasion_mod, payload, run_uuid, job_listener]) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:completed).with(run_uuid, 'evasion-output', evasion_mod) + expect(job_listener).not_to have_received(:failed) + end + end + + context 'when the module raises' do + it 'reports failed with the exception' do + err = RuntimeError.new('evasion-boom') + allow(evasion_mod).to receive(:run).and_raise(err) + expect do + driver.send(:job_run_proc, [evasion_mod, payload, run_uuid, job_listener]) + end.to raise_error(RuntimeError, 'evasion-boom') + expect(job_listener).to have_received(:failed).with(run_uuid, err, evasion_mod) + end + end +end + +RSpec.describe Msf::EvasionDriver, '#initialize' do + let(:framework) { double('Framework') } + + it 'accepts a job_listener keyword argument' do + listener = double('JobListener') + driver = described_class.new(framework, job_listener: listener) + expect(driver.job_listener).to eq(listener) + end + + it 'defaults to the noop job listener' do + driver = described_class.new(framework) + expect(driver.job_listener).to eq(Msf::Simple::NoopJobListener.instance) + end +end + +RSpec.describe Msf::Simple::Evasion, '.run_simple' do + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + + it 'is a singleton method that accepts a job_listener keyword' do + expect(described_class.method(:run_simple).parameters).to include(%i[key job_listener]) + end +end diff --git a/spec/lib/msf/base/simple/exploit_job_tracking_spec.rb b/spec/lib/msf/base/simple/exploit_job_tracking_spec.rb new file mode 100644 index 0000000000000..19d40913cb034 --- /dev/null +++ b/spec/lib/msf/base/simple/exploit_job_tracking_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/exploit' +require 'msf/base/simple/noop_job_listener' +require 'msf/core/exploit_driver' + +RSpec.describe Msf::ExploitDriver, 'job listener integration' do + let(:framework) { double('Framework') } + let(:custom_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + + describe '#initialize' do + it 'accepts an explicit job_listener kwarg' do + driver = described_class.new(framework, job_listener: custom_listener) + expect(driver.job_listener).to eq(custom_listener) + end + + it 'defaults to the noop job listener' do + driver = described_class.new(framework) + expect(driver.job_listener).to eq(Msf::Simple::NoopJobListener.instance) + end + end + + describe '#job_run_proc lifecycle' do + let(:run_uuid) { 'exploit-run-uuid' } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil) } + let(:framework_for_exploit) { double('Framework', events: events) } + let(:exploit_mod) do + double( + 'ExploitModule', + setup: nil, + framework: framework_for_exploit, + exploit: nil, + passive?: false, + wfs_delay: 0, + handler_enabled?: false, + handler_bind?: false, + fail_reason: Msf::Exploit::Failure::None, + :fail_reason= => nil, + :fail_detail= => nil, + report_failure: nil, + cleanup: nil + ) + end + let(:payload_mod) { double('Payload', wfs_delay: 0, start_handler: nil) } + let(:driver) { described_class.new(framework_for_exploit, job_listener: custom_listener) } + + it 'reports start then completed when the exploit succeeds' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).to have_received(:start).with(run_uuid) + expect(custom_listener).to have_received(:completed).with(run_uuid, kind_of(String), exploit_mod) + end + + it 'reports failed when the exploit raises' do + err = RuntimeError.new('exploit-boom') + allow(exploit_mod).to receive(:exploit).and_raise(err) + allow(exploit_mod).to receive(:handle_exception).and_return(Msf::Exploit::Failure::Unknown) + allow(exploit_mod).to receive(:user_output).and_return(nil) + allow(exploit_mod).to receive(:user_output=) + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).to have_received(:failed).with(run_uuid, kind_of(String), exploit_mod) + end + end +end diff --git a/spec/lib/msf/base/simple/post_job_tracking_spec.rb b/spec/lib/msf/base/simple/post_job_tracking_spec.rb new file mode 100644 index 0000000000000..ca872ac3f3161 --- /dev/null +++ b/spec/lib/msf/base/simple/post_job_tracking_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/post' +require 'msf/base/simple/noop_job_listener' + +RSpec.describe Msf::Simple::Post, '#job_run_proc job tracking' do + let(:run_uuid) { 'post-run-uuid' } + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil, on_session_module_run: nil) } + let(:sessions) { double('SessionManager', get: session) } + let(:session) { double('Session') } + let(:framework) { double('Framework', events: events, sessions: sessions) } + let(:datastore) { { 'SESSION' => 1 } } + let(:mod) do + double( + 'PostModule', + framework: framework, + datastore: datastore, + setup: nil, + cleanup: nil, + print_error: nil, + :error= => nil + ) + end + let(:ctx) { [mod, run_uuid, job_listener] } + + context 'when the module run succeeds' do + it 'reports start then completed with the run result' do + result = 'post-result' + allow(mod).to receive(:run).and_return(result) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:completed).with(run_uuid, result, mod) + expect(job_listener).not_to have_received(:failed) + end + end + + context 'when the session is missing' do + let(:session) { nil } + it 'reports failed and does not invoke mod.run' do + expect(mod).not_to receive(:run) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:failed).with(run_uuid, kind_of(String), mod) + expect(job_listener).not_to have_received(:completed) + end + end + + context 'when the module run raises' do + it 'reports failed with the exception' do + err = RuntimeError.new('post-boom') + allow(mod).to receive(:run).and_raise(err) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:failed).with(run_uuid, err, mod) + expect(job_listener).not_to have_received(:completed) + end + end + + context 'when the module raises Msf::Post::Complete' do + it 'reports completed via the listener' do + allow(mod).to receive(:run).and_raise(Msf::Post::Complete) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:completed).with(run_uuid, nil, mod) + end + end +end + +RSpec.describe Msf::Simple::Post, '.run_simple' do + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:datastore) { double('Datastore', :[]= => nil, :[] => nil) } + let(:framework) { double('Framework', jobs: jobs) } + let(:jobs) { double('JobManager') } + let(:mod) do + mod = double( + 'PostModule', + replicant: nil, + refname: 'multi/general/execute', + framework: framework, + datastore: datastore, + actions: [], + action: nil, + passive?: true, + validate: nil, + init_ui: nil, + user_input: nil, + user_output: nil, + _import_extra_options: nil, + :job_id= => nil, + job_id: 42, + :run_uuid= => nil, + run_uuid: nil + ) + allow(mod).to receive(:replicant).and_return(mod) + allow(mod).to receive(:extend) + mod + end + + before do + allow(Msf::Simple::Framework).to receive(:simplify_module) + allow(jobs).to receive(:start_bg_job).and_return(42) + require 'rex/text' + end + + it 'generates a run uuid, notifies the listener, and assigns mod.run_uuid / mod.job_id' do + captured_uuid = nil + expect(job_listener).to receive(:waiting) { |uuid| captured_uuid = uuid } + expect(mod).to receive(:run_uuid=).with(kind_of(String)).at_least(:once) + expect(mod).to receive(:job_id=).with(42).at_least(:once) + + described_class.run_simple(mod, { 'RunAsJob' => true }, job_listener: job_listener) + + expect(captured_uuid).to be_a(String) + expect(captured_uuid.length).to be >= 8 + end +end diff --git a/spec/lib/msf/core/mcp/application_spec.rb b/spec/lib/msf/core/mcp/application_spec.rb index cbaeae1fe6489..432442dca0e62 100644 --- a/spec/lib/msf/core/mcp/application_spec.rb +++ b/spec/lib/msf/core/mcp/application_spec.rb @@ -118,6 +118,20 @@ expect(app.options[:no_auto_start_rpc]).to be_nil end + + it 'parses --enable-dangerous-actions argument' do + app = described_class.new(['--enable-dangerous-actions'], output: output) + app.send(:parse_arguments) + + expect(app.options[:enable_dangerous_actions_cli]).to be true + end + + it 'does not set enable_dangerous_actions_cli by default' do + app = described_class.new([], output: output) + app.send(:parse_arguments) + + expect(app.options[:enable_dangerous_actions_cli]).to be_nil + end end describe '#initialize_logger' do @@ -363,7 +377,8 @@ expect(Msf::MCP::Server).to receive(:new).with( msf_client: mock_client, - rate_limiter: mock_rate_limiter + rate_limiter: mock_rate_limiter, + dangerous_actions: false ).and_return(mock_mcp_server) app.send(:initialize_mcp_server) @@ -371,6 +386,48 @@ expect(app.mcp_server).to eq(mock_mcp_server) expect(output.string).to include('Initializing MCP server...') end + + it 'forwards dangerous_actions: true when the config enables it' do + mock_client = instance_double(Msf::MCP::Metasploit::Client) + mock_rate_limiter = instance_double(Msf::MCP::Security::RateLimiter) + mock_mcp_server = instance_double(Msf::MCP::Server) + + app = described_class.new([], output: output) + app.instance_variable_set(:@msf_client, mock_client) + app.instance_variable_set(:@rate_limiter, mock_rate_limiter) + app.instance_variable_set(:@config, { mcp: { dangerous_actions: true } }) + + expect(Msf::MCP::Server).to receive(:new).with( + msf_client: mock_client, + rate_limiter: mock_rate_limiter, + dangerous_actions: true + ).and_return(mock_mcp_server) + + app.send(:initialize_mcp_server) + + expect(output.string).to include('WARNING: dangerous actions mode is ENABLED') + end + + it 'forwards dangerous_actions: false when the config is absent' do + mock_client = instance_double(Msf::MCP::Metasploit::Client) + mock_rate_limiter = instance_double(Msf::MCP::Security::RateLimiter) + mock_mcp_server = instance_double(Msf::MCP::Server) + + app = described_class.new([], output: output) + app.instance_variable_set(:@msf_client, mock_client) + app.instance_variable_set(:@rate_limiter, mock_rate_limiter) + app.instance_variable_set(:@config, { mcp: {} }) + + expect(Msf::MCP::Server).to receive(:new).with( + msf_client: mock_client, + rate_limiter: mock_rate_limiter, + dangerous_actions: false + ).and_return(mock_mcp_server) + + app.send(:initialize_mcp_server) + + expect(output.string).not_to include('WARNING: dangerous actions mode is ENABLED') + end end describe '#start_mcp_server' do diff --git a/spec/lib/msf/core/mcp/config/loader_spec.rb b/spec/lib/msf/core/mcp/config/loader_spec.rb index 989b2c914b5f9..ea5be67e812cf 100644 --- a/spec/lib/msf/core/mcp/config/loader_spec.rb +++ b/spec/lib/msf/core/mcp/config/loader_spec.rb @@ -881,4 +881,68 @@ end end end + + describe 'dangerous_actions defaults' do + let(:config_hash) { {} } + + it 'defaults mcp.dangerous_actions to false' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:dangerous_actions]).to be false + end + + it 'preserves explicit true' do + config = described_class.load_from_hash(mcp: { dangerous_actions: true }) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'preserves explicit false' do + config = described_class.load_from_hash(mcp: { dangerous_actions: false }) + expect(config[:mcp][:dangerous_actions]).to be false + end + end + + describe 'MSF_MCP_DANGEROUS_ACTIONS env override' do + around do |example| + original = ENV['MSF_MCP_DANGEROUS_ACTIONS'] + example.run + ensure + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = original + end + + it 'enables dangerous_actions when env is "true"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'true' + config = described_class.load_from_hash({}) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'enables dangerous_actions when env is "1"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = '1' + config = described_class.load_from_hash({}) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'enables dangerous_actions when env is "yes"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'yes' + config = described_class.load_from_hash({}) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'disables dangerous_actions when env is "false"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'false' + config = described_class.load_from_hash(mcp: { dangerous_actions: true }) + expect(config[:mcp][:dangerous_actions]).to be false + end + + it 'overrides an explicit hash value' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'true' + config = described_class.load_from_hash(mcp: { dangerous_actions: false }) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'ignores empty env value' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = '' + config = described_class.load_from_hash(mcp: { dangerous_actions: true }) + expect(config[:mcp][:dangerous_actions]).to be true + end + end end diff --git a/spec/lib/msf/core/mcp/config/validator_spec.rb b/spec/lib/msf/core/mcp/config/validator_spec.rb index 73d894762f2d8..f288fde94659b 100644 --- a/spec/lib/msf/core/mcp/config/validator_spec.rb +++ b/spec/lib/msf/core/mcp/config/validator_spec.rb @@ -1016,6 +1016,61 @@ expect(described_class.validate!(config)).to be true end end + + context 'with dangerous_actions validation' do + let(:base_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password' + } + } + end + + it 'accepts dangerous_actions set to true' do + config = base_config.merge(mcp: { dangerous_actions: true }) + expect(described_class.validate!(config)).to be true + end + + it 'accepts dangerous_actions set to false' do + config = base_config.merge(mcp: { dangerous_actions: false }) + expect(described_class.validate!(config)).to be true + end + + it 'does not raise when dangerous_actions is not present' do + config = base_config.merge(mcp: { transport: 'stdio' }) + expect(described_class.validate!(config)).to be true + end + + it 'rejects non-boolean dangerous_actions (string)' do + config = base_config.merge(mcp: { dangerous_actions: 'true' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'mcp.dangerous_actions']).to eq('must be boolean (true or false)') + end + end + + it 'rejects non-boolean dangerous_actions (integer)' do + config = base_config.merge(mcp: { dangerous_actions: 1 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'mcp.dangerous_actions']).to eq('must be boolean (true or false)') + end + end + + it 'rejects non-boolean dangerous_actions (nil)' do + config = base_config.merge(mcp: { dangerous_actions: nil }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'mcp.dangerous_actions']).to eq('must be boolean (true or false)') + end + end + end end describe '#credentials_can_be_generated?' do @@ -1101,4 +1156,4 @@ end end end -end +end \ No newline at end of file diff --git a/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb index 5b560a81b5c26..65eea6d7ea242 100644 --- a/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb +++ b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb @@ -193,6 +193,17 @@ def last_log_entry expect(items[1]).not_to include('secret') expect(items[2]).to eq('also safe') end + + it 'handles hashes with non-symbolizable keys (e.g. Integer keys from session.list)' do + msg = { message: 'response', context: { body: { 1 => { 'type' => 'meterpreter' }, 2 => { 'type' => 'shell' } } } } + expect { + sink.log(:info, 'mcp', 0, msg) + }.not_to raise_error + + body = last_log_entry['context']['body'] + expect(body['1']['type']).to eq('meterpreter') + expect(body['2']['type']).to eq('shell') + end end describe 'exception handling' do diff --git a/spec/lib/msf/core/mcp/metasploit/client_spec.rb b/spec/lib/msf/core/mcp/metasploit/client_spec.rb index c1291cdd4cdd7..c3e568258c4ff 100644 --- a/spec/lib/msf/core/mcp/metasploit/client_spec.rb +++ b/spec/lib/msf/core/mcp/metasploit/client_spec.rb @@ -170,6 +170,46 @@ expect(client.db_notes({ workspace: 'default' })).to eq({ 'notes' => [] }) end + it 'delegates module_execute to underlying client' do + allow(jsonrpc_client).to receive(:module_execute).with('exploit', 'multi/handler', {}).and_return({ 'job_id' => 1, 'uuid' => 'abc' }) + expect(client.module_execute('exploit', 'multi/handler', {})).to eq({ 'job_id' => 1, 'uuid' => 'abc' }) + end + + it 'delegates module_check to underlying client' do + allow(jsonrpc_client).to receive(:module_check).with('exploit', 'multi/handler', {}).and_return({ 'job_id' => 2, 'uuid' => 'def' }) + expect(client.module_check('exploit', 'multi/handler', {})).to eq({ 'job_id' => 2, 'uuid' => 'def' }) + end + + it 'delegates module_results to underlying client' do + allow(jsonrpc_client).to receive(:module_results).with('abc').and_return({ 'status' => 'completed' }) + expect(client.module_results('abc')).to eq({ 'status' => 'completed' }) + end + + it 'delegates running_stats to underlying client' do + allow(jsonrpc_client).to receive(:running_stats).and_return({ 'waiting' => [], 'running' => [], 'results' => [] }) + expect(client.running_stats).to eq({ 'waiting' => [], 'running' => [], 'results' => [] }) + end + + it 'delegates session_list to underlying client' do + allow(jsonrpc_client).to receive(:session_list).and_return({}) + expect(client.session_list).to eq({}) + end + + it 'delegates session_stop to underlying client' do + allow(jsonrpc_client).to receive(:session_stop).with(3).and_return({ 'result' => 'success' }) + expect(client.session_stop(3)).to eq({ 'result' => 'success' }) + end + + it 'delegates session_read to underlying client' do + allow(jsonrpc_client).to receive(:session_read).with(3).and_return({ 'data' => 'output' }) + expect(client.session_read(3)).to eq({ 'data' => 'output' }) + end + + it 'delegates session_write to underlying client' do + allow(jsonrpc_client).to receive(:session_write).with(3, 'cmd').and_return({ 'result' => 'success' }) + expect(client.session_write(3, 'cmd')).to eq({ 'result' => 'success' }) + end + it 'delegates shutdown to underlying client' do allow(jsonrpc_client).to receive(:shutdown) client.shutdown diff --git a/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb index 7f5fec20730db..46c547ea4a569 100644 --- a/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb +++ b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb @@ -403,4 +403,82 @@ expect(call_sequence).to eq([:search_call, :reauth, :search_retry, :hosts_call, :reauth, :hosts_retry]) end end + + describe 'module and session methods' do + before do + client.instance_variable_set(:@token, 'tok') + end + + describe '#module_execute' do + it 'calls module.execute with type, name, and options' do + expect(client).to receive(:send_request) + .with(['module.execute', 'tok', 'exploit', 'multi/handler', { 'RHOSTS' => '192.0.2.10' }]) + .and_return({ 'job_id' => 1, 'uuid' => 'abc' }) + client.module_execute('exploit', 'multi/handler', { 'RHOSTS' => '192.0.2.10' }) + end + end + + describe '#module_check' do + it 'calls module.check with type, name, and options' do + expect(client).to receive(:send_request) + .with(['module.check', 'tok', 'exploit', 'multi/handler', {}]) + .and_return({ 'job_id' => 2, 'uuid' => 'def' }) + client.module_check('exploit', 'multi/handler', {}) + end + end + + describe '#module_results' do + it 'calls module.results with the UUID' do + expect(client).to receive(:send_request) + .with(['module.results', 'tok', 'abc123']) + .and_return({ 'status' => 'completed' }) + client.module_results('abc123') + end + end + + describe '#running_stats' do + it 'calls module.running_stats with no arguments' do + expect(client).to receive(:send_request) + .with(['module.running_stats', 'tok']) + .and_return({ 'waiting' => [], 'running' => [], 'results' => [] }) + client.running_stats + end + end + + describe '#session_list' do + it 'calls session.list with no arguments' do + expect(client).to receive(:send_request) + .with(['session.list', 'tok']) + .and_return({}) + client.session_list + end + end + + describe '#session_stop' do + it 'calls session.stop with the session id' do + expect(client).to receive(:send_request) + .with(['session.stop', 'tok', 3]) + .and_return({ 'result' => 'success' }) + client.session_stop(3) + end + end + + describe '#session_read' do + it 'calls session.interactive_read with the session id' do + expect(client).to receive(:send_request) + .with(['session.interactive_read', 'tok', 5]) + .and_return({ 'data' => 'hello' }) + client.session_read(5) + end + end + + describe '#session_write' do + it 'calls session.interactive_write with the session id and data' do + expect(client).to receive(:send_request) + .with(['session.interactive_write', 'tok', 5, 'sysinfo']) + .and_return({ 'result' => 'success' }) + client.session_write(5, 'sysinfo') + end + end + end end diff --git a/spec/lib/msf/core/mcp/security/input_validator_spec.rb b/spec/lib/msf/core/mcp/security/input_validator_spec.rb index b75ed1ba2707b..b06d83d35e894 100644 --- a/spec/lib/msf/core/mcp/security/input_validator_spec.rb +++ b/spec/lib/msf/core/mcp/security/input_validator_spec.rb @@ -660,6 +660,200 @@ end end + describe '.validate_module_options!' do + it 'accepts an empty hash' do + expect(described_class.validate_module_options!({})).to be true + end + + it 'accepts a hash of valid scalar values' do + options = { 'RHOSTS' => '192.0.2.10', 'RPORT' => 445, 'VERBOSE' => true, 'RATIO' => 1.5, 'NULL_KEY' => nil } + expect(described_class.validate_module_options!(options)).to be true + end + + it 'accepts Symbol keys (MCP transport deep-symbolizes JSON input)' do + options = { RHOSTS: '192.0.2.10', RPORT: 445, VERBOSE: true } + expect(described_class.validate_module_options!(options)).to be true + end + + it 'rejects non-Hash input' do + expect { + described_class.validate_module_options!('not a hash') + }.to raise_error(Msf::MCP::Security::ValidationError, /Module options must be a Hash/) + end + + it 'rejects hashes with too many keys' do + options = (1..(described_class::MODULE_OPTIONS_MAX_KEYS + 1)).map { |i| ["KEY_#{i}", i] }.to_h + expect { + described_class.validate_module_options!(options) + }.to raise_error(Msf::MCP::Security::ValidationError, /too many keys/) + end + + it 'rejects keys that do not match the datastore convention' do + expect { + described_class.validate_module_options!('1BAD' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'accepts namespaced mixin option keys with the `::` separator (HTTP::compression, CMDSTAGER::FLAVOR)' do + options = { + 'HTTP::compression' => 'gzip', + 'CMDSTAGER::FLAVOR' => 'wget', + 'EXE::Custom' => '/tmp/x.exe', + 'SMB::ChunkSize' => 500, + 'DCERPC::ReadTimeout' => 10, + 'HTML::javascript::escape' => true + } + expect(described_class.validate_module_options!(options)).to be true + end + + it 'accepts hyphenated header-style option keys (BEARER-TOKEN)' do + expect(described_class.validate_module_options!('BEARER-TOKEN' => 'abc')).to be true + end + + it 'rejects keys containing whitespace' do + expect { + described_class.validate_module_options!('Domain SID' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'rejects keys with leading separators' do + expect { + described_class.validate_module_options!('::HTTP' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'rejects keys with trailing separators' do + expect { + described_class.validate_module_options!('HTTP::' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'rejects keys exceeding the length cap' do + oversized_key = 'A' * (described_class::MODULE_OPTIONS_KEY_MAX_LENGTH + 1) + expect { + described_class.validate_module_options!(oversized_key => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /exceeds.*chars/) + end + + it 'rejects non-scalar values (Array)' do + expect { + described_class.validate_module_options!('RHOSTS' => ['192.0.2.10']) + }.to raise_error(Msf::MCP::Security::ValidationError, /must be a scalar/) + end + + it 'rejects non-scalar values (Hash)' do + expect { + described_class.validate_module_options!('OPTS' => { nested: 1 }) + }.to raise_error(Msf::MCP::Security::ValidationError, /must be a scalar/) + end + + it 'rejects string values exceeding the per-value cap' do + oversized = 'A' * (described_class::MODULE_OPTIONS_VALUE_MAX_BYTES + 1) + expect { + described_class.validate_module_options!('RHOSTS' => oversized) + }.to raise_error(Msf::MCP::Security::ValidationError, /exceeds.*bytes/) + end + + it 'rejects payloads exceeding the total byte cap' do + max_value = 'A' * (described_class::MODULE_OPTIONS_VALUE_MAX_BYTES) + options = {} + ((described_class::MODULE_OPTIONS_TOTAL_MAX_BYTES / described_class::MODULE_OPTIONS_VALUE_MAX_BYTES) + 1).times do |i| + options["KEY_#{i}"] = max_value + end + expect { + described_class.validate_module_options!(options) + }.to raise_error(Msf::MCP::Security::ValidationError, /total payload exceeds/) + end + end + + describe '.validate_uuid!' do + it 'accepts a 24-char mixed-case alphanumeric UUID (matches Rex::Text.rand_text_alphanumeric(24))' do + expect(described_class.validate_uuid!('Q72h3dKD5nALt39rT0AXpDCR')).to be true + end + + it 'accepts an all-lowercase 24-char alphanumeric UUID' do + expect(described_class.validate_uuid!('abcdef0123456789abcdef01')).to be true + end + + it 'accepts an all-uppercase 24-char alphanumeric UUID' do + expect(described_class.validate_uuid!('ABCDEF0123456789ABCDEF01')).to be true + end + + it 'rejects UUIDs with special characters' do + expect { + described_class.validate_uuid!('abc-1234abcdef0123456789') + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID/) + end + + it 'rejects UUIDs shorter than 24 characters' do + expect { + described_class.validate_uuid!('a1b2c3d4') + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID/) + end + + it 'rejects UUIDs longer than 24 characters' do + expect { + described_class.validate_uuid!('a' * 25) + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID/) + end + + it 'rejects non-String input' do + expect { + described_class.validate_uuid!(12345) + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID must be a String/) + end + end + + describe '.validate_session_id!' do + it 'accepts a valid integer session id' do + expect(described_class.validate_session_id!(1)).to be true + expect(described_class.validate_session_id!(65535)).to be true + end + + it 'rejects 0 and negative ids' do + expect { + described_class.validate_session_id!(0) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session ID/) + end + + it 'rejects ids above the range' do + expect { + described_class.validate_session_id!(65536) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session ID/) + end + + it 'rejects non-Integer input' do + expect { + described_class.validate_session_id!('1') + }.to raise_error(Msf::MCP::Security::ValidationError, /Session ID must be an Integer/) + end + end + + describe '.validate_session_data!' do + it 'accepts non-empty strings' do + expect(described_class.validate_session_data!('sysinfo')).to be true + end + + it 'rejects empty strings' do + expect { + described_class.validate_session_data!('') + }.to raise_error(Msf::MCP::Security::ValidationError, /Session data cannot be empty/) + end + + it 'rejects non-String input' do + expect { + described_class.validate_session_data!(12345) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session data must be a String/) + end + + it 'rejects data exceeding the size cap' do + oversized = 'A' * (described_class::SESSION_DATA_MAX_BYTES + 1) + expect { + described_class.validate_session_data!(oversized) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session data exceeds/) + end + end + describe 'fuzzing tests' do it 'handles random IP-like strings' do 1000.times do diff --git a/spec/lib/msf/core/mcp/server_spec.rb b/spec/lib/msf/core/mcp/server_spec.rb index aa9036f5b0c55..c2d29a4cda55a 100644 --- a/spec/lib/msf/core/mcp/server_spec.rb +++ b/spec/lib/msf/core/mcp/server_spec.rb @@ -84,12 +84,20 @@ tools: array_including( Msf::MCP::Tools::SearchModules, Msf::MCP::Tools::ModuleInfo, + Msf::MCP::Tools::ModuleExecute, + Msf::MCP::Tools::ModuleCheck, + Msf::MCP::Tools::ModuleResults, + Msf::MCP::Tools::RunningStats, Msf::MCP::Tools::HostInfo, Msf::MCP::Tools::ServiceInfo, Msf::MCP::Tools::VulnerabilityInfo, Msf::MCP::Tools::NoteInfo, Msf::MCP::Tools::CredentialInfo, - Msf::MCP::Tools::LootInfo + Msf::MCP::Tools::LootInfo, + Msf::MCP::Tools::SessionList, + Msf::MCP::Tools::SessionStop, + Msf::MCP::Tools::SessionRead, + Msf::MCP::Tools::SessionWrite ) ) ).and_return(mock_mcp_server) @@ -129,6 +137,47 @@ ) end + it 'defaults dangerous_actions to false in server_context' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including(dangerous_actions: false) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + it 'propagates dangerous_actions: true into server_context' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including(dangerous_actions: true) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter, + dangerous_actions: true + ) + end + + it 'coerces non-true dangerous_actions values to false' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including(dangerous_actions: false) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter, + dangerous_actions: 'yes' + ) + end + it 'passes a configuration object with around_request and exception_reporter' do expect(::MCP::Server).to receive(:new) do |args| config = args[:configuration] diff --git a/spec/lib/msf/core/mcp/tools/credential_info_spec.rb b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb index e4a229033f95a..02a4d8378392a 100644 --- a/spec/lib/msf/core/mcp/tools/credential_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb @@ -52,9 +52,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end it 'supports pagination parameters' do diff --git a/spec/lib/msf/core/mcp/tools/loot_info_spec.rb b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb index 73032ec523e7b..ed23c0e21712d 100644 --- a/spec/lib/msf/core/mcp/tools/loot_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb @@ -58,9 +58,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end it 'supports pagination parameters' do diff --git a/spec/lib/msf/core/mcp/tools/module_check_spec.rb b/spec/lib/msf/core/mcp/tools/module_check_spec.rb new file mode 100644 index 0000000000000..f7dbf637d8e80 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/module_check_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ModuleCheck do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + let(:msf_response) { { 'job_id' => 4, 'uuid' => 'aaaa1111bbbb2222cccc3333' } } + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:module_check).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_module_check') + end + end + + describe 'Input Schema' do + it 'requires type, name, and options' do + expect(described_class.input_schema.schema[:required]).to match_array(%w[type name options]) + end + + it 'restricts type to exploit and auxiliary' do + expect(described_class.input_schema.schema[:properties][:type][:enum]).to match_array(%w[exploit auxiliary]) + end + end + + describe 'Annotations' do + it 'is marked as destructive' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('module_check') + end + + it 'forwards type, name, options to msf_client.module_check' do + options = { 'RHOSTS' => '192.0.2.10' } + described_class.call(type: 'exploit', name: 'multi/handler', options: options, server_context: server_context) + expect(msf_client).to have_received(:module_check).with('exploit', 'multi/handler', options) + end + + it 'stringifies Symbol option keys before forwarding (MCP deep-symbolizes JSON input)' do + described_class.call( + type: 'exploit', + name: 'multi/handler', + options: { RHOSTS: '192.0.2.10', RPORT: 445 }, + server_context: server_context + ) + expect(msf_client).to have_received(:module_check).with( + 'exploit', + 'multi/handler', + { 'RHOSTS' => '192.0.2.10', 'RPORT' => 445 } + ) + end + + it 'returns a structured response with job_id and uuid' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.structured_content[:data][:job_id]).to eq(4) + expect(result.structured_content[:data][:uuid]).to eq('aaaa1111bbbb2222cccc3333') + end + + it 'rejects non-exploit/auxiliary types' do + result = described_class.call(type: 'post', name: 'foo/bar', options: {}, server_context: server_context) + expect(result.error?).to be true + end + + it 'returns an unsupported structured response when the framework reports no check method' do + allow(msf_client).to receive(:module_check).and_raise( + Msf::MCP::Metasploit::APIError.new(Msf::Exploit::CheckCode::Unsupported.message) + ) + + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be false + expect(result.structured_content[:data][:status]).to eq('unsupported') + expect(result.structured_content[:data][:message]).to match(/does not implement a check method/i) + end + + it 'returns a normal API error response when the framework reports a different failure' do + allow(msf_client).to receive(:module_check).and_raise(Msf::MCP::Metasploit::APIError.new('Module not found')) + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Module not found/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'does not call msf_client.module_check when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(msf_client).not_to have_received(:module_check) + end + + it 'does not consume rate limit when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/module_execute_spec.rb b/spec/lib/msf/core/mcp/tools/module_execute_spec.rb new file mode 100644 index 0000000000000..d509de4c36f67 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/module_execute_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ModuleExecute do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + let(:msf_response) { { 'job_id' => 7, 'uuid' => 'abc123def456ghi789jkl012' } } + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:module_execute).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_module_execute') + end + end + + describe 'Input Schema' do + it 'requires type, name and options' do + expect(described_class.input_schema.schema[:required]).to match_array(%w[type name options]) + end + + it 'restricts type to the supported enum' do + expect(described_class.input_schema.schema[:properties][:type][:enum]) + .to match_array(%w[exploit auxiliary post payload evasion]) + end + end + + describe 'Annotations' do + it 'is marked as a destructive, non-idempotent tool' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('module_execute') + end + + it 'forwards type, name, and options unchanged to msf_client.module_execute (does not bypass AutoCheck)' do + options = { 'RHOSTS' => '192.0.2.10', 'PAYLOAD' => 'windows/meterpreter/reverse_tcp' } + described_class.call(type: 'exploit', name: 'multi/handler', options: options, server_context: server_context) + expect(msf_client).to have_received(:module_execute).with('exploit', 'multi/handler', options) + end + + it 'stringifies Symbol option keys before forwarding (MCP deep-symbolizes JSON input)' do + described_class.call( + type: 'exploit', + name: 'multi/handler', + options: { RHOSTS: '192.0.2.10', PAYLOAD: 'windows/meterpreter/reverse_tcp' }, + server_context: server_context + ) + expect(msf_client).to have_received(:module_execute).with( + 'exploit', + 'multi/handler', + { 'RHOSTS' => '192.0.2.10', 'PAYLOAD' => 'windows/meterpreter/reverse_tcp' } + ) + end + + it 'returns a structured response with job_id and uuid' do + result = described_class.call(type: 'auxiliary', name: 'scanner/smb/smb_version', options: {}, server_context: server_context) + + expect(result).to be_a(MCP::Tool::Response) + expect(result.structured_content[:data][:job_id]).to eq(7) + expect(result.structured_content[:data][:uuid]).to eq('abc123def456ghi789jkl012') + expect(result.structured_content[:metadata][:query_time]).to be_a(Float) + end + + it 'rejects invalid module types' do + result = described_class.call(type: 'invalid_type', name: 'foo', options: {}, server_context: server_context) + expect(result.error?).to be true + end + + it 'rejects invalid module names' do + result = described_class.call(type: 'exploit', name: 'invalid name!', options: {}, server_context: server_context) + expect(result.error?).to be true + end + + it 'rejects non-Hash options' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: 'string', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Module options/) + end + + it 'returns API error response' do + allow(msf_client).to receive(:module_execute).and_raise(Msf::MCP::Metasploit::APIError.new('Module not found')) + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + it 'returns rate limit error response' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'returns an error response when dangerous_actions key is missing' do + result = described_class.call( + type: 'exploit', name: 'multi/handler', options: {}, + server_context: { msf_client: msf_client, rate_limiter: rate_limiter, config: {} } + ) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + end + + it 'does not call msf_client.module_execute when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(msf_client).not_to have_received(:module_execute) + end + + it 'does not consume rate limit when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/module_results_spec.rb b/spec/lib/msf/core/mcp/tools/module_results_spec.rb new file mode 100644 index 0000000000000..20f10b1be1a52 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/module_results_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ModuleResults do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + let(:valid_uuid) { 'abc12345def67890ghi12345' } + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:module_results).and_return({ 'status' => 'completed', 'result' => 'ok' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_module_results') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(uuid: valid_uuid, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('module_results') + end + + it 'forwards UUID to msf_client.module_results' do + described_class.call(uuid: valid_uuid, server_context: server_context) + expect(msf_client).to have_received(:module_results).with(valid_uuid) + end + + it 'returns the status and result as structured content' do + result = described_class.call(uuid: valid_uuid, server_context: server_context) + expect(result.structured_content[:data][:status]).to eq('completed') + expect(result.structured_content[:data][:result]).to eq('ok') + end + + it 'rejects invalid UUID format' do + result = described_class.call(uuid: 'BAD-UUID!', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/UUID/) + end + + it 'rejects non-string UUID' do + result = described_class.call(uuid: 123, server_context: server_context) + expect(result.error?).to be true + end + + it 'returns an error response for API errors' do + allow(msf_client).to receive(:module_results).and_raise(Msf::MCP::Metasploit::APIError.new('Not found')) + result = described_class.call(uuid: valid_uuid, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is missing or false' do + contexts = [ + { msf_client: msf_client, rate_limiter: rate_limiter, config: {} }, + { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + ] + contexts.each do |ctx| + result = described_class.call(uuid: valid_uuid, server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data][:status]).to eq('completed') + end + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/note_info_spec.rb b/spec/lib/msf/core/mcp/tools/note_info_spec.rb index 0f1a4f576472b..c93e4c6d6a017 100644 --- a/spec/lib/msf/core/mcp/tools/note_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/note_info_spec.rb @@ -64,9 +64,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end end diff --git a/spec/lib/msf/core/mcp/tools/running_stats_spec.rb b/spec/lib/msf/core/mcp/tools/running_stats_spec.rb new file mode 100644 index 0000000000000..145ffab36bfb6 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/running_stats_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::RunningStats do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + let(:msf_response) do + { + 'waiting' => ['uuid_w_1'], + 'running' => ['uuid_r_1', 'uuid_r_2'], + 'results' => ['uuid_d_1'] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:running_stats).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_running_stats') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('running_stats') + end + + it 'returns the running stats arrays as structured content' do + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data][:waiting]).to eq(['uuid_w_1']) + expect(result.structured_content[:data][:running]).to eq(['uuid_r_1', 'uuid_r_2']) + expect(result.structured_content[:data][:results]).to eq(['uuid_d_1']) + end + + it 'tolerates missing keys in the upstream response' do + allow(msf_client).to receive(:running_stats).and_return({}) + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data][:waiting]).to eq([]) + expect(result.structured_content[:data][:running]).to eq([]) + expect(result.structured_content[:data][:results]).to eq([]) + end + + it 'returns an error response for API errors' do + allow(msf_client).to receive(:running_stats).and_raise(Msf::MCP::Metasploit::APIError.new('boom')) + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is false' do + ctx = { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + result = described_class.call(server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data][:waiting]).to eq(['uuid_w_1']) + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/service_info_spec.rb b/spec/lib/msf/core/mcp/tools/service_info_spec.rb index 530a83c09348a..88bcc2c1fb5fc 100644 --- a/spec/lib/msf/core/mcp/tools/service_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/service_info_spec.rb @@ -52,9 +52,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end it 'supports multiple filter parameters' do diff --git a/spec/lib/msf/core/mcp/tools/session_list_spec.rb b/spec/lib/msf/core/mcp/tools/session_list_spec.rb new file mode 100644 index 0000000000000..a81337b28a15f --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_list_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionList do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + let(:msf_response) do + { + 1 => { + 'type' => 'meterpreter', + 'tunnel_local' => '192.0.2.10:4444', + 'tunnel_peer' => '192.0.2.20:52341', + 'via_exploit' => 'exploit/windows/smb/ms17_010_eternalblue', + 'platform' => 'windows' + }, + 2 => { + 'type' => 'shell', + 'tunnel_local' => '192.0.2.10:4445', + 'tunnel_peer' => '192.0.2.30:33333' + } + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_list).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_list') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_list') + end + + it 'returns the upstream session hash as structured content' do + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data]).to eq(msf_response) + end + + it 'reports total_sessions in metadata' do + result = described_class.call(server_context: server_context) + expect(result.structured_content[:metadata][:total_sessions]).to eq(2) + end + + it 'tolerates an empty session list' do + allow(msf_client).to receive(:session_list).and_return({}) + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data]).to eq({}) + expect(result.structured_content[:metadata][:total_sessions]).to eq(0) + end + + it 'returns an error response for API errors' do + allow(msf_client).to receive(:session_list).and_raise(Msf::MCP::Metasploit::APIError.new('boom')) + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is false' do + ctx = { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + result = described_class.call(server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data]).to eq(msf_response) + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/session_read_spec.rb b/spec/lib/msf/core/mcp/tools/session_read_spec.rb new file mode 100644 index 0000000000000..05941597c3506 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_read_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionRead do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_read).and_return({ 'data' => 'meterpreter > sysinfo\n' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_read') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(session_id: 1, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_read') + end + + it 'forwards session_id to msf_client.session_read' do + described_class.call(session_id: 5, server_context: server_context) + expect(msf_client).to have_received(:session_read).with(5) + end + + it 'returns the buffered output as structured content' do + result = described_class.call(session_id: 1, server_context: server_context) + expect(result.structured_content[:data][:data]).to eq('meterpreter > sysinfo\n') + end + + it 'rejects non-integer session_id' do + result = described_class.call(session_id: 'abc', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'rejects out-of-range session_id' do + result = described_class.call(session_id: 0, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'returns a clear error response when the framework reports the session is unknown' do + allow(msf_client).to receive(:session_read).and_raise( + Msf::MCP::Metasploit::APIError.new('Unknown Session ID') + ) + + result = described_class.call(session_id: 9999, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Unknown Session ID/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is false' do + ctx = { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + result = described_class.call(session_id: 1, server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data][:data]).to eq('meterpreter > sysinfo\n') + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/session_stop_spec.rb b/spec/lib/msf/core/mcp/tools/session_stop_spec.rb new file mode 100644 index 0000000000000..fbe6cf3f900dd --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_stop_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionStop do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_stop).and_return({ 'result' => 'success' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_stop') + end + end + + describe 'Annotations' do + it 'is marked as destructive' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(session_id: 1, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_stop') + end + + it 'forwards session_id to msf_client.session_stop' do + described_class.call(session_id: 3, server_context: server_context) + expect(msf_client).to have_received(:session_stop).with(3) + end + + it 'returns the success result as structured content' do + result = described_class.call(session_id: 1, server_context: server_context) + expect(result.structured_content[:data][:result]).to eq('success') + end + + it 'rejects non-integer session_id' do + result = described_class.call(session_id: 'abc', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'rejects out-of-range session_id' do + result = described_class.call(session_id: 0, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'returns a clear error response when the framework reports the session is unknown' do + allow(msf_client).to receive(:session_stop).and_raise( + Msf::MCP::Metasploit::APIError.new('Unknown Session ID') + ) + + result = described_class.call(session_id: 9999, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Unknown Session ID/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(session_id: 1, server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'does not call msf_client.session_stop when blocked' do + described_class.call(session_id: 1, server_context: disabled_context) + expect(msf_client).not_to have_received(:session_stop) + end + + it 'does not consume rate limit when blocked' do + described_class.call(session_id: 1, server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/session_write_spec.rb b/spec/lib/msf/core/mcp/tools/session_write_spec.rb new file mode 100644 index 0000000000000..282a292ce03c2 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_write_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionWrite do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_write).and_return({ 'result' => 'success' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_write') + end + end + + describe 'Annotations' do + it 'is marked as destructive' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(session_id: 1, data: 'sysinfo', server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_write') + end + + it 'forwards session_id and data to msf_client.session_write' do + described_class.call(session_id: 7, data: 'whoami', server_context: server_context) + expect(msf_client).to have_received(:session_write).with(7, 'whoami') + end + + it 'returns the success result as structured content' do + result = described_class.call(session_id: 1, data: 'sysinfo', server_context: server_context) + expect(result.structured_content[:data][:result]).to eq('success') + end + + it 'rejects non-integer session_id' do + result = described_class.call(session_id: 'abc', data: 'sysinfo', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'rejects non-string data' do + result = described_class.call(session_id: 1, data: 12345, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session data/) + end + + it 'rejects empty data' do + result = described_class.call(session_id: 1, data: '', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session data/) + end + + it 'rejects data exceeding the size cap' do + oversized = 'A' * (Msf::MCP::Security::InputValidator::SESSION_DATA_MAX_BYTES + 1) + result = described_class.call(session_id: 1, data: oversized, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session data/) + end + + it 'returns a clear error response when the framework reports the session is unknown' do + allow(msf_client).to receive(:session_write).and_raise( + Msf::MCP::Metasploit::APIError.new('Unknown Session ID') + ) + + result = described_class.call(session_id: 9999, data: 'sysinfo', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Unknown Session ID/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(session_id: 1, data: 'sysinfo', server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'does not call msf_client.session_write when blocked' do + described_class.call(session_id: 1, data: 'sysinfo', server_context: disabled_context) + expect(msf_client).not_to have_received(:session_write) + end + + it 'does not consume rate limit when blocked' do + described_class.call(session_id: 1, data: 'sysinfo', server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb b/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb index 5c118a4566e08..1f785e3db76c8 100644 --- a/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb +++ b/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb @@ -48,4 +48,39 @@ class << self expect(result.error?).to be true end end + + describe '#dangerous_mode_required!' do + it 'returns without raising when dangerous_actions is true' do + expect { + tool_class.dangerous_mode_required!(dangerous_actions: true) + }.not_to raise_error + end + + it 'raises DangerousModeDisabledError when dangerous_actions is false' do + expect { + tool_class.dangerous_mode_required!(dangerous_actions: false) + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) do |error| + expect(error.message).to match(/dangerous actions mode/i) + expect(error.message).to include('--enable-dangerous-actions') + expect(error.message).to include('MSF_MCP_DANGEROUS_ACTIONS') + expect(error.message).to include('mcp.dangerous_actions') + end + end + + it 'raises DangerousModeDisabledError when dangerous_actions key is missing' do + expect { + tool_class.dangerous_mode_required!({}) + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) + end + + it 'raises DangerousModeDisabledError when dangerous_actions is a truthy non-boolean value' do + expect { + tool_class.dangerous_mode_required!(dangerous_actions: 'true') + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) + + expect { + tool_class.dangerous_mode_required!(dangerous_actions: 1) + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) + end + end end diff --git a/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb b/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb index ec99f0aa9b1f5..d46f17150d8fc 100644 --- a/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb @@ -48,9 +48,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end end diff --git a/spec/lib/msf/core/rpc/v10/rpc_module_check_spec.rb b/spec/lib/msf/core/rpc/v10/rpc_module_check_spec.rb new file mode 100644 index 0000000000000..ca8c354e2e7ea --- /dev/null +++ b/spec/lib/msf/core/rpc/v10/rpc_module_check_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/core/rpc/v10/rpc_module' +require 'msf/core/rpc/v10/rpc_job_status_tracker' + +RSpec.describe Msf::RPC::RPC_Module, '#rpc_check propagates NotImplementedError from check_simple' do + let(:service) { double('Service') } + let(:job_status_tracker) { Msf::RPC::RpcJobStatusTracker.new } + let(:framework) { double('Framework', modules: modules) } + let(:modules) { double('ModuleManager') } + let(:rpc) do + instance = described_class.allocate + allow(instance).to receive(:framework).and_return(framework) + allow(instance).to receive(:job_status_tracker).and_return(job_status_tracker) + instance + end + + context 'for an exploit module without a check method' do + it 'lets ::NotImplementedError propagate so the transport layer can surface a 500 with backtrace' do + mod = double('ExploitModule') + allow(modules).to receive(:create).with('exploit/multi/handler').and_return(mod) + allow(mod).to receive(:type).and_return('exploit') + + unsupported_msg = Msf::Exploit::CheckCode::Unsupported.message + allow(Msf::Simple::Exploit).to receive(:check_simple).and_raise(::NotImplementedError.new(unsupported_msg)) + + expect { rpc.rpc_check('exploit', 'multi/handler', {}) } + .to raise_error(::NotImplementedError, unsupported_msg) + end + end + + context 'for an auxiliary module without a check method' do + it 'lets ::NotImplementedError propagate so the transport layer can surface a 500 with backtrace' do + mod = double('AuxiliaryModule') + allow(modules).to receive(:create).with('auxiliary/scanner/portscan/tcp').and_return(mod) + allow(mod).to receive(:type).and_return('auxiliary') + + unsupported_msg = Msf::Exploit::CheckCode::Unsupported.message + allow(Msf::Simple::Auxiliary).to receive(:check_simple).and_raise(::NotImplementedError.new(unsupported_msg)) + + expect { rpc.rpc_check('auxiliary', 'scanner/portscan/tcp', {}) } + .to raise_error(::NotImplementedError, unsupported_msg) + end + end +end diff --git a/spec/lib/msf/core/rpc/v10/rpc_module_execute_spec.rb b/spec/lib/msf/core/rpc/v10/rpc_module_execute_spec.rb new file mode 100644 index 0000000000000..0b479ba2b265c --- /dev/null +++ b/spec/lib/msf/core/rpc/v10/rpc_module_execute_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/core/rpc/v10/rpc_module' +require 'msf/core/rpc/v10/rpc_job_status_tracker' + +RSpec.describe Msf::RPC::RPC_Module, '#rpc_execute returns uuid/job_id for all module types' do + let(:service) { double('Service') } + let(:job_status_tracker) { Msf::RPC::RpcJobStatusTracker.new } + let(:framework) { double('Framework', modules: modules) } + let(:modules) { double('ModuleManager') } + let(:rpc) do + instance = described_class.allocate + allow(instance).to receive(:framework).and_return(framework) + allow(instance).to receive(:job_status_tracker).and_return(job_status_tracker) + instance + end + + shared_examples 'returns a hash with job_id and uuid' do |mtype, mname, simple_klass, simple_method| + it "returns { job_id, uuid } for #{mtype} modules" do + mod = double('Module', uuid: 'mod-uuid', job_id: 99, run_uuid: 'run-uuid') + allow(modules).to receive(:create).with("#{mtype}/#{mname}").and_return(mod) + allow(mod).to receive(:type).and_return(mtype) + + allow(simple_klass).to receive(simple_method) + + opts = mtype == 'exploit' ? { 'PAYLOAD' => 'generic/shell_reverse_tcp' } : {} + response = rpc.rpc_execute(mtype, mname, opts) + expect(response).to include('job_id', 'uuid') + expect(response['job_id']).to eq(99) + expect(response['uuid']).to eq('run-uuid') + end + end + + include_examples 'returns a hash with job_id and uuid', + 'exploit', 'multi/handler', Msf::Simple::Exploit, :exploit_simple + include_examples 'returns a hash with job_id and uuid', + 'auxiliary', 'scanner/portscan/tcp', Msf::Simple::Auxiliary, :run_simple + + context 'for post modules' do + it 'returns the run uuid recorded on the module (not just mod.uuid)' do + mod = double('PostModule', uuid: 'mod-uuid', job_id: 11, run_uuid: 'run-uuid-post') + allow(modules).to receive(:create).with('post/multi/general/execute').and_return(mod) + allow(mod).to receive(:type).and_return('post') + allow(Msf::Simple::Post).to receive(:run_simple) + + response = rpc.rpc_execute('post', 'multi/general/execute', {}) + expect(response['uuid']).to eq('run-uuid-post') + expect(response['job_id']).to eq(11) + end + end + + context 'for evasion modules' do + it 'returns the run uuid recorded on the module (not just mod.uuid)' do + mod = double('EvasionModule', uuid: 'mod-uuid', job_id: 22, run_uuid: 'run-uuid-evasion') + allow(modules).to receive(:create).with('evasion/windows/applocker_evasion_msbuild').and_return(mod) + allow(mod).to receive(:type).and_return('evasion') + allow(Msf::Simple::Evasion).to receive(:run_simple) + + response = rpc.rpc_execute('evasion', 'windows/applocker_evasion_msbuild', {}) + expect(response['uuid']).to eq('run-uuid-evasion') + expect(response['job_id']).to eq(22) + end + end + + context 'falls back to mod.uuid when run_uuid is not set' do + it 'returns mod.uuid for the uuid field' do + mod = double('PostModule', uuid: 'mod-uuid', job_id: 11, run_uuid: nil) + allow(modules).to receive(:create).with('post/multi/general/execute').and_return(mod) + allow(mod).to receive(:type).and_return('post') + allow(Msf::Simple::Post).to receive(:run_simple) + + response = rpc.rpc_execute('post', 'multi/general/execute', {}) + expect(response['uuid']).to eq('mod-uuid') + end + end +end diff --git a/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb b/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb index cbc0d33f81bc2..83766e3162966 100644 --- a/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb +++ b/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb @@ -158,6 +158,36 @@ def core end end + shared_examples 'interactive shell read' do + let(:expected_data) { 'test response' } + + before do + allow(rstream).to receive(:get_once).and_return(expected_data) + end + + it 'returns expected data' do + expect(response).to eq({ 'data' => expected_data }) + end + end + + shared_examples 'interactive shell write' do + let(:test_command) { 'help' } + + it 'writes the command with a trailing newline and returns success' do + expect(rstream).to receive(:write).with("#{test_command}\n").and_return(test_command.length + 1) + expect(response).to eq({ 'result' => 'success' }) + end + + context 'when the input already ends with a newline' do + let(:test_command) { "help\n" } + + it 'does not append an extra newline' do + expect(rstream).to receive(:write).with(test_command).and_return(test_command.length) + expect(response).to eq({ 'result' => 'success' }) + end + end + end + describe '#rpc_compatible_modules' do context 'when the session does not exist' do let(:session) { meterpreter_session } @@ -195,11 +225,13 @@ def core end context 'with shell session' do - let(:session) { shell_session } - - it 'raises an error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell read' end end @@ -221,11 +253,13 @@ def core end context 'with shell session' do - let(:session) { shell_session } - - it 'raises an error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell read' end end @@ -303,11 +337,13 @@ def core subject(:response) { rpc.rpc_interactive_write(target_sid, test_command) } context 'with shell session' do - let(:session) { shell_session } - - it 'raises error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell write' end context 'with meterpreter session' do @@ -329,11 +365,13 @@ def core subject(:response) { rpc.rpc_meterpreter_write(target_sid, test_command) } context 'with shell session' do - let(:session) { shell_session } - - it 'raises error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell write' end context 'with meterpreter session' do diff --git a/tools/dev/msfmcp_integration_test.rb b/tools/dev/msfmcp_integration_test.rb new file mode 100755 index 0000000000000..a9f920ca8522d --- /dev/null +++ b/tools/dev/msfmcp_integration_test.rb @@ -0,0 +1,1164 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# End-to-end integration test for the Metasploit MCP server (msfmcpd). +# +# Acts as an HTTP client that drives the MCP Streamable HTTP transport, +# exercising the full request lifecycle (initialize, tools/list, tools/call) +# against every registered tool. Mirrors what the MCP Inspector does, but +# scripted and assertion-driven so it can run in CI or as a smoke test. +# +# The msfmcpd HTTP transport and the Metasploit RPC server are expected to +# already be running. This script will not start either of them. +# +# Examples: +# +# # Smoke test against a freshly started msfmcpd printing its token +# ruby tools/dev/msfmcp_integration_test.rb \ +# --url http://127.0.0.1:3000 \ +# --token "$MSF_MCP_TOKEN" +# +# # Include the dangerous-tool execution paths +# ruby tools/dev/msfmcp_integration_test.rb \ +# --url http://127.0.0.1:3000 \ +# --token "$MSF_MCP_TOKEN" \ +# --enable-dangerous \ +# --rhost 192.0.2.10 --lhost 192.0.2.20 --lport 4444 +# +# # Override the modules / datastore options used by the dangerous tests +# ruby tools/dev/msfmcp_integration_test.rb \ +# --token "$MSF_MCP_TOKEN" --enable-dangerous \ +# --check-module exploit:linux/http/some_module \ +# --check-option RHOSTS=192.0.2.10 --check-option RPORT=8080 \ +# --execute-module auxiliary/scanner/http/http_version \ +# --execute-option RHOSTS=192.0.2.0/24 --execute-option THREADS=10 +# +# # Run only tests whose name matches a pattern. The filter matches against +# # "
:: ", so you can filter by section or by test. +# ruby tools/dev/msfmcp_integration_test.rb --token "$T" --filter session +# ruby tools/dev/msfmcp_integration_test.rb --token "$T" --filter 'Full exploit lifecycle' +# + +require 'json' +require 'net/http' +require 'optparse' +require 'securerandom' +require 'uri' + +# ---------------------------------------------------------------------------- +# Minimal MCP Streamable HTTP client +# ---------------------------------------------------------------------------- +class McpHttpClient + PROTOCOL_VERSION = '2025-11-25' + ACCEPT = 'application/json, text/event-stream' + CONTENT_TYPE = 'application/json' + + attr_reader :session_id, :server_info + attr_accessor :debug + + def initialize(url:, token: nil, open_timeout: 5, read_timeout: 60, debug: false) + @uri = URI(url) + raise ArgumentError, "URL must be http(s): #{url}" unless %w[http https].include?(@uri.scheme) + + @http = Net::HTTP.new(@uri.host, @uri.port) + @http.use_ssl = (@uri.scheme == 'https') + @http.open_timeout = open_timeout + @http.read_timeout = read_timeout + @token = token + @next_id = 0 + @debug = debug + end + + def initialize_session! + payload = { + jsonrpc: '2.0', + id: next_id, + method: 'initialize', + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'msfmcp-integration-test', version: '1.0' } + } + } + response = post_json(payload) + @session_id = response.http_response['mcp-session-id'] || response.http_response['Mcp-Session-Id'] + raise 'Server did not return Mcp-Session-Id header on initialize' unless @session_id + + body = response.parsed_body + raise "initialize failed: #{body.inspect}" unless body.is_a?(Hash) && body['result'] + + @server_info = body['result'] + send_notification('notifications/initialized') + @server_info + end + + def list_tools + call_method('tools/list') + end + + def call_tool(name, arguments = {}) + response = call_method('tools/call', { name: name, arguments: arguments }) + # The MCP server uses a single shared token bucket (burst defaults to 10). + # Integration tests fire calls back to back, so we transparently retry + # once when we hit the limit instead of forcing every test to think + # about pacing. + retry_after = rate_limit_retry_after(response) + if retry_after + sleep(retry_after) + response = call_method('tools/call', { name: name, arguments: arguments }) + end + response + end + + def send_notification(method, params = nil) + payload = { jsonrpc: '2.0', method: method } + payload[:params] = params if params + post_json(payload, allow_empty: true) + end + + def call_method(method, params = nil) + payload = { jsonrpc: '2.0', id: next_id, method: method } + payload[:params] = params if params + post_json(payload).parsed_body + end + + # Returns the number of seconds to wait, or nil if the response is not a + # rate-limit error. Rate-limit failures come back as tool responses with + # `isError: true` and a message of the form "Rate limit exceeded. Retry + # after N seconds.". + def rate_limit_retry_after(response) + return nil unless response.is_a?(Hash) + return nil unless response.dig('result', 'isError') + + text = response.dig('result', 'content')&.first&.dig('text').to_s + return nil unless text.include?('Rate limit exceeded') + + match = text.match(/Retry after (\d+) seconds?/i) + match ? match[1].to_i + 1 : 2 + end + + def terminate_session! + return unless @session_id + + req = Net::HTTP::Delete.new(@uri.request_uri) + req['Mcp-Session-Id'] = @session_id + req['Authorization'] = "Bearer #{@token}" if @token + @http.request(req) + @session_id = nil + end + + # Issue a raw request without the convenience defaults (used to test auth + # failures, malformed payloads, etc.). + def raw_post(body, headers: {}) + req = Net::HTTP::Post.new(@uri.request_uri) + { 'Accept' => ACCEPT, 'Content-Type' => CONTENT_TYPE }.each { |k, v| req[k] = v } + headers.each { |k, v| req[k] = v } + req.body = body + @http.request(req) + end + + private + + def next_id + @next_id += 1 + end + + def post_json(payload, allow_empty: false) + req = Net::HTTP::Post.new(@uri.request_uri) + req['Accept'] = ACCEPT + req['Content-Type'] = CONTENT_TYPE + req['Authorization'] = "Bearer #{@token}" if @token + req['Mcp-Session-Id'] = @session_id if @session_id + req.body = JSON.generate(payload) + + debug_log_request(req) + http_response = @http.request(req) + debug_log_response(http_response) + RawResponse.new(http_response, allow_empty: allow_empty) + end + + def debug_log_request(req) + return unless @debug + + warn Color.cyan("--> #{req.method} #{@uri}") + req.each_header do |k, v| + # Redact the auth token but show that it was sent. + value = (k.downcase == 'authorization') ? v.sub(/Bearer .+/, 'Bearer ') : v + warn Color.cyan(" #{k}: #{value}") + end + warn Color.cyan(" body: #{pretty_json(req.body)}") if req.body && !req.body.empty? + end + + def debug_log_response(http_response) + return unless @debug + + warn Color.yellow("<-- HTTP #{http_response.code}") + http_response.each_header { |k, v| warn Color.yellow(" #{k}: #{v}") } + body = http_response.body.to_s + warn Color.yellow(" body: #{pretty_json(body)}") unless body.empty? + end + + def pretty_json(raw) + JSON.pretty_generate(JSON.parse(raw)) + rescue StandardError + raw + end +end + +# Wraps a Net::HTTPResponse and parses the body, transparently handling either +# `application/json` or `text/event-stream` (SSE) responses produced by the +# StreamableHTTPTransport. +class RawResponse + attr_reader :http_response + + def initialize(http_response, allow_empty: false) + @http_response = http_response + @allow_empty = allow_empty + end + + def status + @http_response.code.to_i + end + + def content_type + @http_response['Content-Type'].to_s.split(';').first&.strip + end + + def parsed_body + body = @http_response.body.to_s + if body.empty? + return nil if @allow_empty + + raise "Empty response body (HTTP #{status})" + end + + case content_type + when 'application/json' + JSON.parse(body) + when 'text/event-stream' + parse_sse(body) + else + JSON.parse(body) + end + rescue JSON::ParserError => e + raise "Could not parse response (HTTP #{status}, type=#{content_type}): #{e.message}\n#{body}" + end + + private + + # Extract the first `data:` payload from an SSE stream. The MCP transport + # only emits a single event per request/response, so this is sufficient. + def parse_sse(body) + body.each_line do |line| + line = line.chomp + next unless line.start_with?('data:') + + return JSON.parse(line.sub(/^data:\s*/, '')) + end + raise "No `data:` line in SSE response body:\n#{body}" + end +end + +# ---------------------------------------------------------------------------- +# Test framework +# ---------------------------------------------------------------------------- +module Color + def self.red(s) = "\e[31m#{s}\e[0m" + def self.green(s) = "\e[32m#{s}\e[0m" + def self.yellow(s) = "\e[33m#{s}\e[0m" + def self.cyan(s) = "\e[36m#{s}\e[0m" + def self.bold(s) = "\e[1m#{s}\e[0m" +end + +class TestRunner + Result = Struct.new(:name, :status, :message, :duration) do + def passed? = status == :pass + def failed? = status == :fail + def skipped? = status == :skip + end + + attr_reader :results + + def initialize(filter: nil, verbose: false, debug: false) + @filter = filter + @verbose = verbose + @debug = debug + @results = [] + @current_section = nil + @section_printed = false + end + + def run(name) + qualified = @current_section ? "#{@current_section} :: #{name}" : name + return if @filter && !qualified.match?(@filter) + + print_section_header + warn Color.bold(Color.cyan("\n-- #{name} --")) if @debug + started = Time.now + begin + yield + record(name, :pass, nil, Time.now - started) + rescue SkipTest => e + record(name, :skip, e.message, Time.now - started) + rescue AssertionFailed => e + record(name, :fail, e.message, Time.now - started) + rescue StandardError => e + record(name, :fail, "#{e.class}: #{e.message}", Time.now - started) + end + end + + def section(title) + @current_section = title + @section_printed = false + return if @filter # header prints lazily so filtered runs only show sections that have matches + + puts + puts Color.bold(Color.cyan("== #{title} ==")) + @section_printed = true + end + + def print_section_header + return if @section_printed || @current_section.nil? + + puts + puts Color.bold(Color.cyan("== #{@current_section} ==")) + @section_printed = true + end + + def summary + pass = @results.count(&:passed?) + fail = @results.count(&:failed?) + skip = @results.count(&:skipped?) + puts + puts Color.bold("Results: #{pass} passed, #{fail} failed, #{skip} skipped (#{@results.size} total)") + fail.zero? + end + + private + + def record(name, status, message, duration) + @results << Result.new(name, status, message, duration) + label = case status + when :pass then Color.green('PASS') + when :fail then Color.red('FAIL') + when :skip then Color.yellow('SKIP') + end + line = format(' [%s] %-60s %.3fs', label, name, duration) + line += "\n #{message}" if message + puts line + end +end + +class SkipTest < StandardError; end +class AssertionFailed < StandardError; end + +module Assertions + def assert(condition, message = 'assertion failed') + raise AssertionFailed, message unless condition + end + + def assert_equal(expected, actual, message = nil) + return if expected == actual + + raise AssertionFailed, message || "expected #{expected.inspect}, got #{actual.inspect}" + end + + def assert_includes(collection, value, message = nil) + return if collection.include?(value) + + raise AssertionFailed, message || "expected #{collection.inspect} to include #{value.inspect}" + end + + def assert_match(pattern, string, message = nil) + return if string.to_s.match?(pattern) + + raise AssertionFailed, message || "expected #{string.inspect} to match #{pattern.inspect}" + end + + # JSON-RPC level error (transport returned a JSON-RPC error envelope). + def assert_rpc_error(response, code: nil) + assert response.is_a?(Hash), "expected response Hash, got #{response.class}" + assert response['error'], "expected JSON-RPC error, got: #{response.inspect}" + if code + assert_equal code, response.dig('error', 'code'), + "expected error code #{code}, got #{response.dig('error', 'code').inspect}" + end + end + + # Successful tool response (no `isError`). + def assert_tool_success(response) + assert response.is_a?(Hash), "expected Hash, got #{response.class}" + assert response['result'], "expected result key, got: #{response.inspect}" + is_error = response.dig('result', 'isError') + assert !is_error, + "expected tool success, got isError=true: #{response.dig('result', 'content')&.first&.dig('text')}" + end + + # Tool returned a structured error (`isError: true`) -- the typical response + # for validation failures, gated dangerous tools, etc. + def assert_tool_error(response, contains: nil) + assert response.is_a?(Hash), "expected Hash, got #{response.class}" + assert response['result'], "expected result key, got: #{response.inspect}" + assert_equal true, response.dig('result', 'isError'), + "expected isError=true, got: #{response.inspect}" + return unless contains + + text = response.dig('result', 'content')&.first&.dig('text').to_s + assert text.include?(contains), "expected error text to include #{contains.inspect}, got #{text.inspect}" + end +end + +# ---------------------------------------------------------------------------- +# Test suites +# ---------------------------------------------------------------------------- +class IntegrationTests + include Assertions + + EXPECTED_TOOLS = %w[ + msf_search_modules + msf_module_info + msf_module_execute + msf_module_check + msf_module_results + msf_running_stats + msf_host_info + msf_service_info + msf_vulnerability_info + msf_note_info + msf_credential_info + msf_loot_info + msf_session_list + msf_session_stop + msf_session_read + msf_session_write + ].freeze + + DANGEROUS_TOOLS = %w[msf_module_execute msf_module_check msf_session_stop msf_session_write].freeze + + DEFAULT_CHECK_MODULE = 'exploit:windows/smb/ms17_010_eternalblue' + DEFAULT_EXECUTE_MODULE = 'auxiliary:scanner/portscan/tcp' + + def initialize(client:, runner:, options:) + @client = client + @runner = runner + @options = options + end + + def run_all + test_protocol + test_tools_list + test_read_only_happy_paths + test_input_validation_errors + test_dangerous_mode_gate + test_dangerous_mode_execution if @options[:dangerous] + test_exploit_lifecycle if @options[:dangerous] + test_protocol_violations + end + + # --- Protocol ----------------------------------------------------------- + + def test_protocol + @runner.section('Protocol') + + @runner.run('initialize negotiates protocol version') do + info = @client.server_info + assert_includes %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05], + info['protocolVersion'] + assert_equal 'msfmcp', info.dig('serverInfo', 'name') + assert info.dig('serverInfo', 'version').is_a?(String), 'version should be a string' + end + end + + # --- tools/list --------------------------------------------------------- + + def test_tools_list + @runner.section('tools/list') + + @runner.run('tools/list returns all 16 registered tools') do + response = @client.list_tools + assert_tool_success(response) if response.dig('result', 'isError') + tools = response.dig('result', 'tools') || [] + names = tools.map { |t| t['name'] } + EXPECTED_TOOLS.each do |expected| + assert_includes names, expected + end + assert_equal EXPECTED_TOOLS.size, names.size, + "expected #{EXPECTED_TOOLS.size} tools, got #{names.size}: #{names.inspect}" + end + + @runner.run('tools/list flags dangerous tools via annotations') do + tools = @client.list_tools.dig('result', 'tools') || [] + DANGEROUS_TOOLS.each do |name| + tool = tools.find { |t| t['name'] == name } + assert tool, "missing dangerous tool: #{name}" + # MCP SDK serializes annotations under "annotations". Tools without + # destructive_hint=true would default to false. + assert_equal true, tool.dig('annotations', 'destructiveHint'), + "#{name} should have destructiveHint=true" + end + end + end + + # --- Read-only tool happy paths ----------------------------------------- + + def test_read_only_happy_paths + @runner.section('Read-only tools (happy path)') + + @runner.run('msf_search_modules returns results for a common query') do + r = @client.call_tool('msf_search_modules', { query: 'ms17_010', limit: 5 }) + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data].is_a?(Array), 'expected data array' + end + + @runner.run('msf_module_info returns metadata for a known module') do + r = @client.call_tool('msf_module_info', + { type: 'exploit', name: 'windows/smb/ms17_010_eternalblue' }) + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data][:fullname].to_s.include?('ms17_010'), + "unexpected fullname: #{data[:data][:fullname].inspect}" + end + + @runner.run('msf_running_stats returns counters') do + r = @client.call_tool('msf_running_stats') + assert_tool_success(r) + data = parse_tool_data(r) + %i[waiting running results].each do |k| + assert data[:data].key?(k), "missing key #{k}" + assert data[:data][k].is_a?(Array), "expected #{k} to be an Array" + end + end + + @runner.run('msf_session_list returns a hash keyed by session id') do + r = @client.call_tool('msf_session_list') + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data].is_a?(Hash), "expected data Hash, got #{data[:data].class}" + assert data[:metadata][:total_sessions].is_a?(Integer), 'expected total_sessions integer' + end + + # DB-backed tools: succeed regardless of whether records exist. + %w[msf_host_info msf_service_info msf_vulnerability_info + msf_note_info msf_credential_info msf_loot_info].each do |name| + @runner.run("#{name} returns a (possibly empty) listing") do + r = @client.call_tool(name, default_db_query_for(name)) + # If no DB is connected the tool returns an isError; tolerate that. + if r.dig('result', 'isError') + text = r.dig('result', 'content')&.first&.dig('text').to_s + if text.match?(/database|workspace/i) + raise SkipTest, "no database/workspace available: #{text}" + end + + raise AssertionFailed, "unexpected tool error: #{text}" + end + data = parse_tool_data(r) + assert data[:data].is_a?(Array), 'expected data array' + end + end + + @runner.run('msf_module_results returns not-found for a random UUID') do + r = @client.call_tool('msf_module_results', { uuid: SecureRandom.alphanumeric(24) }) + # Unknown UUID -> isError with a 'not found' message. + assert_tool_error(r) + end + end + + # --- Validation errors -------------------------------------------------- + + def test_input_validation_errors + @runner.section('Input validation') + + @runner.run('msf_search_modules rejects empty query') do + r = @client.call_tool('msf_search_modules', { query: '' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('msf_module_info rejects an invalid module type') do + r = @client.call_tool('msf_module_info', + { type: 'wat', name: 'windows/smb/ms17_010_eternalblue' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('msf_host_info rejects an invalid IP address') do + r = @client.call_tool('msf_host_info', { address: 'not.an.ip' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('msf_module_results rejects a malformed UUID') do + r = @client.call_tool('msf_module_results', { uuid: 'too-short' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('tools/call rejects unknown tool name') do + response = @client.call_tool('msf_does_not_exist', {}) + # SDK either returns an isError tool response or an RPC error envelope. + # The MCP Ruby SDK currently returns -32602 with the human-readable + # detail in `error.data` ("Tool not found: ..."), so check there too. + rpc_message = response.dig('error', 'message').to_s + rpc_data = response.dig('error', 'data').to_s + ok = response.dig('result', 'isError') || + rpc_message.match?(/tool|method|not found|invalid/i) || + rpc_data.match?(/tool|method|not found/i) + assert ok, "expected error for unknown tool, got: #{response.inspect}" + end + end + + # --- Dangerous mode gate ------------------------------------------------ + + def test_dangerous_mode_gate + return if @options[:dangerous] + + @runner.section('Dangerous tools (gate closed)') + + DANGEROUS_TOOLS.each do |name| + @runner.run("#{name} is gated when dangerous mode is disabled") do + r = @client.call_tool(name, minimal_args_for(name)) + assert_tool_error(r, contains: 'dangerous actions mode') + end + end + end + + # --- Dangerous mode execution ------------------------------------------- + + def test_dangerous_mode_execution + @runner.section('Dangerous tools (gate open)') + + @runner.run('msf_module_check executes against the configured target') do + type, name = split_module_spec(@options[:check_module] || DEFAULT_CHECK_MODULE) + defaults = {} + defaults['RHOSTS'] = @options[:rhost] if @options[:rhost] + tool_options = merge_options(defaults, @options[:check_option_overrides]) + if tool_options.empty? + raise SkipTest, 'provide --rhost or --check-option to set required datastore options' + end + + r = @client.call_tool('msf_module_check', + { type: type, name: name, options: tool_options }) + # We don't require Vulnerable/Safe, only that the gate let us through + # and the call did not produce a structural error. + assert_tool_success(r) + end + + @runner.run('msf_module_execute launches a module') do + type, name = split_module_spec(@options[:execute_module] || DEFAULT_EXECUTE_MODULE) + if type == 'exploit' + raise SkipTest, 'covered by full exploit lifecycle test (avoids handler contention)' + end + + # When the user keeps the bundled default module, apply its companion + # defaults so the test is runnable with just --rhost. + defaults = @options[:execute_module].nil? ? { 'PORTS' => '22' } : {} + defaults['RHOSTS'] = @options[:rhost] if @options[:rhost] + defaults['LHOST'] = @options[:lhost] if @options[:lhost] + defaults['LPORT'] = @options[:lport] if @options[:lport] + tool_options = merge_options(defaults, @options[:execute_option_overrides]) + if tool_options.empty? + raise SkipTest, 'provide --rhost, --lhost/--lport, or --execute-option to set required datastore options' + end + + r = @client.call_tool('msf_module_execute', + { type: type, name: name, options: tool_options }) + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data][:job_id], 'expected job_id' + assert data[:data][:uuid], 'expected uuid' + end + + @runner.run('msf_session_stop rejects a non-existent session id') do + r = @client.call_tool('msf_session_stop', { session_id: 99_999 }) + # 'Not found' from the RPC counts as a tool error response. + assert_tool_error(r) + end + + @runner.run('msf_session_write rejects a non-existent session id') do + r = @client.call_tool('msf_session_write', + { session_id: 99_999, data: 'whoami' }) + assert_tool_error(r) + end + end + + # --- Full exploit lifecycle --------------------------------------------- + # + # Sequential polling flow (fails fast on explicit error signals): + # + # 1. msf_module_execute -> capture uuid + # 2. msf_running_stats (poll until uuid in :results) -> run finished + # 3. msf_module_results (poll until 'session N opened') -> capture SID + # 4. msf_session_list -> confirm SID + # 5. msf_session_write (type-appropriate probe command) + # 6. msf_session_read (poll until probe output matches) + # 7. msf_session_stop + msf_session_list -> confirm cleanup + # + # Only runs when the module under test is an exploit -- other module types + # don't necessarily open a session. + def test_exploit_lifecycle + type, name = split_module_spec(@options[:execute_module] || DEFAULT_EXECUTE_MODULE) + + @runner.section('Full exploit lifecycle') + + unless type == 'exploit' + @runner.run("skipped for non-exploit module (#{type}:#{name})") do + raise SkipTest, "module type is #{type.inspect}, pass --execute-module exploit:..." + end + return + end + + tool_options = build_execute_options + if tool_options.empty? + @runner.run('exploit lifecycle') do + raise SkipTest, 'provide --rhost, --lhost/--lport, or --execute-option to set required datastore options' + end + return + end + + exploit_uuid = nil + session_id = nil + session_type = nil + probe = nil + + # Step 1: launch the exploit. + @runner.run("msf_module_execute launches exploit #{name}") do + r = @client.call_tool('msf_module_execute', + { type: type, name: name, options: tool_options }) + assert_tool_success(r) + data = parse_tool_data(r) + exploit_uuid = data[:data][:uuid] + assert exploit_uuid, 'expected uuid' + assert data[:data][:job_id], 'expected job_id' + end + + unless exploit_uuid + @runner.run('exploit lifecycle aborted') do + raise SkipTest, 'no uuid returned from msf_module_execute' + end + return + end + + # Step 2: wait for the run to complete (uuid appears in :results). + @runner.run("msf_running_stats reports run #{exploit_uuid} finished") do + done = poll_until( + timeout: @options[:exploit_timeout], + predicate: ->(data) { Array(data[:data][:results]).include?(exploit_uuid) } + ) { @client.call_tool('msf_running_stats') } + assert done, "uuid #{exploit_uuid} not in running_stats.results after #{@options[:exploit_timeout]}s" + end + + # Step 3: poll the run's result text for the 'session N opened' line. + # Fail fast if the result indicates the module errored or explicitly + # reports that no session was created. + @runner.run('msf_module_results reports a session-opened line') do + last_result_text = nil + outcome = poll_until( + timeout: 30, + predicate: lambda do |data| + status = data[:data][:status].to_s + result_text = data[:data][:result].to_s + last_result_text = result_text + + if status == 'errored' + raise AssertionFailed, "module run errored: #{data[:data][:error].inspect}" + end + if NO_SESSION_PATTERNS.any? { |pat| result_text.match?(pat) } + raise AssertionFailed, "module reported no session opened: #{result_text.inspect}" + end + + result_text.match?(SESSION_OPENED_PATTERN) + end + ) { @client.call_tool('msf_module_results', { uuid: exploit_uuid }) } + + unless outcome + raise AssertionFailed, + "no 'session opened' line detected within 30s; last result=#{last_result_text.inspect}" + end + + session_id = extract_reported_sid(outcome[:data][:result].to_s) + assert session_id, "could not parse SID from result: #{outcome[:data][:result].inspect}" + end + + unless session_id + @runner.run('session interaction skipped') do + raise SkipTest, 'no session opened -- cannot exercise interactive tools' + end + return + end + + # Step 4: confirm the session is in msf_session_list, and capture its + # type so the probe command can be chosen accordingly. + @runner.run("msf_session_list contains session #{session_id}") do + r = @client.call_tool('msf_session_list') + assert_tool_success(r) + data = parse_tool_data(r) + entry = data[:data][session_id.to_s.to_sym] + assert entry, "session #{session_id} not in list: #{session_ids_from(data).inspect}" + session_type = entry[:type].to_s + probe = probe_for(session_type) + end + + # Step 5: send the type-appropriate probe command. + @runner.run("msf_session_write sends #{probe[:description]} to #{session_type} session #{session_id}") do + r = @client.call_tool('msf_session_write', + { session_id: session_id, data: probe[:command] }) + assert_tool_success(r) + end + + # Step 6: poll session_read for the expected output (or, for DB/SMB/LDAP + # sessions where no universal probe response exists, verify only that + # the read call succeeds). + @runner.run("msf_session_read returns expected output from session #{session_id}") do + if probe[:verify] == :success_only + r = @client.call_tool('msf_session_read', { session_id: session_id }) + assert_tool_success(r) + else + matched = poll_until( + timeout: 15, + predicate: ->(data) { probe[:verify].call(data[:data][:data].to_s) } + ) { @client.call_tool('msf_session_read', { session_id: session_id }) } + assert matched, "probe output (#{probe[:description]}) not received within 15s for #{session_type} session" + end + end + + @runner.run("msf_session_stop terminates session #{session_id}") do + r = @client.call_tool('msf_session_stop', { session_id: session_id }) + assert_tool_success(r) + end + + @runner.run("msf_session_list no longer contains session #{session_id}") do + gone = poll_until( + timeout: 15, + predicate: ->(data) { !session_ids_from(data).include?(session_id) } + ) { @client.call_tool('msf_session_list') } + assert gone, "session #{session_id} still present after msf_session_stop" + end + end + + # --- Transport-level protocol violations -------------------------------- + + def test_protocol_violations + @runner.section('Transport protocol violations') + + @runner.run('POST without Accept header returns 406') do + response = @client.raw_post( + JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list'), + headers: { 'Accept' => 'application/json', + 'Mcp-Session-Id' => @client.session_id } + ) + assert_equal 406, response.code.to_i + end + + @runner.run('POST with wrong Content-Type returns 415') do + response = @client.raw_post( + '{}', + headers: { 'Content-Type' => 'text/plain', + 'Mcp-Session-Id' => @client.session_id } + ) + assert_equal 415, response.code.to_i + end + + @runner.run('POST without session ID returns 400') do + stale_client = McpHttpClient.new(url: @options[:url], token: @options[:token], debug: @options[:debug]) + response = stale_client.raw_post( + JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list') + ) + assert_equal 400, response.code.to_i + end + + @runner.run('POST with invalid bearer token returns 401') do + raise SkipTest, '--token not provided' unless @options[:token] + + bad_client = McpHttpClient.new(url: @options[:url], token: 'wrong-token', debug: @options[:debug]) + response = bad_client.raw_post( + JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list') + ) + assert_equal 401, response.code.to_i + end + end + + private + + # Extract the structured payload from a tools/call success response. + def parse_tool_data(response) + structured = response.dig('result', 'structuredContent') + return symbolize_deeply(structured) if structured + + text = response.dig('result', 'content')&.first&.dig('text') + symbolize_deeply(JSON.parse(text)) + end + + def symbolize_deeply(obj) + case obj + when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize_deeply(v) } + when Array then obj.map { |v| symbolize_deeply(v) } + else obj + end + end + + def response_indicates_validation_error?(response) + # Validation errors are returned as tool responses with isError=true. + return true if response.dig('result', 'isError') + # SDK schema validation kicks in before our tool runs and returns a + # JSON-RPC error envelope (-32602 Invalid params). + return true if response['error'] + + false + end + + # `TYPE:NAME` -> [type, name]. Colon is unambiguous because module names + # contain forward slashes but never colons. + def split_module_spec(spec) + type, name = spec.to_s.split(':', 2) + if type.nil? || type.empty? || name.nil? || name.empty? + raise ArgumentError, "invalid module spec #{spec.inspect}, expected TYPE:NAME" + end + + [type, name] + end + + # Merge `defaults` with an array of `"KEY=VALUE"` overrides; overrides win. + def merge_options(defaults, overrides) + result = defaults.dup + Array(overrides).each do |entry| + key, raw_value = entry.to_s.split('=', 2) + if key.nil? || key.empty? || raw_value.nil? + raise ArgumentError, "invalid option #{entry.inspect}, expected KEY=VALUE" + end + + result[key] = coerce_option_value(raw_value) + end + result + end + + # Best-effort coercion so users can write `RPORT=8080` and not have to wrap + # numeric or boolean values. The MCP module-options schema accepts string, + # integer, number, boolean, and null, so this widens the accepted forms + # without breaking string-only options (which round-trip unchanged). + def coerce_option_value(value) + case value + when /\A-?\d+\z/ then value.to_i + when /\A-?\d+\.\d+\z/ then value.to_f + when /\Atrue\z/i then true + when /\Afalse\z/i then false + when /\Anull\z/i, '' then nil + else value + end + end + + # Assemble the datastore options passed to msf_module_execute. Kept in one + # place so the standalone execute test and the lifecycle test agree on the + # precedence rules (defaults < --rhost/--lhost/--lport < --execute-option). + def build_execute_options + defaults = @options[:execute_module].nil? ? { 'PORTS' => '22' } : {} + defaults['RHOSTS'] = @options[:rhost] if @options[:rhost] + defaults['LHOST'] = @options[:lhost] if @options[:lhost] + defaults['LPORT'] = @options[:lport] if @options[:lport] + merge_options(defaults, @options[:execute_option_overrides]) + end + + # Poll a tool call until the predicate returns truthy or `timeout` seconds + # elapse. Returns the parsed tool data on success, nil on timeout. + # + # poll_until(timeout: 30, predicate: ->(d) { d[:data][:status] == 'completed' }) do + # @client.call_tool('msf_module_results', { uuid: uuid }) + # end + # + def poll_until(timeout:, predicate:, interval: 1) + deadline = Time.now + timeout + last_data = nil + loop do + response = yield + assert_tool_success(response) + last_data = parse_tool_data(response) + return last_data if predicate.call(last_data) + + break if Time.now >= deadline + + sleep(interval) + end + nil + end + + # session.list returns a hash keyed by session id (as string in JSON). + # Return them as an Array so callers can compute set differences. + def session_ids_from(tool_data) + return [] unless tool_data.is_a?(Hash) + + hash = tool_data[:data] + return [] unless hash.is_a?(Hash) + + hash.keys.filter_map { |k| Integer(k.to_s, 10) rescue nil } + end + + # Pull the numeric SID out of ExploitDriver's completion message. Works + # for every session type because the driver formats it uniformly as: + # " session opened () at